我们的销售管理一直靠Excel表格撑着,客户信息散落在不同同事手里,交接全靠微信群聊和口头传达。有一次谈了两个月的重点项目,因为跟进记录没同步,差点跟丢客户,那次之后我下定决心自己搭一套客户关系管理系统。前后花了两周业余时间,用SpringBoot+Vue3+MyBatis把前后端分离的CRM系统从零写了出来,数据库用的MySQL。需要声明一下,这不是什么高大上的商业系统,而是一套能跑、能管客户、能记录跟进、能统计数据的完整源码,适合中小团队自用,也适合刚学完SpringBoot和Vue3的开发者拿来练手。这套系统的核心价值在于:后端被拆分成清晰的分层结构,前端完成了工程化搭建,对接了Element Plus组件库,把客户、联系人、跟进记录、数据看板四个模块完整串了起来。
如果你是正在学Java技术栈的开发者,想看看一个完整的前后端分离项目到底怎么落地,参考这份源码是再合适不过的路径。也是因为这套系统,我在实际开发中踩透了SpringBoot事务管理、MyBatis动态SQL、Vue3响应式数据这一连串企业开发的常规操作,一点都不虚。
1. 项目整体设计与技术选型
1.1 核心需求与功能拆解
做系统之前,我先把销售管理的痛点梳理了一遍,其实就四件事。
第一,客户资料不能散,得有统一的录入和查询入口。以前客户信息都记在个人通讯录、微信笔记、甚至纸片上面,整理了也白整理。所以我需要一个客户表,支持多条件组合搜索,比如按客户名称、所属行业、客户状态筛选,还要支持分页。第二,跟进记录要有痕迹,谁在什么时间联系了哪个客户、聊了什么、下一步计划是什么,这些都要结构化地存下来。销售团队里经常出现客户被重复跟进或者漏跟的情况,就是没有电子化的跟进记录造成的。第三,联系人得跟客户关联起来,一个客户公司底下可能有采购经理、技术负责人、财务总监三个联系人,他们要挂在同一个客户下统一管理。第四,管理层要看数据,每周跟进了多少客户、商机分布在哪个阶段、业绩大概怎么样,不能靠拍脑袋。做一张数据看板,把客户总数、本周新增客户数、本周跟进次数、商机阶段分布这些指标直接展示出来。
基于这四块需求,我把整个系统拆成了客户管理、联系人管理、跟进记录、数据统计四个核心模块,外加一个用户登录模块做权限入口。功能范围控制在一个合理的规模内,既覆盖了销售管理的基本流程,也不至于让初学的人一头雾水。
1.2 技术栈选型:为什么是SpringBoot+Vue3+MyBatis+MySQL
选这套组合,我是有实际考虑的。
后端用SpringBoot,本质上就是看中它的自动装配能力和生态成熟度。SpringBoot把SpringMVC、事务管理、连接池、日志这些基础设施都整合好了,我只需要关注业务代码本身。版本上,我选了2.7.18,这个版本在稳定性和兼容性之间比较平衡,能兼容JDK8和JDK11,也支持后续升级到3.x。如果你用的是SpringBoot 3.x,需要注意JDK版本必须到17以上,javax包名也要改成jakarta,这个坑后面详谈。
ORM框架我选了MyBatis而不是MyBatis-Plus,是因为CRM系统里的查询逻辑虽然不算特别复杂,但多表联查和按条件动态拼接SQL的场景非常多。客户查询要拼行业条件,跟进记录查询要关联客户表,数据统计要写GROUP BY。MyBatis的XML Mapper在这种场景下极其灵活,SQL写得直接,方便优化和调优。如果你业务简单、都是单表操作,选MyBatis-Plus能省不少事,但想锻炼SQL能力、想在复杂查询上游刃有余,MyBatis是绕不开的基础功,这也是为什么我强烈推荐用原生MyBatis来搭这套系统。
前端选Vue3配Vite构建工具,核心原因是开发体验好。Vite基于ES Module,启动项目基本秒开,热更新也飞快。Vue3的Composition API让代码组织更灵活,逻辑可以按功能拆进独立的函数里,不像Vue2的Option API一样所有东西都要塞在data、methods、computed里。UI组件库配了Element Plus,表格、弹窗、表单、分页这些后台管理页面的常见组件都有现成的,加上表单校验功能也齐全,能大幅缩短开发周期。
数据库选MySQL 8.0,这是目前最流行、资料最多的开源关系型数据库。8.0版本默认字符集是utf8mb4,支持存储emoji和特殊字符,也支持窗口函数,对统计类SQL帮助很大。CRM系统并发量不会特别大,MySQL完全够用,而且部署成本低,一台服务器跑起来很轻松。
整体系统的数据访问关系是:Vue3应用通过HTTP调用后端RESTful API,SpringBoot控制器接收请求,Service层处理业务逻辑,Mapper层封装MyBatis的SQL访问逻辑,底层连接MySQL数据库。请求响应统一采用JSON格式,前端通过Axios封装请求库与后端通信。这也是目前主流的前后端分离架构模式,一套系统搞明白,后面遇到类似项目都能复用这套思路。
2. 数据库设计与后端核心实现
2.1 数据表结构设计
数据库设计是整套系统的地基。我设计了一个名为crm_db的数据库,里边一共四张表:sys_user、customer、contacts、follow_up_record。
sys_user表管登录用户,核心字段有id、username、password、real_name。密码存储我用的是MD5加密后再加盐处理,实际项目我建议你换成BCrypt或其他更安全的加密方式,毕竟MD5在碰撞攻击面前已经不安全了。
customer表存储客户主要信息,我设计了这些字段:id、customer_name(客户名称)、industry(所属行业)、level(客户级别,比如A类重点客户、B类普通客户)、status(状态:潜在客户、跟进中、已成交、已流失)、source(客户来源)、phone(联系电话)、address(地址)、owner_id(归属人ID,关联sys_user表)、create_time、update_time、remark。其中owner_id很关键,有了它以后才能做数据权限——每个销售只能看自己名下的客户,这个字段给后续扩展留了口子。
contacts表存联系人,重要字段有:id、customer_id(关联customer表)、contact_name(联系人姓名)、position(职位)、phone(联系电话)、email、wechat、is_primary(是否首要联系人)。这里的customer_id是外键逻辑,我写了外键约束确保数据一致性。联系人表是一对多关联客户表,一个客户下有多个联系人,删一个客户时,他的联系人和跟进记录要注意处理方式,我采用的是软删除思路,保持数据可追溯。
follow_up_record表存跟进记录,核心字段是:id、customer_id(关联客户)、contact_id(关联联系人)、content(跟进内容)、next_plan(下一步计划)、follow_up_time(跟进时间)、creator(创建人)。这张表对销售管理至关重要,是所有业务分析的基础数据源,数据量增长也最快,后面做数据统计全靠它。
设计表结构时有几个细节需要注意。一是所有表的id都用BIGINT类型,因为主键的值可能会超过INT范围。二是create_time和update_time这两个时间字段,统一用datetime类型,不要用timestamp,避免2038年问题。三是每条业务表都预留一个remark字段,别小看它,等业务上线后你会发现评论和备注类的需求特别多,有字段总比后期改表结构要方便。
具体的建表SQL我在源码里附了完整的脚本,用Navicat或者MySQL命令行直接执行就行。
2.2 SpringBoot工程搭建与MyBatis集成
新建SpringBoot工程可以用Spring Initializr(start.spring.io),选择Java版本、SpringBoot版本2.7.18,勾选SpringWeb依赖,然后手动引入MyBatis、MySQL驱动、Lombok、Druid连接池这几个依赖。这里我直接把pom.xml里的关键依赖列出来:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</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>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>之后在application.yml文件里做核心配置。端口选8080,数据库连接池用Druid,配置连接地址、用户名、密码。MyBatis部分两个关键配置:一个是mapper-locations指定XML文件位置,我用的是classpath:mapper/*.xml;另一个是configuration.map-underscore-to-camel-case设为true,这样数据库下划线字段能自动映射成Java驼峰字段,类型为physical type。
server: port: 8080 spring: datasource: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/crm_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: root druid: initial-size: 5 min-idle: 5 max-active: 20 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.crm.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl最后一个log-impl配置建议开起来,开发阶段能在控制台看到MyBatis执行的具体SQL语句和参数值,对排查问题帮助巨大,部署上线前再把它关掉。
启动类上记得加@MapperScan注解扫描Mapper接口:
@SpringBootApplication @MapperScan("com.crm.mapper") public class CrmApplication { public static void main(String[] args) { SpringApplication.run(CrmApplication.class, args); } }2.3 后端分层架构与核心业务实现
后端代码分成controller、service、mapper、entity四个包。Controller只负责接收请求、参数校验和返回统一结果;Service层写业务逻辑;Mapper是MyBatis的接口层;Entity层是数据库表对应的实体类。
统一返回结果我定义了一个Result类,包含code、message、data三个字段。成功返回200,业务异常返回400,系统错误返回500。这样前端只用判断code就能知道接口执行状态,不用每个接口单独处理不同的响应结构。
以客户分页查询为例,这是CRM系统最常见的接口。前端需要传pageNum(页码)、pageSize(每页条数)、还要带上customerName、industry、status等过滤条件。由于这些条件可能为空,SQL必须做动态拼接。MyBatis的<where>标签和多if判断组合是处理这种情况的最佳方案,效果是条件非空才拼进SQL,全为空时SQL退化为最简单的全表分页查询。
<select id="selectCustomerPage" resultType="com.crm.entity.Customer"> SELECT * FROM customer <where> <if test="customerName != null and customerName != ''"> AND customer_name LIKE CONCAT('%', #{customerName}, '%') </if> <if test="industry != null and industry != ''"> AND industry = #{industry} </if> <if test="status != null and status != ''"> AND status = #{status} </if> </where> ORDER BY create_time DESC LIMIT #{offset}, #{pageSize} </select>同时还要写一个count查询,统计满足条件的总记录数,分页组件要拿这个总数来计算总页数。这里要注意,count查询的条件和列表查询的条件必须保持一致,否则页数会显示错乱。我的习惯是这两个SQL放一块写,用注释标记清楚,改一个忘改另一个的尴尬局面就不会出现。
写客户新增接口的时候我加了一个经验:customer_name不能为空,同一归属人名下不能有重复客户。这个校验在Service层加了一段代码,先查一次库判断同名客户是否存在,存在就抛出业务异常。另外,数据库层也要给customer表加上unique唯一索引兜底,双保险,防止并发情况下两条相同记录同时写入。
联系人模块的核心逻辑比较简单,关键操作是新增联系人的同时要判断customer_id是否存在,避免挂到不存在的客户下面。更新联系人时,用updateById类型的方法根据主键动态更新非空字段,前端只传要修改的字段就能完成部分更新。
跟进记录模块是业务核心。新增跟进记录时,我用了一个方法并加了@Transactional事务注解。一次完整的跟进动作包含两步操作:往follow_up_record表插入一条跟进记录,同时更新customer表对应的客户状态——如果客户原本是潜在客户,跟进后要改成跟进中。这两个操作要么都成功,要么都失败,绝对不能出现记录写了但客户状态没更新这种情况。@Transactional默认只在RuntimeException出现时回滚,检查异常是不会触发的,这是个容易踩的坑。自定义业务异常最好继承RuntimeException,事务才有保障。
2.4 数据统计模块的关键SQL
数据统计是老板最关心的模块,也是SQL最有发挥空间的地方。我写了三个统计接口,每个都踩过坑。
第一个是客户总数和本周新增客户数。客户总数好写,SELECT COUNT(*) FROM customer就行。本周新增要算本周一到现在的新增记录,用MySQL的YEARWEEK函数最方便:
SELECT COUNT(*) FROM customer WHERE YEARWEEK(create_time, 1) = YEARWEEK(CURDATE(), 1)这里第二个参数1表示周一作为一周的第一天,如果不写这个参数,默认是周日,输出结果对不上业务认知。这个细节很容易被忽视,我一开始就栽在这里。
第二个是本周跟进次数。需要按天展示趋势,用日期函数分组:
SELECT DATE_FORMAT(follow_up_time, '%Y-%m-%d') AS followDate, COUNT(*) AS count FROM follow_up_record WHERE YEARWEEK(follow_up_time, 1) = YEARWEEK(CURDATE(), 1) GROUP BY DATE_FORMAT(follow_up_time, '%Y-%m-%d') ORDER BY followDate前端拿到这个结果后,遍历补全没有数据的日期,数量填0,就能画出连续的趋势图。
第三个是商机阶段分布。统计不同状态的客户数量,直接用GROUP BY status:
SELECT status, COUNT(*) AS count FROM customer GROUP BY status后端返回数组,前端直接渲染成饼图或者柱状图。这套系统我配了ECharts,效果很好,是展示客户分布的重要组件。
3. 前端Vue3工程化实现
3.1 Vite工程搭建与目录规划
前端我用的Vite脚手架创建Vue3项目。执行命令:
npm create vite@latest crm-web -- --template vue然后安装依赖:
npm install npm install vue-router@4 element-plus axios echarts这里有个经验要分享:Vue3项目必须配vue-router的4.x版本,3.x是配Vue2的,版本装错会导致路由完全跑不起来。Element Plus同理,也要用最新匹配Vue3的版本。
前端目录结构我做了清晰拆分:
src/ api/ # 存放所有接口请求封装 router/ # 路由配置 store/ # Pinia状态管理 views/ # 页面视图 login/ # 登录页 layout/ # 主布局框架 customer/ # 客户管理页面 contacts/ # 联系人管理页面 followup/ # 跟进记录页面 dashboard/ # 数据统计看板 components/ # 公共组件 utils/ # 工具函数,axios封装等这个结构是后台管理系统的经典模式,页面按业务模块拆文件夹,公共组件单独抽出来,后续加功能只需要在对应模块下面加文件就行。刚开始学的时候很容易把所有组件堆在components里,或者页面文件全部平铺放一堆,项目一大就乱套了。分层清晰的项目结构,从第一天就应该养成习惯。
3.2 Axios封装与登录鉴权
所有的HTTP请求我统一封装在utils/request.js里。Axios实例需要设置baseURL为/api,配合后端接口路径。关键在于请求拦截器和响应拦截器。
请求拦截器做token携带。用户登录成功之后,后端返回一个token字符串,前端把它存到localStorage中。每次发请求之前,请求拦截器自动从localStorage取token,加在请求头的Authorization字段后面。这样有权限校验的接口就能正常访问了。
响应拦截器统一处理后端返回的响应。判断data.code是否等于200,等于才正常返回;不等于就弹出Message提示错误信息,比如用户名或密码错误。如果遇到HTTP 401状态码,说明token失效,直接清除本地存储并跳回登录页。拦截器是处理统一逻辑的最好位置,不用在每个页面单独判断token有效性。
登录逻辑简单直接:登录页拿到表单数据,调用登录接口,成功之后把用户信息和token存起来,然后跳转到首页。为了保护需要登录才能访问的页面,路由配置里我加了前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { next('/login') } else { next() } })这段代码的含义是:访问登录页以外的任何页面,只要本地没有token就直接踢回登录页,这就是最基础的登录拦截方案。
3.3 客户管理页面实现
客户管理页面是前端工作量最大的部分。页面整体分为三个区域:顶部的搜索表单,中间的操作按钮,下方的数据表格。
搜索表单用Element Plus的el-form组件,里面的输入框我用了el-input,下拉框用el-select,点搜索按钮时重新加载第一页数据,点重置按钮时清空表单条件再刷新数据。实现搜索功能时,搜索条件要绑定在一个叫queryParams的响应式对象上,传给后端的分页查询接口。
表格区域用el-table组件绑定客户数据列表,表格列展示客户名称、所属行业、客户级别、状态、联系电话、创建时间。客户状态是枚举值,直接显示数字会看得一头雾水,我写了一个格式化函数:0对应潜在客户、1对应跟进中、2对应已成交、3对应已流失,配合el-tag组件渲染成带颜色的标签,效果非常直观。列表底部是el-pagination分页组件,页数变化时重新向后端发起请求。这部分的逻辑其实就是表单数据的变化触发数据重新加载,理解了数据驱动视图的理念,Vue3开发的核心就抓住了一大半。
新增和编辑客户用同一个Dialog弹窗组件,里面是表单校验。打开弹窗时判断当前是新增还是编辑模式,编辑模式先把当前行的数据拷贝一份放到表单里,用户点保存时再提交。这个操作有个很关键的细节:编辑数据一定要浅拷贝一份,不能在原对象上直接改,否则弹窗还没保存,表格里的数据就已经变了。这个bug看起来小,但一旦线上出现数据错误,排查起来会非常头疼。
3.4 跟进记录与统计看板
跟进记录页面是前后端交互最复杂的页面。因为每条跟进记录要同时展示客户名称和联系人姓名。列表中需要展示跟进的客户名称和联系人姓名。为了拿到这些信息,后端查询SQL采用多表联查,把跟进记录表和客户表LEFT JOIN,再加上和联系人表的LEFT JOIN。
前端展示这些数据的时候,我用了el-timeline时间线组件,按时间倒序展示每条跟进记录,每条记录显示跟进内容、客户名、联系人、下次计划、创建时间。这个设计比表格更适合浏览业务跟进过程,管理者看的时候视觉信息更清晰。时间线组件是Element Plus里边不太常用但非常好用的组件,CRM系统用它来做跟进记录展示再合适不过。
统计看板我用ECharts实现。页面挂载时同时调用三个统计接口,拿到数据后用ECharts的折线图展示本周跟进趋势,饼图展示客户状态分布,卡片组件展示客户总数和本周新增客户数。ECharts初始化有个关键点:图表必须在DOM渲染完成之后初始化,用Vue3的onMounted生命周期钩子准没错。如果数据是异步加载的,要等数据到了再setOption,否则图表会空白。还有一个容易忽略的细节:组件销毁的时候要调用dispose方法销毁ECharts实例,否则重复进入页面会导致内存泄漏。
4. 前后端联调与部署上线
4.1 开发环境的跨域配置
前后端分离开发模式下,前端跑在5173端口,后端跑在8080端口,跨域问题躲不掉。
最直接的方案是后端配置跨域过滤器,允许前端域名访问所有接口。我在后端加了一个配置类,注册CorsFilter,允许的来源是全配还是指定,根据实际情况来。开发阶段我配置的是http://localhost:5173,如果之后有多个前端环境在跑,也可以用allowedOriginPatterns方法做更灵活的模式匹配。要注意的是,跨域配置放在网关或反向代理层也很常做,但本地联调阶段后端统一配置最省事。
更好的方案是配置Vite代理。修改vite.config.js文件,把前端对/api的请求代理到后端服务的8080端口:
export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })这个方案的核心优势是浏览器上看起来请求的是同源地址,不存在跨域问题,开发时调试更方便。production环境部署时则用Nginx做反向代理,把/api路径的请求转发到后端服务,前端静态文件直接由Nginx托管,这是前后端分离项目最经典的生产部署方案。
4.2 生产环境部署要点
生产环境我推荐直接用Docker Compose来编排部署,一套组合把MySQL、后端Java应用、前端Nginx服务全部管起来,管理起来比手动安装省心很多,环境隔离也好,一台机器跑多个项目互不干扰。如果Docker不熟,也可以直接在服务器上装MySQL、打一个jar包跑后端、再配个Nginx托管前端dist文件夹也不是不行,只是手工操作步骤多、可重复性差。
后端部署打包命令是:
mvn clean package执行完会在target目录生成crm.jar文件。运行时用java -jar crm.jar --spring.profiles.active=prod指定生产环境配置。生产环境的数据库密码、连接地址这些配置不要写在application.yml里,用环境变量传参的方式更安全。SpringBoot支持使用${DB_PASSWORD}这种方式占位,在服务器启动命令里通过环境变量注入进来的实际值,能避免把密码硬编码进配置文件。
前端构建命令是:
npm run build构建完会在dist目录生成纯静态文件,把这个目录传给Nginx就行。Nginx的server配置大致如下:
server { listen 80; server_name your-domain.com; root /var/www/crm/dist; index index.html; location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这个配置里的关键点是location /api/规则:前端发出的所有api请求都会被转发给后端的8080端口服务,而静态页面资源会直接从前端dist目录返回。Nginx配好之后记得执行nginx -t检查配置语法,再执行nginx -s reload平滑重载配置,线上环境重启Nginx会导致短暂的连接中断,能reload就别restart。
4.3 前端静态资源优化
生产环境前端页面的JS和CSS文件体积都不小,Element Plus组件库本身就几百KB,加上ECharts和其他依赖,首次访问白屏时间会比较长。我对构建做了几个优化。
首先是路由懒加载,把每个页面的组件改成动态import引入。这样做的好处是首屏只加载当前页面需要的JS文件,其他页面的代码在用的时候才加载:
const Dashboard = () => import('../views/dashboard/index.vue')其次是按需引入Element Plus组件,配合unplugin-auto-import和unplugin-vue-components这两个Vite插件,组件库中没用到的组件就不会打进最终的JS包里。ECharts体积大,按需引入图表类型和渲染器也很有效:
import * as echarts from 'echarts/core' import { BarChart, LineChart, PieChart } from 'echarts/charts' import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components' import { CanvasRenderer } from 'echarts/renderers'最后是压缩构建产物,Vite默认开启了Gzip压缩,但Nginx服务端也要开启gzip,两个环节搭配起来效果才明显。前端构建产物从2MB压到不到600KB,首屏加载时间从3秒多降到1秒以内,用户体验直接上一个台阶。
5. 常见问题与排查技巧实录
5.1 高频报错问题速查表
这个项目调试过程我积累了不少一手问题记录,整理了高频报错和排查方面的心得,列成一张表,遇到同类问题可以对照着处理。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 前端请求后端404 | 接口路径不匹配,或者Vite代理路径配置错误 | 检查后端Controller的@RequestMapping路径和前端api目录中的请求路径是否完全一致,日志里对照实际请求的URL |
| 数据库中文乱码 | MySQL连接URL缺少characterEncoding参数,或数据库本身字符集不是utf8mb4 | 在JDBC连接URL中强制指定characterEncoding=utf8,建库时指定utf8mb4字符集 |
| MyBatis报BindingException | Mapper接口和XML文件没有正确绑定,接口方法名与XML中id不匹配 | 确认mapper-locations路径正确,XML文件namespace指向完整接口名,每条SQL的id对应接口方法名 |
| 前端表格数据显示[object Object] | 字段名映射错误,后端返回的JSON字段与前端绑定的prop不一致 | 使用map-underscore-to-camel-case时检查数据库下划线字段与Java驼峰字段是否对应,前端el-table-column的prop和返回数据字段名保持一致 |
| 登录成功后刷新页面失效 | token只存在内存中,没有持久化 | 登录成功后localStorage.setItem保存token,每次刷新时从localStorage中读取。所有请求在拦截器里统一携带token |
| 分组查询统计结果不对 | SQL中group by的字段包含NULL值或者数据类型不一致 | 在group by之前用IFNULL处理NULL值,用CAST保证参与分组的字段类型一致 |
| 部署到服务器后上传文件失败 | Nginx默认限定了客户端请求体大小 | 在Nginx配置中加client_max_body_size 10m,再重启服务 |
| 时间字段差8小时 | 数据库连接时区设置不对 | JDBC连接URL中设置serverTimezone=Asia/Shanghai,同时确认MySQL服务端时区为东八区 |
5.2 前后端联调的高频报错分析
前后端分离项目最大的拦路虎就是联调阶段的接口不通。我遇到过的最典型的情况是,前端说请求400,后端说没收到请求。
第一个高频原因是POST请求的Content-Type不对。前端Axios默认发送JSON格式数据,Content-Type是application/json,而后端Controller方法用表单接收数据时,JSON格式反而解析不了。解决方案是统一规范:后端接口统一设计成接收JSON对象,方法参数上加@RequestBody注解,前端Axios请求统一用JSON格式。只要两边都遵守这个约定,常见的data传参坑就能完全避开。
第二个高频原因是路径参数传递错位。例如后端接口是@GetMapping("/customer/{id}"),前端试图用/customer?id=1这种查询参数的形式访问,结果必然404。排查接口问题时,先打开浏览器开发者工具看Network面板,找到实际请求的URL,和后端日志里收到的请求比对,一眼就能看出差别。先把URL对齐了,再看参数和响应格式,这是排查前后端接口问题最可靠的三步走思路。
第三个隐蔽问题是数据库字段映射不对。MySQL的字段名是customer_name,Java实体类字段是customerName,如果MyBatis没有开启map-underscore-to-camel-case配置,实体类字段就是null,前端展示出来就是个空列,排查半天都不知道问题出在哪。这个配置项一行就能解决,没加配置的话一天也找不出原因。
5.3 性能优化与踩坑经验
开发完第一版之后,我感觉查询速度还行,但有几个细节可以优化。
第一,MySQL慢查询日志要开起来。开发阶段可能看不出性能差异,数据量大了以后,SQL是不是慢得离谱一看日志就知道。MySQL的slow_query_log配置开启后,执行时间超过阈值比如1秒的SQL都会被记录下来,拿这些SQL做EXPLAIN分析,加合适的索引,系统的吞吐能力立刻就不一样。客户查询接口我用得最多的就是customer_name模糊查询,给这个字段加了普通索引,查询速度立刻提升一个量级。
第二,MyBatis的日志打印开关建议在开发环境开启,生产环境关闭。日志打印SQL是排查问题最直接的手段,但生产环境开启会拖慢系统并把大量SQL打满磁盘。我在application-prod.yml文件里把log-impl配置去掉,生产环境的日志干净很多。
第三,列表查询接口的N+1问题要提前防范。比如查询跟进记录时,最开始我是在循环里逐个查客户名称,客户有100条记录就多出100次数据库查询。优化方案是改用JOIN联查,一条SQL把所有关联表的数据全部查出来,响应时间从几秒降到了几十毫秒。这种性能瓶颈在数据量小的时候感觉不到,但数据量上来了就是灾难,写SQL的初始阶段就要有联查意识。
第四,Druid连接池的参数要和业务量匹配。默认配置的initial-size是5,如果并发上来了,连接数不够就会排队等着获取连接。我把min-idle设置成5、max-active设置成20,同时在Druid监控页面观察连接池的使用情况,再根据实际流量调整参数值。连接池太小,系统在高峰期顶不住;连接池太大,空闲连接又浪费数据库资源,一定要压测完再定最终数值。
还有一个实打实的经验:测试环境尽量和生产环境保持相同的数据库版本和配置。我在开发环境用MySQL 8.0,结果有一同事用5.7测试,JSON字段和窗口函数直接不支持,浪费了一天时间排查兼容性问题。工具的版本差异看着不大,落地的时候全是坑。
5.4 事务边界设计与异常处理
CRM系统核心业务操作几乎都涉及多表数据变更,事务处理的颗粒度决定了系统的数据一致性,这块儿的经验教训就多了。
最值得说的是新增跟进记录这个操作。最初版本我是先insert跟进记录,再update客户状态,两边都是独立的SQL操作。有一次其中一条SQL执行报错,客户状态没更新,跟进记录却写进去了,数据就不一致了。后来加上@Transactional注解,事务内任意一步抛错都会触发全量回滚。但要记住一个前提,@Transactional只对非受检异常生效。我自定义的BizException如果继承的是Exception类,Spring默认不会回滚事务,必须用RuntimeException做基类,或者给注解指定rollbackFor = Exception.class参数。
事务边界的经验另一方面服务于性能。不是所有方法都适合加事务,大事务会长时间占用数据库连接,削弱系统的并发能力。我的处理原则是:查询操作不加事务,单表插入不加事务,只有跨多张表写操作的方法才加。事务控制在Service层,Controller层保持事务不可见性,这是经典的分层设计规范。
异常处理也不要散落在各处。我写了一个全局异常处理器,用@RestControllerAdvice注解拦截各个层抛出的异常,业务异常统一返回code 400和对应的错误信息,系统异常统一返回code 500并记录日志,前端只需要根据code做判断。这样后端每个Controller的方法体里都不需要写try-catch,代码瞬间清爽很多。全局异常拦截的好处还有一层:业务异常提示语直白准确,前端直接展示给用户看,客户的真实使用体验也好。
6. 这套系统的扩展空间与个人心得
整套系统跑起来之后,我又断断续续加了几个功能,这些扩展思路你后面可以参考。
权限控制方面,目前系统是单角色管理,所有用户登录后的操作权限都一样。如果销售团队比较大,可以引入Spring Security或者Sa-Token,加上基于角色的访问控制模型,管理员、销售、市场不同角色分配不同的菜单权限和数据范围。customer表已经预留了owner_id字段,做数据权限时过滤条件直接拼到SQL语句里就行。
文件管理方面,客户管理中经常需要上传合同附件、报价单、产品资料,可以引入MinIO对象存储服务,前端上传文件后拿到URL存到数据库。MinIO的部署成本极低,却能极大提升系统的实用性。
消息提醒方面,跟进计划到了时间没人执行,是销售团队管理的常见痛点。可以加一个定时任务,用SpringBoot自带的@Scheduled注解每天早上扫描当天的跟进计划,发送邮件或者企业微信机器人通知对应的销售员。这个功能不用改动现有结构,独立加一个任务模块就行。
移动端适配方面,销售外出见客户时经常需要用手机查客户信息,当前PC端的Element Plus布局在手机上很难用。可以单独做一个小程序端或移动H5端,只保留客户查询和跟进记录录入这两个高频功能,复杂度可控,但投入的精力不算小。
在做这套系统过程中,我最深的体会是:前后端分离项目真正的开发难点不在任何一个单一技术点上,而是跨技术栈的数据流对接能力和围绕业务的工程化思维。用Vue3写前端、SpringBoot写后端、MyBatis操作MySQL,每一步单看都有教程,但要把它们丝滑地串起来,需要自己动手踩坑和调试。
如果你也想复刻一套类似的系统,我的建议是从数据库设计开始,先把表结构理顺了,再写后端接口,最后才能安安心心做前端页面。很多新手喜欢先写页面,写到一半发现接口对不上,又回头改后端,反反复复效率极低。自顶向下、分层推进的路子,才是前后端分离项目正确的打开方式。源码里我把每一层的核心配置和注释都写到位了,不管是照着敲一遍还是直接拿来做二次开发,都能帮你省掉不少自己琢磨的时间。