
上个月刚把一套手机售后服务系统交付上线趁热把整个过程中的关键决策和踩坑记录给写下来。技术栈很明确Java SpringBoot Vue。业务对象也很具体客户的手机坏了要在线报修门店客服接单工程师检测维修配件库存实时扣减修完还要回访整个链路涉及客户、客服、网点工程师、库管、财务等多个角色。听起来像是普通的管理后台但真正做下去才发现售后系统的复杂度不在增删改查而在状态流转和多方协作。这篇文章不是教科书式的项目介绍而是按我做完一期的逻辑来复盘先从业务拆分讲清楚为什么要这么做再落到表结构、前后端代码、文件存储和部署上线最后再把线上排查的经验一并交代给准备做同类项目或者刚接触 SpringBoot Vue 前后端分离开发的朋友一个可参考的样本。开篇还是把系统边界说清楚这套手机售后服务系统核心价值是把“用户送修”变成一条可追踪、可催办、可结算的工单流程。它和普通CRM的区别在于每笔工单都绑定了一台具体手机维修过程会产生配件消耗、人工费用、保内保外判断最终可能还要联动财务。所以哪怕是一个实训项目或者内部工具也值得按照生产环境的标准去设计。1. 把售后业务流程拆成状态机而不是一张表1.1 角色与权限客户、客服、工程师、库管怎么协作做售后系统之前最容易犯的错误是上来就画菜单工单管理、客户管理、配件管理、报表统计再看哪个菜单能对应一张表。实际上菜单只是角色入口真正的核心是”谁在什么环节能干什么”这件事。在这套系统里用户角色划分成五类客户提交报修单、查看进度、确认完成、发起投诉门店客服受理工单、审核保内保外、派单、回访维修工程师接单、补充检测详情、填写维修方案、领取配件、完成维修库管配件入库、出库、盘点管理员/财务查看全量数据、结算工程师工时、导出报表每个角色看到的菜单不一样能点击的按钮也不一样。比如工程师只能看到分派给自己的工单并且只能做“接单—开始维修—完成维修”这几个动作客服能看到整个门店的工单但无法修改配件成本。这个规则在系统里就是RABC模型用户-角色-权限把权限精确到按钮级别。实现上后端用 Spring Security JWT 做认证和鉴权前端用 Vue 的路由守卫和按钮权限控制。关键在权限数据不能写死而是要支持管理员配置角色对应的菜单否则网点增加一个新岗位就要改代码后面维护成本很高。我设计的权限表是标准的五张sys_usersys_rolesys_user_rolesys_menusys_role_menu登录之后后端返回当前用户可访问的菜单/按钮编码列表前端再根据这个列表生成动态路由和按钮显隐。这样做的优点是新增功能只需要在菜单表里加记录给角色授权即可不用重新发版。1.2 工单状态机状态变更收口到服务层别让前端乱改 status业务跑通之后才知道工单最核心的字段不是金额不是客户姓名而是 status。手机维修工单的典型流转是这样的待受理 → 已受理 → 待派单 → 维修中 → 待质检 → 已完成 / 已取消中间还可能插入“待客户确认方案”和“待支付”这两个分支。比如工程师检测后发现主板损坏换主板费用超出客户预期这时候工单不能一直停在维修中要变成“待确认报价”客户确认后又回到维修中。如果每个页面都直接 update status那系统很快就会乱。哪怕你在前端加了字段校验也拦不住接口被绕过。所以我在后端单独写了一个工单状态服务所有状态变更都走这一个入口核心逻辑用状态机校验当前状态和下一状态是否合法。public enum RepairOrderStatus { PENDING(待受理), ACCEPTED(已受理), ASSIGNED(已派单), REPAIRING(维修中), QUOTE_REQUIRED(待确认报价), QUALITY_INSPECTION(待质检), COMPLETED(已完成), CANCELLED(已取消); private final String desc; private final SetString allowedNext; static { PENDING.allowedNext Set.of(ACCEPTED, CANCELLED); ACCEPTED.allowedNext Set.of(ASSIGNED, CANCELLED); ASSIGNED.allowedNext Set.of(REPAIRING, CANCELLED); REPAIRING.allowedNext Set.of(QUOTE_REQUIRED, QUALITY_INSPECTION); QUOTE_REQUIRED.allowedNext Set.of(REPAIRING, CANCELLED); QUALITY_INSPECTION.allowedNext Set.of(COMPLETED, REPAIRING); } }每个接口在更新状态前先调用状态机校验不通过直接抛业务异常。另外所有状态变化都要写入操作日志表这一条在后续和客户对账时帮了大忙。有一次客户投诉说“手机没有修就显示完成”我们调出操作日志发现工程师误操作点了完成日志里有操作人ID和操作时间马上定位到人比跟客户解释半天有效得多。2. 表结构设计六张核心表和一张日志表2.1 从业务对象到模型客户、工单、工程师、配件设计数据库表的时候我建议先把主业务对象列出来。这套系统最核心的表就是维修工单 repair_order它几乎关联了所有其他表。字段不用贪多但下面这些是必须有的。idorder_no业务单号方便人工查询和对账customer_id关联客户表phone_brand / phone_model / imei手机信息IMEI建议做普通索引因为售后经常按串号反查fault_type / fault_desc故障类型和用户描述status工单状态与状态机对应shop_id处理门店engineer_id当前处理工程师cost_estimate / cost_actual预估费用和实际费用is_warranty是否保内appointment_time预约时间created_at / updated_at / deleted配件库存单独拆成两张表spare_part 保存配件基础信息和理论库存spare_part_stock_log 保存每一次出入库流水。原因是配件库存必须可追溯哪怕后来表数据错了也能靠流水修正。工程师和门店也要建表。很多人会把工程师做成用户表里的一个字典字段比如“工程师”角色但实际业务里一个工程师可以属于多个门店一个人每天工时也可能参与多个工单所以还是要有 work_order 和 engineer_shift 这类关联信息后期做绩效统计才有的放矢。2.2 单号生成策略和唯一键工单号看起来像小事选错方案会给自己挖坑。最常见的问题是并发情况下生成重复单号。有一次测试时两个客服同时点接到同一批报修生成的一长串订单号居然出现了重复排查原因是用了“时间戳 随机数”的方式随机数冲突导致单号重复。后来我采用“日期 门店编号 自增序列”的规则自增序列用 Redis 的 INCR每天凌晨重置。Redis 不存在就生成当天的起始值public String generateOrderNo(String shopCode) { String key order:seq: LocalDate.now().toString(); long seq redisTemplate.opsForValue().increment(key); if (seq 1) { redisTemplate.expire(key, 2, TimeUnit.DAYS); } return WO LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)) shopCode String.format(Locale.ROOT, %04d, seq); }数据库层面我还在 order_no 字段上加了一个唯一索引双保险。设计唯一键的时候不要只想到主键和逻辑删除字段业务单号这种用户能看得到的编号必须和主键一样有唯一约束否则数据出问题后很难定位是哪条重复记录。2.3 操作日志表用 AOP 统一记录关键动作售后系统很容易出现纠纷所以“谁在什么时候做了什么”必须随时能查。操作日志表设计得比较宽idoperator_id / operator_nameorder_idactionbefore_dataJSON字符串after_dataJSON字符串create_time写日志如果每处都手动 set 很烦。我用了 Spring AOP 自定义注解在需要记录的方法上打一个 OperationLog 注解然后在切面里统一处理。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface OperationLog { String action() default ; }切面里拿到请求参数和返回值把关键字段序列化到 before_data / after_data。这里有两个小坑一是不要把大字段比如图片 base64 也序列化进日志否则日志表会爆二是要注意方法参数里的 HttpServletRequest 是不能直接序列化的要在切面里过滤掉非业务参数。操作日志不一定要立刻异步写入早期流量不大直接同步写也没问题。后期如果并发上来了再改成 MQ 异步写避免影响主流程。3. 后端落地接口规范、幂等、事务与异步任务3.1 统一返回体和错误码前端不用猜前后端分离项目最怕接口返回格式不一致。有的接口返回 {code:200}有的返回 {success:true}前端对接起来会非常痛苦。我们一开始就统一了返回结构 Result public class ResultT { private int code; private String message; private T data; }约定 code 0 表示成功非 0 表示异常。异常分两类业务异常比如库存不足、工单状态不允许变更返回 code 5001系统级异常返回 5000。前端拿到非 0 的 code 后弹出 message不需要在 try catch 里再套一层。统一返回体最大的价值不是好看而是方便做全局异常处理。我在 Controller 层不写 try catch而是写一个 RestControllerAdvice 接管所有异常。像参数校验失败、业务异常、未知异常全部转成统一的 Result 返回。这样代码干净测试也好写。3.2 并发下的幂等和库存扣减用 Redis 防重 乐观扣减售后系统里有个非常容易出问题的场景客户提交报修单时连续点了两次“提交”或者工程师在维修完成时点了两次“完成领料”。后端的接口如果没做幂等第两次请求就会出现“重复工单”或“配件扣两次库存”。我做了个简易版防重前端在提交表单时生成一个 requestId后端 Redis 里用 SET NX 占位key 存在就直接拒绝。核心代码如下boolean firstRequest redisTemplate.opsForValue() .setIfAbsent(repeat:submit: requestId, 1, 10, TimeUnit.SECONDS); if (Boolean.FALSE.equals(firstRequest)) { throw new BusinessException(请求处理中请勿重复提交); }配件库存扣减则不能用先 select 再 update 的方式。最稳妥的是把库存当乐观锁扣减时直接检查剩余量int rows sparePartMapper.deductStock(partId, count); if (rows 0) { throw new BusinessException(库存不足); }对应的 SQL 是update spare_part set stock stock - #{count} where id #{partId} and stock #{count}。这种写法在高并发下不会出现超卖也不用手动加悲观锁。3.3 维修完成时的跨表事务状态更新、扣库存、结算单必须同进同退真正的售后流程里维修完成不是只改一个表的字段。通常要同时做三件事把 repair_order 状态改成待质检把这次维修用掉的配件扣库存生成一个结算单记录配件费和人工费如果这三步分散在两个 Service 方法里中间任何一步失败库存和工单就对不上了。我用 Transactional 把它们包在同一个方法里并通过事务传播机制保证一致性。这里有三个容易踩的坑Transactional 默认只会回滚 RuntimeException 和 Error自定义 BusinessException 如果继承 Exception不会触发回滚。我所有业务异常都继承 RuntimeException避免事务失效。同一个类内部调用加了 Transactional 的方法事务不会生效。比如 RepairOrderService 里的 completeRepair 调用同一类的另一个方法那个方法有事务注解但没走代理等于没开事务。跨表更新时顺序尽量一致先更新主表再更新明细最后写日志避免死锁。3.4 延期工单的扫描Spring Scheduled 异步通知很多手机售后的体验差在于“没人主动管”。工单超过约定时间没完成客户没有收到提醒也没人跟进。这属于典型的定时扫描场景。我用 Spring Scheduled 写了一个五分钟跑一次的任务找出所有 status 还在维修中、且 appointment_time 已经超过两小时以上的工单然后把工单ID和门店ID推送出去。推送动作做成异步的避免定时任务阻塞。Async public void notifyOverdue(ListLong orderIds) { // 组装消息并发送 }这里提醒一下Spring Boot 默认的线程池比较小并发高了之后 Async 会堵塞。最好自己配置一个 ThreadPoolTaskExecutor核心线程数按实际并发来。还有定时任务不要直接用 cron “每五分钟”就有多准服务刚启动的那次扫描可能比较重建议第一次扫描延迟两分钟执行等系统初始化完毕再扫。4. 前端组件化Vue3 Vue Router 动态菜单与工单工作台4.1 项目初始化与目录设计前端用的是 Vue3 Vite Element Plus Pinia开发体验比 Vue2 Webpack 时期好太多。目录我习惯按功能划分而不是按类型划分src/api接口请求封装src/components公共组件src/views/order工单相关页面src/views/customer客户相关页面src/views/part配件相关页面src/router静态路由和动态路由逻辑src/storePinia 状态src/utils工具函数创建 Vite 项目有些细节容易忽略。比如 npm install 装包慢、版本冲突建议直接用 pnpm。我一开始用 npm 装Element Plus 和 Vue 的版本没锁好构建时报了一堆类型错误换成 pnpm lock 文件才稳定下来。Chrome 里建议装上 vue devtools 插件对调试动态路由、查看组件状态非常有帮助。4.2 动态路由菜单跟着权限走现在主流后台都要求“不同登录用户看到不同菜单”所以前端不能把全部菜单做成静态路由而是要等用户登录之后拿到后端返回的菜单列表再动态注册路由。我用的做法是登录成功后把用户信息和菜单编码写入 Pinia然后在全局路由守卫 beforeEach 里判断如果当前用户还没有注册过动态路由就执行 addRoute 注册。顺序是这样的用户登录后端返回 token、用户信息、菜单列表前端把菜单列表存到 store路由守卫里遍历菜单列表调用 router.addRoute 注册对应页面组件再调用 next({ ...to, replace: true }) 重新进入当前路由这里有个特别坑的地方刷新页面后Pinia 里的菜单会丢失动态路由自然也没了。我一开始没做持久化用户一刷新就白屏还以为是权限接口问题。后来我用 localStorage 把菜单列表存了一份路由守卫里判断 store 为空就从 localStorage 重新加载再执行 addRoute。注意敏感信息不要放 localStorage但菜单编码这种数据结构问题不大。4.3 工单列表页筛选、分页、状态标签工单列表是整个系统的核心页面包含筛选条件门店、时间、状态、IMEI、表格字段、分页、刷新、导出。这个页面的写法直接决定了后续开发的效率。我建议所有列表页都统一用一个 useTable 组合式函数把分页、加载、查询参数、重置逻辑封装起来。示例export function useTable(api) { const loading ref(false) const list ref([]) const total ref(0) const query reactive({ page: 1, size: 10 }) async function loadData() { ... } return { loading, list, total, query, loadData } }页面里只需要写业务列和筛选项。工单状态用标签展示因为颜色和文案能让人一眼看出当前流程在哪。这个页面上“状态”字段建议直接显示中文同时保留 code 用于筛选不要只渲染一个英文字母一线客服根本不知道 REPAIRING 是什么意思。4.4 移动端适配给外勤工程师用的 H5 页面这套系统一开始只在 PC 后台里做后来发现维修工程师在现场需要拍照上传、查看配件库存总不能让他们抱着笔记本干活。我们把工程师端做成了 H5 页面通过同一套 Vue 代码只是路由和组件换了一套移动端布局。实现上可以用 Vant 组件库配合 viewport 适配方案。如果你还在纠结用什么移动端框架我建议不要一开始就引入原生 App 打包先做 H5部署到服务器后用浏览器测试等满足不了需求再考虑 Electron 或 uni-app。对于中小团队H5 的成本最低改动最快的。移动端列表页注意不要一次渲染所有工单移动端内存有限。我们做了虚拟滚动和分页结合每次只渲染当前屏幕上的20条记录以及上下各几行缓冲这样长时间挂着页面不卡顿。5. 文件上传、推送与导出售后场景的三个隐形需求5.1 维修凭证上传本地磁盘还是 MinIO售后工单里经常需要上传照片客户拍的故障照片、工程师换下来的旧配件照片、维修完成后的手机照片。图片不能直接存数据库需要落到文件系统。早期为了图省事我直接存在应用服务器本地目录/uploads结果遇到两个问题一是图片越来越大磁盘很快吃满二是应用如果是多实例部署用户第一次请求到A实例上传第二次请求到B实例就找不到文件了。后来我用了 MinIO把图片统一放到对象存储里。MinIO 部署简单一个 docker 命令就能跑起来API 兼容 S3后端集成也很方便。// MinIO 客户端上传文件 public String uploadFile(MultipartFile file) { String objectName repair/ UUID.randomUUID() file.getOriginalFilename(); minioClient.putObject( PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .contentType(file.getContentType()) .stream(file.getInputStream(), file.getSize(), -1) .build()); return minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucketName) .object(objectName) .expiry(3600) .build()); }上传接口做了统一过滤不能说 pdf、图片随便传。至少要校验文件名不能包含../或脚本片段还有就是文件类型用 MIME 判断不能只靠扩展名。如果前端有上传 PDF 的需求要做到上传后在线预览而不是直接当成附件下载否则审核人员看一份维修报告要先下载再打开效率非常低。5.2 服务进度通知WebSocket 与第三方推送的取舍售后系统的用户体验很大程度上取决于“主动告诉用户进度”。客户提交报修后希望第一时间知道客服是否受理、工程师是否接单、手机是否修好。我在系统里通过两种途径发通知订单状态变化时给客户手机发短信/公众号模板消息调用第三方 HTTP 接口网点电脑端页面通过 WebSocket 实时刷新当前待处理工单数量WebSocket 的实现不需要引入太多依赖Spring Boot 原生就支持WebSocketHandler。我写了一个/ws/order端点工单状态一变就通过 session 推送工单号和新状态给对应门店。注意 WebSocket 的连接需要鉴权不能裸奔用 token 参数校验一下 session。如果你不做重实时场景我建议别急着上 WebSocket。短信、轮询已经能满足大多数售后需求。轮询的代价只是每分钟多几个请求对单个网点来说完全可接受但对全国上百家门店来说每分钟几百个请求也算不上压力。选型永远要基于业务量不要为了技术面子堆一个 MQ 和一堆连接。5.3 导出报表Excel 够用Word 生成图表别自己造轮子售后期末要给门店管理员对账很多人要求导出 Excel 工单报表。只要设置好列、汇总金额、筛选条件就行用 EasyPoi 足够。我在导出时遇到一个隐藏问题Excel 如果有合并单元格行数多了之后容易内存溢出后来改成分批查询每查一万条写一次文件流再循环写入 Excel内存占用明显下降。有同事问过 “POI 能不能在 Word 里生成图表”这其实是把数据图表做进 Word 文档的需求。我的结论是除非是固定模板的合同流水否则别用 POI 做 Word 图表开发成本高、样式不好调。更实际的做法是导出一个 Excel 数据透视表或者直接生成一张图片再插入 Word。售后报告如果一定要图文混排前端可以直接用 HTML 模板后端生成 PDF比 POI 省心得多。6. 部署上线与维护从 jar 包到稳定运行的最后一公里6.1 Maven 多环境打包与敏感配置加密项目代码写完之后上线前要处理多环境配置。我的做法是三个 yml 文件application.yml公共配置application-dev.yml开发环境application-prod.yml生产环境这里有个经验数据库密码、Redis 密码、MinIO 密钥千万不要直接以明文写在 application-prod.yml 里一旦源码泄露线上数据就等于裸奔。我用了 jasypt-spring-boot-starter把敏感配置用密文形式写入启动时用环境变量传入解密密钥。配置示例spring: datasource: password: ENC(加密后的密文)环境中启动时设置JASYPT_ENCRYPTOR_PASSWORD这样配置文件里即使出现密码别人也看不到明文线上排查问题时也避免泄露。Maven 打包没什么特别的但要会用mvn clean package -DskipTests跳过测试否则测试不过就出不了包。打成 jar 包之后我一直用java -jar部署不用外置 Tomcat 了。Spring Boot 内置的 Tomcat 在启动时自动配置好方便很多。6.2 前端 history 路由刷新 404 与反向代理前端如果用 Vue Router 的 history 模式部署到线上后刷新页面很容易出现 404。原因是前端路由是仅在浏览器端存在的虚拟路径后端服务器并不知道/dashboard应该返回什么刷新时就把不存在路径当成静态资源来找于是找不到。Nginx 里加一行 try_files 就能解决核心配置如下location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }这样所有请求都会回退到 index.html再由 Vue Router 自己处理路径。另一个常犯的错是把/api路径也交给 index.html 去兜底后端接口就全 404 了。需要单独给/api写一个 location做反向代理到 Spring Boot 服务location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }跨域问题也可以在 Nginx 层解决比后端 CORS 配置更干净。如果当前项目是前后端分离开发阶段后端开 CORS 调试方便但上线时我建议尽量把跨域收口到 Nginx后端代码不写全放开的 CorsFilter减少安全隐患。6.3 线上排查慢 SQL、CPU 飙升、反编译找问题线上出问题后速度最重要。我先说慢 SQLSpring Boot 项目可以在 application.yml 里打开 MyBatis 的慢 SQL 日志把超过 3 秒的 SQL 打印出来mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl实际定位时我一般先看是否缺索引。工单表默认是按创建时间排序但如果业务经常按 customer_id 查询就必须给 customer_id 加索引。加了普通索引之后单表查询基本都能压在百毫秒以内慢 SQL 95% 都是索引没建对。CPU 飙升先别怀疑代码业务逻辑先用top -Hp线程 id再用 jstack 转成堆栈看是不是某个线程一直在执行死循环。还有一次排查的辅助神器是 Arthas一条命令可以看方法调用耗时、入参出参不用重新部署服务。有朋友问“SpringBoot jar 反编译成项目”的问题。线上如果出了问题自己本地又找不到对应源码用 JD-GUI 或者 IDEA 自带的反编译工具确实可以把 class 转成可读 Java 代码跟踪某一段逻辑到底怎么写的。但反编译出来的内容只能用来定位问题不能替代源码更不能拿来做二次开发。而且如果是别人的项目这涉及合规问题你自己项目的话用来排查线上 bug 没问题但要记住把数据库密码等机密信息先保护起来。6.4 运维巡检的日常系统上线不是结束售后系统尤其需要巡检。我每周会看一遍工单表里是否有长期卡在“维修中”的单子异步任务队列是否堆积MinIO 存储空间是否快满数据库慢查询日志数量这些监控不用上重型方案Spring Boot Actuator /health 接口即可。如果想更直观可以用 Spring Boot Admin 搭一个简单的监控面板看内存、线程、磁盘信息。虽然没有商业监控那么全但中小项目完全够用。如果哪天出现“用户说收到了重复短信但工单数据正常”的问题先别怀疑后端业务去查消息服务商回调的 status 是否返回异常再检查是否有重试机制导致通知发了两遍。售后系统的通知和工单状态是两个链路建议把消息发送记录和工单状态日志分开表存储互不影响。后端服务稳定运行后还可以把 Spring Boot 启动时的默认 banner 替换成自己项目名字和版本这纯粹是团队内部乐趣但对识别线上服务是哪个版次有帮助。做完整套项目我最大的体会是SpringBoot Vue 是当前做后台管理系统最高效的组合之一但前一个阶段想不清楚业务流程后一个阶段一定会用频繁改状态、改表结构来还债。售后系统的难点不在技术而在“把一次售后过程拆成明确状态、明确角色、明确记录”。如果你也在做类似的管理系统建议先把工单状态机、操作日志表、权限模型画清楚再开始写代码后面会省非常多事。最后分享一个我自己处理线上故障的小习惯数据库里所有状态字段一定设置默认值并且不要允许 null。哪怕业务上觉得某个状态暂时用不到也先给一个初始值。因为 null 状态在列表筛选、统计报表、定时任务里都会变成边界条件排查起来让人头大。给状态一个默认值比十个防御式判断都有用。