做企业客户管理系统,最怕的不是功能做不出来,而是做出来之后没人用。很多团队一上来就堆功能,客户档案、跟进记录、合同管理、数据分析全塞进去,最后销售嫌录入麻烦,领导嫌报表不准,系统就成了摆设。我去年帮一家做装备制造的中型企业重做客户管理系统,用的就是SpringBoot+Vue3+MyBatis+MySQL这套组合,前后端完全分离,从需求梳理到上线用了不到两个月。今天把整个项目的设计思路、核心实现和踩过的坑完整梳理一遍,给正在做同类系统的朋友一个可以直接参考的样板。
这套系统说白了三件事:管住客户资料、记录跟进过程、给管理层看数据。听起来简单,落地时牵扯的细节非常多。比如客户状态怎么流转、跟进记录怎么和商机关联、销售数据怎么做权限隔离,每一样都需要前后端配合。我用的技术栈在目前中后台项目里属于最稳的组合:SpringBoot负责后端接口,MyBatis处理SQL,Vue3做前端界面,MySQL存数据。为什么这么选,每一步怎么搭,下文按实际开发顺序展开。
1. 这套架构为什么这么选
1.1 技术栈选型的底层逻辑
先说SpringBoot。客户管理系统是典型的CRUD密集型应用,SpringBoot的自动配置和Starter机制能大幅减少样板代码。我见过有人用SSH(Spring+Struts+Hibernate)做类似系统,配置文件和XML能把人绕晕,招人都不好招。SpringBoot+MyBatis的组合在中小型公司里认知度极高,新同事上手快,出了问题网上随便一搜都是答案。
Vue3是另一个考虑。客户管理系统90%的页面是表格、表单、弹窗这种中后台界面,Vue3的组合式API(Composition API)让组件逻辑复用变得非常顺手。比如客户列表页需要同时处理分页、筛选、排序、批量操作,用setup函数组织代码,每个关注点拆成一个函数,比Vue2的Options API清晰得多。加上Vite构建速度比Webpack快一大截,开发体验完全不在一个档次。
数据库选MySQL没什么悬念。这套系统的事务压力不大,单表数据量在几十万级别,MySQL InnoDB引擎完全够用。系统本身需要的是可靠、可控、好维护,这三条MySQL都满足。真要到了需要分库分表的规模,那是另一个故事了。
1.2 前后端分离的边界划分
前后端分离的核心不是把代码拆成两个仓库,而是把职责边界划清楚。我在这套系统里遵循几个原则:
后端负责数据存取、业务校验、权限控制。前端负责页面渲染、交互反馈、本地状态管理。接口只传JSON,不含任何页面逻辑。
具体到模块划分,系统分五个大块:登录认证、客户档案、跟进管理、统计报表、系统管理。每个模块后端对应一个Controller+Service+Mapper的组合,前端对应一个视图目录加若干组件。
前后端联调时最容易出问题的就是接口边界不清晰。比如客户列表页的筛选条件,前端传JSON给后端,后端返回分页结果。这个"筛选条件"到底哪些字段由前端过滤、哪些由后端过滤,一开始就要约定清楚。我定的规矩是:原始数据全部由后端过滤,前端拿到什么就展示什么,不允许前端在内存里二次筛选。这样接口语义单一,出问题好排查。
1.3 目录结构与团队协作约定
后端按功能分包,不是按层分包。这一点很多人搞反了。我见过把Controller、Service、Mapper各放一个包下的项目,改一个客户功能要同时改三个包的代码,非常痛苦。正确做法是按业务域分包:
com.company.crm ├── common // 通用类:返回结果、异常、工具 ├── auth // 登录鉴权模块 ├── customer // 客户模块 ├── follow // 跟进模块 ├── report // 统计模块 └── system // 用户、角色、操作日志每个业务包下面再分Controller、Service、Mapper、Entity、DTO。这样改客户模块就在customer包里改完,不用跨包跳来跳去。
前端目录也做了对应设计:
src ├── api // 接口请求封装 ├── views // 页面级组件 ├── components // 通用业务组件 ├── store // Pinia状态 └── utils // 工具函数团队协作上约定了几条硬规则:接口地址统一以/api前缀开头,每个接口都标注请求方法和返回格式;前端组件命名用大驼峰,变量命名用小驼峰;数据库字段统一下划线命名,实体类用驼峰映射。这些约定文档里写清楚,新人照着做就行。
2. 数据库设计:客户管理系统的表结构怎么落
2.1 核心表清单与关联关系
客户管理系统的数据库设计,核心就五张表:系统用户表、角色表、客户表、联系人表、跟进记录表。再加两张辅助表:操作日志表和字典表。我建表时做了个取舍——客户表不直接关联销售员,而是关联系统用户表,这样将来调整分配规则不受限制。
客户表的关键字段:
CREATE TABLE `t_customer` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `customer_name` varchar(100) NOT NULL COMMENT '客户名称', `customer_type` tinyint(4) DEFAULT '1' COMMENT '客户类型 1-企业 2-个人', `industry` varchar(50) DEFAULT '' COMMENT '所属行业', `source` varchar(30) DEFAULT '' COMMENT '客户来源', `status` tinyint(4) DEFAULT '1' COMMENT '状态 1-潜在 2-意向 3-成交 4-流失', `owner_id` bigint(20) DEFAULT NULL COMMENT '负责人ID', `phone` varchar(20) DEFAULT '' COMMENT '联系电话', `address` varchar(255) DEFAULT '' COMMENT '联系地址', `remark` varchar(500) DEFAULT '' COMMENT '备注', `created_by` bigint(20) DEFAULT NULL, `created_at` datetime DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_owner_status` (`owner_id`, `status`), KEY `idx_customer_name` (`customer_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;一张表把客户核心属性都放进去,不需要多余的关联表。比如客户来源、客户类型这种字典含义明确的字段,直接用数字编码存,查询时用SQL的CASE语句转成具体含义。这样省去关联查询,列表页速度明显更快。
联系人和跟进记录是密集写入的表,数据增长很快,设计时就要想好分页方案。联系人表按客户ID建普通索引,跟进记录表按客户ID和创建时间建联合索引,支持"查某个客户的跟进时间线"这类高频查询。
2.2 字段设计的关键决策
几个字段设计上的决策值得单独说。
状态字段用tinyint不用varchar。状态是固定枚举值,用数字存储占用空间小、索引效率高,而且避免中文字符带来的编码问题。程序里用枚举类定义常量,前端用字典组件配合数字展示对应文本,整个链路清清楚楚。
跟进内容用text类型。跟进记录通常是一大段文字描述,用varchar(255)肯定不够,用text又要注意索引问题。方案是跟进内容不参与查询条件,列表页只查记录的标题、时间、跟进人,详情页才取完整内容。这样即使数据量大,列表页速度也不受影响。
创建时间和更新时间这两个字段必须有。客户管理系统要做数据审计,什么时候录的、什么时候改的,都是基本要求。创建时间默认值设为CURRENT_TIMESTAMP,更新时间用ON UPDATE CURRENT_TIMESTAMP自动更新。后面查操作日志、做数据恢复都靠这两个字段。
2.3 索引与查询优化
客户列表页的查询场景非常集中:按负责人筛选、按状态筛选、按关键词模糊搜索。针对这三个条件,我建了两个联合索引:(owner_id, status)和(owner_id, created_at)。
第一个索引支撑按负责人+状态的筛选,第二个索引支撑负责人+时间维度的排序和分页。实际测试中,40万条数据量下,带筛选条件的查询都能稳定在几十毫秒内返回。
模糊搜索用的是LIKE '%关键词%',这种写法无法命中索引,但我没有专门做全文索引。原因是客户系统里关键词搜索的频次不高,而且数据集不大,全表扫描也就几百毫秒。如果后续客户量增长明显,再考虑引入Elasticsearch或者MySQL全文索引,现阶段不做过度设计。
分页用的是MyBatis的PageHelper插件,物理分页,不会出现数据重复或丢失的问题。这里有个细节:排序字段要白名单化。用户传来的排序字段名不要直接拼进SQL,否则会有注入风险。我在Service层做了一层校验,只允许排序字段落在预设的几个字段名里。
3. 后端核心模块:SpringBoot+MyBatis的落地细节
3.1 工程搭建与依赖版本选择
项目创建用的是Spring Initializr,Java版本选8,SpringBoot版本选了2.7.x。有人问为什么不上SpringBoot 3.0,理由是3.0要求Java 17,很多公司生产环境的JDK还是8,为了一个没有硬性需求的新版本去升级整个基础环境,不划算。MyBatis版本2.x,配合mybatis-spring-boot-starter使用。
核心依赖配置:
<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>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.4.0</version> </dependency>JWT直接用成熟的库,不自己造轮子。之前看过有人手写JWT工具类,其实没什么必要,直接用官方推荐的库,签名、验签、过期判断都封装好了,而且社区维护更新及时。
3.2 统一响应封装与全局异常处理
接口返回值如果不统一,前端光处理各种返回结构就能耗掉半天时间。我定义了一个通用的Result<T>类:
public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> error(int code, String message) { Result<T> r = new Result<>(); r.setCode(code); r.setMessage(message); return r; } }编码约定:200表示成功,400表示参数错误,401表示未登录,403表示无权限,500表示服务异常。业务错误码从10001开始递增,比如10001是客户名称不能为空,10002是客户已存在。这样前端拿到错误码能精确定位问题。
全局异常处理用@RestControllerAdvice注解,所有抛出的业务异常都统一包装成Result返回。特别注意处理了MethodArgumentNotValidException,这个异常是参数校验失败时抛出的,需要把具体的校验错误信息提取出来返回给前端,否则前端只能看到一句笼统的"参数错误",排查问题全靠猜。
3.3 JWT登录鉴权:从拦截器到注解
登录模块用的是JWT无状态方案。用户登录成功后,后端生成一个带过期时间的token返回给前端,前端存起来,之后每次请求都在请求头里带上。后端通过拦截器验证token合法性。
拦截器逻辑不复杂,核心几句:
public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (request.getRequestURI().contains("/auth/login")) { return true; } // 从header获取token String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { throw new BusinessException(401, "未登录"); } // 验证token try { DecodedJWT jwt = JWT.require(Algorithm.HMAC256(secret)) .build() .verify(token.replace("Bearer ", "")); request.setAttribute("userId", jwt.getClaim("userId").asLong()); return true; } catch (JWTVerificationException e) { throw new BusinessException(401, "登录已过期"); } } }拿到token后解析出userId,存到request的attribute里,后续Controller里直接从request取当前登录用户ID。这样各个业务模块都不用自己解析token,逻辑集中,减少重复代码。
权限控制用注解实现。自定义一个@RequirePermission注解,标注在Controller的方法上,标注后拦截器会校验当前用户是否有对应权限。客户模块里,普通销售只能看自己名下客户,销售主管可以看整个部门客户,这个用数据权限规则实现,在SQL里拼上条件即可。
3.4 MyBatis动态SQL实战:多条件客户筛选
客户列表页的筛选条件可能有五六个:客户名称、状态、类型、来源、负责人。每个条件都可选,不能写死SQL,这时候动态SQL派上用场。
<select id="selectCustomerList" resultType="com.company.crm.customer.entity.Customer"> SELECT * FROM t_customer <where> <if test="query.customerName != null and query.customerName != ''"> AND customer_name LIKE CONCAT('%', #{query.customerName}, '%') </if> <if test="query.status != null"> AND status = #{query.status} </if> <if test="query.customerType != null"> AND customer_type = #{query.customerType} </if> <if test="query.ownerId != null"> AND owner_id = #{query.ownerId} </if> </where> ORDER BY updated_at DESC </select><where>标签自动处理了第一个条件前面的AND,不会出现SQL语法错误。这是一个很小的细节,但写SQL时漏掉这段,报错时间和排查成本都不低。
写动态SQL要克制,不能把所有逻辑都写进XML里。我见过有些项目的Mapper XML动辄几百行,各种if套if,看着头皮发麻。复杂到一定程度的查询,拆成单独的方法,或者分步骤在Service层组合,反而更清晰。
3.5 客户导入导出与分页实现
Excel导入导出是客户管理系统的标配功能。后端用阿里EasyExcel,版本3.x,API简单、内存友好,百万行级别的数据也能流式处理。
导出功能做成了异步任务。客户列表可能导出几千条数据,同步接口会让前端一直等待。方案是:先创建导出任务,返回一个任务ID,前端轮询查询导出状态,后台线程生成Excel文件后上传到本地磁盘,前端再通过另一个接口下载文件。这个流程要做好任务状态管理和文件清理,不然磁盘很快被撑满。
导入功能做的是模板校验。用户上传Excel,后端逐行校验数据合法性,把错误信息收集到一张结果表里,返回给前端展示。比如客户名称不能为空、手机号格式不对、客户类型编码不存在,每一行哪里有错都标出来。这个功能节省了销售助理大量手工核对时间,是好评度最高的功能之一。
4. 前端Vue3核心实现
4.1 工程初始化与目录规范
前端工程用Vite创建。需要注意Node版本要求:Vite 4要求Node 16+,如果团队里有人还是Node 14,初始化就会直接报错。这个细节在项目文档里写清楚,避免新人卡在环境上。
npm create vite@latest crm-web -- --template vue-ts我用了TypeScript模板。客户管理系统虽然不算复杂,但字段类型和接口返回类型定义清楚,能避免大量低级错误。比如客户状态从后端返回的是数字,如果前端不定义联合类型,很容易出现判断条件写错的情况。
目录规范上,业务页面全部放在views下,每个模块一个文件夹,比如views/customer下面放CustomerList.vue和CustomerForm.vue两个页面级组件。公共组件放components下,按功能拆成SearchBar.vue、Pagination.vue、CustomerTable.vue。这样组件职责单一,代码复用率高。
4.2 Vue Router+Pinia的状态设计
路由配置用了懒加载,每个页面组件单独打包,首屏加载速度会明显提升。登录页面单独一个路由,业务页面统一挂在一个带布局的父路由下。
const routes = [ { path: '/login', component: () => import('@/views/login/Login.vue') }, { path: '/', component: () => import('@/layouts/MainLayout.vue'), redirect: '/customer/list', children: [ { path: 'customer/list', component: () => import('@/views/customer/CustomerList.vue'), meta: { title: '客户列表', permission: 'customer:list' } }, { path: 'customer/form', component: () => import('@/views/customer/CustomerForm.vue'), meta: { title: '新增客户', permission: 'customer:create' } } ] } ]路由的meta字段里带上页面标题和权限标识,路由守卫检查权限,没权限就跳转到403页面。这样权限控制的逻辑收拢在路由层,业务组件里不用写重复的判断。
Pinia负责的全局状态不多:用户基本信息、token、菜单展开状态、客户筛选条件的跨页面共享。这里有个使用技巧:不要把所有数据都往Pinia里塞,只有多个组件共享的状态才放进去。客户列表页自己的筛选条件就用组件内部的ref或者sessionStorage存,刷新后还能恢复,但不需要全局管理。
4.3 封装Axios:请求拦截器与错误处理
前端接口请求统一走封装好的Axios实例。拦截器做三件事:请求前在header里注入token、响应后统一解包data字段、错误后统一弹提示。
const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res.data } // token过期,跳转登录页 if (res.code === 401) { localStorage.removeItem('token') window.location.href = '/login' return Promise.reject(new Error('未登录')) } ElMessage.error(res.message) return Promise.reject(new Error(res.message)) }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )接口请求单独放在api/customer.ts文件里,每个后端接口对应一个前端方法,比如getCustomerList(params)、createCustomer(data)。这样页面组件里不直接出现URL字符串,接口管理井井有条。
4.4 客户列表页的组件拆分与实现
客户列表页是系统的核心页面,功能集齐了查询、分页、批量操作、状态标签展示。我把页面拆成了四个组件:SearchBar.vue负责筛选区、CustomerTable.vue负责表格展示、Pagination.vue负责分页、页面本身负责数据编排。
筛选区用响应式表单收集查询条件,点击查询按钮时触发父组件的fetchData方法。表格组件通过Props接收数据列表,通过Emit向父组件抛出编辑、删除、分配等事件。数据流是单向的:父组件管数据,子组件管展示。
表格列定义时有个需要留意的点:时间格式化。后端返回的created_at是ISO时间字符串,前端如果直接展示会带个T和时区信息,非常不友好。我在utils里写了一个formatTime函数,把ISO时间转成YYYY-MM-DD HH:mm:ss格式。所有表格列都调用这个函数处理,统一美观。
批量操作做了个设计:表格行勾选状态放在CustomerTable.vue内部,父组件只接收选中的ID数组。要做到"跨页选择不丢失",就要把选中的ID集合存在页面级变量里,而不只是表格组件内的临时变量。这个小点容易被忽略,但实际使用时非常重要。
4.5 客户表单页的校验逻辑
新增和编辑客户共用一个表单页,通过路由参数区分是新增还是编辑。表单用了Element Plus的表单校验能力,验证规则在rules对象里集中定义。
校验规则几个关键点:客户名称必填且最长50字;手机号用正则校验合法格式;客户类型必须选择,不能留空;状态默认值设为"潜在",这样新客户进来不用强制选择,减少录入成本。
表单提交流程要处理防重复提交。点击提交按钮后,按钮进入loading状态,等接口返回后再恢复。这个细节看着不起眼,但线上很多重复客户数据就是因为在网络慢时用户多次点击提交按钮产生的。用了loading,这个问题彻底解决。
编辑场景下,表单数据初始化要注意异步时序。打开编辑页面时,先根据ID调接口获取详情,再把数据回填到表单。接口返回前,表单是空的,用户在填写的短暂瞬间看到空白表单,体验不好。我在页面里加了个loading状态,数据加载完成前显示加载动画。
5. 联调、部署与上线维护
5.1 开发环境代理配置
开发环境后端默认跑在8080端口,前端用Vite跑在5173端口,天然存在跨域。解决方式有两种:后端开CORS,或者前端配代理。我推荐前端配代理,因为部署上线时Nginx也要做类似转发,开发环境的代理配置可以原样迁移到Nginx层。
Vite的代理配置:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端请求/api/customer/list就会自动转发到http://localhost:8080/api/customer/list。注意changeOrigin要设为true,否则后端拿到的Host还是前端域名,可能会触发某些基于Host做校验的后端逻辑。
5.2 前后端接口契约管理
前后端分离项目联调阶段最痛苦的就是接口对不上。我问你一个问题:后端改了接口字段名,前端不知道,一直传旧字段,这种问题怎么快速发现?
我的做法是维护一份接口文档,用Apifox管理。每次后端修改接口,先在Apifox里更新定义,前端直接看文档调试,不需要每次都问后端"这个字段到底叫什么"。同时接口文档也作为新同事的入门资料,看一遍文档基本就知道系统有哪些功能。
更严格的做法是引入OpenAPI规范,后端代码生成接口文档,前端根据文档自动生成TypeScript类型定义。不过这个改造工作量不小,对于客户管理系统有点杀鸡用牛刀。中小团队用Apifox这类工具已经能解决大部分沟通问题。
5.3 打包构建与Nginx部署
前端打包后生成dist目录,包含静态文件。部署时用Nginx托管,配置里要做几件事:静态文件缓存策略、历史路由的try_files处理、后端API的反向代理。
server { listen 80; server_name crm.example.com; root /opt/crm-web/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; } location ~* \.(js|css|png|jpg)$ { expires 7d; } }try_files配置是关键。Vue Router用的history模式,刷新某个子路由页面时Nginx会去找对应的物理文件,找不到就返回404。加上try_files重写到index.html,刷新就不会出问题。
后端打包用的mvn package,产出的jar包用systemd守护运行。Java进程的JVM参数我会预设-Xms512m -Xmx1024m,避免默认参数启动后动态扩堆导致性能波动。
5.4 上线后的监控与日志
系统上线只是开始,真正考验在后面。我做了三件事保证系统可控:日志格式统一、接口耗时记录、关键操作审计日志。
日志统一用Logback,按天滚动,保留30天。每个请求的访问日志记录了客户端IP、请求路径、处理耗时、响应状态。SQL日志打到单独文件,排查慢查询时直接看SQL日志就能定位,不需要再开调试模式。
接口耗时记录用Spring的过滤器实现,超过2秒的接口单独打一条WARN日志。上线后我会定期扫AWR日志,找出高耗时的接口做针对性优化。绝大多数情况下,慢是因为SQL没走索引,或者前端一次性调了太多接口。
操作审计日志记录谁在什么时间对哪个客户做了什么样的操作。这个功能客户方的管理者很看重,也是合规审计的必备能力。用Spring AOP实现,被@OperLog注解标注的方法自动记录日志,业务代码里不用手动插日志逻辑。
6. 常见问题与避坑清单
6.1 联调阶段的头疼问题
时区导致的时间差八小时。MySQL驱动连接串里指定serverTimezone=Asia/Shanghai可以解决,否则数据库存的UTC时间在展示时会差8小时。我在项目一开始就踩了这个坑,列表里的创建时间全部慢8小时,排查了半天才发现是连接参数问题。
Long类型ID在JavaScript中的精度丢失。后端主键是bigint,用雪花算法生成,到前端就变成Number类型,一旦超过2^53就会丢失精度。客户ID传回给后端的更新接口时,可能已经被转成一个错误的数字。解决方案是在后端把Long类型序列化为字符串,需要加一个全局配置,让Jackson在序列化时把Long转为String。
6.2 数据库性能问题的排查
客户列表越来越慢。数据量超过10万后,不筛选条件下的排序查询明显变慢,耗时可到两秒。排查过程:先在Navicat里手动执行SQL看执行计划,发现走的是全表扫描加filesort。解决方案是给owner_id和updated_at建联合索引,让排序快速命中索引。改成以后耗时降到180毫秒。
批量导入导致MySQL锁表。用EasyExcel的批量插入模式时,一次性插入5000条数据的SQL会让InnoDB的行锁升级为表锁,业务写入会全部阻塞。解决方案是把批量插入拆成每批500条循环执行,配合事务提交,性能没有下降多少,但锁竞争问题消失。
6.3 前端的交互细节问题
Vue3响应式丢失。这个坑很典型:用reactive()声明的对象,在请求返回后整体赋值,页面不更新。原因是赋值操作把整个对象替换掉了,响应式代理关系被破坏。解决方案是循环逐字段赋值,或者改用ref()管理对象,赋值时直接.value =替换整个对象,响应式依然生效。
级联选择器性能问题。客户来源字段原本想用Element Plus的级联选择器实现多级字典,测了下发现数据量一大,渲染和响应都明显变慢。后来改成字典表+下拉框平铺的方案,虽然多了一层,但用户选择起来更直接,性能也好。
我把整理过的问题清单合并成一张表,后续新团队成员可以直接对照排查:
| 症状 | 根因 | 解决方案 |
|---|---|---|
| 时间显示相差8小时 | MySQL时区配置不正确 | 连接串增加serverTimezone=Asia/Shanghai |
| 大ID数据错乱 | Long戳失精度 | Jackson序列化Long转String |
| 列表页数据不更新 | Vue3响应式数据被整体赋值 | 使用ref或逐字段更新 |
| 接口偶发超时 | 未使用索引的模糊查询 | 优化SQL、增加联合索引 |
| 上传Excel后系统卡顿 | 批量插入锁表 | 拆分为小批量事务 |
6.4 部署上线前的自查清单
系统完全开发完,不要急着交工。我在上线前固定走一遍自查流程,按重要程度排了六级:
第一,检查所有接口是否包含登录鉴权。有些新加的接口忘了加权限控制,直接暴露出去,等于把脱裤子放屁做成常规操作。第二,检查分页接口是否都做了最大条数限制。某些导出的接口没限制,用户传一个超大页码能把后端内存打满。第三,检查所有文件上传接口是否有类型和大小限制。第四,检查前后端接口字段命名是否统一,避免线上才发现前端传的是customerName,后端接收的是name。第五,检查数据库是否有备份策略。第六,检查Nginx日志和Java日志的轮转配置。
做完这套自查再上线,我有把握说至少不会犯低级错误。
回头看这个项目,最让我满意的不是用了多新的技术,而是把一些老生常谈的工程实践落到了实处:接口约定清晰、代码结构规整、数据库设计克制、错误处理全面。客户管理系统不是一个技术含量极高的项目,但它是工程量最体现功力的项目之一。如果让我给后来者一个建议,就是想清楚自己要解决什么问题、用户的真实痛点在哪里,再开始写代码。技术细节本文已经写得比较全,踩过的坑也整理成表格,按图索骥可以省去不少弯路。后续如果大家对这个系统的某个模块特别感兴趣,比如权限设计或者Excel导入,我也可以单独拆出来写一篇详细拆解。