☰
基于SpringBoot的维修工单系统:从状态机设计到分布式锁部署实战
2026/10/7 9:45:04 网站建设 项目流程

简介:基于SpringBoot的维修工单系统是一份面向毕业设计、课程设计及Java全栈学习者的完整项目源码资源。系统覆盖工单创建、分配、执行、完成到反馈的全周期流程,后端采用SpringBoot提供RESTful API与数据库交互,前端使用Vue.js构建动态交互界面,并包含权限控制、数据统计与报表生成等进阶功能,适合用于理解前后端分离开发模式及企业级工单管理场景。资源包共55个文件,主要包括33个Java源码文件、11个HTML页面、4个properties配置、2个XML配置,以及README说明文档、Maven构建脚本等,压缩包仅50KB,整体结构清晰,便于快速导入IDE运行与二次开发。目前已有61人学习下载。通过该资源可以获得完整的前后端工程代码、数据库配置说明、项目构建与部署指引,帮助快速掌握SpringBoot+Vue项目的实战开发技巧。

1. 维修工单系统最难的从来不是增删改查:SpringBoot在这里到底承担什么

一个小团队或后勤部门最常用的内部工具,可能就是报修和派单。用Excel登记、微信群喊一嗓子,工单一旦多了,漏处理、没人接、超时没人管几乎是必然结果。基于SpringBoot的维修工单系统要解决的,不是把报修信息存进数据库这么简单,而是把“谁提交、谁接单、处理到哪一步、卡在谁手里”这条状态链钉死,让每一单都有据可查、有责可追。这也是这类系统最有价值的部分:查得清、催得动、统计得出。

对正在做毕业设计、课程设计,或者打算在公司内部搭一套轻量报修系统的开发者来说,这套SpringBoot方案是最常见也最容易上手的选择:单体内聚、启动快、部署不挑环境,配一个MySQL就能把整个业务跑起来。真正需要花心思的,是状态机的设计、接口的权限边界,以及上线之后那些“本地没事、部署就挂”的问题。接下来的内容会按照做这套系统的顺序,从表结构讲到部署避坑,全部是可复现的细节。

2. 先把工单状态机画对:表结构、角色权限与SpringBoot项目结构

2.1 状态机与角色权限:先定规则再写代码

维修工单系统最容易出现的返工,就是状态字段失控。有人用int随机填,有人用字符串存中文,还有人把状态变化直接写死在十几个Controller方法里。结果做到一半,需求要加一个“待验收”状态,几乎要把所有接口翻一遍。我现在的做法是,在写任何代码之前,先画一张状态流转表,把可能出现的状态和允许的跳转路径固定下来。

以最常见的维修流程为例,状态可以定义成:0待接单、1处理中、2待验收、3已结束、4已驳回、5已取消。合法流转是:0→1(接单)、0→5(用户取消)、1→2(维修工提交完成)、1→4(驳回)、2→3(用户确认结束)、2→1(验收不通过退回)。这张表的价值在于,Service层的updateStatus方法只需要一张Map或switch做合法性校验,非法跳转直接抛业务异常,绝不让Controller直接去setStatus。这样即使后面加了管理员改派、重新指派,状态流的边界依然是清晰的。

角色权限上,内部系统不建议一上来就上Spring Security全家桶。工单系统的角色就三类:普通用户(提交工单、确认验收)、维修工(接单、处理、填写结果)、管理员(分派、驳回、看统计报表)。用拦截器加一个简单的角色注解就能撑住。前后端分离的话,在SpringBoot侧写一个HandlerInterceptor,从请求头里拿token解析出userId和role,放到ThreadLocal里,Service层直接从上下文取当前操作人。等业务真的复杂到需要动态权限、细粒度数据权限的时候,再迁移到Spring Security也不迟。

2.2 三张核心表与SpringBoot项目结构

工单系统的表可以做得很多,但最小可用集是两张主表加一张日志表:工单主表work_order、流程记录表work_order_log、附件表work_order_attachment。很多教程喜欢把所有字段塞进work_order一张表,流程记录用备注字段存,这在工单量小的时候没问题,可一旦要统计“每个维修工平均处理时长”“驳回率”“超时率”,没有独立的log表会非常痛苦。

