做了两三个后台管理系统之后我越来越觉得“客户管理”这类业务是最适合拿来练手也最适合拿来落地的项目。它不像电商那样牵扯复杂的交易链路也不像审批流那样被一堆状态机追着跑但该有的东西全都有用户登录鉴权、数据列表分页、表单校验、权限控制、报表统计样样都能涉及到。这篇文章想完整复盘一下我用 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 Initializrstart.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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependencyapplication.yml里的关键配置我一般这样写server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/customer_manager?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/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 ResultPageResultCustomerVO page(CustomerQuery query) { return Result.success(customerService.page(query)); } PostMapping public ResultVoid save(RequestBody Valid CustomerSaveDTO dto) { customerService.save(dto); return Result.success(); } PutMapping(/{id}) public ResultVoid update(PathVariable Long id, RequestBody Valid CustomerUpdateDTO dto) { customerService.update(id, dto); return Result.success(); } DeleteMapping(/{id}) public ResultVoid delete(PathVariable Long id) { customerService.delete(id); return Result.success(); } }统一返回体Result是我自己封装的泛型类里面有code、message、data三个字段。这样前端才能用同一套逻辑去处理成功和失败的响应而不是每次都要从各种不同的返回结构里猜。2.3 MyBatis 使用的几个关键细节MyBatis 这层是后端最容易出问题的地方我挑几个实际开发中高频遇到的知识点来讲。第一点是 XML 映射文件和注解的选择。简单的单表查询我一般用注解就能搞定可一旦涉及到动态条件查询、批量更新、多表关联注解就变得异常难维护。客户列表那种条件组合查询我会把 SQL 写到 XML 文件里利用where和if标签做动态拼接。一个典型的分页条件查询select idselectPage resultTypecom.example.crm.entity.Customer SELECT * FROM customer where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testphone ! null and phone ! AND phone #{phone} /if if testownerId ! null AND owner_id #{ownerId} /if if teststatus ! 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 vitelatest customer-web -- --template vue-ts这里我选了vue-ts模板直接带 TypeScript。可能有朋友会纠结到底要不要用 TypeScript我的建议是后台管理系统一定要用。客户管理这种业务实体字段多类型复杂用 TypeScript 可以在编译阶段就帮你挡住一堆低级错误比如把数字类型的客户状态传成了字符串。初始化完成后我会再安装几个必不可少的依赖npm install vue-router4 pinia element-plus axiosvue-router4是 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) Promiseany) { 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户表;有几个设计上的细节值得说一下。第一字段类型上status、level这种固定枚举值我用TINYINT不要用字符串因为数字在索引和存储上都更有优势。第二deleted字段用来做逻辑删除所有涉及客户列表的查询SQL 后面都要跟WHERE deleted 0否则很容易把已经“删除”的客户拉出来。第三phone字段虽然加了索引但不要作为唯一键因为很多企业的客户库里可能确实存在同号不同名的客户强制唯一会拦截掉真实业务。跟进记录表我额外建了一个follow_recordCREATE 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 idupdateById UPDATE customer set if testname ! nullname #{name},/if if testphone ! nullphone #{phone},/if if testremark ! nullremark #{remark},/if /set WHERE id #{id} /update还有一个非常容易踩的坑就是 MySQL 连接串没有加serverTimezoneAsia/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] DescriptionCustomer Manager Backend Afternetwork.target [Service] ExecStart/usr/bin/java -Xms512m -Xmx1024m -jar /opt/app/customer-system.jar Restartalways [Install] WantedBymulti-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 来做前端可视化后端加几个聚合统计的接口就行二是客户公海池机制超过一定时间未跟进的客户自动掉入公海其他销售可以申请领取这个功能对销售团队的客户流转率提升帮助非常大。客户管理系统这类项目功能边界扩展性很强只要基础架构不打歪后面加模块就像搭积木。我个人在实际开发中最深的体会是客户管理系统虽然看起来“只是增删改查”但真正决定系统好用的往往是那些不起眼的细节列表筛选条件的组合是否合理、权限控制是否精细、跟进提醒是否及时。建议你在做完基础功能之后多去问一问真正使用系统的销售同事他们的反馈才是系统迭代最重要的方向。