ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Java的高校后勤报修系统:Spring Boot+Vue全栈设计与实现

基于Java的高校后勤报修系统:Spring Boot+Vue全栈设计与实现

简介:基于JavaWeb的高校后勤报修系统设计与实现文档,面向高校计算机专业毕业生、Java开发学习者及后勤信息化项目人员,针对传统人工报修管理效率低、数据处理滞后等问题,提供从需求分析、系统设计到功能测试的完整方案。文档以B/S架构为基础,围绕IntelliJ IDEA、Java、MySQL与SSM框架展开技术选型,详细说明报修时间、报修人、报修内容、处理状态等数据表设计,并涵盖可行性分析、数据库规划、模块测试及主要功能界面说明,有助于理解SSM框架整合流程、数据库表结构规划方法和B/S模式下报修业务的功能落地。资源整体结构完整、层次分明,可作为毕业设计论文撰写、课程设计或项目开发的参考资料;全部内容共1个docx文件,压缩包约3.05MB,内含中英文摘要、目录及核心章节,便于直接查阅。已有66人学习浏览,适合需要快速获取高校后勤报修系统设计思路与实现细节的读者。

1. 高校后勤报修系统:为什么我说这是Java课设里最值得做的方向

宿舍灯管坏了报修三天没人管,水龙头漏水后勤处电话永远占线,维修师傅手上攒了一沓手写工单分不清谁先谁后——这类场景你只要在大学住过一年,大概率都亲历过。基于Java的高校后勤报修系统,本质就是把这个低效的线下流程搬到线上:学生提交报修单、后勤管理员派单、维修工接单完工、师生评价回访,全链路数据化。它不是那种为了凑课设硬造出来的玩具项目,而是真实存在需求、流程边界清楚、技术点覆盖合理的信息管理系统,拿来做Java方向的设计与实现,性价比很高。

这套系统的核心价值在于:业务闭环清晰——报修不是“提交完就结束”,而是要有状态流转、超时提醒、满意度评价;角色权限明确——学生、维修工、后勤管理员各看各的数据,互不越权;技术栈通用——Spring Boot + MyBatis-Plus + MySQL + Vue(或Thymeleaf)的组合,既覆盖了Java后端主流技能点,又能让答辩时有东西可讲。适合的人群很广:正在选毕业设计题目的本科生、想巩固SSM/Spring Boot全流程的Java学习者,以及需要一套可二次开发模板的后勤信息化小团队。

2. 业务闭环与角色权限:三层工单状态机为什么是必修课

业务建模是这类系统的主心骨,数据库表和接口的成败都压在这里。很多初次做管理系统的人一上来就画表建库,结果做到权限体系时推倒重来。正确的顺序是先把角色和工单状态边界定死,再让表结构和代码去贴合它。

2.1 四类角色与各自的数据边界

高校后勤报修系统一般会落到四类角色:学生(报修人)、维修工(接单人)、后勤管理员(派单与复核人)、系统管理员(维护基础数据)。前三类是业务角色,第四类负责账号初始化和字典维护。

角色的核心诉求各不一样,对应到功能设计上是这样的:

学生侧要的是“提交容易、看得见进度”——报修单需要支持文字描述、图片上传、位置选择(宿舍楼栋/房间号)、联系方式留手机号或微信;同时能查自己的工单列表和状态,完工后能打分评价。

维修工侧要的是“任务聚焦、不扯皮”——只看自己被派的工单,能接单(对应“待接单”状态)、上报维修结果(对应“完工”状态),如果发现是配件问题可以挂起申请采购,这个在高级设计里才做,但权限表里要预留。

后勤管理员侧是最重的角色,要能看到所有工单,按楼栋或报修类型筛选,手动派单或按“维修工当前工单数最少”的规则自动派单,还要能处理超时未接单的工单,以及查看维修工的工作量统计。

系统管理员则维护楼栋、房间、维修工种分类(水工、电工、木工)等基础字典数据。