work_order表的核心字段包括:id、order_no(工单编号)、title、description、reporter_id(提交人)、assignee_id(指派人/维修工)、status(当前状态)、urgent_level(紧急程度)、version(乐观锁版本号)、created_at、updated_at。索引上,status和created_at联合索引基本够用,因为列表页最常见的查询是“某状态下的工单按时间倒序”。work_order_log表记的是每次状态跳转:order_id、operator_id、from_status、to_status、action、remark、created_at。这张表不参与业务写操作,只做追加,是后续统计和排障的底牌。

CREATE TABLE `work_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '工单编号', `title` varchar(100) NOT NULL COMMENT '报修标题', `description` text COMMENT '报修描述', `reporter_id` bigint NOT NULL COMMENT '提交人ID', `assignee_id` bigint DEFAULT NULL COMMENT '维修工ID', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0待接单 1处理中 2待验收 3已结束 4已驳回 5已取消', `urgent_level` tinyint NOT NULL DEFAULT '0' COMMENT '0普通 1加急', `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, PRIMARY KEY (`id`), KEY `idx_status_created` (`status`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维修工单主表'; CREATE TABLE `work_order_log` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_id` bigint NOT NULL COMMENT '工单ID', `operator_id` bigint NOT NULL COMMENT '操作人ID', `from_status` tinyint DEFAULT NULL, `to_status` tinyint NOT NULL, `action` varchar(50) NOT NULL COMMENT '动作描述', `remark` varchar(255) 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='工单流程记录表';

这两张表创建之后,对应的SpringBoot项目结构就清晰了。常见的做法是按业务模块分包,而不是按技术层往死里分层。比如com.company.workorder下面直接分controller、service、mapper、entity、dto,其中entity对应数据库表,dto对应前端入参。这样单人维护或交接到下一任手里,都只需要打开一个包就能看懂整个业务链路。

项目里还要注意MyBatis-Plus的全局配置默认开启了驼峰映射,也就是说order_no会自动映射到orderNo字段,不用手写一堆resultMap。如果沿用我上面的表结构,实体类里对应写Long id、String orderNo、Integer status、Integer version就行。写updateById的时候,MyBatis-Plus会自动带上id条件,但状态更新必须用更新的UpdateWrapper显式加上status条件,避免把并发场景下的脏写带进系统,这个在后面的避坑章节会展开说。

3. 用SpringBoot把核心接口写出来:自动装配、MyBatis-Plus与定时任务

3.1 SpringBoot自动装配原理:为什么加了starter就能跑

很多同学第一次接触SpringBoot项目时,最大的困惑是:我只加了一个spring-boot-starter-web依赖,为什么Tomcat就能起来、@RestController就能用?这背后是SpringBoot的自动装配机制,也正是面试里高频的“springboot自动装配原理”。SpringBoot在启动时,会扫描依赖jar包里的自动配置类,根据当前classpath下有没有对应的类、有没有用户自定义的Bean,来决定要不要把这个配置生效。

SpringBoot 2.7及之前,自动配置类声明在META-INF/spring.factories里;从SpringBoot 3.0开始,改成了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件。这个差异在升级版本的时候很容易踩坑,如果你在别人的项目里看到spring.factories里有一大串AutoConfiguration,但项目用的是SpringBoot 3.x,那这个自动配置大概率不会生效。自定义自动配置类的模样,核心就是下面这个框架:

@AutoConfiguration @ConditionalOnClass(WorkOrderService.class) @EnableConfigurationProperties(WorkOrderProperties.class) public class WorkOrderAutoConfiguration { @Bean @ConditionalOnMissingBean public WorkOrderService workOrderService() { return new WorkOrderService(); } }

这段代码的逻辑是:当classpath下存在WorkOrderService时,这个配置才有资格生效;同时如果容器里已经有一个WorkOrderService的Bean,就以用户自定义的为准,不再重复创建。@ConditionalOnClass和@ConditionalOnMissingBean是自动装配里最常见的一对条件注解,前者保证“依赖没到就不启动”,后者保证“用户有自定义就不覆盖”。理解了这套机制,再用到各种starter时就不会再觉得是黑匣子了。

3.2 创建工单接口:从Controller到Mapper的完整写法

工单最核心的接口就是“提交工单”。这里要特别注意:Controller只负责收参数和返回结果,真正的状态初始化和流程记录必须放在Service层。常见的错误做法是Controller里直接new一个WorkOrder然后save,还会漏写log,后面查“这个单子谁什么时候提交的”就找不着北了。

@RestController @RequestMapping("/api/order") public class WorkOrderController { @PostMapping public Result<String> create(@RequestBody @Validated CreateOrderDTO dto) { return Result.ok(orderService.create(dto)); } }
@Transactional(rollbackFor = Exception.class) public String create(CreateOrderDTO dto) { WorkOrder order = new WorkOrder(); order.setOrderNo(generateOrderNo()); order.setTitle(dto.getTitle()); order.setDescription(dto.getDescription()); order.setUrgentLevel(dto.getUrgentLevel()); order.setStatus(WorkOrderStatus.PENDING); order.setReporterId(SecurityUtils.getUserId()); this.save(order); OrderLog log = new OrderLog(); log.setOrderId(order.getId()); log.setOperatorId(order.getReporterId()); log.setFromStatus(null); log.setToStatus(WorkOrderStatus.PENDING); log.setAction("提交工单"); orderLogService.save(log); return order.getOrderNo(); }

这里有两个参数值得细看。第一个是@Transactional(rollbackFor = Exception.class),默认情况下Spring事务只在RuntimeException时回滚,如果业务方法里抛的是自定义的CheckedException,不加rollbackFor会导致工单保存成功、日志写入失败,两边数据不一致。第二个是generateOrderNo(),工单编号我习惯用WO加年月日再加四位随机数,比如WO202501150037,避免直接用自增id暴露工单量,同时排序也直观。

CreateOrderDTO里放的是前端传过来的title、description、urgentLevel,userId不从前端拿,从登录上下文取。这一点务必遵守,否则用户改一下请求体就能冒充别人提交工单,属于最基本的接口安全红线。

3.3 定时任务与超时提醒:让工单自动流转起来

维修工单系统里一定要有定时任务,不然“超时未处理”就永远只能靠人肉催。SpringBoot里做定时任务只需要两步:启动类或配置类上加上@EnableScheduling,然后在方法上标注@Scheduled。下面是一个典型的“超时未接单自动报警”任务:扫描所有待接单且创建时间超过24小时的工单,把状态标记为超时,同时给管理员推送一条提醒。

@Component public class OrderTimeoutTask { @Scheduled(fixedDelayString = "${task.timeout.delay:60000}") public void scanTimeoutOrders() { List<WorkOrder> list = workOrderService.list( new LambdaQueryWrapper<WorkOrder>() .eq(WorkOrder::getStatus, WorkOrderStatus.PENDING) .lt(WorkOrder::getCreatedAt, LocalDateTime.now().minusHours(24)) .last("limit 200")); for (WorkOrder order : list) { order.setStatus(WorkOrderStatus.TIMEOUT); workOrderService.updateById(order); orderLogService.record(order.getId(), "system", WorkOrderStatus.PENDING, WorkOrderStatus.TIMEOUT, "超时未接单"); } } }

这里fixedDelayString = "${task.timeout.delay:60000}"的意思是:从配置文件读取task.timeout.delay,读不到就用默认值60000毫秒。注意fixedDelay是上一次执行完成后再等60秒,fixedRate是每60秒执行一次开始,cron则按表达式走。对于扫描型任务,fixedDelay更安全,因为如果某一次扫描耗时较长,不会出现两个任务重叠执行的情况。但要注意,@Scheduled默认只在单线程池里跑,如果有多个定时任务互相阻塞,就必须给SchedulingConfigurer单独配置线程池,这个坑在避坑章节会展开讲。

4. 从本地到服务器:Vue打包放进SpringBoot与Docker部署的完整路径

4.1 Vue打包放进SpringBoot:前端刷新404怎么解决

很多基于SpringBoot的维修工单系统,前端用Vue做页面,后端用SpringBoot,中间通过接口通信。部署时最省事的方式是:前端执行npm run build,然后把dist目录里的文件全部复制到src/main/resources/static目录下,和SpringBoot打成一个jar包。这样一个Tomcat端口同时服务前端页面和接口,不用单独为前端开Nginx,小项目完全够用。

但这里有一个高频翻车点:Vue如果开了history路由(即URL里没有#号),用户直接访问/order/list并刷新页面,SpringBoot会认为这是个服务端请求,结果返回404。原因是前端路由是浏览器端的跳转,后端根本没有这个静态资源。常见做法是加一个视图控制器兜底,把不包含点号的所有路径转发到index.html:

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{path:[^\\.]*}") .setViewName("forward:/index.html"); } }

这段配置的含义是:只要URL路径里不含点号(排除静态文件),就一律转发到index.html,由Vue前端路由去解析。加了这段之后,刷新页面不会再404,同时接口路径如/api/order不受影响,因为/api/order是Controller映射,优先级高于视图控制器。接口和页面的路由冲突问题,归纳起来就是一句话:后端路由管/api,前端路由管页面,静态资源兜底转发。

4.2 application.yml配置:端口、数据库、文件上传这些最容易翻车的点

application.yml是SpringBoot项目运维最需要抠细节的地方。维修工单系统的典型配置长这样:

server: port: 8080 servlet: context-path: / spring: datasource: url: jdbc:mysql://localhost:3306/work_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: ${DB_PASSWORD} servlet: multipart: max-file-size: 20MB max-request-size: 50MB mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

这个配置里有几个细节值得较真。第一是数据库连接串里的serverTimezone=Asia/Shanghai,不写这个参数,MySQL驱动和JVM本地时区不一致,会出现“存进数据库的时间比实际慢了/快了8小时”的玄学问题。第二是multipart配置,不限制的话,默认上传文件大小只有1MB,用户传一张维修现场照片就报错。第三是password用${DB_PASSWORD}读取环境变量,这个习惯比把明文密码写死在yml里强太多,尤其项目要交给别人维护的时候。

还要注意context-path这个参数。如果配了/server,那么所有接口都要变成/api/order加上/server前缀,前端打包进static后,页面里的相对路径很容易因为前缀问题白屏。我的建议是小项目一律不配context-path,保持根路径,少一个变量就少一个坑。

4.3 用宝塔Docker部署SpringBoot:镜像构建与版本选择

部署阶段,最常见的落地做法是宝塔面板里的Docker管理器来构建和运行。先把项目打成jar包,或者直接在服务器上用多阶段Docker构建,Dockerfile写法如下:

FROM maven:3.8-jdk-11 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn clean package -DskipTests FROM openjdk:11-jre-slim WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app.jar"]

这个Dockerfile把构建和运行拆成了两个阶段:第一阶段用Maven镜像编译出jar包,第二阶段只拷贝jar到精简的JRE镜像里。这样最终镜像里没有Maven和源码,体积能小一半以上。如果是SpringBoot 3.x的项目,基础镜像要换成maven:3.9-jdk-17和eclipse-temurin:17-jre,JDK版本对不上,启动直接报UnsupportedClassVersionError。

说到版本,就一定要提“springboot版本太高”这个话题。SpringBoot 2.x和3.x之间不只是JDK版本要求不同,包名也变了。3.x把javax.servlet换成了jakarta.servlet,所有依赖这个坐标的老版本第三方库都会编译失败。MyBatis-Plus在SpringBoot 3.x下必须用3.5.3以上的版本,否则启动报错。用宝塔部署时,如果镜像里的JDK版本不对,症状是启动一两秒就退出,docker logs一查基本上都是版本问题,这类问题排查起来最省时间的办法就是先确认SpringBoot版本、JDK版本、MyBatis-Plus版本三方匹配。

5. 维修工单系统常见坑排查与避坑指南

5.1 并发更新:为什么两个接单操作会把工单状态改乱

现象:两个维修工同时点击“接单”,两个请求都查到status=0,都执行setStatus(1),最后都返回成功。但实际只有一个应该成功,另一个必须提示“工单已被接走”。

原因:updateById默认只以id作为更新条件。第一次更新成功后status已经变成1,但第二个请求在点击时已经读到了旧状态,发出的update语句因为没有status条件,依然会把status覆盖为1并向日志里插入一条“接单”记录,看起来两边都成功了。

解决:更新时显式带上status条件,配合MyBatis-Plus的乐观锁@Version。实体类的version字段加上@Version注解后,updateById会自动生成UPDATE work_order SET status=1, version=version+1 WHERE id=? AND version=旧值。两个并发请求只有一个能命中version条件,另一个影响行数为0,在Service里检查int值即可判断是否抢单成功。这是维修工单系统上并发场景下最值得做的一道防护。

5.2 定时任务不执行与文件路径消失

现象:本地IDE里启动,定时任务每隔一分钟正常执行。打成jar包部署到服务器后,到点不跑了,或者跑了一次就再也不动。

原因:常见的有两种情况。第一种是启动类没有加@EnableScheduling,本地可能因为IDE里其他配置类间接开启了定时,但jar包环境没有。第二种是多个@Scheduled任务共用默认单线程池,某个任务执行时间过长或抛出异常,把调度线程阻塞了,后面的任务全部卡死。要注意,@Scheduled方法如果抛出未捕获异常,这个任务的调度不会恢复,日志里可能什么都没留下。

解决:明确在启动类或独立配置类上写@EnableScheduling,同时把定时任务线程池调整到4个以上,见前面第3章的配置代码。更稳妥的做法是每个定时方法内部用try/catch包住业务逻辑,异常打到独立日志里,绝不让异常往外穿透。

文件上传路径消失则是另一个经典问题:本地运行时把文件写到项目的uploads目录,jar包部署后这个相对路径实际上指向了java命令运行的工作目录,一旦用systemd或Docker启动,这个目录的位置非常难找,有时还在临时目录里,重启后文件全没了。解决方式是文件存储路径不写相对路径,读取配置项file.upload-dir,Docker部署时通过挂载卷把宿主机的/data/work-order-files映射到容器内目录,数据才留得下来。

5.3 SpringBoot版本迁移与前端路由白屏

现象:项目从SpringBoot 2.x升级到3.x,启动时报ClassNotFoundException,报错信息里是javax.servlet.*相关类。

原因:SpringBoot 3.x使用了Jakarta EE 9规范,原来的javax包名整体迁移到jakarta。项目里如果手动依赖了老版本的PageHelper、Druid、或某些工具库,这些库编译时写死了javax坐标,在3.x运行时就会找不到类。

解决:先升级这些第三方依赖。Druid要1.2.9+,PageHelper要5.3.2+,MyBatis-Plus要3.5.3+。如果某个库已经不维护,就果断换掉。还有一个实际经验:SpringBoot 3.x最低要求JDK17,如果服务器上只有JDK8,想跑新版SpringBoot就需要先升级JDK,这是一条硬性门槛,不要试图绕过。

前端路由白屏的坑也一样常见,做完第4.1节的视图控制器转发之后,要记得把static目录下的index.html里的静态资源路径改成相对路径或绝对路径。有些Vue项目默认的publicPath是/,打出来的包在根路径访问没问题,一旦部署在子路径或通过nginx转发到子目录,页面会是白屏。把vue.config.js里的publicPath改成'./'最省心,这样打包出来的引用路径都是相对路径,放进SpringBoot的static目录后,不管context-path怎么变都不会白屏。

6. 给定时任务加上分布式锁:一个必会的小技巧

6.1 实现与验证

如果工单系统只部署一个实例,@Scheduled完全够用。可一旦上了两台服务器做负载均衡,同一个定时任务会在两个实例上各跑一遍,超时工单会被重复扫描、重复记录日志,用户会收到两遍一模一样的催办通知。这个问题的标准解法是给定时任务加分布式锁,最常见的实现是Redis的SETNX。

@Component public class OrderTimeoutTask { @Scheduled(fixedDelayString = "${task.timeout.delay:60000}") public void scanTimeoutOrders() { String lockKey = "work_order:timeout_lock"; String requestId = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMinutes(1)); if (!Boolean.TRUE.equals(locked)) { return; } try { // 执行真正的超时扫描逻辑 doScan(); } finally { releaseLock(lockKey, requestId); } } }

setIfAbsent是SETNX语义,如果key不存在就设置成功并加过期时间,存在则不做任何操作。requestId用来标识当前线程,释放锁时不能直接del(lockKey),而是要用requestId做对校再删除,防止“锁过期之后误删别人刚拿到的锁”。严谨的做法是用Lua脚本保证“取值、比较、删除”三步原子执行;如果项目里没引入Redis,也可以用数据库行锁(SELECT * FROM work_order FOR UPDATE)或者ShedLock框架,但Redis方案是维护成本最低的。

这个技巧我从翻车里学到的教训是:单机环境下永远测不出分布式锁的问题。我当时用两个实例做验证,一个实例正常跑,另一个实例的日志里没有任何扫描输出,说明锁的竞争逻辑生效了。如果你在测试环境没有条件起两个实例,也可以在本地改两个端口,同时启动同一份打包产物来观察,看到只有一份日志打印,就说明锁没有白写。这套逻辑往后不管做订单系统、库存系统还是审批系统,凡是定时任务写状态变更,我都会默认加一道锁。希望帮到你。

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

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

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

立即咨询