SpringBoot+Vue3售后工单系统实战:前后端分离与状态机设计
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的底子更扎实。数据库连接池用HikariCPSpringBoot默认集成性能可靠基本不用额外调优。整体就是一个务实的组合每个组件都能回答出“为什么用它”。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 LTSnpm默认跟过来建议把镜像源切到国内常用源安装依赖会快很多。第三步安装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工具类、常量定义。统一返回体我写成了泛型类ResultTpublic class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ResultT fail(String message) { ResultT 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 idselectWorkOrderPage resultTypecom.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 teststate ! null AND wo.state #{state} /if if testtechnicianId ! null AND wo.technician_id #{technicianId} /if if teststartTime ! null AND wo.create_time gt; #{startTime} /if if testendTime ! null AND wo.create_time lt; #{endTime} /if AND wo.deleted 0 /where ORDER BY wo.create_time DESC /select分页我用的是PageHelperController层接收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还有一个典型问题是N1查询。比如一次查出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参数加上serverTimezoneAsia/ShanghaiSpringBoot的spring.jackson.date-format和time-zone明确指定前端拿到时间字符串后统一用dayjs解析显示。日期这件事看起来很小但在跨时区的团队协作中特别容易翻车。我现在的习惯是后端接口统一返回时间戳或ISO格式字符串前端只管格式化不再依赖后端到底在哪个时区运行。7. 经验总结与可扩展方向7.1 如果重新做一遍我会改变什么nuct系统整体是能用的但如果在条件允许的情况下重新做一遍我会把工单的“字段版本”设计得更灵活一些。现在工单表字段是固定的如果业务方后续要增加“发票号”“物流单号”这类售后补充信息每次都需要改表结构、改实体类、改前端表单。更好的做法是主表只保留公共字段扩展信息放到一个JSON字段或单独的KV表里这样业务变化时不用频繁动表结构。另一个我会调整的点是操作记录表。现在插入操作记录分散在各Service方法中代码存在一定重复。如果重做我会提炼一个WorkOrderEventPublisher组件所有状态变更统一走这个组件的方法由它负责校验状态、更新主表、写入记录这样业务规则可以收敛到一个地方新同事接手时看一眼这个类就明白整套流转规则。7.2 后续能继续加哪些能力这个系统已经具备了一个售后管理平台的核心骨架后续扩展空间还很大。首先是消息通知工单分配、回访超时都可以接入站内信和模板消息推送。因为系统本身没有接入复杂消息中间件先用Spring的事件机制做站内通知再逐步扩展企业微信机器人消息就行。其次是附件管理当前故障图片存的是URL路径后续可以接一个对象存储服务统一管理图片和维修报告附件。如果业务量增长还可以增加服务时效预警比如工单在“待分配”超过24小时自动提醒主管在“处理中”超过48小时自动升级工单等级。这些能力都不需要改核心表结构只要在现有的状态机和字段上增加定时任务和统计判断就能实现。在我看来售后系统的核心竞争力不是把表建得多复杂而是把流转规则、权限边界、数据闭环梳理清楚后续想叠加什么功能都会很顺手。最后再分享一个我的实际体会做这类管理系统尽量不要一上来就纠结用了哪个最新框架版本先把工单状态机画在纸上把每个角色能做什么动作列成表格代码写起来会顺很多。nuct项目之所以开发周期短就是因为在设计阶段把状态流转和权限边界定死了开发阶段反而没出过大的返工。这套方法值得你在下一个管理系统里试试。