这里有一个常见的权限设计误区:只做菜单级权限(谁能看到这个页面),不做数据级权限(谁能看到哪些行数据)。实际业务里最痛的就是数据越权——维修工A能看到维修工B的工单列表,这放在真实后勤场景里是不能接受的。所以设计的时候我会把用户ID、楼栋ID作为数据过滤条件的常驻维度,而不是靠前端按钮隐藏。

角色核心用例数据可见范围状态变更权限
学生提交报修单、查询进度、评价本人提交的工单提交、评价(仅限已完工)
维修工查看被派工单、接单、上报完工被指派给自己的工单接单、完工
后勤管理员派单、改派、稽核、统计全部工单派单、改派、驳回
系统管理员账号管理、字典维护系统配置数据无业务状态变更权

这个表的精妙之处在于,每个角色变更的状态是有边界的,不允许跨状态操作。比如维修工不能把“待派单”直接改成“完工”,必须等后台派单后才能接单。把这张表在需求阶段定下来,后面做接口校验就有据可依。

2.2 工单状态机:五个状态与流转条件

工单状态是整个系统的核心状态机,建议设计成五个状态:待派单→待接单→维修中→待验收→已完工,外加一个“已驳回”作为异常分支。

待派单是学生提交后的初始态,只有后勤管理员能看到所有待派单;管理员指派维修工后进入待接单,待接单只有对应维修工能看到;维修工点击接单进入维修中,此后可以上报完工,进入待验收;学生或管理员确认无误,核销工单进入已完工;如果学生提交时信息不完整,管理员可以驳回并在驳回理由里写明原因,工单回到学生侧可修改后重新提交。

这个五态流转看起来简单,但落地时有个容易翻车的细节:状态变更不是只改一个status字段就行,还要在记录表里追加一条变更流水,记录“谁在什么时间把状态从X变成Y”。这个设计在答辩时属于加分项——它说明你考虑了可追溯性和审计需求。对应数据库上的设计是,工单主表只存当前状态,工单流水表存每次变更记录。

用代码表达状态流转校验,我习惯写一个简单的状态机校验工具类:

