做了两三个后台管理系统之后,我越来越觉得“客户管理”这类业务是最适合拿来练手也最适合拿来落地的项目。它不像电商那样牵扯复杂的交易链路,也不像审批流那样被一堆状态机追着跑,但该有的东西全都有:用户登录鉴权、数据列表分页、表单校验、权限控制、报表统计,样样都能涉及到。这篇文章想完整复盘一下我用 Java SpringBoot + Vue3 + MyBatis + MySQL 这套组合实现的企业客户管理系统,从前端工程化到后端接口设计,从数据库表结构到实际部署中踩过的坑,一次讲清楚。
如果你是刚准备做毕设、刚进公司需要快速上手企业级项目、或者想自己搞一套客户管理工具来用,这篇内容会很对胃口。我尽量把技术选型的原因、关键代码的写法、以及文档里查不到的细节经验都补上,让你看完之后不只是“看过”,而是真的能动手复现。
1. 系统整体设计与技术选型思路
做客户管理系统这类项目,第一步不是急着写代码,而是先把架构和技术栈定下来。架构一旦定了,后面几乎所有的开发工作都被框在里面,换技术栈的成本远比你想象的高。我这次选择的是前后端完全分离的方案,后端只提供 RESTful API,前端用 Vue3 独立构建,两边通过 JSON 交互。
1.1 前后端分离架构:为什么这是当下最稳妥的选择
在早些年做 Java Web 项目,主流方案是 JSP + Servlet,页面和后端逻辑揉在一个工程里,前端改一个按钮样式都要重新编译部署。前后端分离之后,前端工程和后端工程可以完全独立开发、独立部署,联调阶段只需要保证接口文档一致就行。实际开发中,前端同学可以并行开发页面,同时用 mock 数据模拟后端返回,后端同学也不用等页面做好就能直接调试接口,整个项目的并行度提高了非常多。
另一个好处是部署方式的灵活性。后端打成一个 jar 包跑在服务器上,前端构建后是一堆静态文件,可以扔到 Nginx、OSS、或者任意一个静态文件服务上。后续就算要做负载均衡、CDN 加速,前端资源这一层也几乎不用改代码。
1.2 技术栈选型:SpringBoot + Vue3 + MyBatis + MySQL 的组合逻辑
先说说后端为什么选 SpringBoot。Spring Boot 最核心的价值是“自动配置”,它把 Spring 生态里那些繁琐的 XML 配置几乎全部干掉了,一个注解加上依赖就能快速把一个 Web 服务跑起来。而且它的生态太成熟了,社区里几乎你能遇到的任何问题,都有人踩过坑并且给出了答案。
MyBatis 则是我个人非常偏好的持久层框架。相比之下,Spring Data JPA 虽然省事,但复杂查询、多表关联的时候,生成的 SQL 往往不是你想要的,性能调优起来很别扭。MyBatis 的半自动特性让我对 SQL 有完全的控制权,一条 SQL 怎么写、索引怎么命中,都可以精确把控。客户管理系统中恰恰有大量的动态条件查询,比如按客户名称模糊搜索、按跟进状态筛选、按标签过滤,这种场景用 MyBatis 的<where>、<if>动态 SQL 来处理非常顺手。
数据库选 MySQL,这个没什么好多说的,开源免费、性能稳定、运维资料一堆,中小型企业的客户管理系统的数据量,MySQL 应对起来绰绰有余。前端选 Vue3 的原因是它已经是当前国内前端开发的事实标准了,组合式 API 让代码复用变得非常方便,配合 Vite 构建,开发体验比 Vue2 + Webpack 时代快了不止一个档次。
2. 后端核心模块设计与实现
后端这部分我按照实际开发的流程来讲,从项目初始化开始,到依赖配置、分层设计、核心接口开发,再讲 MyBatis 使用中容易出问题的细节。这样你跟着走一遍,基本就能把一个可运行的后端服务搭出来。
2.1 项目初始化与依赖配置
项目的创建我建议直接用 Spring Initializr(start.spring.io),别手动去搭目录结构,也没必要。创建的时候注意几个关键选择:
- Java 版本:如果你打算用 Spring Boot 3.x,那就必须选 Java 17 以上。如果公司环境还在用 Java 8,那就选 Spring Boot 2.7.x,这两个版本的差异后面我会专门讲。
- 依赖选择:Spring Web、MyBatis Framework、MySQL Driver、Lombok、Spring Validation,这几个是最基础的。登录鉴权用 JWT,我会手动引入
jjwt库。 - 构建工具:Maven 就好,Gradle 虽然也不错,但国内大多数团队还是 Maven 为主,出了问题好查资料。
项目生成后,pom.xml里的核心依赖大致长这样:
<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.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>application.yml里的关键配置我一般这样写:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/customer_manager?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 你的密码 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.crm.entity configuration: map-underscore-to-camel-case: true这段配置里有个细节很多人会忽略:map-underscore-to-camel-case一定要设置为true,否则数据库字段customer_name无法自动映射到实体类的customerName属性上,后面写代码会被各种空值折磨到崩溃。mapper-locations则是告诉 MyBatis 去哪找 XML 映射文件。
2.2 客户管理核心接口的设计思路
后端不能一上来就写代码,我习惯先把接口文档理清。客户管理系统最核心的接口无非这四类:客户信息的增删改查、客户跟进记录的添加与查询、客户分配与转移、看板统计数据的聚合接口。
我按照 RESTful 风格来设计,实际项目里这组接口非常典型:
GET /api/customers:分页查询客户列表,支持客户名称、手机号、所属销售、客户状态、标签 ID 等条件组合过滤。POST /api/customers:新增客户,需要校验客户名称、联系方式是否已存在。PUT /api/customers/{id}:修改客户信息,通常只有该客户的负责人和管理员有权限。DELETE /api/customers/{id}:逻辑删除,而不是物理删除,否则客户数据彻底消失,后续统计会出问题。GET /api/customers/{id}:查询客户详情,包括基本信息和最近的跟进记录。POST /api/follow-records:新增一条跟进记录,同时更新客户表上的“最近跟进时间”和“下次跟进时间”。
在编码实现上,我严格采用三层结构:Controller 层只做参数接收和结果封装,不写任何业务逻辑;Service 层承载业务规则,比如“客户不能重复录入”“客户状态变更时要有操作日志”;Mapper 层只做数据库读写。这样分层之后,后续加功能、改逻辑时,你大概知道去哪找代码,不会把整个项目折腾成一锅粥。
Controller 层的代码结构大致如下:
@RestController @RequestMapping("/api/customers") public class CustomerController { @Resource private CustomerService customerService; @GetMapping public Result<PageResult<CustomerVO>> page(CustomerQuery query) { return Result.success(customerService.page(query)); } @PostMapping public Result<Void> save(@RequestBody @Valid CustomerSaveDTO dto) { customerService.save(dto); return Result.success(); } @PutMapping("/{id}") public Result<Void> update(@PathVariable Long id, @RequestBody @Valid CustomerUpdateDTO dto) { customerService.update(id, dto); return Result.success(); } @DeleteMapping("/{id}") public Result<Void> delete(@PathVariable Long id) { customerService.delete(id); return Result.success(); } }统一返回体Result是我自己封装的泛型类,里面有code、message、data三个字段。这样前端才能用同一套逻辑去处理成功和失败的响应,而不是每次都要从各种不同的返回结构里猜。
2.3 MyBatis 使用的几个关键细节
MyBatis 这层是后端最容易出问题的地方,我挑几个实际开发中高频遇到的知识点来讲。
第一点是 XML 映射文件和注解的选择。简单的单表查询我一般用注解就能搞定,可一旦涉及到动态条件查询、批量更新、多表关联,注解就变得异常难维护。客户列表那种条件组合查询,我会把 SQL 写到 XML 文件里,利用<where>和<if>标签做动态拼接。一个典型的分页条件查询:
<select id="selectPage" resultType="com.example.crm.entity.Customer"> SELECT * FROM customer <where> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> <if test="phone != null and phone != ''"> AND phone = #{phone} </if> <if test="ownerId != null"> AND owner_id = #{ownerId} </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY updated_time DESC LIMIT #{offset}, #{pageSize} </select>第二点是 MyBatis 的缓存机制。一级缓存是 SqlSession 级别的,同一个会话内多次查询相同 SQL 会直接命中缓存,但 Spring 整合 MyBatis 后,每次执行 Mapper 方法都可能创建新的 SqlSession,一级缓存作用有限。二级缓存是 Mapper 级别的,跨 SqlSession 生效,听起来很美,但我建议大部分业务系统直接关闭二级缓存。原因很简单:客户管理系统的数据更新频率不算低,一旦某个操作改了数据而缓存没有及时刷新,用户看到的客户联系方式、跟进状态就是脏数据。比起那一点性能提升,数据一致性重要得多。系统并发量如果还没到数据库扛不住的程度,就别开二级缓存。
第三点是 MyBatis 拦截器的妙用。拦截器可以拦截四大对象的方法调用,我用它实现过两个非常实用的功能:一个是通用的分页拦截,用 PageHelper 当然也可以,但自己实现一个简单的分页拦截器,你能更清楚它背后的原理;另一个是公共字段自动填充,比如插入数据时自动填充create_time和update_time,更新时自动更新update_time。用拦截器统一处理之后,业务代码里就不需要到处手动去 set 这几个字段了。
现在很多开发者喜欢直接用 MyBatis-Plus,它确实能少写不少 CRUD 代码。但我个人建议如果你是想深入理解框架原理,或者项目需要大量复杂 SQL 的定制优化,还是先踏踏实实把 MyBatis 本身用熟。
3. 前端 Vue3 工程化落地
前端部分我按从零到一完成一个客户管理后台的思路来写。涉及项目初始化、目录结构、登录鉴权、以及核心业务页面的实现思路。
3.1 Vue3 项目初始化:Vite 还是 Webpack
现在做 Vue3 项目,首选的构建工具是 Vite,而不是 Webpack。不是 Webpack 不好,而是 Vite 的开发服务器启动速度和热更新速度比 Webpack 快了一个量级,那种改动代码后 2 到 3 秒才刷新页面的体验,用习惯了 Vite 之后真的回不去。
初始化命令很简单:
npm create vite@latest customer-web -- --template vue-ts这里我选了vue-ts模板,直接带 TypeScript。可能有朋友会纠结到底要不要用 TypeScript,我的建议是后台管理系统一定要用。客户管理这种业务,实体字段多,类型复杂,用 TypeScript 可以在编译阶段就帮你挡住一堆低级错误,比如把数字类型的客户状态传成了字符串。
初始化完成后,我会再安装几个必不可少的依赖:
npm install vue-router@4 pinia element-plus axiosvue-router@4是 Vue3 对应的路由版本。pinia是 Vue3 官方的状态管理库,取代了 Vue2 时代的 Vuex,写法更简洁,也没有那么多繁琐的概念。element-plus是 Element UI 的 Vue3 版本,后台管理系统的 UI 组件库我基本首选它,组件全、文档好、中文化做得也好。axios不用多说,发 HTTP 请求必备。
项目的目录结构我会按模块来组织:
src/ api/ # 接口请求的封装 assets/ # 静态资源 components/ # 公共组件 router/ # 路由配置 stores/ # Pinia 状态管理 views/ # 页面级组件 customer/ # 客户管理模块 dashboard/ # 工作台/看板模块 system/ # 系统管理模块 utils/ # 工具函数把目录按业务模块划分,而不是按文件类型堆在一起,后期维护时会轻松很多,因为你会本能地去customer目录下找客户相关的所有代码。
3.2 登录鉴权与 token 处理的完整方案
客户管理系统一定要有登录功能,而且权限模型不复杂:管理员、销售主管、普通销售,三个角色基本够用。我用 JWT 来处理登录鉴权,流程是:用户输入账号密码,后端校验通过后生成一个 token 返回前端,前端把 token 存起来,后续每个请求在请求头里带上Authorization: Bearer <token>,后端通过过滤器解析 token 来识别用户身份。
前端这部分有两个关键点。第一个是 axios 请求拦截器和响应拦截器。请求拦截器统一添加 token,响应拦截器统一处理错误码,比如后端返回 401 表示 token 过期了,就跳转回登录页。
// utils/http.ts import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const http = axios.create({ baseURL: '/api', timeout: 10000 }) http.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) http.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.response?.data?.message || '请求失败') return Promise.reject(error) } ) export default http第二个是路由守卫。在进入路由之前检查有没有 token,没有就直接重定向到登录页。同时可以在每次进入页面时根据后端返回的用户信息,判断当前用户有没有权限访问该路由。路由守卫函数如下:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() return } if (!token) { next('/login') return } next() })这里要注意 Vue2 和 Vue3 在写法上的差异。Vue2 的全局守卫也是router.beforeEach,但 Vue3 中路由实例通过createRouter创建,写法上略有不同。如果你是从 Vue2 平滑过渡到 Vue3 的,最大的感受变化其实是组合式 API:在 Vue3 的<script setup>里,你不需要再写data()、methods这些选项了,直接定义变量和函数即可,整体心智负担小了很多。
3.3 客户列表页与表单组件的封装思路
客户列表页是整个系统中访问频率最高的页面,也是数据交互最复杂的页面。它必须支持:分页、条件搜索、表格展示、批量操作(转移、删除、导出)。这些功能如果全部堆在一个.vue文件里,代码量会膨胀到上千行,维护起来非常痛苦。
我的做法是拆组件:页面容器CustomerList.vue负责整体布局和数据加载;CustomerSearch.vue负责搜索条件的表单;CustomerTable.vue负责表格展示和操作按钮;CustomerForm.vue负责新增和编辑的弹窗表单。组件之间通过props和emit通信。
这里我强烈推荐使用组合式 API 封装一个通用的useTable函数,把列表页常见的“加载数据、分页变化、搜索重置”逻辑提取出来,避免每个页面都重复写一遍。大致思路:
// composables/useTable.ts import { ref, reactive } from 'vue' export function useTable(fetchApi: (params: any) => Promise<any>) { const loading = ref(false) const list = ref([]) const total = ref(0) const queryParams = reactive({ pageNum: 1, pageSize: 10 }) const loadData = async () => { loading.value = true try { const data = await fetchApi(queryParams) list.value = data.list total.value = data.total } finally { loading.value = false } } const handleSearch = () => { queryParams.pageNum = 1 loadData() } const handlePageChange = (page: number) => { queryParams.pageNum = page loadData() } return { loading, list, total, queryParams, loadData, handleSearch, handlePageChange } }表单部分需要注意的是校验。客户名称必填,手机号要校验格式,客户来源要在枚举值里选。Element Plus 的Form组件自带校验机制,把规则写在rules里就行。但是要注意触发方式,一般用blur和change,并且要确保必填项的校验信息对用户足够友好。
另外,客户表单弹窗在新增和编辑两种模式下要复用。我的经验是让同一个弹窗组件接收一个visible和当前编辑的row对象,row为空就是新增,不为空就是编辑。保存成功后,父组件关闭弹窗并刷新列表。这个模式在后台管理系统中几乎通用。
4. 数据库设计与 MySQL 性能优化
后端接口写得再快,数据库表结构设计不合理、SQL 语句写得很烂,系统一样会在数据量上来后卡成 PPT。这章我们来看客户管理系统数据库层面的核心设计思路,以及我在性能优化中的实操记录。
4.1 客户管理系统的表结构设计
客户管理系统的核心表不会太多,我总结下来至少需要这几张:
sys_user用户表:存放系统登录账号、姓名、角色、部门。customer客户主表:客户基本信息和业务属性。follow_record跟进记录表:每次客户跟进的回访内容、沟通方式、下次跟进时间。customer_tag标签表 +customer_tag_relation标签关联表:给客户打标签,实现按标签筛选。operation_log操作日志表:记录谁在什么时间对哪条数据做了什么操作。
customer表的核心字段我是这样设计的:
CREATE TABLE `customer` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `name` VARCHAR(100) NOT NULL COMMENT '客户名称', `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', `level` TINYINT DEFAULT 1 COMMENT '客户等级: 1普通 2重要 3核心', `status` TINYINT DEFAULT 1 COMMENT '客户状态: 1潜在 2跟进中 3已成交 4已流失', `source` VARCHAR(50) DEFAULT NULL COMMENT '来源渠道', `owner_id` BIGINT DEFAULT NULL COMMENT '负责人ID', `address` VARCHAR(200) DEFAULT NULL COMMENT '地址', `remark` TEXT COMMENT '备注', `deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除标记', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户表';有几个设计上的细节值得说一下。
第一,字段类型上,status、level这种固定枚举值我用TINYINT,不要用字符串,因为数字在索引和存储上都更有优势。第二,deleted字段用来做逻辑删除,所有涉及客户列表的查询,SQL 后面都要跟WHERE deleted = 0,否则很容易把已经“删除”的客户拉出来。第三,phone字段虽然加了索引但不要作为唯一键,因为很多企业的客户库里可能确实存在同号不同名的客户,强制唯一会拦截掉真实业务。
跟进记录表我额外建了一个follow_record:
CREATE TABLE `follow_record` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `customer_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `content` TEXT COMMENT '跟进内容', `contact_type` TINYINT DEFAULT 1 COMMENT '沟通方式: 1电话 2微信 3拜访 4其他', `next_time` DATETIME DEFAULT NULL COMMENT '下次跟进时间', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_customer_id` (`customer_id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跟进记录表';这张表会持续增长,所以customer_id一定要建索引,否则从客户详情页查历史跟进记录时,数据量一大就是全表扫。如果数据量真的很大了,还可以考虑按月分表或者用 ES 来存储,但对于中小企业系统,一个索引就能解决绝大部分问题。
4.2 索引设计:从慢查询到快响应的优化记录
客户列表页最常见的操作就是多条件组合筛选。筛选条件可能同时包含客户名称、手机号、状态、负责人、创建时间范围。这种查询最怕的是每个条件都建了索引,但组合查询时却一个都用不上。
MySQL 的索引遵循最左前缀原则。比如你在name、status、owner_id这三个字段上建了一个联合索引(name, status, owner_id),那么查询条件中只要包含name,这个索引就能被用到;但如果条件里没有name,只有status和owner_id,索引就无法命中。
所以面对组合查询,我的通用策略是:把区分度最高、查询频率最高的字段放在联合索引的最左侧。分析当前系统的查询习惯,owner_id是查询频率最高的条件,因为每个销售登录后第一眼看到的必然是“我名下的客户”。我在customer表上建立了几个关键索引:
ALTER TABLE `customer` ADD INDEX `idx_owner_status` (`owner_id`, `status`); ALTER TABLE `customer` ADD INDEX `idx_phone` (`phone`);另外,不要对name字段直接加普通索引,因为客户名称的搜索通常是模糊搜索,LIKE '%关键词%'是没法走索引的。更合理的方式是给name加上前缀索引,比如INDEX idx_name (name(10)),但实际效果对模糊匹配依然有限。真要实现高性能的客户名称搜索,需要引入 Elasticsearch 或者至少是全文索引,而中小企业客户量级下,直接 LIKE 就够了,没必要为了噱头引入复杂组件。
排序字段也是索引优化的重点。我发现一个很常见的场景:列表页默认按update_time DESC排序,但update_time上没有索引,结果系统数据量大之后,排序操作每次都在临时表里进行,查询慢得明显。加一个INDEX idx_update_time (update_time)之后,排序效率会有明显提升。当然,如果排序的同时还有WHERE owner_id = ?,那么更优的方式是把排序字段也放进联合索引中,让索引既能过滤又能排序。深分页问题我在这里也顺便提一下,一页 10 条却翻到 100 页的时候,传统写法LIMIT 990, 10会让 MySQL 先查 1000 行再丢掉 990 行,非常浪费。优化手段是先查出主键范围再回表查询:
SELECT * FROM customer WHERE id > (SELECT id FROM customer WHERE owner_id = 1 ORDER BY id LIMIT 1 OFFSET 990) AND owner_id = 1 ORDER BY id LIMIT 10;这种方式在数据量大时性能差距非常明显。
5. 常见问题与排查技巧实录
这部分是我在整个项目开发周期中实打实遇到的典型问题,每一个都折腾了不止半天。我整理成一份问题清单,你可以收藏起来当排查手册用。
5.1 跨域问题排查与解决方案
前后端分离项目,前端跑在http://localhost:5173,后端跑在http://localhost:8080,这两个端口不一样,浏览器就会触发跨域限制。第一次联调的时候,前端页面上的每一个请求都在控制台报错,报错信息类似 “CORS policy: No 'Access-Control-Allow-Origin' header”,排查半天发现是跨域问题。
跨域问题有几种解决方案:
- 后端开启 CORS 配置,允许指定的域名跨域访问。
- 前端通过 Nginx 反向代理解决,把
/api请求转发到后端服务。 - 开发环境下,通过 Vite 的
proxy配置实现代理。
我个人最推荐开发环境用 Vite 代理,生产环境用 Nginx 代理。原因很简单,后端不需要写任何跨域代码,也更安全。Vite 配置如下:
// vite.config.ts export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端请求/api/customers时,Vite 开发服务器会把它代理到http://localhost:8080/api/customers,由于是同源请求,浏览器的跨域限制根本不会被触发。后端也不用在代码里设置@CrossOrigin或者全局配置 CORS,省心很多。
5.2 SpringBoot 版本过高引发的兼容性坑
这个问题特别典型,也是我踩得最深的一个坑。我用 Spring Initializr 创建项目时选了最新版 Spring Boot 3.2,本地开发环境却是 Java 8。结果项目一启动就报错,说UnsupportedClassVersionError,后来才发现 Spring Boot 3.x 强制要求 JDK 17 及以上。
如果你公司里的服务器环境比较保守,还在用 JDK 8,那你千万别选 Spring Boot 3.x,老老实实用 2.7.x。Spring Boot 2.7 和 3.x 还有一个非常大的差异:Spring Boot 3 里 包名从javax.*改成了jakarta.*。这意味着很多第三方库如果不支持jakarta命名空间,就根本没法在 Spring Boot 3 下使用。
举个例子,很多教程里写import javax.servlet.Filter;,在 Spring Boot 3 里要写成import jakarta.servlet.Filter;。如果你的项目需要对接一些老旧的第三方组件,很可能就卡在包名不一致上。我的建议是:如果没有特别强烈的需求,新项目还是用 Spring Boot 2.7 + JDK 8 的组合最稳,毕竟兼容性和资料都是最多的。如果你非要尝鲜 Spring Boot 3,那务必保证你的 JDK 已经升级到 17,并且所有依赖库都已经适配了jakarta命名空间。
5.3 MyBatis 更新操作执行慢的真实案例
在一个版本的迭代中,有同事反馈某条客户数据的备注更新保存时,接口响应时间长达 3 到 4 秒。我一开始以为是网络问题,后来发现只对特定客户生效,数据量也不大,排查过程非常有意思。
首先我用EXPLAIN查看了更新语句的执行计划,发现走的是全表扫描。原因是更新条件里写的是id = ?,但id字段上的主键索引没生效?这不对劲。再看一眼,原来同事在 SQL 里写的是:
UPDATE customer SET remark = #{remark} WHERE id = #{id} AND deleted = 0单独看id确实是主键,但是加上deleted = 0之后,MySQL 优化器有时候会选择先过滤deleted字段,而deleted上没有索引,这才导致全表扫描。解决方案很简单,给deleted字段加一个普通索引:
ALTER TABLE `customer` ADD INDEX `idx_deleted` (`deleted`);这个操作做完,更新语句的执行时间直接从秒级降到了毫秒级。所以这里我给所有人的建议是:遇到 SQL 执行慢,不要靠猜,先用EXPLAIN看执行计划,看它到底有没有走索引、走了哪个索引、扫描了多少行,一切以数据说话。
另一个 MyBatis 相关的坑是@Update注解的动态 SQL 问题。注解里写简单的 UPDATE 没问题,可一旦需要根据传入字段动态更新部分列,注解写法非常别扭。比如我只想更新remark,但不想动phone,用注解只能拼命拼<script>标签,可读性很差。这种场景我强烈建议改到 XML 里去写:
<update id="updateById"> UPDATE customer <set> <if test="name != null">name = #{name},</if> <if test="phone != null">phone = #{phone},</if> <if test="remark != null">remark = #{remark},</if> </set> WHERE id = #{id} </update>还有一个非常容易踩的坑,就是 MySQL 连接串没有加serverTimezone=Asia/Shanghai。不加的话,如果你服务器时区跟北京时间不一致,查询出来的时间数据可能会出现早 8 个小时或晚 8 个小时的问题,用日志排查时根本想不到是时区问题。数据库连接串里把serverTimezone、useUnicode、characterEncoding这几个参数都明确写上,能省掉很多莫名其妙的麻烦。
5.4 Vue3 + TypeScript 集成中的类型报错问题
Vue3 项目里我用 TypeScript 之后,最头疼的是 Element Plus 组件的类型定义问题。比如给ElMessage传递参数时,有时候 TS 会报Property 'xxx' does not exist on type。这通常是因为没有正确引入组件的类型声明。
解决方式很简单,在tsconfig.json里配置:
{ "compilerOptions": { "types": ["element-plus/global"] } }还有一个很常见的坑:Vue3 项目从 npm 上安装的依赖版本不一致,导致 TS 编译时出现各种奇奇怪怪的错误。网上常有人问“若依 Vue3 的 TS 报错怎么处理”,其实就是因为没有把vue-tsc的版本和 TypeScript 版本对齐。我的建议是,在项目根目录package.json里锁定 TypeScript 和相关类型依赖的具体版本号,不要用默认的^去装最新版,否则版本漂移会让编译报错变得非常不可控。
6. 部署上线与后续扩展建议
系统开发完成后,部署上线也是一个值得说说的环节。后端直接用 Maven 打成 jar 包:
mvn clean package -DskipTests java -jar customer-system.jar生产环境建议不要直接裸跑java -jar,用 systemd 或者 Docker 来管理进程。systemd 的好处是开机自启、崩溃自动重启、日志管理方便,我一般会写一个简单的 service 文件:
[Unit] Description=Customer Manager Backend After=network.target [Service] ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar /opt/app/customer-system.jar Restart=always [Install] WantedBy=multi-user.target前端构建完之后,产物在dist目录,把静态文件扔到 Nginx 的 html 目录下,然后配置反向代理把/api转发到后端服务:
server { listen 80; server_name your-domain.com; root /opt/www/customer-web/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }try_files那一步非常关键,因为 Vue3 是单页应用,如果直接刷新/customer/detail/1这类地址,Nginx 找不到对应的物理文件,必须让它回退到index.html由前端路由接管,否则就会出现“刷新页面就 404”的问题。
关于系统的后续扩展方向,我目前正在做的有两个方向:一是数据看板,把客户的成交转化率、跟进活跃度、流失预警做成图表,这部分可以用 ECharts 来做前端可视化,后端加几个聚合统计的接口就行;二是客户公海池机制,超过一定时间未跟进的客户自动掉入公海,其他销售可以申请领取,这个功能对销售团队的客户流转率提升帮助非常大。客户管理系统这类项目,功能边界扩展性很强,只要基础架构不打歪,后面加模块就像搭积木。
我个人在实际开发中最深的体会是,客户管理系统虽然看起来“只是增删改查”,但真正决定系统好用的,往往是那些不起眼的细节:列表筛选条件的组合是否合理、权限控制是否精细、跟进提醒是否及时。建议你在做完基础功能之后,多去问一问真正使用系统的销售同事,他们的反馈才是系统迭代最重要的方向。