搞客户关系管理系统这事说难不难说简单也不简单。市面上现成的CRM要么贵得要命要么功能堆到用不上很多团队最后都会选择自己搭一套。我前段时间正好整理了一套 SpringBoot Vue3 MyBatis MySQL 的前后端分离客户关系管理系统源码这里把整个项目的设计思路、核心实现和踩坑经验完整梳理一遍给正准备做类似系统的朋友一份能直接参考的实操笔记。这套系统不是那种只跑通登录的玩具项目客户管理、跟进记录、商机转化、合同管理、数据看板、用户权限这些核心业务都有完整落地。对正在准备毕业设计的同学、想转全栈开发的Java工程师、或者接私活需要快速交付的技术人来说都很有参考价值。我会从技术选型讲到表结构设计从前端联调讲到线上排查尽量把每一个“为什么这么做”都说清楚。1. 项目整体设计与技术选型思路1.1 为什么是SpringBoot Vue3 MyBatis MySQL这套组合这套技术栈在目前的Java后端开发里属于绝对的主流配置。SpringBoot负责把后端工程的复杂度降下来不用再像传统SSH项目那样写一大堆XML配置内嵌Tomcat意味着一个java -jar命令就能启动整个后端服务对中小型项目和独立开发者特别友好。Vue3作为前端框架组合式API写起来比Vue2的选项式API更顺手配合Vite构建工具开发时的热更新速度快到飞起体验比Webpack时代好了太多。MyBatis在这个体系里承担的是数据库访问层的角色。它不像JPA/Hibernate那样试图把数据库表完全映射成对象而是把SQL直接摆在开发者面前复杂查询、多表关联、动态条件拼接都很好控制。说白了做CRM这类业务系统SQL的灵活性和可优化性比对象关系映射的便利性重要得多这是我在实际项目中反复体会到的。MySQL就不用多说了轻量、稳定、社区生态好做中小型业务系统用MySQL作为存储层是性价比最高的选择。这套组合在一起形成了“前端Vue3管界面、后端SpringBoot管业务、MyBatis管SQL、MySQL管存储”的清晰分工。每个层面的技术都是经过大量项目验证的成熟方案招人容易、出问题好排查、网上资料满天飞这三个优势合在一起项目的可维护性就有了基本保障。1.2 前后端分离架构的边界划分“前后端分离”这四个字说起来简单但真正落地时边界划分很重要。我在设计这套系统时遵循的核心原则是前端只负责页面渲染和用户交互后端只负责业务逻辑和数据结构前端通过HTTP接口向后端请求数据后端只返回JSON绝不返回任何页面代码。举个例子当用户在前端点击“新增客户”按钮时Vue组件里会调用封装的API方法通过Axios发送一个POST请求到后端的/api/customer接口后端Controller接收到请求后调用Service层完成数据校验和业务处理Service再通过Mapper接口把数据写入MySQL数据库最后后端把操作结果以统一的JSON结构返回给前端。这个链路里的每一步职责都单一切明确前端开发和后端开发可以并行开工只要提前约定好接口文档互不阻塞。这套架构还有一个好处是部署灵活。后端可以部署在不受固定IP限制的服务器上前端构建出来的静态文件部署在Nginx这类Web服务器里请求时通过Nginx把/api开头的路径反向代理到后端服务。生产环境里遇到性能瓶颈后端可以单独横向扩容前端只需要把静态资源放到CDN就能提升访问速度这些扩展操作对架构本身不需要任何改动。1.3 业务模块拆解与功能边界一套合格的CRM系统核心业务模块我梳理为六个部分缺一个都不能算完整的客户关系管理系统。第一个是客户管理模块负责客户基本信息的增删改查包括客户名称、所属行业、联系人、联系电话、客户来源、客户等级这些基础字段。这个模块看起来简单但对数据规范性的要求很高我在设计时给客户名称加了唯一索引避免同一客户被重复录入。第二个是跟进记录模块每次销售和客户沟通后都要写跟进记录包括跟进方式、沟通内容、下次跟进时间。这个模块是销售过程管理的核心也是后面数据统计的数据来源。第三个是商机管理模块一个客户可以对应多个商机商机有三个关键属性预计金额、赢单概率、当前阶段。阶段流转是有状态机的比如“初步接触→需求确认→方案报价→商务谈判→赢单/输单”每次阶段变更都要记录在案。第四个是合同管理模块商机一旦赢单就会签订合同合同里有关联的客户和商机信息、合同金额、签订时间、合同状态。这里要注意合同金额和商机金额不一定相同实际成交价可能和报价有出入。第五个是数据统计模块用图表展示客户新增趋势、商机漏斗转化、销售业绩排行。这块给管理层看价值最大。第六个是系统管理模块包含用户管理、角色管理、菜单权限配置。系统采用的是RBAC模型用户-角色-权限三层关联后端接口配合权限注解做控制。2. 数据库设计与后端核心实现2.1 核心表结构设计思路数据库设计是整个CRM系统的地基表结构设计不好后续业务逻辑写起来会极其痛苦。我在这套系统里的核心表包括客户表、跟进记录表、商机表、合同表、用户表、角色表和权限相关表这里挑几张最核心的表讲讲设计思路。客户表是最基本的数据实体字段上除了客户名称、联系人、联系电话这些基础外我特别加了几个字段owner_id用来记录这个客户属于哪个销售人员实现数据归属status字段表示客户状态1正常、0禁用deleted字段做逻辑删除0代表未删除1代表已删除。这里很多新手会直接把客户记录从数据库里DELETE掉这是大忌业务系统里的数据一旦物理删除后续审计、统计、恢复全都做不了。商机表里有一个字段设计值得重点说就是阶段字段stage。很多人会直接存字符串“初步接触”“方案报价”这样做虽然直接但极难维护。我的做法是在代码里定义枚举常量数据库里存数字比如0代表初步接触、1代表需求确认、2代表方案报价这样既方便程序判断状态流转又不会在数据库里存一堆中文导致索引效率下降。所有核心业务表我都加上了create_time和update_time两个时间字段并且在创建表时用DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP让MySQL自动维护这两个时间。有些项目喜欢在代码里手动set时间但如果是多服务实例部署应用服务器时间不一致就会造成数据混乱让数据库统一管理省心得多。MySQL 8.0之后的用法是create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间和update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间。2.2 MyBatis使用要点XML与注解的取舍用MyBatis写数据访问层一直存在“用注解还是用XML”的争论。我的实践体会是简单SQL用注解复杂SQL和动态SQL用XML两者结合使用不搞一刀切。比如根据ID查询客户信息这种单表操作直接在Mapper接口的方法上写Select(SELECT * FROM customer WHERE id #{id} AND deleted 0)就够了简洁清晰不用额外维护XML文件。但像客户列表这种带多条件搜索、分页、多表关联的场景就必须用XML。原因很简单条件可能为空、可能有多个条件组合、关联查询的SQL动辄十几行写在注解字符串里既难阅读又难调试而XML文件里可以写清晰的动态SQL。这里重点说一个MyBatis使用中特别重要的点#{}和${}的区别。#{}是预编译占位符MyBatis会把它替换成?然后通过PreparedStatement传参天然防止SQL注入${}是字符串拼接直接把参数值拼到SQL语句里存在注入风险。凡是用户输入的参数必须用#{}${}只能在表名、排序字段这种不会直接暴露给用户的场景下使用而且必须做好白名单校验。动态SQL里我跟大家分享一个容易踩坑的地方就是在写多条件查询时很多人会把所有查询条件都放在一个where标签里。where标签会自动处理掉第一个多余的AND或OR这个设计本来很好用但一旦条件里出现if判断的字段类型问题就很头疼。比如状态字段如果是从前端传来的Integer类型判断条件应该用if teststatus ! null而不是if teststatus ! 因为Integer类型判断不等于空字符串永远返回true这样就会导致条件永远拼进去。2.3 SpringBoot三层架构与统一返回体后端代码结构我采用的是标准的Controller-Service-Mapper三层架构每层职责严格区分。Controller层只做三件事接收前端参数、调用Service层方法、把结果封装成统一格式返回。Service层写业务逻辑比如新增客户前检查客户名称是否已存在、更新商机阶段时判断状态流转是否合法这些都属于Service层的职责。Mapper层定义数据访问接口只负责和数据库交互。Service层的事务管理也很关键。最典型的场景是创建合同这个操作需要在合同表插入一条记录的同时把关联商机的状态更新为“已赢单”。这两个操作必须在一个事务里要么都成功要么都失败。我的做法是在Service方法上加上Transactional注解Spring会自动管理事务的提交和回滚一旦方法中抛出运行时异常数据库就会回滚到操作之前的状态。统一返回体是我特别想强调的设计。如果没有统一返回结构有的接口直接返回对象有的接口返回Map有的接口返回List前端联调的时候就会非常痛苦。我在系统里定义了一个Result类包含code、message、data三个字段。code为200时表示成功其他值表示各种业务异常前端判断code就能统一处理错误弹窗和提示。配上RestControllerAdvice全局异常处理器即使后端代码抛出未捕获的异常系统也能返回一个规范的错误JSON给前端不会直接把一堆堆栈信息暴露给用户。2.4 用户登录与鉴权方案CRM系统里必然涉及销售数据权限所以登录鉴权是必须做牢的一环。我采用的方案是JWT BCrypt密码加密 Spring拦截器组合的方式这是一套非常经典且容易落地的方案。用户注册或修改密码时密码通过BCrypt算法加密后存储到数据库。BCrypt和MD5/SHA不同它内置了随机盐值同一个密码每次加密的结果都不相同这样即使数据库泄露攻击者也很难通过彩虹表反推出原始密码安全强度足够应对业务系统的需求。登录成功后后端会根据用户ID、用户名、过期时间生成一个JWT Token返回给前端。JWT由Header、Payload、Signature三部分组成Signature部分是服务端用密钥对前面两部分签名生成的任何对Token内容的篡改都会导致签名校验失败。前端拿到Token后存储在本地或者内存中每次发送请求时在HTTP请求头里加上Authorization: Bearer token。后端写一个拦截器拦截所有需要登录才能访问的接口从请求头里取出Token调用JWT工具类校验签名和有效期校验通过后把用户信息塞进当前请求上下文后续业务代码直接获取当前登录用户。配合上用户所属角色和菜单权限菜单在登录时根据角色动态返回按钮级别的权限在前端通过自定义指令控制后端接口再通过权限注解二次校验双保险防止越权操作。3. 前端Vue3实现与前后端联调3.1 Vue3项目搭建与环境配置前端工程我采用Vite作为构建工具这也是目前Vue3项目最主流的选择。环境准备这块我踩过几次坑这里给大家列一个能一次跑通的清单。首先Node.js版本建议用18以上低于16会报很多莫名其妙的错然后用npm create vitelatest命令创建项目选择Vue3 JavaScript模板即可如果团队统一用TypeScript也可以选TS模板但工程量会大一些。项目基础依赖装好之后我会再安装几个核心库。element-plus是Vue3的组件库表单、表格、弹窗、分页这些现成组件都能直接拿来用配合按需导入不会让打包体积变得太大。vue-router是做前端路由的用来管理页面跳转和路由守卫。pinia是Vue3官方推荐的状态管理库相比Vuex它的API更简洁去掉了mutation的概念直接修改state就行在系统里主要用来存储用户登录信息和菜单权限。这里重点提醒一下element-plus的按需导入配置。Vite环境下推荐使用unplugin-auto-import和unplugin-vue-components这两个插件通过配置文件自动按需导入用到的组件和API既不用在main.js里全面引入导致首屏加载变慢也不用在每个页面里手动import一堆东西。配置好之后直接在模板里写el-table这种标签就能用构建时插件会帮你自动引入对应的样式和组件代码。3.2 页面结构与核心业务组件前端页面结构我用的是经典的“左侧菜单 右侧内容区”布局。登录成功后进入主界面左侧是菜单列表根据用户的角色权限动态生成右侧是内容区由路由控制的子页面渲染。这种布局模式在后台管理系统里几乎成了标准样式用户上手成本很低。客户列表页是整个系统里最核心的页面。页面顶部是搜索条件区包括客户名称、所属行业、客户状态等搜索项中间是操作按钮区放“新增客户”“批量删除”“导出Excel”等操作下面是数据表格表格列展示客户名称、联系人、联系电话、所属行业、客户等级、负责人、创建时间等字段最后一列是操作按钮组包括查看详情、编辑、删除、新增跟进记录等操作。客户详情页则采用了“客户信息 Tab切换”的设计。头部展示客户的基本信息和负责人信息下面的Tab页签切换不同维度的数据跟进记录时间线、关联商机列表、关联合同列表。这种设计好处是信息分层清晰用户查看不同维度的数据不会被大量信息淹没交互上也符合现代后台管理系统的习惯。3.3 Axios请求封装与跨域问题处理前端所有HTTP请求我都封装在一个request模块里创建一个Axios实例统一设置baseURL为/api然后通过请求拦截器在每次请求发出前从状态管理里取Token并添加到请求头。响应拦截器里做统一处理后端返回的code为200就直接返回data给业务代码code为401就说明Token过期或未登录自动跳转到登录页面code为其他值就弹出Element Plus的Message组件显示后端返回的错误信息。前后端联调时跨域是最常见的问题。开发环境下的最佳方案是配置Vite的proxy代理。在vite.config.js里配置server.proxy把/api开头的请求代理到后端的http://localhost:8080这样浏览器里看到的请求是同源的不会产生跨域问题开发体验也很顺滑。生产环境下则是在Nginx里配置location /api { proxy_pass http://后端服务地址; }实现反向代理。这里的关键原则是不要让后端通过设置CORS允许跨域来解决开发环境的问题因为一旦上线跨域问题依然存在而反向代理才是生产环境的标准方案。4. 常见问题与排查技巧实录4.1 SpringBoot版本过高引发的依赖冲突SpringBoot的版本选择是个老生常谈但又不得不谈的话题。网上很多教程还在用SpringBoot 2.x的方式写代码但如果你新创建项目直接选SpringBoot 3.x会踩不少坑。最大的改动是SpringBoot 3.x强制要求JDK17及以上版本很多老项目的代码在JDK8上跑得好好的一升到3.x就编译报错。还有一个更隐蔽的坑是javax和jakarta的包名变更SpringBoot 3.x把javax.servlet等包迁移到了jakarta.servlet哪怕你只是import一个HttpServletRequest包名写错了项目都启动不了。如果你是想快速跑通一个毕业设计或者内部项目建议直接选SpringBoot 2.7.x JDK8的组合资料最多、踩坑答案最好搜。如果你要新项目长期维护且愿意折腾新特性那就选SpringBoot 3.x JDK17的组合社区资料越来越多但遇到问题要有自己查源码的心理准备。4.2 MyBatis里单个数字字符比较的经典坑MyBatis动态SQL中if teststatus ! 这个写法在处理数字类型字段时是个非常经典的坑。当status是Integer类型时判断status ! 实际上是Integer和String做比较MyBatis的OGNL表达式在这种场景下会得出永远为true的结果导致这个条件无论如何都会拼进SQL。我就因为这个问题排查过整整一个下午搜索的结果一眼看去全部正常但实际查出来的数据就是不对。判断数字类型的正确方式是if teststatus ! null只要判断是不是null就好根本不需要也不应该去判断空字符串。如果确实有“既可能为空、又可能存在默认值”的情况就在Java代码里处理不要把复杂判断写进MyBatis表达式。这个问题的排查方法也很简单在MyBatis的XML配置里开启log-impl: org.apache.ibatis.logging.stdout.StdOutImpl控制台就会打印实际执行的SQL和传入参数一眼就能看出条件是否被正确拼接。4.3 MySQL连接报错时区与SSL问题MySQL 8.x版本在连接时和5.x有个很大的区别新版驱动默认开启了SSL连接并要求指定时区。很多朋友第一次连接MySQL 8.x时会遇到报错The server time zone value Öйú±ê׼ʱ¼ä is unrecognized这是因为MySQL驱动版本升级后对时区做了更严格的校验。这个问题的解决办法是在JDBC连接串后面加上serverTimezoneAsia/ShanghaiuseSSLfalse同时记得URL里要用useUnicodetruecharacterEncodingUTF-8确保中文不会乱码。还有一个容易被忽略的问题是驱动类名变化。MySQL 5.x时代驱动类是com.mysql.jdbc.DriverMySQL 8.x以后变成了com.mysql.cj.jdbc.Driver。如果项目还在用老的驱动类名连接新版本MySQL启动时会直接报ClassNotFound。顺带说一句如果你的SpringBoot项目用的是2.5的版本依赖管理里已经自动匹配了MySQL 8.x驱动写配置的时候直接按照新驱动来就好。4.4 常见问题排查速查表这里我把开发这套系统时遇到的高频问题整理成一个速查表方便大家遇到同类问题时快速定位。问题现象可能原因解决方案前端请求接口报404前端代理路径和后端Controller映射不一致检查vite proxy配置和RequestMapping里的路径后端接口报401Token缺失或已过期检查请求拦截器是否添加Authorization头重新登录获取Token中文乱码数据库连接串缺少编码参数或表编码不是UTF-8URL加上useUnicodetruecharacterEncodingUTF-8建表时指定utf8mb4MyBatis查询结果为空但不报错SQL条件拼错或逻辑删除条件没带开启MyBatis SQL日志打印检查实际执行的SQL项目启动时端口被占用上次进程未退出命令行执行netstat -ano找占用进程并kill或修改server.portnpm install失败Node版本过低或镜像源问题升级Node到18使用国内镜像源安装依赖4.5 项目启动与部署的完整步骤最后把整套系统从拉下来到跑起来的完整流程梳理一遍。后端部分先把项目导入IDEA等待Maven把依赖下载完然后检查application.yml里MySQL的连接地址、用户名、密码是否匹配你的本地环境。第一次启动前需要先执行项目里的数据库初始化脚本脚本会创建数据库和所有表结构并插入默认的admin用户。启动后端项目后访问Swagger接口文档页面可以很快确认接口层是否正常工作。前端部分在项目目录下先执行npm install安装依赖然后执行npm run dev启动开发服务器。浏览器访问Vite输出的本地地址输入默认账号密码就能进入系统。部署到生产环境时前端执行npm run build生成dist目录把dist下的静态文件放到Nginx的html目录后端打包成jar包用java -jar启动再在Nginx里配置一条location /api的反向代理规则就完成了。我个人在实际开发中的体会是这类管理系统的难点从来不是某个技术点而是数据关系梳理和接口约定。表结构设计多花一天时间想清楚后面开发能省一个星期的返工时间。先把整体数据流转画清楚再动手写代码比边写边想靠谱得多。后面如果想让这套系统更完整可以考虑加上Redis做缓存和验证码存储引入MyBatis-Plus简化单表操作有需要的话再给商机报表加上数据权限维度这些扩展方向都值得自己动手试一试。