public class RepairOrderStateMachine { // key: 当前状态 + "->" + 目标状态,value: 允许执行该流转的角色 private static final Map<String, List<String>> TRANSITIONS = new HashMap<>(); static { // 学生提交时产生待派单,只有管理员能派单 TRANSITIONS.put("PENDING_ASSIGN->PENDING_ACCEPT", Arrays.asList("ADMIN")); // 管理员派单后,维修工可以接单 TRANSITIONS.put("PENDING_ACCEPT->REPAIRING", Arrays.asList("WORKER")); // 维修中 -> 待验收,维修工上报完工 TRANSITIONS.put("REPAIRING->PENDING_VERIFY", Arrays.asList("WORKER")); // 待验收 -> 已完工,学生或管理员核销 TRANSITIONS.put("PENDING_VERIFY->FINISHED", Arrays.asList("STUDENT", "ADMIN")); // 任何状态 -> 已驳回,管理员可驳回 TRANSITIONS.put("ANY->REJECTED", Arrays.asList("ADMIN")); } public static boolean canTransition(String currentStatus, String targetStatus, String role) { if (!TRANSITIONS.containsKey(currentStatus + "->" + targetStatus)) { return false; } return TRANSITIONS.get(currentStatus + "->" + targetStatus).contains(role); } }

这段代码的逻辑说明:TRANSITIONS表定义了合法流转路径以及每个路径对应有权限的角色。canTransition方法返回一个布尔值,在Service层更新状态前调用。如果返回false,就直接抛业务异常,不允许走到SQL更新那一步。这样做的好处是状态校验集中在单一类里,不会散落在各个Service方法中,后续如果要加一个“已挂起”状态,改这一处即可。注意参数说明:currentStatus和targetStatus对应枚举值如“PENDING_ASSIGN”,role是当前登录用户的角色编码,这三个参数缺一不可。

这里还有一层:状态机的校验不能只在后端做,前端也要根据当前状态决定按钮展示。后端是安全底线,前端是体验优化,两者都要做,不要因为后端有了校验就忽略前端交互。

2.3 用户认证与技术栈选型

认证方案在课设/毕设场景里,不要上Spring Security + OAuth2那一套重型组合,学习成本高,答辩也不好讲。我一般推荐Session + 拦截器方案,或者Spring Boot 2.7后的Spring Security的简单表单登录。更轻量的做法是自定义一个AuthInterceptor,在HandlerInterceptor里校验Session中是否存在loginUser,路径白名单放行登录接口和静态资源。

技术栈的选型取决于你希望展示的层次:

基本盘方案是Spring Boot + MyBatis-Plus + MySQL + Thymeleaf,适合做单体内聚、页面直接用模板渲染,少一套前后端联调的痛苦;进阶方案是Spring Boot + MyBatis-Plus + MySQL + Vue + Axios,前后端分离,需要处理跨域但展示面更好,和热搜词里大量“基于springboot vue”的项目方向一致。两者都能做,关键看你的时间预算——如果距离答辩还有一个月以上,建议上Vue分离式;如果只有两周,Thymeleaf更稳妥。

我在实际项目里用的是第二种。跨域配置放在一个WebMvcConfigurer里,allowedOriginPatterns写前端IP地址,允许携带Cookie,maxAge设3600秒。这块在后端单独拆一个CorsConfig类,不要写在启动类里,脏。

3. 数据库设计三张核心表:报修单字段与MyBatis-Plus的四个坑

数据库是这类“基于java的设计与实现”里老师最看重的一部分。表建得合理,直接体现你对业务流程的理解深度。报修系统的核心表我一般拆成三张基础表和一张扩展表:用户表、报修单表、工单流转记录表、附件表(或者把附件字段内嵌到报修单表里,视图片数量而定)。

3.1 报修单主表的设计与建表SQL

报修单主表是整张数据模型的重心,字段设计要覆盖业务流、时间流与状态流。下面是可直接使用的建表语句:

CREATE TABLE `repair_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `order_no` VARCHAR(32) NOT NULL COMMENT '工单编号,格式:BX+yyyyMMdd+4位随机数', `student_id` BIGINT NOT NULL COMMENT '报修学生用户ID', `building_id` BIGINT NOT NULL COMMENT '楼栋ID,关联building表', `room_name` VARCHAR(64) NOT NULL COMMENT '房间号,如 3-512', `repair_type` VARCHAR(32) NOT NULL COMMENT '报修类型:WATER/ELECTRIC/CARPENTRY/OTHER', `description` TEXT COMMENT '问题文字描述', `contact_phone` VARCHAR(20) COMMENT '联系电话', `appointed_time` DATETIME COMMENT '期望维修时间,可为空', `status` VARCHAR(20) NOT NULL DEFAULT 'PENDING_ASSIGN' COMMENT '当前状态:PENDING_ASSIGN/PENDING_ACCEPT/REPAIRING/PENDING_VERIFY/FINISHED/REJECTED', `assign_worker_id` BIGINT DEFAULT NULL COMMENT '被指派的维修工用户ID', `assign_time` DATETIME DEFAULT NULL COMMENT '派单时间', `accept_time` DATETIME DEFAULT NULL COMMENT '维修工接单时间', `finish_time` DATETIME DEFAULT NULL COMMENT '完工时间', `evaluation_score` TINYINT DEFAULT NULL COMMENT '评价分数 1-5', `evaluation_comment` VARCHAR(500) DEFAULT NULL COMMENT '评价内容', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', PRIMARY KEY (`id`), KEY `idx_order_no` (`order_no`), KEY `idx_student_id` (`student_id`), KEY `idx_status` (`status`), KEY `idx_assign_worker_id` (`assign_worker_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报修工单主表';

参数说明:order_no是业务编号,不要拿主键id直接展示给学生,一是泄露数据量,二是感受不专业。编号生成规则我用“BX + 日期 + 4位随机数”,在高并发量不大的场景(日均千单以内)足够。status字段用字符串枚举而不是int,查询时可读性强,不需要join字典表。assign_worker_id是外键逻辑关联,我平时不在DDL里写物理外键约束,因为维修工配置变更或账号删除时会牵制主表,用逻辑关联可以避免那些“删除失败”的莫名问题。索引方面,student_id和assign_worker_id是查询高频字段,必须建索引。

这里有一个细节值得注意:is_deleted字段加上了,这是应付MyBatis-Plus逻辑删除标配的,后面配置@TableLogic就用得上。

3.2 用户表与流水表:权限落地的两翼

用户表不需要多解释,核心注意点是role字段和building_scope字段。building_scope存维修工负责的楼栋ID集合(JSON数组或多个逗号分隔ID),这是后面做数据权限过滤的核心依据。

CREATE TABLE `sys_user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录账号', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密存储', `real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名', `role` VARCHAR(20) NOT NULL COMMENT 'STUDENT/WORKER/ADMIN/SYS_ADMIN', `phone` VARCHAR(20) DEFAULT NULL, `building_scope` VARCHAR(255) DEFAULT NULL COMMENT '管理员或维修工负责的楼栋ID集合', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '账号状态:1启用,0禁用', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表';

工单流转记录表的逻辑在上面状态机部分已经讲过,但它也很关键,用来记录每一次状态跳转。要做到按时间追溯,所以最少要有order_id、from_status、to_status、operator_id、remark、create_time六个字段。这张表的数据量会随着工单累积越来越多,是合理的。需要注意的是:不要在Service层手动去插这张表,而是用MyBatis-Plus的元对象处理器或者AOP自动完成,否则并发操作下容易漏记录。

3.3 附件存储:路径入库还是Base64入库

学生提交报修单时往往会拍一张现场照片,这是刚需。附件落地的坑不少,常见做法有两个方向:把图片压缩成Base64字符串直接存TEXT字段,和把图片文件传到服务器磁盘然后数据库只存访问路径。

Base64方案的优点是数据完整、迁移方便(备份一个SQL就全带走了),缺点是单张图片塞进JSON传输会撑大请求体,并且大量行读取时内存开销明显。文件路径方案是生产环境的常规选择,但需要额外处理静态资源映射、文件大小校验、命名冲突避免,还要给上传目录设置写权限——这些细节多且烦琐。

我做课设和内部系统时选中间路线:图片压缩到200KB以内再存Base64,因为高校内部并发量有限、图片尺寸控制住之后性能无感,而且答辩演示时不用额外配置nginx或者虚拟路径。Base64字段用MEDIUMTEXT类型,一张图大小控制在250KB以内换算后约340KB字符。如果后续要接OSS或MinIO,字段里再存URL即可,影响面很小。

3.4 MyBatis-Plus配置的四个高频坑

我见过的报修系统相关代码里,MyBatis-Plus用得很多,但坑也集中。第一个坑是逻辑删除字段没加@TableLogic注解,导致删除操作变成物理DELETE,is_deleted形同虚设。第二坑是update_time没有自动填充,需要手动set,经常被漏掉。第三坑是分页插件拦截器没有配置,selectPage返回全量数据。第四坑是主键策略用了ASSIGN_ID(雪花ID),在非分布式场景下主键看起来是大整数,不直观,统一用AUTO会好一些。

针对第二坑,用MetaObjectHandler做字段自动填充是标准解法:

@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }

这段代码的逻辑说明:MyBatis-Plus在执行insert时会自动调用insertFill方法,把createTime和updateTime两个字段填充为当前时间;执行update时调用updateFill刷新updateTime。这解决了在Service层每一处写order.setUpdateTime(LocalDateTime.now())的重复劳动,也避免遗漏。使用前提是实体类字段上加了@TableField(fill = FieldFill.INSERT)和@TableField(fill = FieldFill.INSERT_UPDATE),这个注解别落下。

4. 报修单提交到派单:Spring Boot接口设计与乐观锁防并发抢单

业务代码是系统的血肉,也是答辩时能拿出的硬货。这一节从学生提交报修单开始,到后台派单结束,串起完整链路。选这个切片是因为它覆盖了参数校验、文件处理、数据权限过滤、并发控制这几个最容易被追问的考点。

4.1 学生提交报修单:Controller层与Service层分离

先把Controller写清楚,代码讲究层次:

@RestController @RequestMapping("/api/repair") public class RepairOrderController { @Autowired private RepairOrderService repairOrderService; @PostMapping("/submit") public ApiResult<Long> submit(@RequestBody @Valid RepairOrderSubmitDTO dto, HttpSession session) { LoginUser loginUser = (LoginUser) session.getAttribute("loginUser"); if (loginUser == null) { return ApiResult.error(401, "未登录"); } if (!"STUDENT".equals(loginUser.getRole())) { return ApiResult.error(403, "仅学生可提交报修"); } Long orderId = repairOrderService.submit(dto, loginUser.getUserId()); return ApiResult.success("提交成功", orderId); } }

