1. 项目背景与整体思路
做售后管理系统这块,我一直有个很深的感触:很多中小团队不是没有售后流程,而是缺一套能按自己业务改动的售后系统。这次要拆解的项目,是一个内部代号叫“nuct”的产品售后管理系统,技术栈是Java SpringBoot + Vue3 + MyBatis + MySQL,整体采用的是前后端分离架构。整套源码落地之后,覆盖了售后工单登记、派单处理、进度跟踪、回访确认、报表统计这些核心环节,适合正在准备自研客服工单系统、想参考前后端分离项目结构、或者需要快速搭一套售后中后台的开发者来读。
先交代一下项目来源。这个系统的业务甲方是某硬件设备厂商的售后部门,产品型号多、返修渠道杂,之前主要靠群消息加Excel表格流转,经常出现工单漏登记、技术员处理进度不透明、月底统计对不上账的情况。nuct系统要解决的根本问题,不是做一个漂亮的工单页面,而是把“用户报修 -> 客服登记 -> 管理人员派工 -> 技术员处理 -> 回访归档”这条链路上的数据完整地串起来,让每一张售后工单从产生到关闭都有据可查、有迹可循。
整体设计上我没有选择微服务,而是用单体SpringBoot应用,只在代码层面严格切分模块边界。原因很简单:售后管理系统的瓶颈从来不是并发量,而是业务流程是否清晰。内部用户规模撑死几百人,微服务拆下去只会增加部署和联调成本。后端守住经典的三层结构,Controller负责接收参数、Service负责业务规则、Mapper负责数据访问,前端用Vue3组件化拆页面,配合Pinia管理全局状态,前后端统一走RESTful JSON接口,这样的一套组合对中小团队来说,维护起来是最舒服的。
1.1 需求边界和角色模型
动手写代码之前,我习惯先把角色模型画清楚。nuct系统里没有做成超级管理员通吃一切,而是区分了四类角色:客服专员、技术工程师、售后主管、系统管理员。客服专员负责创建工单和补充客户信息,技术工程师负责接收派工并填写维修说明,售后主管负责分配工单、审核处理结果和查看团队数据,系统管理员则维护产品型号、用户账号、角色权限这些基础数据。
这个角色划分直接决定了接口权限设计。比如技术工程师调工单列表时,只能看到分配给自己的单子;售后主管可以看到全量工单,但不能随意修改客户联系方式;客服可以新增工单,却没有关闭工单的权限。把这些边界在URL注解和前端路由里同时控制住,比单纯靠前端隐藏按钮要可靠得多。后面我会细说权限这块的具体实现,但先想清楚角色,才能避免后面开发到一半推倒重来。
1.2 为什么选定前后端分离而不是服务端渲染
做这类管理后台,很多人会问:为什么不用Thymeleaf模板,非要拆成前后端分离?我的理由是三点。第一,售后系统的页面交互复杂度不低,工单筛选、状态变更、编辑弹窗、统计图表混在一个页面里,Vue3的响应式组件模型比服务端拼HTML好维护太多。第二,接口可以复用,同一个查询工单的接口,网页端要用,未来如果要接企业微信的H5端或者移动端审批,后端不用改动,前端多一个适配层就行。第三,前后端能并行开发,后端把接口契约定好,前端工程师拿着mock数据先跑页面,联调阶段的冲突会少很多。
当然,前后端分离也会带来跨域、Token存储、接口安全这些额外问题。这些问题不是不能解决,只是在项目初期就要把方案定死,不能等开发中段再补。如果团队只有一个人且页面很简单,服务端渲染或许更快;但只要超过两个人协作,前后端分离的优势就非常明显。nuct项目实际开发时就是后端一个、前端一个并行推进,总共花了三周多就把主流程跑通了,这个效率放在传统模板渲染模式下很难做到。
2. 技术栈选型与环境准备
2.1 后端技术栈选型解读
后端我选了SpringBoot 2.7.x + JDK17 + MyBatis + MySQL 8.0这套组合,没有引入Spring Cloud相关组件。SpringBoot负责简化配置和自动装配,相比传统SSH框架,省掉了大量XML配置,写一个可运行的工程几乎是一分钟内的事。JDK17带来的好处主要是Record、Switch表达式这些语法糖,配合Lombok一起用,实体类能精简很多。
为什么用MyBatis而不是Spring Data JPA?售后工单查询的场景特别吃SQL控制力。比如工单列表经常要按客户姓名、产品型号、工单状态、创建时间范围做多条件组合查询,不同条件下拼接的SQL差别很大,MyBatis的动态SQL能很直观地控制where子句。另一个原因是问题排查方便,生产环境的慢SQL可以直接拿出来EXPLAIN分析,SQL长什么样就是什么样,不像JPA自动生成的SQL还要脑补。
MySQL 8.0主要看中窗口函数和更好的JSON支持。虽然这个系统只用到了很基础的窗口函数做统计排名,但后续如果要扩展比较复杂的报表,8.0的底子更扎实。数据库连接池用HikariCP,SpringBoot默认集成,性能可靠,基本不用额外调优。整体就是一个务实的组合,每个组件都能回答出“为什么用它”。
2.2 前端技术栈与工程化选择
前端使用Vue3 + Vite + Vue Router + Pinia + Element Plus。Vite作为构建工具,开发环境冷启动速度远快于Webpack,热更新也是按需编译,这对售后后台那种频繁改表单页面的场景很友好。状态管理选择Pinia,它相比Vuex更轻,API设计也更符合组合式API的习惯,在工单详情页需要缓存当前用户信息、筛选条件时非常方便。
UI组件库选用Element Plus,因为这个系统的页面形态就是大量表格、弹窗、表单,Element Plus的表格和表单校验能力稳定,能省不少造轮子的时间。图表部分接入了ECharts,售后主管看板上用到饼图和柱状图,按工单状态、按工程师工作量两个维度展示。前端语言我建议直接上TypeScript,即便团队不熟悉也不用全部写类型,慢慢在关键接口和全局状态上补类型,能提前暴露很多联调阶段才会发现的字段错误。
2.3 本地开发环境搭建和初始化
环境搭建这块,我按实际的操作顺序整理如下。第一步安装JDK17和Maven 3.8以上版本,配置好环境变量后执行mvn -v确认。第二步安装Node.js 18 LTS,npm默认跟过来,建议把镜像源切到国内常用源,安装依赖会快很多。第三步安装MySQL 8.0,字符集选utf8mb4,时区设置为东八区,避免后面时间字段出现8小时偏移。第四步从代码仓库拉取源码,后端是标准的Maven工程,前端是Vite工程,目录各自独立。
全部安装好后,需要先初始化数据库。项目里有一个sql/nuct_aftersale.sql脚本,里面包含了建库、建表、初始管理员账号。执行导入之后,打开后端项目修改application.yml里的数据库账号密码,然后启动SpringBoot应用,默认端口8080。前端在终端执行npm install和npm run dev,默认端口5173,通过Vite配置把/api接口转发到8080,这样就能在浏览器里直接访问本地开发环境。
这套环境变量组合我试过很多次,最需要注意的是JDK版本和SpringBoot版本的匹配。SpringBoot 2.7.x对JDK17支持良好,但有些低版本的第三方依赖可能报模块访问错误。如果遇到类似问题,优先查依赖版本兼容清单,而不是硬改JVM参数。另一个容易忽视的是MySQL驱动包,SpringBoot 2.7默认用com.mysql.cj.jdbc.Driver,如果配错驱动类,启动时会有很明确的报错,按提示换掉即可。
3. 核心模块设计与数据库实操
3.1 工单状态机是售后系统的灵魂
nuct系统不是简单的增删改查,真正的业务复杂度集中在工单状态流转上。我在早期版本见过不少售后系统,把工单状态存成字符串,代码里到处判断“如果是待处理就改处理中”,逻辑散在各个Service方法里,后来没人敢改。这次我直接用状态机约束流转路径,把所有合法迁移收敛到一个地方。
售后工单我定义了五个基础状态:待分配、处理中、待回访、已完成、已关闭。创建工单后默认是待分配,售后主管点击“分配”之后,工单变成处理中,同时绑定技术工程师。技术员处理完填写维修结果,流转到待回访。客服做回访并填写回访记录后,工单变成已完成。已关闭是终态,可以由主管手工关闭,也可以由系统在已完成超过7天后自动关闭。
状态变更表如下:
| 当前状态 | 允许执行动作 | 目标状态 | 操作角色 |
|---|---|---|---|
| 待分配 | 分配工单 | 处理中 | 售后主管 |
| 处理中 | 填写处理结果 | 待回访 | 技术工程师 |
| 待回访 | 回访确认 | 已完成 | 客服专员 |
| 已完成 | 关闭工单 | 已关闭 | 售后主管 |
| 待分配 | 驳回工单 | 已关闭 | 售后主管 |
每个状态变更同时做两件事:修改工单主表状态字段,并插入一条处理记录表数据,记录操作人、动作、备注、操作时间。这样就算后面有人误改数据,也能从操作记录里还原现场。
3.2 核心数据表设计与字段取舍
数据库是这套系统的地基,我花了大半天来设计表结构。核心表一共有六张:用户表、角色表、用户角色关联表、客户信息表、产品信息表、售后工单表、工单处理记录表,外加一个工单附件表备用。很多字段看起来简单,但取舍非常讲究。
售后工单表的核心字段包括:工单编号、关联客户ID、关联产品ID、工单类型(换货/维修/退换)、故障描述、故障图片URL、工单状态、指派人ID、技术员ID、预约上门时间、处理结果、回访内容、创建人ID、创建时间、更新时间。我特意用了逻辑删除字段deleted,而不是物理删除,售后工单有审计需求,删库跑路式操作在这个业务里坚决禁止。
工单编号我设计成业务规则生成:NACT + yyyyMMdd + 四位序号,例如NACT202506160012。为什么不直接把自增ID暴露给前端?一是自增ID会泄露业务量,二是客服在电话里念编号时,带日期和前缀的编号不容易读错。序号用每天从0开始计数的Redis计数器生成,如果没有Redis,可以用数据库里的每日流水表,插入前查一下当天最大序号加1。这种方案简单可靠,且能保证同一天内不重复。
3.3 索引规划、唯一约束与写库时序
工单表的数据量在售后场景下不会特别大,一般日增几十条,但查询条件组合多变,索引不能乱建。我在核心表上添加了这些索引:工单表的state、assignee_id、create_time组合索引,因为售后主管最常用的筛选是“某个技术员在某个时间段内处理了哪些状态的单子”。客户信息表的phone字段建唯一索引,避免重复录客户。产品信息表的model_code字段建唯一索引,保证同一型号只有一个编码。
这里有个容易踩坑的点:MySQL索引顺序很重要。(state, assignee_id, create_time)这个索引在查询“按状态过滤”时可以命中,在查询“按技术员过滤”时也能部分命中,但如果单独按创建时间范围来查,索引就没有意义了。所以在设计组合索引时,要把等值条件的字段放前面,范围条件的字段放后面。
写入时序上,创建工单和插入操作记录要放在同一个事务里。我先在Service层开启事务,先插入工单主表拿到自增ID,再插入处理记录表,状态初始为“待分配”。这里有个细节,事务方法不能是private方法,否则Spring事务代理失效;如果有内部方法调用,要确保入口方法经过代理对象调用,不然数据会出现一半写成功一半没写进去的诡异问题。
4. 从零搭建项目骨架与关键实现
4.1 后端工程结构和统一响应体
后端工程采用标准的Maven目录结构,package按业务模块而不是按技术类型划分。我把代码分成controller、service、mapper、entity、dto、common几个包。common里面放统一响应体、全局异常处理器、JWT工具类、常量定义。
统一返回体我写成了泛型类Result<T>:
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> fail(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }这样设计的好处是前端axios拦截器只需要判断code === 200,业务异常和系统异常都能走统一的处理逻辑。再配合一个全局异常处理器@RestControllerAdvice,捕获业务异常、数据库异常、未知异常,并转换成对应的Result返回。调试接口的时候,响应结构永远是一致的,不会出现一会儿返回字符串、一会儿返回对象的情况。
4.2 基于MyBatis的多维查询与分页
工单列表是最典型的多条件分页查询,我把它放在Mapper的XML文件里实现。动态SQL最核心的部分是<where>标签,它可以自动去掉第一个and,避免手写拼接条件时多出语法错误。下面是一个简化示例:
<select id="selectWorkOrderPage" resultType="com.nuct.aftersale.entity.WorkOrder"> SELECT wo.id, wo.order_no, wo.customer_id, wo.product_id, wo.state, wo.assignee_id, wo.technician_id, wo.create_time, cus.customer_name, prod.model_name FROM aft_work_order wo LEFT JOIN aft_customer cus ON wo.customer_id = cus.id LEFT JOIN aft_product prod ON wo.product_id = prod.id <where> <if test="state != null"> AND wo.state = #{state} </if> <if test="technicianId != null"> AND wo.technician_id = #{technicianId} </if> <if test="startTime != null"> AND wo.create_time >= #{startTime} </if> <if test="endTime != null"> AND wo.create_time <= #{endTime} </if> AND wo.deleted = 0 </where> ORDER BY wo.create_time DESC </select>分页我用的是PageHelper,Controller层接收pageNum和pageSize,在Service层调用PageHelper.startPage(pageNum, pageSize),紧接着执行的Mapper查询会被自动拦截拼接LIMIT,同时返回PageInfo对象,里面包含了总数、总页数等分页信息。只要注意一点,startPage一定要和查询方法紧紧挨着,中间不能插入其他数据库操作,否则分页拦截器会作用到错误的查询上。
4.3 Vue3端页面组织与API封装
前端页面按业务模块组织,目录结构为views下面分login、dashboard、workorder、customer、product、system。公共组件放在components目录,包括工单状态Tag、数据字典Select、分页表格封装等。
API请求封装是前端的一个关键点,我统一用axios实例来发送请求:
import axios from 'axios' import { ElMessage } from 'element-plus' import { useRouter } from 'vue-router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('nuct_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.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 }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('nuct_token') useRouter().push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } ) export default service把这层统一封装做好,页面里调用接口就非常清爽。每个模块的请求方法集中放在api目录,比如api/workorder.js里导出getWorkOrderPage、createWorkOrder、updateWorkOrderState等方法,页面只需要引入并调用,不用关心URL拼接和Header设置。这样一旦后端接口路径调整,前端只需要改一个文件。
5. 前后端联调与售后流程闭环实现
5.1 登录鉴权:JWT从后端签发到前端拦截
登录这块我选了JWT方案,不依赖Session,后端不存储登录状态,接口天然支持横向扩展。流程是:用户提交账号密码,后端校验通过后签发一个有效期为24小时的JWT,返回给前端;前端把它存到localStorage,后续每个请求都在请求头里带上Authorization: Bearer token。
后端用一个过滤器拦截除登录接口之外的请求,从Header中取出Token,解析并校验签名和过期时间。SpringBoot里实现方式很简单,定义一个OncePerRequestFilter,注册到过滤器链中。校验通过后把用户ID和角色列表放到ThreadLocal的上下文对象里,后续Service层可以从CurrentUserHolder.getUserId()拿到当前操作人。这里要特别注意,ThreadLocal用完必须清理,否则高并发下会出现用户数据串线。
前端路由守卫同样要控制权限。Vue Router的beforeEach里检查本地是否有Token,以及当前路由的meta信息中要求的角色列表是否和用户角色匹配。如果不匹配就跳转到403页面或登录页。这里最容易出现的问题是路由拦截和后端接口权限不一致,导致前端口子放行了、接口却报403。我在实际联调时统一维护了一个权限映射表,前端路由的meta和后端注解里的权限码保持一致,才彻底解决这个问题。
5.2 从工单创建到回访归档的完整链路
把一条工单完整跑一遍,最能检验系统是否顺手。先从客服创建工单说起。客服在页面上选择客户、选择产品型号、填写故障类型和描述,点击提交后,前端调用POST /api/workorder,后端新增工单并写入一条状态为“待分配”的操作记录。创建完成之后,工单列表会出现一条待分配的数据,同时售后主管的待办角标增加数字。
售后主管进入工单列表,筛选“待分配”状态的工单,点击分配按钮,弹窗里选择一个技术工程师。提交时调用PUT /api/workorder/assign,后端校验当前用户角色必须是售后主管,且工单状态必须是待分配,然后用一条UPDATE语句把状态改成处理中,同时绑定技术员ID,并插入操作记录。这里状态校验要放在update语句的where条件里,也就是乐观锁的用法,后面我会单独展开。
技术工程师登录后看到的是“我的待办”列表,点击处理工单,填写维修说明和处理结果。提交后工单变为待回访,客服可以在回访列表里看到。客服执行回访,询问用户是否解决,填写回访备注和回访结果,工单变为已完成。到这里,一条工单的核心业务链路就闭环了,管理员在仪表盘上能看到当天新增、处理中、已完成的实时数量。
5.3 工作量与处理时效统计
售后主管比较关心的数据有三个:团队成员工作量、工单状态分布、平均处理时长。这些统计不需要额外维护一张表,直接在Mapper里写聚合SQL即可。
计算每个技术员当月处理工单数的SQL大概是这样:
SELECT u.real_name, COUNT(wo.id) AS total_count FROM aft_work_order wo LEFT JOIN sys_user u ON wo.technician_id = u.id WHERE wo.technician_id IS NOT NULL AND wo.state IN ('待回访', '已完成', '已关闭') AND wo.create_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') GROUP BY wo.technician_id, u.real_name ORDER BY total_count DESC平均处理时长需要先算出每个工单从“处理中”变成“待回访”的时间差。如果每次状态变更都有记录表,那可以直接从状态变更记录表查取两张记录的时间差然后求平均。我在工单表上额外存了handle_start_time和handle_end_time两个字段,技术员接单和提交结果时更新,统计就简单很多。
前端图表部分使用ECharts的柱状图和饼图展示,数据从接口一次性拉取。这里不建议每个图表单独请求接口,因为售后系统数据量不大,一个聚合接口返回全部统计结果,前端拆开渲染,调用简单还能减少网络请求次数。
6. 常见问题与排查技巧实录
6.1 跨域配置:从报错到转发的正确处理
前后端分离项目第一个拦路虎基本都是跨域。开发环境下,前端跑在5173端口,后端跑在8080端口,浏览器直接请求http://localhost:8080/api会触发同源策略限制。我推荐的开发期方案不是在后端写死CORS配置,而是利用Vite的dev server转发能力,把前端请求的/api路径转到后端。
在Vite配置文件里的实现大致是:
server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }这样做的好处是前端代码里永远只写相对路径,不会硬编码后端地址,以后部署到测试环境只需要改proxy配置。生产环境则用Nginx把同一个域名下的/api转发到后端服务,前端代码无需感知后端地址。
如果后端必须要开启跨域,也可以在SpringBoot中配置CorsFilter,允许指定来源、指定Header。但在真实的售后内网环境中,我更推荐统一网关或者Nginx转发方式,让前后端域名保持一致,减少大量的跨域兼容问题。
6.2 多表关联查询性能排查
有一次测试同事反馈工单列表响应变慢,排查后发现是分页查询中LEFT JOIN了客户表和产品表,客户表没有在phone上建索引,每查一页都要全表扫描。处理方法是给关联字段补索引,再把inspect出来的执行计划看清楚。这里说一个经验:不管最终数据量多大,关联查询的关联字段都应当建索引,这是成本最低的优化手段。
MyBatis还有一个典型问题是N+1查询。比如一次查出20条工单,然后在循环里逐条去查客户信息和产品信息,表面上看代码很合理,实际会产生21次查询。解决思路是直接在一条SQL里用JOIN把关联信息查出来,或者使用MyBatis的collection标签配置嵌套结果映射,不过嵌套映射在某些复杂分页场景下可能踩坑,我更喜欢直接在XML里写清楚JOIN查询。
6.3 并发修改工单状态的安全更新
售后主管和技术工程师可能同时对同一张工单做操作。比如主管刚把工单分配给工程师A,工程师B在页面上也点了接单,两条请求同时更新状态,就有可能出现状态错乱。我采用的方案是乐观锁:更新时带上期望状态作为where条件。
UPDATE aft_work_order SET state = '处理中', technician_id = #{technicianId} WHERE id = #{orderId} AND state = '待分配'如果更新影响行数为0,说明这张工单已经被别人处理过,后端可以直接抛出“工单状态已变更,请刷新页面”的提示。这个方法没有引入额外锁,代码简单,对售后这种冲突不频繁的场景来说足够。比加Redis分布式锁容易维护得多,而且只要事务内先执行update后插入记录,数据库行锁就能保证安全。
6.4 日期时区和JSON序列化
售后工单的时间展示出现过一次很典型的“8小时偏差”问题:数据库存的是正确时间,接口返回给前端时却少了一天。排查之后发现是MySQL连接URL没有配置serverTimezone,后端Jackson序列化时又使用了默认时区,两个问题叠加导致时间偏移。
解决办法是统一规范三个地方:MySQL连接URL参数加上serverTimezone=Asia/Shanghai;SpringBoot的spring.jackson.date-format和time-zone明确指定;前端拿到时间字符串后统一用dayjs解析显示。日期这件事看起来很小,但在跨时区的团队协作中特别容易翻车。我现在的习惯是后端接口统一返回时间戳或ISO格式字符串,前端只管格式化,不再依赖后端到底在哪个时区运行。
7. 经验总结与可扩展方向
7.1 如果重新做一遍,我会改变什么
nuct系统整体是能用的,但如果在条件允许的情况下重新做一遍,我会把工单的“字段版本”设计得更灵活一些。现在工单表字段是固定的,如果业务方后续要增加“发票号”“物流单号”这类售后补充信息,每次都需要改表结构、改实体类、改前端表单。更好的做法是主表只保留公共字段,扩展信息放到一个JSON字段或单独的KV表里,这样业务变化时不用频繁动表结构。
另一个我会调整的点是操作记录表。现在插入操作记录分散在各Service方法中,代码存在一定重复。如果重做,我会提炼一个WorkOrderEventPublisher组件,所有状态变更统一走这个组件的方法,由它负责校验状态、更新主表、写入记录,这样业务规则可以收敛到一个地方,新同事接手时看一眼这个类就明白整套流转规则。
7.2 后续能继续加哪些能力
这个系统已经具备了一个售后管理平台的核心骨架,后续扩展空间还很大。首先是消息通知,工单分配、回访超时都可以接入站内信和模板消息推送。因为系统本身没有接入复杂消息中间件,先用Spring的事件机制做站内通知,再逐步扩展企业微信机器人消息就行。其次是附件管理,当前故障图片存的是URL路径,后续可以接一个对象存储服务,统一管理图片和维修报告附件。
如果业务量增长,还可以增加服务时效预警,比如工单在“待分配”超过24小时自动提醒主管,在“处理中”超过48小时自动升级工单等级。这些能力都不需要改核心表结构,只要在现有的状态机和字段上增加定时任务和统计判断就能实现。在我看来,售后系统的核心竞争力不是把表建得多复杂,而是把流转规则、权限边界、数据闭环梳理清楚,后续想叠加什么功能都会很顺手。
最后再分享一个我的实际体会:做这类管理系统,尽量不要一上来就纠结用了哪个最新框架版本,先把工单状态机画在纸上,把每个角色能做什么动作列成表格,代码写起来会顺很多。nuct项目之所以开发周期短,就是因为在设计阶段把状态流转和权限边界定死了,开发阶段反而没出过大的返工。这套方法,值得你在下一个管理系统里试试。