SpringBoot+Vue3+MyBatis实战:打造可复用的售后工单管理系统
智能硬件厂商那阵子最头疼的事售后还停留在Excel加微信群报修单谁录的、单子派给谁、工程师处理到哪一步、客户有没有验收全靠人肉追问。漏单、扯皮、超时没人管是常态。我基于SpringBootVue3MyBatis做了一套前后端分离的产品售后管理系统内部代号叫nuct后端走REST接口前端Vue3独立工程数据落在MySQL里前后端分离的思想贯穿始终这套源码结构我前后复用了好几个类似项目稳定跑过多个版本。这套东西适合两类人一类是想找完整全栈项目练手、做课设的开发者另一类是团队里需要快速搭内部售后工单能力的后端工程师。先把话说在前头这个系统真正的难点不在增删改查而在工单状态怎么建模、流程怎么流转、权限怎么分清楚。1. 售后业务的核心工单从哪里来到哪里去1.1 把售后流程拆成四个节点第一个要搞明白的不是表结构而是业务流程。售后不是用户提交一条记录就完事的它是一条链客户报修、客服受理、调度派单、工程师处理、客户验收、回访归档。我在设计nuct时把它收敛成四个核心环节报修登记、派单流转、维修处理、验收归档。每个环节有明确的操作人和输入输出。报修登记客户或者代录的客服填写产品型号、序列号、故障描述、联系方式、期望处理方式。这一步最重要的是关联保修信息系统要知道这台设备在不在保、是否人为损坏。派单流转客服根据故障类型和工程师技能组、当前负载把工单指派给具体工程师。产品故障一般分软件问题和硬件问题涉及不同技能组。维修处理工程师接单后更新处理结果比如更换配件、重装系统、上门检测把耗费工时和材料成本记录下来。验收归档客户确认服务结果并评价客服做回访工单状态走到最终关闭。这个链条如果只做一个表单加列表后面百分之百要返工。因为每个节点在业务上都有时限要求比如未派单超过24小时要自动提醒维修超过48小时要升级给主管。这些规则全部依赖工单状态和时间字段所以一开始就要把节点想清楚而不是上来就建表写CRUD。1.2 状态是工单系统的灵魂不是装饰字段很多人一上来给工单加一个status字段就完事了存一些待处理已处理这种模糊值。真实跑起来你会发现需求根本兜不住客服不知道单子卡在谁手里工程师不知道下一步有没有权限操作管理员想统计也统计不出来指标。nuct里我给工单定义了六个状态码见下表。这套定义从售后业务里抽象出来基本可以覆盖大多数产品售后服务场景。状态码含义允许流转到的状态0待派单处理中、已关闭1处理中待验收、已驳回2待验收已完成、已驳回、已关闭3已完成终态4已关闭终态5已驳回待派单状态之间不是任意跳的。待派单只能去处理中或已关闭处理中只能去待验收或已驳回待验收能去已完成、已驳回或已关闭驳回之后回到待派单重新分配。这套白名单流转逻辑直接决定了后端的代码怎么写也决定了客服操作界面上哪些按钮亮、哪些按钮置灰。提示状态用连续整数0、1、2比字符串更有扩展性枚举映射也方便。不要搞01、02这种带层级语义的字符串后面维护非常痛苦。这个状态机思路是整个系统的地基。后面要讲的数据库设计、后端接口、前端按钮本质上都是在给这张状态表做配套服务。2. 技术选型SpringBootVue3MyBatis的组合逻辑2.1 为什么后端选SpringBoot而不是更重的框架这套系统的定位是中小规模的企业内部系统并发量不大但业务逻辑不简单权限、流程、单据、报表都要有。用SpringBoot做单体应用内嵌Tomcat、打成Jar包就能跑没有外部容器依赖部署成本极低一两个人也能维护。对比一下如果上微服务那套网关、注册中心、配置中心全下来光运维成本就比业务成本高如果退回JSP加Servlet前后端耦合太紧改一个交互要动整个页面客服和工程师两边根本没法并行开发。SpringBoot在这个体量上是性价比最高的选择。配合JWT做轻量鉴权已经足够覆盖角色权限真要精细到按钮级权限再往Spring Security上靠也不费劲但没必要一上来就上全套。2.2 MyBatis在售后单据查询上的不可替代性售后工单列表查询有一个很典型的特点筛选条件极多且不固定。状态、紧急程度、工单类型、客户姓名、手机号、序列号、工程师、创建时间段用户今天按这三个条件查明天按另外四个条件查。用固定查询写起来会非常别扭而MyBatis的XML动态SQL天生就是干这个的where加一堆if条件自由组合SQL还能手工调优分页也顺手。另一个场景是工单汇总统计按状态分组、按天统计创建量、按工程师统计处理量。这些报表SQL写起来直白、易调试MyBatis可以直接映射到DTO不需要额外做一堆VO转换。有人可能问JPA加Specification也能做动态查询但实际调试起来SQL的生成链路太长复杂关联时临时拼条件会很头疼。售后系统这种单据密集、筛选多变的业务MyBatis的直观性和可控性优势非常明显。2.3 Vue3和MySQL的配合前端选Vue3核心考虑是单页应用交互重工单列表要实时筛选、弹窗操作、状态标签变化纯静态页面很难做到流畅。Vue3的组合式API比Option API更接近业务逻辑的自然分组配合Pinia管理用户信息和全局状态开发效率提升明显。Element Plus覆盖了表格、表单、弹窗、日期选择这些后台管理常用组件不用自己造轮子。数据库层面MySQL在中小系统里依然是首选。InnoDB事务保证工单创建和日志写入的一致性utf8mb4字符集让客户备注里各种特殊字符、表情符号都能存。如果公司业务规模再涨这套表结构也方便迁移表之间关系清晰没有绑死特定数据库方言。2.4 技术栈版本写代码前先锁定版本不锁定联调就是灾难。我这边实际用的是JDK 8Spring Boot 2.7.x前端Vue 3.4.xVite 5.xPinia 2.xElement Plus 2.xMyBatis 3.5.xMySQL 8.0。如果读者用的是Spring Boot 3.x注意javax包名变成jakarta拦截器和配置类的写法有差异其余业务逻辑一样。3. 数据库建模工单表、流转日志与索引细节3.1 一张主表还是多张表售后系统的核心是工单但工单不能孤立存在。我拆了这些表用户表含客服、工程师、管理员三种角色、产品表包含型号、序列号、保修记录表、工单主表、工单流转日志表、附件表。有人会把产品和保修记录直接合并进工单表从远期看不建议。因为同一序列号可能保修多次拆开才能支持查这台设备的历史维修记录这个高频需求。另外用户表和角色我用一个role字段区分不建user_role关联表因为角色固定三个后续也不打算做细粒度权限引擎。但对工单来说engineer_id和creator_id同时指向用户表这种同一张表多个业务角色的关系在查询时用别名区分即可不需要额外建关系表。3.2 工单主表的关键字段工单主表字段设计直接影响查询效率和后续统计。建表脚本我简化后放下面实际项目里可以再加几个冗余展示字段但核心结构就是这些。CREATE TABLE work_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 工单编号如AS20250101001, customer_name varchar(50) NOT NULL COMMENT 客户姓名, customer_phone varchar(20) DEFAULT NULL, product_model varchar(50) DEFAULT NULL COMMENT 产品型号, serial_no varchar(64) DEFAULT NULL COMMENT 产品序列号, fault_desc varchar(500) DEFAULT NULL COMMENT 故障描述, order_type tinyint(4) DEFAULT 1 COMMENT 1保修 2非保修 3换货, priority tinyint(4) DEFAULT 2 COMMENT 紧急程度 1低 2中 3高, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 工单状态见状态机, engineer_id bigint(20) DEFAULT NULL COMMENT 工程师ID, creator_id bigint(20) NOT NULL COMMENT 工单创建人ID, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, assigned_at datetime DEFAULT NULL COMMENT 派单时间, accepted_at datetime DEFAULT NULL COMMENT 接单时间, finished_at datetime DEFAULT NULL COMMENT 处理完成时间, closed_at datetime DEFAULT NULL COMMENT 关闭时间, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_status_created (status, created_at), KEY idx_engineer_status (engineer_id, status), KEY idx_serial_no (serial_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT售后工单主表;几个容易被忽视的点order_no不能只用自增id充数。工单号是要给人看的AS20250101001这种格式一眼能读出日期和序号对客户沟通和查账都有用。生成逻辑是AS yyyyMMdd 当天自增序号后端用Redis或数据库唯一键保证并发不重号。状态字段我用tinyint不用varchar省空间程序里用枚举映射。有人嫌可读性差那就在查询接口做字典翻译前端也通过字典转中文标签问题不大。时间字段要分开存。created_at、assigned_at、accepted_at、finished_at、closed_at各管各的这样统计从派单到接单平均耗时从创建到关闭周期直接减日期即可不需要翻日志或者用字符串截取。索引别乱建但高频查询组合要覆盖。idx_status_created支撑按状态筛选加时间倒序的列表页idx_engineer_status支撑工程师登录后看自己名下工单serial_no加索引是为客户来电查询设备历史全表扫一次可能很慢几十万条数据就能明显感觉到。3.3 独立流转日志表的价值工单表里status永远只存当前状态过程去哪了答案是流转日志表。每次状态变更不管是谁操作的都要往order_flow_log插一条记录字段包括order_id、from_status、to_status、operator_id、remark、created_at。CREATE TABLE order_flow_log ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL, from_status tinyint(4) DEFAULT NULL, to_status tinyint(4) NOT NULL, operator_id bigint(20) NOT NULL, remark varchar(500) DEFAULT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT工单流转日志表;这张表有三个作用一是审计追溯客户投诉没人管我的单子时拉日志能还原谁在什么时候把单子卡在了哪一步二是异常排查工单走到奇怪状态时看日志比看代码方便三是数据统计比如工程师平均处理时长从日志里取处理中到待验收的时间区间非常精准比从主表猜要靠谱得多。警告更新工单状态和写日志必须在一个事务里两张表一致提交或一致回滚。我见过同事只更新状态不写日志的版本出问题后完全没有回溯能力这种坑千万别踩。4. 后端实现从JWT鉴权到状态机流转4.1 常规分层但Controller只做传声筒nuct后端的包结构没有魔法就是一个标准分层的单模块Maven工程after-sale/ ├── Controller/ # 接收REST请求参数校验 ├── Service/ # 业务逻辑状态机校验在这里 ├── Mapper/ # MyBatis接口 ├── resources/mapper/ # XML动态SQL ├── entity/ # 与表对应的实体 ├── dto/ # 接口出入参 └── common/ # 统一返回、异常、工具类我个人的习惯是Controller层非常薄只做参数绑定和调用Service真正的业务判断全部下沉Service。如果哪个Controller里写了超过十行逻辑基本就该重构了。统一返回结构用Result 包装code、message、data三个字段前端axios拦截器统一处理所有接口的响应格式从头到尾保持一致。4.2 JWT鉴权和三角色权限用户表里用role字段区分客服、工程师、管理员。登录接口验证用户名密码后签发JWTtoken里放userId和role过期时间默认24小时操作频繁的管理员可以单独调长。签发核心代码大致这样String token Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(role, user.getRole()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();每个需要登录的接口走拦截器先从请求头拿Authorization: Bearer xxx解析成功再放行解析失败统一返回401。角色控制在权限上做得比较粗主要三处只有客服和管理员能创建工单工程师登录后看的是指派给我的列表。只有管理员能查看所有工单和流转日志工程师只能看自己有权限的单子。前端根据角色渲染菜单后端在Service层再做一次角色校验。前端隐藏菜单只是体验优化后端校验才是安全底线两者缺一不可。4.3 动态SQL撑起多条件筛选列表页的筛选条件有七个以上。我在resources/mapper/WorkOrderMapper.xml里写的查询片段直接贴出来这是整套系统里被复用最多的SQL。select idlistOrders resultTypecom.example.aftersale.dto.WorkOrderQueryVO select id, order_no, customer_name, customer_phone, product_model, serial_no, fault_desc, order_type, priority, status, engineer_id, creator_id, created_at, assigned_at, finished_at from work_order where if teststatus ! null and status #{status} /if if testorderType ! null and order_type #{orderType} /if if testkeyword ! null and keyword ! and (order_no like concat(%, #{keyword}, %) or customer_name like concat(%, #{keyword}, %) or customer_phone like concat(%, #{keyword}, %) or serial_no like concat(%, #{keyword}, %)) /if if testengineerId ! null and engineer_id #{engineerId} /if if testpriority ! null and priority #{priority} /if if teststartTime ! null and created_at gt; #{startTime} /if if testendTime ! null and created_at lt; #{endTime} /if /where order by priority desc, created_at desc /select几个实用细节关键字搜索用了like模糊匹配序列号字段加了索引数据量大点也扛得住。等单表真的超过几十万条再考虑上搜索引擎或ES现在没必要提前复杂化。排序用priority降序、created_at降序紧急单优先显示这是售后场景的基本要求不要做成默认按id升序。日期范围查询用gt;和lt;是因为XML里、需要转义这个最容易忘不转义XML直接编译报错。4.4 状态机不写在数据库里写在代码里工单状态校验我放在Service层用一个转移表来做比if-else瀑布清晰得多private static final MapInteger, ListInteger STATE_TRANSITIONS new HashMap(); static { STATE_TRANSITIONS.put(0, Arrays.asList(1, 4)); // 待派单 - 处理中/已关闭 STATE_TRANSITIONS.put(1, Arrays.asList(2, 5)); // 处理中 - 待验收/已驳回 STATE_TRANSITIONS.put(2, Arrays.asList(3, 5, 4)); // 待验收 - 已完成/已驳回/已关闭 STATE_TRANSITIONS.put(5, Arrays.asList(0)); // 已驳回 - 待派单 } private void assertCanTransition(Integer from, Integer to) { ListInteger allowed STATE_TRANSITIONS.get(from); if (allowed null || !allowed.contains(to)) { throw new BusinessException(非法状态流转 from - to); } }每次接口更新状态前先查当前状态再校验目标状态是否在白名单里校验通过才updateupdate成功后写日志。其实这一步完全可以扩展成配置化的流程引擎但如果状态就六个、角色就三个硬上工作流引擎只会增加学习成本和部署复杂度。我的建议是先用代码把状态机写清楚等真有几十种状态分支再演进成规则引擎也不迟。别一开始就给项目上重武器。4.5 派单接口的完整逻辑链派单是售后系统里最典型的一个动作拿它展示完整链路。客服点击派单传入工单id和工程师id后端Service做四步查工单判断当前状态是否为待派单不是就抛异常判断工程师是否存在且角色为工程师顺手校验是否在职更新engineer_id、status1处理中、assigned_atnow写流转日志from0to1remark填派给工程师。这四个动作在一个事务里提交任何一个失败全部回滚。前端派单按钮也根据status实时渲染只有待派单时才亮避免用户对着一个已经派出去的单子重复操作。5. Vue3端到端的前端落地5.1 从Vite脚手架到工程目录前端用官方Vite创建Vue3工程目录结构大致是src/ ├── api/ # 按模块拆分的接口请求 ├── router/ # 路由与守卫 ├── stores/ # Pinia状态 ├── views/ # 页面 │ ├── order/ # 工单列表/新建/详情 │ ├── user/ # 用户管理 │ └── login/ ├── components/ # 公共组件状态标签、表单弹窗 └── utils/ # axios实例、字典常量建议把字典常量放一个统一文件里状态码、工单类型、优先级全放在一起。前端展示文案、标签颜色、按钮显隐全从字典推导不写死。这样后端加一个状态码前端只需要改一处字典而不是全局搜索替换。5.2 工单列表页的三段式实现列表页采用三段式筛选区、表格区、分页区。筛选区绑定一个响应式查询对象Element Plus的select、input、date-picker直接v-model绑定点击查询时把对象传到后端表格区列比较多产品型号、序列号、故障描述内容长用show-overflow-tooltip处理、状态标签、时间分页区处理好total和当前页即可。状态标签做成独立公共组件根据字典映射出文字和颜色待派单用info灰色、处理中用primary蓝色、待验收用warning橙色、已完成用success绿色、已关闭用default灰色、已驳回用danger红色。客服一眼扫过去就知道哪些单子卡在哪个环节。5.3 操作台新建、派单、处理、验收的交互前端交互最重的两个场景是新建工单和派单。新建工单是弹窗表单故障描述必填序列号填完以后先调接口查保修状态后端返回在保内、过保、无记录三种情况前端根据结果联动order_type的默认值。这个联动体验很关键客服不用自己判断是保修还是收费系统直接给建议。派单是另一个弹窗里面放一个工程师选择下拉数据来自用户接口按角色过滤。点击派单后前端乐观更新当前行状态为处理中同时提示成功。处理结果和验收评价也是弹窗处理结果里带一个可选的费用字段工程师提交材料费、上门费验收评价用评星组件客户点几下就完成。这类弹窗交互关键是一个原则所有能由系统推导的值不要让人工再输入一遍。保修状态、可选角色、状态流转规则都应该由后端来支撑前端只做展示和交互。5.4 axios拦截器与权限联动axios实例统一封装在utils里const service axios.create({ baseURL: /api, timeout: 10000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer token; } return config; }); service.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(error); } );这样写之后所有页面都不用关心token怎么带不用关心401怎么处理。路由守卫里再根据角色做菜单过滤客服看不到工程师那个只属于个人的聚合视图操作按钮也按权限渲染。前端的按钮显隐本质上只是体验后端Service层该有的校验一次都不能少。6. 联调、打包部署与常见坑6.1 开发环境跨域Vite proxy解决前后端分离最烦的第一件事就是跨域。开发阶段最简单的做法不是在后端写CORS配置而是让前端Vite把请求代理到后端server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }前端所有请求都写/api/xxxVite启动的dev server把请求代理转发到后端。这样在浏览器层面根本没有跨域token、cookie都顺畅。生产环境用Nginx做同源代理前端请求同域的/api路径Nginx转发到后端服务完全不需要后端开启CORS跨域设置。6.2 MySQL时区与Jackson日期格式化联调时最容易一眼看出bug的就是时间对不上。原因是MySQL连接串没指定时区或者后端返回的日期格式和前端默认的不一致。连接串里务必带上jdbc:mysql://localhost:3306/aftersale?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai后端Jackson再统一格式化spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai这样数据库、后端、前端显示的时间才在同一时区。另一个建议所有业务时间字段用datetime自动更新时间可以用timestamp。datetime没有时区转换的历史包袱业务统计也直观。6.3 附件上传路径与Nginx映射售后工单里客户经常会传故障照片附件表存的是相对路径文件本体存在本地磁盘目录。Nginx需要单独做一个location把上传目录暴露出来server { listen 80; server_name your-domain.com; location / { root /opt/aftersale/dist; try_files $uri $uri/ /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 /uploads/ { alias /opt/aftersale/uploads/; } }运行Vue3项目要注意history路由模式下刷新页面会404try_files必须把请求回退到index.html否则用户直接访问域名下的某个子路径会白屏。uploads目录权限要设置好和后端写入用户保持一致不然传完图看不见这个问题在服务器上排查起来非常费时。6.4 打包运行与上线检查清单前端打包npm run build产物在dist目录拷贝到服务器/opt/aftersale/dist即可。后端打包mvn clean package -DskipTests生产运行用nohupnohup java -jar -Xms512m -Xmx1024m after-sale.jar --spring.profiles.activeprod app.log 21 上线前我习惯过一遍检查单数据库字符集是不是utf8mb4连接串时区JWT密钥有没有写在配置里而不是硬编码在代码中前端build时API地址是不是相对路径Nginx配置有没有配try_files上传目录权限MySQL连接数调没调。这些比功能bug隐蔽得多我基本每一个都踩过一遍。提示生产环境不要用root跑Java服务用独立账号运行避免文件权限和安全问题。最后再分享一个实战体会这套系统上线跑了一段时间后我最大的感受是技术栈复杂度根本不是瓶颈业务建模的清晰度才是。SpringBoot、Vue3、MyBatis这套组合我也复用了几次每次新项目最花时间的地方永远是状态怎么定义、节点怎么切分、权限怎么收敛。如果现在让我重新做一遍nuct我会先把状态机画在纸上找业务方确认再写一行代码。还有一个值得分享的小技巧把工单状态变更写成统一日志除了记from和to务必把操作人的id、操作时间、备注、甚至请求IP都存下来。平时用不上真出事的时候它是你唯一能向客户和上级讲清楚的证据链。售后系统的价值从来不是能登记个工单而是每个工单都有迹可循、有据可查。这一点做到位系统就真正立住了。