☰
SpringBoot+Vue3+MyBatis实战:打造可复用的售后工单管理系统
2026/10/22 18:56:32 网站建设 项目流程

智能硬件厂商那阵子最头疼的事,售后还停留在Excel加微信群:报修单谁录的、单子派给谁、工程师处理到哪一步、客户有没有验收,全靠人肉追问。漏单、扯皮、超时没人管,是常态。我基于SpringBoot+Vue3+MyBatis做了一套前后端分离的产品售后管理系统,内部代号叫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. 技术选型:SpringBoot+Vue3+MyBatis的组合逻辑

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 8,Spring Boot 2.7.x;前端Vue 3.4.x,Vite 5.x,Pinia 2.x,Element Plus 2.x;MyBatis 3.5.x;MySQL 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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字段区分客服、工程师、管理员。登录接口验证用户名密码后签发JWT,token里放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 id="listOrders" resultType="com.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 test="status != null"> and status = #{status} </if> <if test="orderType != null"> and order_type = #{orderType} </if> <if test="keyword != 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 test="engineerId != null"> and engineer_id = #{engineerId} </if> <if test="priority != null"> and priority = #{priority} </if> <if test="startTime != null"> and created_at &gt;= #{startTime} </if> <if test="endTime != 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 Map<Integer, List<Integer>> 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) { List<Integer> allowed = STATE_TRANSITIONS.get(from); if (allowed == null || !allowed.contains(to)) { throw new BusinessException("非法状态流转:" + from + " -> " + to); } }

每次接口更新状态前,先查当前状态,再校验目标状态是否在白名单里,校验通过才update,update成功后写日志。其实这一步完全可以扩展成配置化的流程引擎,但如果状态就六个、角色就三个,硬上工作流引擎只会增加学习成本和部署复杂度。我的建议是:先用代码把状态机写清楚,等真有几十种状态分支,再演进成规则引擎也不迟。别一开始就给项目上重武器。

4.5 派单接口的完整逻辑链

派单是售后系统里最典型的一个动作,拿它展示完整链路。客服点击"派单",传入工单id和工程师id,后端Service做四步:

  1. 查工单,判断当前状态是否为待派单,不是就抛异常;
  2. 判断工程师是否存在且角色为工程师,顺手校验是否在职;
  3. 更新engineer_id、status=1(处理中)、assigned_at=now;
  4. 写流转日志,from=0,to=1,remark填"派给工程师"。

这四个动作在一个事务里提交,任何一个失败全部回滚。前端派单按钮也根据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/xxx,Vite启动的dev server把请求代理转发到后端。这样在浏览器层面根本没有跨域,token、cookie都顺畅。生产环境用Nginx做同源代理,前端请求同域的/api路径,Nginx转发到后端服务,完全不需要后端开启CORS跨域设置。

6.2 MySQL时区与Jackson日期格式化

联调时最容易一眼看出bug的就是"时间对不上"。原因是MySQL连接串没指定时区,或者后端返回的日期格式和前端默认的不一致。连接串里务必带上:

jdbc:mysql://localhost:3306/aftersale?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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路由模式下刷新页面会404,try_files必须把请求回退到index.html,否则用户直接访问域名下的某个子路径会白屏。uploads目录权限要设置好,和后端写入用户保持一致,不然传完图看不见,这个问题在服务器上排查起来非常费时。

6.4 打包运行与上线检查清单

前端打包:

npm run build

产物在dist目录,拷贝到服务器/opt/aftersale/dist即可。后端打包:

mvn clean package -DskipTests

生产运行用nohup:

nohup java -jar -Xms512m -Xmx1024m after-sale.jar --spring.profiles.active=prod > app.log 2>&1 &

上线前我习惯过一遍检查单:数据库字符集是不是utf8mb4;连接串时区;JWT密钥有没有写在配置里而不是硬编码在代码中;前端build时API地址是不是相对路径;Nginx配置有没有配try_files;上传目录权限;MySQL连接数调没调。这些比功能bug隐蔽得多,我基本每一个都踩过一遍。

提示:生产环境不要用root跑Java服务,用独立账号运行,避免文件权限和安全问题。

最后再分享一个实战体会

这套系统上线跑了一段时间后,我最大的感受是:技术栈复杂度根本不是瓶颈,业务建模的清晰度才是。SpringBoot、Vue3、MyBatis这套组合我也复用了几次,每次新项目最花时间的地方永远是"状态怎么定义、节点怎么切分、权限怎么收敛"。如果现在让我重新做一遍nuct,我会先把状态机画在纸上找业务方确认,再写一行代码。

还有一个值得分享的小技巧:把工单状态变更写成统一日志,除了记from和to,务必把操作人的id、操作时间、备注、甚至请求IP都存下来。平时用不上,真出事的时候,它是你唯一能向客户和上级讲清楚的证据链。售后系统的价值从来不是"能登记个工单",而是"每个工单都有迹可循、有据可查"。这一点做到位,系统就真正立住了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询