SpringBoot+ECharts构建CRM客户管理系统:从SSH迁移到报表可视化全复盘
接手过老式jspservlet项目的人应该都有同感一个客户关系管理系统看着不复杂真动起手来却发现客户、商机、跟进、审批、报表、权限这些模块盘根错节牵一发动全身。最近我刚好把一套用了好几年的SSH老系统整体迁移到SpringBoot上顺手用ECharts把销售漏斗和业绩报表重做了一遍技术栈就是标题里这套java springboot echarts freemarker layui maven mysql。整个过程从工程搭建到核心模块落地再到打包部署排查踩了不少坑今天完整复盘一下给准备自研CRM或者接手SpringBoot项目的朋友一个可参考的蓝本。整个项目不是简单套个增删改查框架而是要把销售日常真正用起来。下面我按项目的实际推进顺序来拆解从业务设计、技术选型到模块实现、数据库优化再到部署排查每一块都会讲清楚为什么这么选、怎么做以及实践里容易翻车的位置。1. CRM项目的业务拆解与技术选型逻辑1.1 业务需求拆解不要把CRM做成一个客户列表很多团队一提CRM第一反应就是把客户信息表格化做几个页面能增删改查再导个Excel就算完事。这种系统上线以后大概率没人用因为它只解决了“记录”问题没有解决“推进”问题。一个能给销售带来真实帮助的CRM至少要包含三条业务主线客户资料沉淀客户基本信息、所属行业、客户分级、来源渠道、下次跟进时间。重点不是字段多而是要让销售一打开客户详情就能判断“这个人值不值得继续投入精力”。商机阶段推进从初步接触到需求确认、方案报价、商务谈判、赢单/输单。每个阶段要有状态字段、预计成交金额、预计成交日期这样销售管理层才能从数据里看出哪些单子有风险。跟进过程留痕谁在什么时候通过什么方式电话、拜访、微信跟进过哪个客户聊了什么下一步计划是什么。这部分最容易被忽视但恰恰是后续复盘客户流失原因的关键。除此之外老板关心的报表也不能省每个销售的量、成交率、漏斗转化率、月度回款趋势这些数据如果全靠人工汇总Excel系统就失去了意义。我当时设计的时候把业务模块拆成了五张核心主表外加日志、部门、用户表整体上采用一个“客户-联系人-商机-跟进”的一对多树形结构。数据模型理顺之后后面的代码才是水到渠成的事否则一边写代码一边改表结构返工成本非常大。1.2 技术选型SpringBootFreeMarkerLayui这套老牌组合为什么还值得用技术选型上我仔细考虑过要不要上前后端分离最后放弃VueSpringBoot的组合选回了FreeMarkerLayui。原因很简单团队规模不大没有专职前端销售系统又强依赖服务端渲染的快速迭代能力。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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties java.version1.8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-freemarker/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdcom.alibaba/groupId artifactIddruid-spring-boot-starter/artifactId version1.2.20/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue 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里的useSSLfalse一定要加上否则高版本MySQL驱动默认开启SSL握手碰到自签名证书会直接报错明明账号密码对却连不上库。serverTimezone务必指定我一开始漏了启动报“The server time zone value”的错加上Asia/Shanghai才消停。allowPublicKeyRetrievaltrue这个参数很多新项目会碰到特别是用MySQL 8.0以上配合caching_sha2_password认证方式时不加这个参数驱动会拒绝通过非SSL通道获取公钥启动能过但第一次查询连接就闪断。FreeMarker的cache:false在开发调试阶段必须关掉否则每次改模板都要重启服务才能看到效果。生产环境记得改回true否则性能会受很大影响。MyBatis的驼峰映射也要打开数据库字段是create_timeJava属性是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 classlayui-container #-- 主体内容区域 -- /div #include common/footer.ftl变量渲染时有个细节如果后端传值为nullFreeMarker默认会直接报错而不是输出空字符串。我用两种方式处理一是在模板里给变量加默认值比如${customer.phone!-}二是在application中配置spring.freemarker.settings.classic_compatibletrue兼容空值输出。不管哪种都不要让一个null值把整个页面拖崩。3. 核心业务模块的设计与实现3.1 登录认证、用户权限与Session管理登录认证这块我选的是比较传统的Session方案SessionId种在Cookie里拦截器统一校验没有引入JWT。原因很实际CRM的用户量不大几十到几百人Session方案够用且实现简单JWT解决的是无状态和跨域问题但会带来会话失效不好控制、密钥管理复杂的问题对内部系统反而增加负担。密码存储一定不能明文我用的方案是MD5加盐盐值固定一串随机字符串注册时加密入库登录时用同样算法加密比对。MD5现在不是最安全的但对内部系统足够如果安全要求更高可以替换成BCrypt做法类似。拦截器实现思路是定义一个HandlerInterceptorpreHandle方法里检查当前请求的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); ListCustomer list customerMapper.selectByCondition(query); PageInfoCustomer pageInfo new PageInfo(list); model.addAttribute(pageInfo, pageInfo); model.addAttribute(query, query); return customer/list; }关键点在Mapper的XML里条件是否生效要用if标签动态拼接不能靠字符串硬拼SQL那是SQL注入的重灾区。例如按客户名模糊搜索姓名可能为空就需要select idselectByCondition resultTypecom.example.crm.entity.Customer SELECT id, name, phone, industry, level, owner_id, next_contact_time FROM customer where if testkeyword ! null and keyword ! AND (name LIKE CONCAT(%, #{keyword}, %) OR phone LIKE CONCAT(%, #{keyword}, %)) /if if testownerId ! null AND owner_id #{ownerId} /if if testlevel ! 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;商机表、跟进表在结构上类似都会冗余一个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_READMySQL InnoDB引擎下基本没问题。需要注意的一点是长事务要避免如果一个事务里做了大量查询或者分批插入会持有持锁时间过长并发一高就容易死锁。我的习惯是事务只包裹必要的写操作查询逻辑放在事务方法外面完成。5. 打包部署与生产环境问题排查5.1 Maven多环境打包与可执行jar部署开发完成之后就是构建部署。SpringBoot项目用Maven打包非常简单核心命令是mvn clean package -Dmaven.test.skiptrue如果需要指定生产环境配置使用profile参数mvn clean package -Dmaven.test.skiptrue -Pprod对应pom.xml里配置多套profile每套profile指定不同的application-{env}.yml。我实际部署时没用Docker直接在服务器上跑jar启动命令是nohup java -jar crm-system.jar --spring.profiles.activeprod /data/logs/crm.log 21 这里有两个容易忽略的点一是nohup一定要配合使用不然SSH断开后进程就没了二是日志重定向最好落到独立文件方便出问题时用grep定位异常。生产环境我建议在启动参数里增加-Xms和-Xmx把堆内存上下限定成一样避免JVM动态扩容引起性能波动比如-Xms512m -Xmx512mCRM这种中小系统给512M到1G完全够了。5.2 从开发到上线的高频报错速查表我整理了这份项目里遇到的高频问题列表按现象、原因、解决办法三列对查报错现象根本原因解决办法MySQL连接时报SSL connection error高版本驱动默认开启SSLURL后加useSSLfalsePublic Key Retrieval is not allowedMySQL8新认证机制URL上加allowPublicKeyRetrievaltrueUnknown time zone / 时区错误未指定serverTimezoneURL上加serverTimezoneAsia/ShanghaiFreeMarker模板访问报错或404模板路径或文件后缀错误确认模板在templates目录且后缀是.ftl页面CSS/JS全部加载失败拦截器拦截了静态资源拦截器排除/static/**路径Layui表格数据正常但分页不工作PageHelper分页被其他查询污染startPage后紧跟目标查询语句部署jar后中文乱码Linux默认编码不是UTF-8启动参数加-Dfile.encodingUTF-8Druid监控页面无法打开未放行druid的Servlet配置web-stat-filter并开启enabledMaven依赖下载极慢默认中央仓库网络不稳定settings.xml配置阿里云镜像表里这些坑有好几个都是我实际踩过的。最典型的是部署到Linux中文乱码本地开发Windows没问题一上服务器全变问号。排查下来发现是Linux系统locale没有设置UTF-8Java读取文件默认用了系统编码加上Dfile.encoding参数后解决。5.3 生产环境数据初始化与权限配置上线前有一件事一定要做数据初始化。我先在测试环境把系统跑通然后用mysqldump导出表结构和基础数据再导入生产库。不要在生产库手工执行一遍建表脚本漏一个索引后面补起来相当被动。权限初始化这块管理员账号、角色、部门数据我用一个init.sql脚本管理脚本里先判断记录是否存在再插入保证脚本可以重复执行。生产环境的日志级别也要改开发环境MyBatis打印了所有SQL生产环境如果还开着会把磁盘写爆。我把log-impl配置成org.apache.ibatis.logging.nologging.NoLoggingImplSpringBoot的日志级别设为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这些都是随手就能上手的工具难的是把“客户、商机、跟进”这条链路理解透再把它们稳妥地落到代码和表结构里。希望这篇复盘能帮你在自己的项目里少走几条弯路。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →