☰
工单管理系统源码实战:状态机设计、Spring Boot+Vue前后端分离
2026/10/13 7:25:16 网站建设 项目流程

简介:这是一份基于THINKPHP框架二次开发的免费开源工单管理系统源码,面向企业内部服务请求、问题跟踪与运维处理场景,适合需要快速搭建或深度定制工单流程的PHP开发者。系统覆盖用户提交工单、自动分类与优先级设定、全程状态追踪、人员分配、邮件通知、数据统计、API集成及多角色权限管理,还支持自定义字段和知识库扩展,功能相当完整。压缩包共1267个文件,约17.69MB,其中376个PHP文件构成核心业务逻辑,225个JS和75个CSS负责前端交互与界面样式,另含图片图标、数据库配置、初始化脚本和第三方组件资源,部署后即可参照学习。已有5591人浏览学习,这份源码可直接当作企业级工单系统的原始架构,也能作为学习THINKPHP MVC分层思想与后台开发流程的实践素材。从代码分层到常规模块组织均有清晰脉络,适合在真实项目中复用、改造或二次开发。

1. 工单管理系统源码:先想清楚状态机,再谈跑起来

工单管理系统这个品类,网上源码一抓一大把,但很多要么是只有 CRUD 的演示项目,要么是业务耦合太深、压根拆不动的泥潭。我拆的这套源码,定位很明确:给中小团队做内部支撑用的工单后台。它覆盖了用户提交问题、管理员审核分派、处理人接单处理、超时催办、关闭归档的完整闭环,自带部门、角色、附件、操作记录和基础统计。适合两类人:一是刚接触前后端分离项目、想拿真实业务练手的开发者;二是公司里没有现成工单系统、想快速部署一套内部工具的负责人。技术形态是 Spring Boot 后端加 Vue 前端,MySQL 存数据,Redis 管登录态和待办计数。

2. 架构与数据模型:工单流转靠哪几张表撑起来

2.1 功能拆解:七个节点组成的生命周期

工单系统和普通 CRUD 最大的区别在于,状态是有生命的。一个报障工单从提交到关闭,中间会经过不同的处理人,每一步都必须留下可追溯的记录。这套源码把生命周期拆成了七个节点:提交 SUBMITTED → 审核 VERIFIED → 分派 ASSIGNED → 处理中 PROCESSING → 挂起 HOLD → 恢复 PROCESSING → 关闭 CLOSED。注意,挂起和恢复是同一条边上的两个方向,有些内部工单还需要退回给用户补充信息,这时状态会从审核直接跳回提交。

为什么把提交和审核拆开,而不是合并成一个?关键在于权限归属。提交人是普通用户,审核人是工单管理员,两个动作的操作者身份完全不同。拆开后,统计报表可以分别输出用户提交量和管理员受理量,不用靠创建时间硬猜。如果当初合并,后面加任何维度分析都会很痛。

状态流转在代码里由枚举加校验器实现,而不是随意 setStatus。每个状态维护一个允许跳转的「目标状态集合」,非法流转直接抛业务异常。比如处理中不能直接关闭,必须先走到待关闭确认,防止处理人还没写结论就把单子关了。这套做法不复杂,但它把所有规则收敛在一个文件里。后续想加超时自动关闭,只需要在最小单元里增加一条边,不用到处改 if 判断。我拆这套源码时,第一件事就是把这个校验器翻出来,因为大部分工单系统的业务逻辑分散在前端按钮显隐、后端接口、数据库触发器三个地方,迟早出问题。

2.2 技术栈选型:为什么是 Spring Boot + Vue,而不是其他组合

这套源码用的是 Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis,前端是 Vue 3 + Element Plus + Pinia + Axios。选 Spring Boot 而不是 SSM,是因为 Spring Boot 把配置收敛在 application.yml 里,内置 Tomcat 直接 java -jar 就能跑,少掉一大堆 XML。对中小团队来说,不需要 Spring Cloud 那套注册中心、配置中心的重型玩法。一台服务器跑后端,一台跑 MySQL 和 Redis,前端打包后放 Nginx,足够撑起几百人公司的日常工单流转。

MyBatis-Plus 在这里不是必须的,但确实省模板代码。工单系统的数据模型不复杂,主要是工单主表、操作记录、用户、部门这几张表。用 BaseMapper 加 LambdaQueryWrapper 能动态拼接查询条件,比如按状态、优先级、部门组合筛选工单列表,手写 SQL 要每个条件判断空值,用 LambdaQueryWrapper 两三行搞定。

Redis 在这套系统里有三个用途。登录后生成的 token 存 Redis,设置过期时间,后台可以主动踢人下线;验证码也存 Redis,防止登录接口被脚本刷;待办计数用 INCR/DECR 实时维护,工单处理完数字立刻减一,不用每秒去数据库 count。这里提醒一句:Redis 不是必选项。如果团队不想多维护一个中间件,token 改成无状态 JWT 也能跑,只是后台无法主动让某人的登录态失效。取舍要看运维能力,没有标准答案。

2.3 数据表设计:六张核心表的字段逻辑

工单系统最忌讳把所有状态和业务字段堆在一张超级大表里。这套源码的表设计很克制,核心表只有六张。

表名作用关键字段
t_ticket工单主表id, ticket_no, title, description, priority, status, assignee_id, creator_id, dept_id, sla_deadline, version
t_ticket_record操作记录表id, ticket_id, operator_id, action, content, created_at
t_user用户表id, username, password, name, dept_id, role_id, status
t_dept部门表id, dept_name, parent_id
t_role角色表id, role_code, role_name, permission_json
t_attachment附件表id, ticket_id, file_name, file_path, uploader_id, created_at

t_ticket 里的 ticket_no 是业务编号,比如 GD20250217-001,由后端在创建时生成。规则是日期加当日序号,序号用 Redis 的 INCR 获取,避免并发建单时重复。status 字段存字符串枚举值,不存中文,防止数据库里出现「处理中」「已完成」这类无法枚举的值。sla_deadline 是超时时间,创建时根据优先级自动计算,高优先级 4 小时、普通优先级 24 小时,后面的定时任务靠它扫超时单。

t_ticket_record 这张表新手最容易忽略,但它才是审计的关键。每一次状态变更都要插入一条记录,内容包含谁操作的、做了什么动作、写了什么结论。以后查「这个工单为什么拖了三天」,直接按 ticket_id 查这张表。主表不存操作历史,行体积小,列表查询速度才稳。

建表时注意三点。ticket_no 建唯一索引防止并发重复;status、assignee_id、priority 建联合索引,覆盖列表页最常用的筛选组合;t_ticket_record 的 ticket_id、created_at 建联合索引,操作记录只按单查和历史排序。下面是简化后的建表脚本:

CREATE TABLE t_ticket ( id BIGINT AUTO_INCREMENT PRIMARY KEY, ticket_no VARCHAR(32) NOT NULL, title VARCHAR(200) NOT NULL, description TEXT, priority TINYINT NOT NULL DEFAULT 2 COMMENT '1高 2普通 3低', status VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED', assignee_id BIGINT DEFAULT NULL, creator_id BIGINT NOT NULL, dept_id BIGINT DEFAULT NULL, sla_deadline DATETIME DEFAULT NULL, version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_ticket_no (ticket_no), KEY idx_status_assignee (status, assignee_id, priority) );

version 字段给乐观锁用。两个处理人同时操作同一个工单,都读到 PROCESSING,一个人点击关闭、一个人点击挂起,没有锁的话后写入的人直接覆盖先写入的人。加了 version,更新时 where version = 读到的旧值,成功后 version + 1,冲突就抛异常,前端提示刷新重试。这个逻辑展开放在第 4 章的避坑部分。

3. 本地跑起来:从空环境到浏览器出页面需要几步

3.1 环境准备:JDK、MySQL、Redis、Node 的版本下限

动手之前先把环境对齐,不然坑都在后面。我一般建议:JDK 用 8 或 11,Spring Boot 2.7 在这两个版本下最稳,用 17 也可以但没必要折腾编译参数;MySQL 用 8.x 或 5.7 都能兼容初始化脚本;Redis 需要 5.0 以上,因为分布式锁用到了 SETNX;前端构建需要 Node 16 以上,npm 版本不要太老。

先验证工具链是否齐全,直接终端跑:

java -version mysql --version redis-cli ping node -v npm -v

mysql --version 有输出、redis-cli ping 返回 PONG、node -v 显示 v16 以上,环境就算过了。如果 redis-cli ping 报 Connection refused,先检查 Redis 服务有没有启动。Windows 下打开安装目录运行 redis-server.exe,Mac 和 Linux 用 redis-server 带配置文件启动,然后再测一次。

这里有个容易踩的细节:后端项目里用了 Lombok 时,JDK 版本和 IDE 编译版本不一致,编译会报「找不到符号 getXxx」。原因不是代码有问题,而是 Lombok 注解处理器没生效。解决方法是把 IDE 的 Java 编译等级和项目 pom.xml 里的 source/target 设成一致,IDE 清理后再重新编译。这类问题源码包自带的文档里未必会写,碰到了先怀疑它。

3.2 后端启动:建库、改配置、跑起来三个动作

后端启动分三步:建库、改配置、启动。源码包里会带 init.sql,里面是建库、建表、初始化管理员账号的语句。先执行它:

mysql -u root -p < init.sql

执行完登录 MySQL 确认一下,show databases 能看到工单系统的库,use 进去后 show tables 能看到六张核心表,说明初始化成功。管理员初始密码一般写在 SQL 里,是 BCrypt 加密后的值,登录后台后第一时间改掉。

接着打开后端 application.yml,重点改三处:数据库连接、Redis 连接、日志级别。简化后的配置:

spring: datasource: url: jdbc:mysql://localhost:3306/ticket_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 改成你自己的密码 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 mybatis-plus: mapper-locations: classpath:mapper/**/*.xml configuration: map-underscore-to-camel-case: true logging: level: com.example.ticket: debug

三个参数容易出问题。allowPublicKeyRetrieval=true 是 MySQL 8.0 的典型坑;serverTimezone=Asia/Shanghai 不配置,时间字段会差 8 小时;map-underscore-to-camel-case 不打开,数据库的 created_at 映射不到实体的 createdAt,查询结果全是 null。这三个问题 README 里不一定写了,跑起来马上遇到。

提示:启动报错里如果出现 Public Key Retrieval is not allowed,就是少了 allowPublicKeyRetrieval=true,加上即可。

配置改完启动后端:

mvn spring-boot:run

看到 Tomcat started on port(s): 8080 就说明后端起来了。如果 Maven 依赖下载慢,调整本地仓库镜像,不要卡在下载阶段不动。第一次构建下载依赖需要几分钟,属于正常情况。

3.3 前端联调:接口前缀、跨域与登录态

后端起来后处理前端。前端项目用 Vite 开发服务器,默认端口 5173,和后端的 8080 跨域,所以要用环境变量把接口前缀指过去。源码包里带 .env.development 示例文件,复制成真实配置,内容大致如下:

VITE_API_BASE_URL = http://localhost:8080/api VITE_APP_TITLE = 工单管理系统

VITE_API_BASE_URL 是 Axios 的 baseURL,所有请求自动拼上前缀。改完启动前端:

npm install npm run dev

浏览器打开 http://localhost:5173,用管理员账号登录。如果登录提示接口失败,先确认后端地址真的通:直接浏览器访问 http://localhost:8080/api/auth/login,能返回统一响应结构就说明后端在跑,问题多半出在跨域或请求头。

跨域处理有两种方式。后端加全局 CORS 配置,适合开发环境;生产环境用 Nginx 反向代理,把 /api 转发到 8080,前端访问同源地址。后端 CORS 配置:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:5173") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

allowedMethods 里必须加 OPTIONS。浏览器在发起带 Authorization 头的请求前,会先发一个预检请求 Preflight。如果后端拦截器把 OPTIONS 拦截了,前端会看到 CORS error,但网络请求列表里 POST 根本没发出去。登录接口因为带认证头也会触发预检,所以这个配置开发阶段必须加上,生产环境再让 Nginx 接管。

登录态这块,Axios 请求拦截器把 token 从 localStorage 拿出来加到 Authorization 头,响应拦截器统一处理 401 跳回登录页。如果登录后第一次请求就 401,检查本地存储 token 的 key 和后端拦截器读取的头名是否一致,这类问题放到下一章详细说。

4. 避坑实录:本地联调和部署最容易翻车的五处

这一章把我在本地搭这套源码和帮团队部署时翻过的车集中写出来,每条都是现象、原因、解决三段式结构,照着排查能省不少时间。

4.1 启动秒退,控制台报数据库连接超时

现象:后端启动不到两秒就退出,日志里出现 Error creating bean with name 'sqlSessionFactory',跟着 Communications link failure。

原因:大部分是数据库 URL 的 IP 或密码不对,少数情况是 MySQL 8.0 驱动要求额外参数。很多人本机装过两个 MySQL 实例,一个 5.7 一个 8.0,默认 3306 被旧实例占着,新项目连的却是新实例的 3307。

解决:先确认端口,Windows 用 netstat -ano | findstr 3306,Mac 和 Linux 用 lsof -i:3306,看实际监听端口是哪一个,再回到 application.yml 改 url 端口。如果端口没错还报 Public Key Retrieval is not allowed,就是少了 allowPublicKeyRetrieval=true。这类启动失败不要在日志里翻太久,直接检查三件套:端口、密码、URL 参数。

4.2 登录成功,但所有列表接口返回 401

现象:登录页正常拿到 token 并跳转后台,刷新后任何列表请求都返回 401,F12 看响应是 token 过期或无效。

原因:最常见的是本地存储 key 不一致。前端拦截器从 localStorage.getItem('admin_token') 读取,后端要求请求头 Authorization,但登录成功时 token 被写到了另一个 key 里。或者后端 JWT 签名密钥和校验密钥配置不一致,application-dev.yml 里配了 secretA,启动参数又覆盖成 secretB。另一个容易被忽略的是服务器时间和本地时间差太多,JWT 的 nbf、exp 字段判断失效。

解决:前端用一个常量封装 token 的读写,不要一个地方写一个地方读。后端密钥统一放一个配置项,用启动参数注入,避免多处硬编码。如果是时间不同步,检查 NTP,本地开发时把 JWT 过期时间设到 24 小时以上,减少时间漂移的影响。

4.3 前端白屏,控制台报跨域

现象:npm run dev 打开页面,界面空白,所有接口调用报 CORS error。

原因:白屏通常是 JS 报错中断了渲染,CORS error 只是表象。跨域报错要从两个方向看。如果预检请求就挂了,是后端没放行 OPTIONS;如果预检过了但真实请求挂,是 Access-Control-Allow-Origin 里没带当前的源地址。例如前端从 http://127.0.0.1:5173 访问,后端只配了 http://localhost:5173,hostname 不同,浏览器判定为跨域。

解决:CORS 配置里开发阶段用 allowedOriginPatterns("*") 放行,或者确保前端访问的 host 和后端白名单完全一致,包括端口。我习惯开发时统一用 localhost,不用 127.0.0.1,能少踩很多看似玄学的跨域坑。联调阶段最快验证方式是用 curl 带 Origin 头请求一次,看响应头里有没有 Access-Control-Allow-Origin。有就一定是前端源地址不匹配,逐个字符对比 Host。

4.4 工单状态卡住,点任何按钮都无反应

现象:工单列表正常显示,处理人点开始处理或关闭工单后,前端转圈、接口报 500,数据库状态没变。

原因:这个问题九成是状态机校验和乐观锁冲突。状态机里当前状态不在目标状态集合中,后端抛了 IllegalArgumentException,但全局异常处理器没捕获运行时异常,前端看到的是 500 和一段看不懂的堆栈。乐观锁冲突则发生在 version 不匹配时,程序没有做失败重试,直接把异常抛给了用户。

解决:第一步看后端日志里的异常类型。如果是 StatusTransitionException 或 OptimisticLockException,说明规则本身没错,是并发导致。处理办法是给更新接口加重试逻辑:捕获乐观锁异常后,重新查一次最新记录,把用户操作重新执行一遍。如果是状态机校验失败,后端返回明确中文提示,例如「当前状态不允许直接关闭,请先完成处理结论」。我一般接手别人写的工单系统,第一件事就是翻全局异常处理器,看有没有兜底 ExceptionHandler,没有先补一个,否则所有状态类问题都是 500,排查成本极高。

4.5 定时催办任务重复执行

现象:每天晚上 8 点催办工单的定时任务,只部署了一个后端实例,系统里却出现重复的催办记录。

原因:有没有多开副本?开发时本地起一个服务,IDE 自动重启也算第二个进程。Spring 的 @Scheduled 默认只保证单个 JVM 内不重跑,不保证跨实例。只要有两次进程同时活着,定时任务就各跑一遍,接口里有通知逻辑,就给处理人发两遍提醒。

解决:给定时任务加 Redis 分布式锁,用 SETNX 抢锁,抢到锁的实例才执行任务,锁设置合理过期时间。不能单纯用时间戳判断,因为多实例服务器时钟可能不一致。简单实现是生成一个任务唯一 ID,SET key ID EX 30 NX,返回 OK 才执行,任务跑完后删锁。用 ShedLock 现成组件也可以,但必须保证所有实例连同一个 Redis,且锁的过期时间大于任务最长执行时间,否则任务没跑完锁就释放,还是会出现重复执行。

5. 二次开发与验证:把默认工单改成你的内部流程

默认源码跑通只是第一步,真正的工作量在二次开发。我最建议先改三处:工单编号规则、SLA 超时提醒、通知渠道。编号规则优先改,它是业务感知最直接的字段。改成「年份-部门编码-当日序号」,创建工单时从 Redis 拿序号拼接,注意每天生产环境要做自然日重置。SLA 提醒是价值最大的一块,定时扫描快过期的单子:

@Component public class SlaRemindTask { @Resource private RedisTemplate<String, String> redisTemplate; @Scheduled(fixedDelay = 300000) public void scanExpiredTickets() { String lockKey = "sla:remind:lock"; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofMinutes(10)); if (!Boolean.TRUE.equals(locked)) { // 没抢到锁说明其他实例正在执行,直接返回 return; } try { // 查询 status = 'PROCESSING' 且 sla_deadline 在 30 分钟内到期 // 组装提醒消息,调用统一通知服务 } finally { redisTemplate.delete(lockKey); } } }

fixedDelay 是任务跑完后隔 5 分钟再跑一次,不会重叠执行。锁过期时间 10 分钟大于任务最长执行时间,保证极端情况下不提前释放。扫描条件建议加「距离 deadline 还有 30 分钟」的阈值,提前给处理人留反应时间,而不是超时后才催。通知渠道里,企业微信机器人或邮件是成本最低的接入方式。先把消息接口抽象成 NotifyService,后续加渠道只改实现类,不动定时任务逻辑。

验证方法我习惯分三步。第一步造数据:写临时脚本造 50 条工单,覆盖高、普通、低优先级,分别设不同创建时间,检查 SLA 展示和超时提醒是否按预期触发。第二步测权限:建只读角色和普通处理人角色,分别登录看列表、详情、操作按钮是否正常隐藏,这一步经常能发现前端按钮显隐只做了路由判断、没做接口鉴权的漏洞。第三步做并发:用 JMeter 模拟 100 个用户同时提交工单,重点看 ticket_no 是否重复、乐观锁冲突率多高,再打开 t_ticket_record 确认每一步状态流转都有记录。

从那以后我每次接触带状态流转的系统,都强制自己先画一张完整的状态迁移图再动代码,起因就是在这套源码上吃过少画一条边、线上工单全部卡死的亏。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询