接手过老式jsp+servlet项目的人应该都有同感:一个客户关系管理系统看着不复杂,真动起手来却发现客户、商机、跟进、审批、报表、权限这些模块盘根错节,牵一发动全身。最近我刚好把一套用了好几年的SSH老系统整体迁移到SpringBoot上,顺手用ECharts把销售漏斗和业绩报表重做了一遍,技术栈就是标题里这套:java + springboot + echarts + freemarker + layui + maven + mysql。整个过程从工程搭建到核心模块落地,再到打包部署排查,踩了不少坑,今天完整复盘一下,给准备自研CRM或者接手SpringBoot项目的朋友一个可参考的蓝本。
整个项目不是简单套个增删改查框架,而是要把销售日常真正用起来。下面我按项目的实际推进顺序来拆解,从业务设计、技术选型,到模块实现、数据库优化,再到部署排查,每一块都会讲清楚为什么这么选、怎么做,以及实践里容易翻车的位置。
1. CRM项目的业务拆解与技术选型逻辑
1.1 业务需求拆解:不要把CRM做成一个客户列表
很多团队一提CRM,第一反应就是把客户信息表格化,做几个页面能增删改查,再导个Excel就算完事。这种系统上线以后大概率没人用,因为它只解决了“记录”问题,没有解决“推进”问题。
一个能给销售带来真实帮助的CRM,至少要包含三条业务主线:
- 客户资料沉淀:客户基本信息、所属行业、客户分级、来源渠道、下次跟进时间。重点不是字段多,而是要让销售一打开客户详情就能判断“这个人值不值得继续投入精力”。
- 商机阶段推进:从初步接触到需求确认、方案报价、商务谈判、赢单/输单。每个阶段要有状态字段、预计成交金额、预计成交日期,这样销售管理层才能从数据里看出哪些单子有风险。
- 跟进过程留痕:谁在什么时候通过什么方式(电话、拜访、微信)跟进过哪个客户,聊了什么,下一步计划是什么。这部分最容易被忽视,但恰恰是后续复盘客户流失原因的关键。
除此之外,老板关心的报表也不能省:每个销售的量、成交率、漏斗转化率、月度回款趋势,这些数据如果全靠人工汇总Excel,系统就失去了意义。
我当时设计的时候,把业务模块拆成了五张核心主表外加日志、部门、用户表,整体上采用一个“客户-联系人-商机-跟进”的一对多树形结构。数据模型理顺之后,后面的代码才是水到渠成的事,否则一边写代码一边改表结构,返工成本非常大。
1.2 技术选型:SpringBoot+FreeMarker+Layui这套老牌组合为什么还值得用
技术选型上,我仔细考虑过要不要上前后端分离,最后放弃Vue+SpringBoot的组合,选回了FreeMarker+Layui。原因很简单:团队规模不大,没有专职前端,销售系统又强依赖服务端渲染的快速迭代能力。
SpringBoot负责整体框架这一点没有悬念,它对Spring生态的整合几乎零配置,内嵌Tomcat让开发环境不用再单独装服务器。选FreeMarker而不是JSP,核心是JSP在SpringBoot里支持得比较别扭,特别是jar包部署时JSP的编译和访问总会出现莫名其妙的404,而FreeMarker天然适合模板渲染,语法干净,对Java对象直接访问属性,前端同事配合起来阻力也小。
Layui在这个项目里是作为后台管理UI使用的。它基于jQuery,组件成熟,表格、表单、弹层、分页都有现成封装。现在前端框架五花八门,但企业后台系统的场景下,Layui这种“不需要node环境、不折腾构建工具”的方案依然很能打,一套JS文件引入即可用,特别适合服务端渲染架构。
ECharts的定位是数据可视化。CRM里面最常见的图表是漏斗图(商机阶段转化)和折线图(业绩趋势),ECharts对这类场景支持特别完善,配置项直观,图表交互细腻,而且是完全本地化的开源项目,没有外网依赖。
MySQL负责所有业务数据落地,Maven负责依赖管理和构建。这套组合最适合的是中小型企业内部管理系统:人力有限、上线周期短、不需要过度设计,又能保证代码结构清晰、后续可维护。
2. 工程初始化与SpringBoot核心配置
2.1 Maven工程结构设计与POM依赖管理
项目刚入手第一步是创建Maven工程。这里我不建议一上来就搞微服务多模块拆分,CRM这种体量,一个spring-boot-maven-plugin打出的可执行jar包就能搞定所有功能,多模块只会增加维护成本。
我在pom.xml里核心引入了这么几个依赖,这里直接贴一段简化版:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-freemarker</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.20</version> </dependency> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency> </dependencies>几个细节值得注意。第一是SpringBoot版本,我特意选了2.7.x而不是3.x,因为3.0开始强制JDK17,而且很多旧版三方组件兼容性踩坑严重,很多小团队生产环境还在JDK8,直接上3.x会异常痛苦。第二是mysql-connector-j这个新坐标,老版本的com.mysql.cj.jdbc.Driver驱动名依然可用,如果需要反编译排查底层驱动行为,新坐标对应jar包结构更清晰。第三是PageHelper分页插件,CRM列表页的搜索分页是刚需,自己手写limit计算工作量不大但容易疏忽total数,用插件能把精力集中在业务SQL上。
2.2 application.yml中的关键配置与连接参数
SpringBoot的配置集中在application.yml里,我用多环境配置管理,默认开发环境、生产环境通过启动参数切换。数据库连接这块配置直接决定了系统稳定性,重点看这几项:
server: port: 8080 servlet: context-path: /crm spring: datasource: type: com.alibaba.druid.pool.DruidDataSource url: jdbc:mysql://localhost:3306/crm_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 time-between-eviction-runs-millis: 60000 validation-query: SELECT 1 freemarker: template-loader-path: classpath:/templates suffix: .ftl cache: false charset: UTF-8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.crm.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这里有几个我踩过的大坑,单独说。
数据库URL里的useSSL=false一定要加上,否则高版本MySQL驱动默认开启SSL握手,碰到自签名证书会直接报错,明明账号密码对却连不上库。serverTimezone务必指定,我一开始漏了,启动报“The server time zone value”的错,加上Asia/Shanghai才消停。allowPublicKeyRetrieval=true这个参数很多新项目会碰到,特别是用MySQL 8.0以上配合caching_sha2_password认证方式时,不加这个参数,驱动会拒绝通过非SSL通道获取公钥,启动能过但第一次查询连接就闪断。
FreeMarker的cache:false在开发调试阶段必须关掉,否则每次改模板都要重启服务才能看到效果。生产环境记得改回true,否则性能会受很大影响。MyBatis的驼峰映射也要打开,数据库字段是create_time,Java属性是createTime,映射规则打开后就不用写一堆繁琐的resultMap了。
2.3 FreeMarker模板与Layui静态资源的整合方式
FreeMarker模板放在src/main/resources/templates下,Layui的css、js文件放在src/main/resources/static下,SpringBoot默认会把这些资源映射出去。一个容易踩的坑是,如果把Layui的layui.js放在templates目录下,页面访问会404,因为templates目录默认不对外暴露静态资源,必须走static。
我习惯把公共布局抽成独立ftl片段,比如header.ftl、sidebar.ftl、footer.ftl,再用FreeMarker的include指令引入,这样每个功能页面只需要关心自己的主体内容:
<#include "common/header.ftl"> <div class="layui-container"> <#-- 主体内容区域 --> </div> <#include "common/footer.ftl">变量渲染时有个细节:如果后端传值为null,FreeMarker默认会直接报错,而不是输出空字符串。我用两种方式处理,一是在模板里给变量加默认值,比如${customer.phone!'-'};二是在application中配置spring.freemarker.settings.classic_compatible=true,兼容空值输出。不管哪种,都不要让一个null值把整个页面拖崩。
3. 核心业务模块的设计与实现
3.1 登录认证、用户权限与Session管理
登录认证这块我选的是比较传统的Session方案,SessionId种在Cookie里,拦截器统一校验,没有引入JWT。原因很实际:CRM的用户量不大,几十到几百人,Session方案够用且实现简单;JWT解决的是无状态和跨域问题,但会带来会话失效不好控制、密钥管理复杂的问题,对内部系统反而增加负担。
密码存储一定不能明文,我用的方案是MD5加盐,盐值固定一串随机字符串,注册时加密入库,登录时用同样算法加密比对。MD5现在不是最安全的,但对内部系统足够,如果安全要求更高,可以替换成BCrypt,做法类似。
拦截器实现思路是定义一个HandlerInterceptor,preHandle方法里检查当前请求的Session是否存在loginUser对象。没有就跳转到登录页。SpringBoot中注册拦截器要继承WebMvcConfigurer接口:
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") .excludePathPatterns("/login", "/doLogin", "/static/**", "/favicon.ico"); } }这里最关键的细节是白名单:静态资源路径一定要排除,否则页面压根加载不了CSS和JS。我在一开始就漏了static放行,登录页丑得没法看,排查半天才发现是拦截器把layui文件全给拦住了。
Session对象里我建议只放用户ID、用户名、部门ID、角色标识这些轻量信息,千万别把整个用户对象塞进去,用户表字段一多,Session会膨胀,而且用户资料更新后旧的Session数据不会自动同步,容易造成权限判断陈旧。
3.2 客户列表、分页搜索与多条件查询的实现套路
客户列表是CRM系统信息密度最高的页面。我实现它时用PageHelper插件完成物理分页,配合动态SQL处理搜索条件。先看Controller层接口:
@RequestMapping("/customer/list") public String list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer limit, CustomerQuery query, Model model) { PageHelper.startPage(page, limit); List<Customer> list = customerMapper.selectByCondition(query); PageInfo<Customer> pageInfo = new PageInfo<>(list); model.addAttribute("pageInfo", pageInfo); model.addAttribute("query", query); return "customer/list"; }关键点在Mapper的XML里,条件是否生效要用if标签动态拼接,不能靠字符串硬拼SQL,那是SQL注入的重灾区。例如按客户名模糊搜索,姓名可能为空,就需要:
<select id="selectByCondition" resultType="com.example.crm.entity.Customer"> SELECT id, name, phone, industry, level, owner_id, next_contact_time FROM customer <where> <if test="keyword != null and keyword != ''"> AND (name LIKE CONCAT('%', #{keyword}, '%') OR phone LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="ownerId != null"> AND owner_id = #{ownerId} </if> <if test="level != null and level != ''"> AND level = #{level} </if> </where> ORDER BY create_time DESC </select>分页插件一个容易出问题的地方是,PageHelper.startPage之后必须紧跟第一条查询语句,中间不能有其他MyBatis查询和逻辑处理,否则分页会串到别的SQL上。我刚开始写的时候在startPage和Mapper调用之间加了一个日志查询,结果列表页每次都查出来全表数据,还特别慢。
多条件查询页面上我用了Layui的form组件,搜索按钮触发table.reload,把表单数据作为参数提交。后端返回的是HTML页面就不用table.reload那套了,直接让form提交GET请求,保持URL可读可分享,更适合服务端渲染的形态。
3.3 商机阶段流转与跟进记录的落地方案
商机是销售漏斗的数据基础,设计上它必须挂在客户之下,又有独立的阶段状态。我在数据库表里设计了几个关键字段:stage(阶段)、amount(预计金额)、expect_deal_date(预计成交日期)、probability(赢单概率)。阶段用数字编码,1到6分别对应初次接触、需求确认、方案报价、商务谈判、赢单、输单,这个枚举放在代码里统一管理,避免前后端各维护一份。
页面上的商机看板按阶段分列展示,每一列是一个Kanban卡片。实现上不需要特复杂的技术,后端按阶段分组查询,前端用Layui的栅格布局排开,拖拽流转我用的是前端form select切换阶段,提交后由后端更新stage和probability。状态流转这里一定要做校验,比如从“初次接触”跳转到“赢单”就不合理,我加了一个简单的阶段顺序校验拦截器,保证数据逻辑完整。
跟进记录做成时间线样式,与客户详情页并列展示。每次跟进的类型、内容、下次跟进时间、创建人都要记录。代码里就是一张follow_up表的多表插入,关键点是插入后要更新customer表的next_contact_time字段,否则销售永远不知道自己下一次该联系谁。这个联动我一开始没做,后续跑了一段时间数据后,发现很多没有下次跟进时间的客户躺在列表里,销售根本想不起来还有这些待办,这就是典型的数据孤岛问题。
3.4 ECharts报表模块:从SQL聚合到前端可视化的完整链路
报表模块是这个系统里最出效果的部分。我做了三个核心图表:月度业绩趋势折线图、销售个人业绩排行柱状图、商机阶段漏斗图。
后端接口不做模板渲染,而是返回JSON数据。比如漏斗图接口,按商机表里的stage分组统计数量,SQL很简单:
SELECT stage, COUNT(*) AS cnt FROM business_opportunity WHERE del_flag = 0 GROUP BY stage ORDER BY stageController把查询结果封装成Map,用@ResponseBody注解直接吐JSON。前端页面通过Ajax拿到数据后,组装成ECharts的series数据格式,调用setOption渲染。
这里有一个每次都会踩的坑:ECharts实例如果已经渲染过一次,第二次setOption时旧数据不会自动清掉。我在漏斗图上调了好久,发现切换时间范围后柱子高度对不上,因为新旧数据叠加了。解决方法是每次请求前调用chart.clear(),或者在setOption时设置notMerge参数为true。
$.get('/report/funnel', function (resp) { var chart = echarts.init(document.getElementById('funnelChart')); chart.clear(); chart.setOption({ series: [{ type: 'funnel', left: '10%', width: '80%', data: resp.data, label: { show: true, formatter: '{b} : {c}' } }] }); });图表容器div的宽高一定要显式设置,很多刚接触ECharts的人把容器写成百分比高度,外层父div没有高度,最后渲染出来图表是扁的。我在报表页面里给每个图表容器都设了固定高度,实测500px左右比较合适。
4. 数据库表结构与性能优化实战
4.1 CRM核心表结构设计思路
数据库设计是整个系统稳定性的地基,我把核心表结构列出来,可以作为一个自己动手建库时的参考模板。
客户表:
CREATE TABLE customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT '客户名称', phone VARCHAR(20) COMMENT '联系电话', industry VARCHAR(50) COMMENT '行业', level TINYINT COMMENT '客户等级 1-3', owner_id BIGINT COMMENT '负责人用户ID', next_contact_time DATETIME COMMENT '下次跟进时间', del_flag TINYINT DEFAULT 0 COMMENT '删除标记', create_time DATETIME, update_time DATETIME, KEY idx_owner (owner_id), KEY idx_next_contact (next_contact_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;商机表、跟进表在结构上类似,都会冗余一个customer_id外键和多一个del_flag逻辑删除标记。用逻辑删除而不是物理删除,对销售数据尤其重要,客户虽然可能被标为无效,但历史数据还需要追溯分析,真从库里删掉后续做统计就断层了。
所有表的主键用BIGINT自增,字符集用utf8mb4而不是utf8,原因是utf8在MySQL里最多3个字节,存不了emoji和生僻字。销售填客户备注的时候可能带个表情符号,如果是utf8会直接报错。基础的表结构设计不想多说,但这两个坑我见到太多人踩过了。
4.2 最容易出问题的SQL与索引优化
CRM系统的查询模式相对固定,优化重点就在几个高频列表页。我最常做的优化是给外键和查询条件字段建索引,像customer表的owner_id、next_contact_time都加了索引。
模糊搜索要特别注意。LIKE ‘%关键词%’这种写法会导致索引失效,全表扫描。客户量没上十几万的时候问题不大,但数据一旦膨胀,一个不带条件的列表查询能把数据库CPU打满。折中的办法是:如果确实需要模糊搜索,把范围限定在20万行以内强制走索引;更合理的方案是引入专门的全文索引,但小项目我一般建议保持简单,先把高频搜索条件做成下拉选择,减少模糊匹配的触发频率。
另一个大坑是深分页。销售使用系统半年后,客户表可能几十万行,翻到第1000页时limit 9000,10会导致MySQL扫描前9000行后丢弃,特别浪费I/O。我用它替换成基于最大主键的查询方式:
SELECT * FROM customer WHERE id > #{lastId} ORDER BY id ASC LIMIT 10这种“上一页最后一条记录的ID”游标分页方式在数据量大时性能提升非常明显,页面交互改成“加载更多”而不是跳页。对CRM这种业务,用户真的需要看到很深的分页吗?我实测下来,几乎没人会翻到第100页之后。所以游标分页完全够用,还有利于前端做无限滚动。
4.3 连接池配置、事务控制与数据一致性问题
CRM业务里涉及多表联动的场景很多,比如新增跟进记录的同时要更新客户的下次跟进时间,这两条SQL必须在一个事务里,否则就会出现“记录了跟进但客户的待办时间没变”这种不一致问题。SpringBoot里最简单的方式就是给Service方法加@Transactional注解,默认遇到RuntimeException就回滚。我项目里统一定义了业务异常继承RuntimeException,这样事务在业务校验失败时也能正确回滚。
连接池这块我用的Druid,除了作为连接池,它的监控页面还能看到慢SQL统计,对排查性能问题极有帮助。在application.yml里配置好druid的stat filter和web-stat filter后,浏览器访问/druid/index.html就能看到每个SQL的执行时间。我给客户列表页做优化的时候,就是靠监控页发现慢SQL,才定位到索引缺失的问题。
事务隔离级别我用了默认的REPEATABLE_READ,MySQL InnoDB引擎下基本没问题。需要注意的一点是,长事务要避免,如果一个事务里做了大量查询或者分批插入,会持有持锁时间过长,并发一高就容易死锁。我的习惯是事务只包裹必要的写操作,查询逻辑放在事务方法外面完成。
5. 打包部署与生产环境问题排查
5.1 Maven多环境打包与可执行jar部署
开发完成之后就是构建部署。SpringBoot项目用Maven打包非常简单,核心命令是:
mvn clean package -Dmaven.test.skip=true如果需要指定生产环境配置,使用profile参数:
mvn clean package -Dmaven.test.skip=true -Pprod对应pom.xml里配置多套profile,每套profile指定不同的application-{env}.yml。我实际部署时没用Docker,直接在服务器上跑jar,启动命令是:
nohup java -jar crm-system.jar --spring.profiles.active=prod > /data/logs/crm.log 2>&1 &这里有两个容易忽略的点:一是nohup一定要配合&使用,不然SSH断开后进程就没了;二是日志重定向最好落到独立文件,方便出问题时用grep定位异常。生产环境我建议在启动参数里增加-Xms和-Xmx,把堆内存上下限定成一样,避免JVM动态扩容引起性能波动,比如-Xms512m -Xmx512m,CRM这种中小系统给512M到1G完全够了。
5.2 从开发到上线的高频报错速查表
我整理了这份项目里遇到的高频问题列表,按现象、原因、解决办法三列对查:
| 报错现象 | 根本原因 | 解决办法 |
|---|---|---|
| MySQL连接时报SSL connection error | 高版本驱动默认开启SSL | URL后加useSSL=false |
| Public Key Retrieval is not allowed | MySQL8新认证机制 | URL上加allowPublicKeyRetrieval=true |
| Unknown time zone / 时区错误 | 未指定serverTimezone | URL上加serverTimezone=Asia/Shanghai |
| FreeMarker模板访问报错或404 | 模板路径或文件后缀错误 | 确认模板在templates目录且后缀是.ftl |
| 页面CSS/JS全部加载失败 | 拦截器拦截了静态资源 | 拦截器排除/static/**路径 |
| Layui表格数据正常但分页不工作 | PageHelper分页被其他查询污染 | startPage后紧跟目标查询语句 |
| 部署jar后中文乱码 | Linux默认编码不是UTF-8 | 启动参数加-Dfile.encoding=UTF-8 |
| Druid监控页面无法打开 | 未放行druid的Servlet | 配置web-stat-filter并开启enabled |
| Maven依赖下载极慢 | 默认中央仓库网络不稳定 | settings.xml配置阿里云镜像 |
表里这些坑有好几个都是我实际踩过的。最典型的是部署到Linux中文乱码,本地开发Windows没问题,一上服务器全变问号。排查下来发现是Linux系统locale没有设置UTF-8,Java读取文件默认用了系统编码,加上Dfile.encoding参数后解决。
5.3 生产环境数据初始化与权限配置
上线前有一件事一定要做:数据初始化。我先在测试环境把系统跑通,然后用mysqldump导出表结构和基础数据,再导入生产库。不要在生产库手工执行一遍建表脚本,漏一个索引后面补起来相当被动。
权限初始化这块,管理员账号、角色、部门数据我用一个init.sql脚本管理,脚本里先判断记录是否存在再插入,保证脚本可以重复执行。
生产环境的日志级别也要改,开发环境MyBatis打印了所有SQL,生产环境如果还开着会把磁盘写爆。我把log-impl配置成org.apache.ibatis.logging.nologging.NoLoggingImpl,SpringBoot的日志级别设为INFO,只保留业务关键日志。
6. 项目实操心得与后续扩展方向
6.1 复盘:这次CRM改造里最值得说的几个教训
整个项目做完,我复盘了一遍,有几个点值得单独提醒。
第一个是关于Session存储。一开始我把用户对象全部放进Session,里面有位图、头像字段等冗长信息,用户在个人中心上传新头像后,Session里的旧数据还留着,展示还是旧头像。后来把Session对象瘦身成ID和关键标识,需要完整信息时再查库,问题解决。
第二个是关于FreeMarker模板缓存。上线前我把cache改成了true,结果有一次改了页面上的一个错别字,重新部署后发现页面没变,排查才想起来是模板缓存的问题。生产环境如果有模板变更,要么重启服务,要么用脚本清空freemarker缓存目录,这个坑影响不大但很恼人。
第三个是SQL注入。我在写客户搜索功能时,曾经图省事直接用字符串拼接条件,结果被测试同事传入特殊字符搞崩了查询。后来全部改成MyBatis的#{}预编译参数,这个教训我在代码里贯彻得很彻底,所有动态条件都走XML的if标签配合#{},绝不拼字符串。
第四个是ECharts图表的旧数据覆盖。前面已经提过,这个问题如果不在setOption时处理,报表数据一刷新就显示混乱。我后来做了一个公共封装,每次绘制图表前都强制清空旧实例。
6.2 CRM系统可以持续演进的几个方向
核心功能落地后,我整理了目前没做但值得扩展的方向,给有同样需求的朋友一个参考:
- 导出Excel:销售经常要把客户名单导出给领导看,我用Apache POI解决了这个问题,模板导出客户列表、商机报表,按照标题里提到的“java poi word能生成图表吗”这个思路扩展,数据还可以导出为带图表的Word文档。
- 自动提醒:每个销售登录后能看到今天需要跟进的客户列表,靠cron定时任务扫描next_contact_time,把过期未跟进的客户推送到首页待办。
- WebSocket消息通知:客户被分配、商机阶段变化时给相关销售实时推送消息,比刷新页面体验好很多。
- 数据大屏:把ECharts的报表做成一整块大屏页面,投到销售办公室电视上,实时展示整体业绩和漏斗转化,这对管理层的直观冲击力很强。
- 多租户隔离:如果以后要给不同分公司独立使用,可以在所有业务表加一个org_id字段实现数据隔离,而不用重新设计表结构。
我在实际使用这套系统的过程中,最深的感受是:技术的复杂度永远是第二位的,业务模型是不是贴合销售真实工作流,才是CRM能不能活下去的核心。SpringBoot、ECharts、MySQL这些都是随手就能上手的工具,难的是把“客户、商机、跟进”这条链路理解透,再把它们稳妥地落到代码和表结构里。希望这篇复盘能帮你在自己的项目里少走几条弯路。