这段代码的逻辑说明:第一层是登录态校验,从Session取出loginUser;第二层是角色校验,防止维修工或管理员调用学生接口——这层不能省,接口越权是常见的安全漏洞;第三层将DTO和当前登录用户ID传给Service。@Valid注解配合DTO上的@NotBlank等约束完成入参校验。注意Controller里不出现任何业务判断代码,比如“状态是否允许提交”这类逻辑一律下沉到Service层。

点出参数说明:RepairOrderSubmitDTO里至少包含repairType、description、buildingId、roomName、contactPhone,其中repairType用@Pattern(regexp = "WATER|ELECTRIC|CARPENTRY|OTHER")限定取值范围,避免脏数据入库。

Service层做核心业务编排:

@Service public class RepairOrderServiceImpl implements RepairOrderService { @Autowired private RepairOrderMapper repairOrderMapper; @Override @Transactional(rollbackFor = Exception.class) public Long submit(RepairOrderSubmitDTO dto, Long studentId) { // 生成工单编号 String orderNo = "BX" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + RandomStringUtils.randomNumeric(4); // 组装实体,初始状态为待派单 RepairOrder order = new RepairOrder(); order.setOrderNo(orderNo); order.setStudentId(studentId); order.setBuildingId(dto.getBuildingId()); order.setRoomName(dto.getRoomName()); order.setRepairType(dto.getRepairType()); order.setDescription(dto.getDescription()); order.setContactPhone(dto.getContactPhone()); order.setAppointedTime(dto.getAppointedTime()); order.setStatus("PENDING_ASSIGN"); // 插入主表 repairOrderMapper.insert(order); // 插入流转记录表 repairFlowMapper.insert(order.getId(), null, "PENDING_ASSIGN", studentId, "学生提交报修单"); return order.getId(); } }

这段代码的逻辑说明:编号生成用日期加随机数,放在Service层做是为了和Insert操作保持在同一个事务里,避免表里出现大量编号重复的孤儿数据。状态设置为PENDING_ASSIGN,同时向流水表插入一条初始流转记录,保证“任何状态变更都留痕”的约束在第一个动作就生效。@Transactional(rollbackFor = Exception.class)特别重要——不加rollbackFor的话,Spring默认只在RuntimeException时回滚,而很多自定义业务异常不是RuntimeException的子类,会导致流水已插入但主表失败的数据脏读。

4.2 维修工接单:乐观锁防止两个人同时接同一单

维修工接单是系统里典型的并发场景——同一张待接单的工单,两个维修工同时点击“接单”,如果没有防护,会出现两个人都接单成功、数据库里assign_worker_id被后写入者覆盖的情况。这就是竞态条件,也叫超卖问题的变体。

解决方案是数据库层面的乐观锁。MyBatis-Plus对乐观锁有内置支持,在实体类上加@Version注解,更新时就会自动带上版本号条件。

public class RepairOrder { // 其他字段省略 @Version private Integer version; }

更新SQL在MyBatis-Plus里会被改写成类似这样的形式:

UPDATE repair_order SET assign_worker_id = #{workerId}, status = 'REPAIRING', accept_time = #{nowTime}, version = version + 1 WHERE id = #{orderId} AND version = #{oldVersion} AND status = 'PENDING_ACCEPT'

注意最后一行加了status = 'PENDING_ACCEPT'条件,这是双保险:乐观锁保证同一个工单不会被并发修改,状态条件保证“已接走的单不能被再接”。接单接口的核心判断长这样:

@Override public void acceptOrder(Long orderId, Long workerId) { // 先查一遍当前状态,提前拦截非待接单 RepairOrder current = repairOrderMapper.selectById(orderId); if (current == null) { throw new BizException("工单不存在"); } if (!"PENDING_ACCEPT".equals(current.getStatus())) { throw new BizException("工单不在待接单状态"); } if (!workerScopeService.isWorkerInBuilding(workerId, current.getBuildingId())) { throw new BizException("您不负责该楼栋的维修任务"); } // 执行带乐观锁的更新 RepairOrder update = new RepairOrder(); update.setId(orderId); update.setAssignWorkerId(workerId); update.setStatus("REPAIRING"); update.setAcceptTime(LocalDateTime.now()); int rows = repairOrderMapper.updateById(update); if (rows == 0) { throw new BizException("接单失败,工单可能已被其他维修工接走"); } // 写流转记录 repairFlowMapper.insert(orderId, "PENDING_ACCEPT", "REPAIRING", workerId, "维修工接单"); }

这段代码的逻辑说明:三层防护拼成完整的接单逻辑。第一层是状态预检查,提前拦截绝大多数请求,不浪费数据库更新开销;第二层是楼栋范围检查,确保维修工无法接非自己片区的工单,这是数据权限落在业务层的一个体现;第三层是乐观锁更新,updateById返回0表示版本号已被其他并发事务抢先修改,直接报“已被接走”。流水表的写入和主表更新在同一个事务里。

参数说明:version字段是每张工单自带的整数,每次更新自动加一。这里有个踩坑提醒——如果实体类没有加@Version注解,MyBatis-Plus不会生成版本条件,updateById会直接按主键更新,乐观锁形同虚设。检查方式看SQL日志里有没有AND version = ?条件即可。

4.3 自动派单规则:最少工单数优先

后台管理员徒手派单在工单量大时效率很差,可以写一个简单的自动派单接口。规则设计为:从候选维修工里选当前维修中 + 待接单数量最少的人。

@GetMapping("/auto-assign/{orderId}") public ApiResult<Void> autoAssign(@PathVariable Long orderId) { RepairOrder order = repairOrderMapper.selectById(orderId); if (order == null || !"PENDING_ASSIGN".equals(order.getStatus())) { throw new BizException("工单状态不允许自动派单"); } // 查询该楼栋候选维修工,按当前工单数升序 List<WorkerWorkloadVO> workers = repairOrderMapper.selectCandidateWorkers(order.getBuildingId()); if (workers.isEmpty()) { throw new BizException("该楼栋暂无空闲维修工"); } Long targetWorkerId = workers.get(0).getWorkerId(); // 复用更新逻辑,加乐观锁控制 assignToWorker(order, targetWorkerId); return ApiResult.success(); }

这里的核心不是“自动”这个动作,而是selectCandidateWorkers的SQL写法。它要做两件事:过滤出负责该楼栋的维修工;对每个维修工统计当前未完成工单数。统计逻辑建议用子查询而不是在Java内存里做,数据量大了以后内存方案会越来越吃力。SQL大概是“按assign_worker_id分组,status在待接单/维修中里计数,然后过滤building_scope包含目标楼栋的用户”。这种方式能确保每次查询都是最新的工作量快照,也算是一个值得在答辩时展开的技术点。

5. 避坑排查:并发、权限、附件与状态流转的五个翻车现场

管理系统做得多了,坑基本集中在几个固定角落。这里写五条我在这类项目里亲历过的、或者是给同学代码评审时看到的高频问题。每一条都是先给现象,再讲原因,最后给解决手段。

5.1 维修工接单“抢单成功”后工单归属错乱

现象:两个维修工同时点击接单,都看到“操作成功”,但后台工单的assign_worker_id指向了后点击的那个人,前者的已完成页面里也出现了这张单。

原因:接单接口只做了状态判断就执行update,没有带版本号条件,后写覆盖先写,这是数据库并发场景下最经典的丢失更新问题。

解决:按上文的方式引入MyBatis-Plus的@Version乐观锁,更新语句自动追加WHERE version = oldVersion和version = version + 1,更新影响行数为0时抛业务异常。加上状态条件AND status = 'PENDING_ACCEPT'做第二重保险。验收方式:用JMetre或Postman并发脚本对同一个工单发20个接单请求,最终库表里只能有1条assign_worker_id被写入。

5.2 维修工列表里出现其他人的工单

现象:维修工A登录后,在“我的工单”页面能看到维修工B处理中的工单。

原因:查询工单列表的SQL只过滤了status,没有过滤assign_worker_id = 当前用户ID。这就是数据权限漏了行级维度——菜单权限只是外壳,行级权限才是数据的边界。

解决:所有维修工查询接口的SQL统一带上assign_worker_id = #{workerId}条件,不允许在Service层手动拼接。同时后端写一个DataScopeAspect切面,在Mapper执行前动态注入权限条件,避免每个接口重复写这段过滤逻辑。注意列级数据权限(比如查看学生手机号)在字段上加脱敏注解即可,但优先级低于行级权限。

5.3 图片上传报错“MultipartException: File exceeds max size”

现象:学生上传手机拍摄原图(2MB左右)时接口直接报400,日志提示文件超出最大限制。

原因:Spring Boot默认spring.servlet.multipart.max-file-size为1MB,max-request-size为10MB。手机照片随便一拍就是两兆多,不在配置里放大就会撞上限。

解决:在application.yml里按需调整单文件大小和请求大小,同时在前端做同频限制:

spring: servlet: multipart: max-file-size: 5MB max-request-size: 10MB

注意设置上限不是越大越好——上传体积变大意味着数据库Base64字段膨胀、请求体变大,要配合前端压缩(canvas重采样或compress.js)把图片压到500KB以内再传,双重保险才不会在演示时翻车。

5.4 工单状态被跳过,从“待接单”直接变成“已完工”

现象:数据库中某张工单没有“维修中”的流水记录,状态却直接变成了“已完工”。

原因:Service层更新状态的代码直接setStatus("FINISHED")然后updateById,没有调用状态机校验。任何接口只要能拿到orderId,传一个目标状态就能篡改,流程完全失控。

解决:把状态变更收敛到一个统一方法里:

public void changeStatus(Long orderId, String targetStatus, LoginUser operator) { RepairOrder order = repairOrderMapper.selectById(orderId); if (order == null) { throw new BizException("工单不存在"); } if (!RepairOrderStateMachine.canTransition(order.getStatus(), targetStatus, operator.getRole())) { throw new BizException("非法状态流转: " + order.getStatus() + " -> " + targetStatus); } RepairOrder update = new RepairOrder(); update.setId(orderId); update.setStatus(targetStatus); repairOrderMapper.updateById(update); repairFlowMapper.insert(orderId, order.getStatus(), targetStatus, operator.getUserId(), "状态流转"); }

所有业务接口都调changeStatus,Controller层不直接碰updateById,状态机校验就不可绕过了。

5.5 导出的Excel报错,中文表名乱码

现象:用EasyExcel导出工单列表,文件名“报修工单.xlsx”在Windows下载后变成“__报修工单.xlsx”或者内容正常但文件名乱码。

原因:响应头Content-Disposition里的文件名没做URL编码,浏览器解析时按ISO-8859-1处理中文导致乱码。

解决:生成附件时对文件名做编码处理:

String fileName = URLEncoder.encode("报修工单.xlsx", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + fileName);

注意filename和filename*两段都要处理好,老版本浏览器看filename,新版本看filename*,漏一个就会在某个浏览器上翻车。

6. 进阶:三个从“能答辩”到“能上线”的验证视角

如果前面的内容做完了,系统跑得通,能演示了,那基本可以达到课程设计/毕设的“完成”线。但如果你想在答辩时多讲一层,或者这个项目后续真的要在学院内部试运行,还有三件事值得补上,按投入产出比排序。

6.1 用接口压测发现隐藏的并发问题

找一台普通笔记本,用Postman的Runner或者Apache JMeter对三个接口做并发测试:学生提交报修单(写操作)、维修工接单(读改写操作)、工单列表查询(读操作)。线程组设置50个并发,循环20次,重点观察错误率和响应时间。

维修工接单接口在压测中会暴露乐观锁是否真正生效——错误率里应该出现“接单失败,工单可能已被其他维修工接走”的业务异常,这是正常现象,反而说明锁在起作用。如果压测结果里没有任何接单失败,回到5.1查@Version注解是否生效。提交报修单接口则需要关注数据库连接池是否打满,HikariCP默认最大连接数10,50并发下如果每条SQL执行慢,连接池很快耗尽,表现为请求长时间挂起后超时。应对手段是把最大连接数调到20,同时确认慢SQL的索引是否到位。

6.2 给轮询列表加一层Redis缓存

工单列表页是访问频率最高的接口,学生每刷新一次就查一次数据库,重复的SQL和白读没有区别。最廉价的优化是用Redis缓存当前登录用户的工单ID列表,而非缓存整个工单对象。原因很简单:工单状态随时在变,缓存整单会导致数据不一致,需要频繁失效处理;缓存ID列表后,按ID从DB取单仍然走主键索引,成本极低,还能稳定保证数据新鲜度。

用Spring的@Cacheable注解就能实现:

@Cacheable(cacheNames = "repair:orderIds", key = "#studentId", unless = "#result == null || #result.isEmpty()") public List<Long> listOrderIdsByStudent(Long studentId) { return repairOrderMapper.selectIdsByStudentId(studentId); }

一旦发生状态变更,执行cacheManager.getCache("repair:orderIds").evictIfPresent(studentId)清掉对应缓存。这套方案代码改动量小,但能作为“性能优化”话题写进论文文档的改进与展望章节里。

6.3 用订阅通知替代学生反复刷页面

查进度是高频动作,但与其让学生拼命刷新,不如在关键节点主动通知。系统里预留一个notification表,在工单状态进入待接单、维修中、完工这三个节点时各插入一条通知记录,学生在首页右上角看到一个红色小圆点。这个功能在答辩演示时非常直观——“学生提交报修单后,刷新页面看到状态变化与站内通知”。

真正做企业级会接企业微信或短信,课设/毕设阶段做到站内通知就够,重点是把这个“状态变更→生成通知→用户已读”的流程讲清楚。代码上不复杂,在changeStatus方法里同步插入一条通知即可,但注意插入操作和状态更新要@Transactional包在同一个事务里,避免状态变了通知没生成的尴尬。

说一个我自己带队时的小习惯:每次交付这种管理系统,我都会在最后花半小时把“管理员派了一张单 → 维修工接单 → 学生收到提醒”的完整链路用手机录一遍视频存档。因为答辩现场往往不是你最放松的时候,演示只要断网一次、服务重启一次、某个配置文件路径写死过一次,前面的流畅都可能清零。这段视频既是验收依据,也是回滚的后悔药。

这三件事做完,这个项目就不再是一个“能跑的页面”,而是一套经得起并发追问、有明确优化路径、能讲清楚通知闭环的信息系统。设计文档里相应补上“业务设计→表结构→接口实现→验证优化”的主线,答辩时从业务讲到技术、从技术讲到数据,底气会足得多。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进