Java宿舍管理系统实战:Spring Boot事务、并发与工单状态机 简介面向高校课程设计、毕业设计及Java Web入门进阶场景的学生宿舍管理系统项目源码采用Bootstrap搭建管理端界面后台基于SpringBoot与MySQL按管理员、宿管、学生三类角色划分权限。管理员可维护学生、楼宇、宿舍、入住、宿管及管理员信息并支持批量导入与表格导出宿管侧重学生、宿舍与入住管理学生端提供学生与宿舍信息查询维护功能链路较完整。压缩包共661个文件约72.37MB包含Java源码、编译后的class、前端js与css、页面html、数据库sql、xml配置及少量mp4演示素材并附MySQL 5与MySQL 8两套适配项目包便于按本机环境选择运行。已有469人学习下载。读者可据此理解分层结构、权限分配、增删改查与Excel导入导出的实现方式也能作为答辩演示和功能扩展的基础工程。1. 从一张手写报修单说起这套 Java 宿舍管理系统把六件事归到了一个后台每年开学前两周宿管值班桌上都会堆一摞纸调宿申请、报修单、晚归登记、卫生检查表。这套基于 Java 的学生宿舍管理系统做的就是把这摞纸挪进一个能点得动的后台——学生端提报修、申请调宿宿管端受理派单管理员端看楼栋床位统计。技术栈是 Spring Boot MyBatis-Plus MySQL页面用模板引擎直出属于典型的单体 Java Web 管理系统数据库脚本、启动配置、页面模板都给齐了。对两类人它最合适一类是要交课程设计或毕业设计、需要一份结构规整还能讲清楚的 Java 源码另一类是公司让快速上个内部管理系统想拿个现成底座改。下面从目录结构、表设计、三条核心链路一路讲到怎么跑起来、怎么改造能抄的地方直接抄。2. 骨架先看清Spring Boot 分层目录与七张表的设计拿到一份 Java 源码我会先不看业务代码先看两样东西——包结构和建表脚本。这两样决定了它是「真做过」还是「拼出来的」。这份学生宿舍管理系统在这两点上还算干净包名按职责切分表之间外键关系克制没有为了显得功能多硬塞冗余字段。2.1 技术选型与依赖清单表格先立起来版本号是我实际跑通的那一套Maven 里对着改就行。依赖版本作用备注Spring Boot2.7.6容器与自动装配别急着上 3.xjavax 换 jakarta 会牵连一片MyBatis-Plus3.5.2ORM 与分页自带逻辑删除和乐观锁插件MySQL8.0主数据库5.7 也能跑驱动类名要换Druid1.2.16连接池带监控页调试期好用Thymeleaf随 Boot服务端模板页面直接渲染省掉一套前端工程Lombok1.18.24样板代码IDE 要装插件不然一片飘红Hutool5.8.11工具类日期、加密、Excel 导出都用得上为什么不用前后端分离不是说分离不好是一旦分离读者要同时启动 Node 工程和 Java 工程光环境就能劝退一半人。模板引擎直出浏览器一刷新就能看到效果调试链路短这在校验阶段价值极高。Druid 我单独说一句它的/druid/index.html监控页能看到每条 SQL 的执行次数和耗时写分页查不出数据的时候这个页面就是黑匣子比在代码里到处打日志快得多。2.2 目录结构与包职责包结构如下这是整份源码最值得先记住的部分dorm-system ├── src/main/java/com/example/dorm │ ├── DormApplication.java # 启动类MapperScan 在这儿 │ ├── common # 公共层 │ │ ├── Result.java # 统一返回体 │ │ ├── BizException.java # 业务异常 │ │ ├── GlobalExceptionHandler.java │ │ └── PageQuery.java # 分页入参基类 │ ├── config # 配置层 │ │ ├── WebConfig.java # 拦截器注册 │ │ ├── MybatisPlusConfig.java # 分页插件 乐观锁插件 │ │ └── DruidConfig.java │ ├── controller # 接口层只做参数校验和转发 │ │ ├── LoginController.java │ │ ├── DormController.java │ │ ├── BedController.java │ │ └── RepairController.java │ ├── service # 业务层 │ │ ├── impl │ │ └── ... │ ├── mapper # 数据访问层继承 BaseMapper │ ├── entity # 与表一一对应的实体 │ ├── dto / vo # 入参出参不跟 entity 混用 │ └── interceptor # 登录拦截器 └── src/main/resources ├── application.yml ├── mapper/**/*.xml # 手写 SQL 放这儿 ├── templates/ # Thymeleaf 页面 └── db/init.sql # 建库建表 初始数据几个约定值得强调。entity只映射表字段dto接前端入参vo拼返回结果三者不混。很多人图省事直接用 entity 接参、返回结果一到「查询列表要带学生姓名」这种需求就卡住——entity 里没有姓名字段只能临时加个TableField(exist false)加着加着实体就成了一张不成形的宽表。这个项目一开始就分了层改起来舒服。mapper接口继承BaseMapperT拿到单表 CRUD复杂查询多表联查、按条件拼装写 XML。我一般把「超过两张表关联」的查询全部下沉到 XMLSQL 摊开在文件里出了问题能直接复制到客户端跑。启动类上的MapperScan别漏SpringBootApplication MapperScan(com.example.dorm.mapper) // 少了这行Mapper 注入直接报 NoSuchBeanDefinition public class DormApplication { public static void main(String[] args) { SpringApplication.run(DormApplication.class, args); } }MapperScan的路径要跟实际包名完全一致写成com.example.dorm.dao这种启动时不会报错等到第一次请求进 Controller 才抛Field xxxMapper required a bean排查起来很费时间。这是血泪经验Mapper 找不到先看这行。2.3 数据库表设计七张表撑起整栋楼核心是七张表sys_user账号、student学生、dorm_building楼栋、dorm_room房间、dorm_bed床位、repair_order报修工单、change_request调宿申请。这里只贴最关键的两张其余结构类似。CREATE TABLE dorm_bed ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, room_id bigint NOT NULL COMMENT 所属房间, bed_no tinyint NOT NULL COMMENT 床位号4人间为1-4, student_id bigint DEFAULT NULL COMMENT 占用学生NULL 表示空闲, status tinyint NOT NULL DEFAULT 0 COMMENT 0空闲 1占用 2锁定, occupy_time datetime DEFAULT NULL COMMENT 入住时间, PRIMARY KEY (id), UNIQUE KEY uk_room_bed (room_id,bed_no), -- 同一房间不能有两个 3 号床 KEY idx_student (student_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT床位;uk_room_bed这个唯一索引是整个设计里最不能删的东西。它从数据库层面保证了「一间房一张床号只有一条记录」哪怕应用层并发判断全失效重复插入也会被数据库拦下来。很多同类项目的床位表是id room_id 学生没有唯一约束跑着跑着就出现一个房间三张「2 号床」后面统计人数全错。CREATE TABLE repair_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 工单号年月日6位随机, student_id bigint NOT NULL, bed_id bigint NOT NULL, type tinyint NOT NULL COMMENT 1水电 2家具 3网络 4其他, content varchar(500) NOT NULL, img_urls varchar(1000) DEFAULT NULL COMMENT 图片逗号分隔, status tinyint NOT NULL DEFAULT 0 COMMENT 0待受理 1已受理 2处理中 3已完工 4已关闭, handler_id bigint DEFAULT NULL COMMENT 处理人, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, finish_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_student_status (student_id,status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT报修工单;idx_student_status这个联合索引对应的是学生端最高频的查询——「我的待处理工单」。索引顺序不能反student_id在前是因为它的区分度虽然不高但配合status能一次过滤到几十条以内。提示img_urls用逗号分隔存字符串是这类小系统的常见做法好处是简单、不用多一张表坏处是没法按图片维度查询。如果后续要做「按图片查重复报修」这里得拆成独立的repair_img表。3. 核心模块拆解登录、床位分配、报修工单三条主链路业务代码不用全看抓住三条链路就理解了整个系统人怎么进来登录鉴权、床位怎么占并发分配、工单怎么流状态机。这三条链路各自踩的坑不一样我按代码顺序讲。3.1 登录鉴权拦截器 Session 的极简方案这套系统没有上 Spring Security用的是 Session 拦截器。理由很实际用户角色只有三种学生 / 宿管 / 管理员权限粒度到「菜单级」就够了引入 Security 反而要写一堆UserDetailsService、PasswordEncoder配置对读者是负担。拦截器本体Component public class LoginInterceptor implements HandlerInterceptor { // 在 Controller 方法执行前统一判断登录态 Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user request.getSession().getAttribute(loginUser); if (user ! null) { return true; // 已登录放行 } String uri request.getRequestURI(); if (uri.startsWith(/api/)) { // 接口请求不能重定向否则前端收到一整页 HTML 却没反应 response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\登录已过期\}); return false; } response.sendRedirect(/login); // 页面请求跳登录页 return false; } }preHandle的返回值决定是否继续往下走false会中断请求。这里的关键判断是「接口请求还是页面请求」——/api/开头的返回 JSON其它返回重定向。为什么必须分开因为前端用fetch调接口时如果收到 302 重定向浏览器会静默跟到登录页并把 HTML 当 JSON 解析报一个莫名其妙的Unexpected token 。这个错误网上搜一堆人踩过。注册拦截器Configuration public class WebConfig implements WebMvcConfigurer { Resource private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) // 默认全拦 .excludePathPatterns( // 再开白名单 /login, /api/login, /css/**, /js/**, /img/**, // 静态资源不拦 /druid/** // 监控页不拦 ); } }addPathPatterns(/**)配excludePathPatterns是「默认拒绝」思路比反过来安全。反过来只拦几个关键路径的后果是新加一个页面忘了加拦截就成了一扇没锁的后门。静态资源必须在白名单里否则登录页自己的 CSS 也被拦页面会变成纯文本。登录接口写 SessionPostMapping(/api/login) public ResultUserVO login(RequestBody LoginDTO dto, HttpSession session) { UserVO user userService.login(dto.getUsername(), dto.getPassword()); // 登录成功先清一次旧 Session防止会话固定攻击 session.invalidate(); session ...; // 实际代码里由容器重建 session.setAttribute(loginUser, user); return Result.ok(user); }密码这块源码里用的是 MD5 加盐够用但不算好。我改成 BCrypt 只要两步spring-security-crypto单依赖引进来new BCryptPasswordEncoder().matches(raw, encoded)。BCrypt 每次加密结果都不一样盐内嵌在密文里不存在「撞库彩虹表」的问题改造成本也就十几行。3.2 宿舍分配一个 Transactional 里藏着的三个坑床位分配是这类系统唯一真正有并发风险的地方。场景很具体一批新生同时在线选床位两个人点了同一张床谁先谁后源码里的实现值得逐行看。Service public class BedAllocationService { Resource private DormBedMapper bedMapper; Resource private StudentMapper studentMapper; // rollbackFor 必须显式写 Exception默认只回滚 RuntimeException Transactional(rollbackFor Exception.class) public void allocate(Long studentId, Long bedId) { DormBed bed bedMapper.selectById(bedId); if (bed null) { throw new BizException(床位不存在); } if (bed.getStatus() ! 0) { throw new BizException(该床位已被占用); } // 关键带条件的 update靠数据库行锁保证原子性 int rows bedMapper.occupy(bedId, studentId); if (rows 0) { throw new BizException(手慢了床位刚被抢走请换一张); } // 学生表回填床位同样带条件防止一个学生占两张床 int s studentMapper.bindBed(studentId, bedId); if (s 0) { throw new BizException(该学生已有床位); } } }对应的 XMLupdate idoccupy update dorm_bed set status 1, student_id #{studentId}, occupy_time now() where id #{bedId} and status 0 /update逐段解释。先selectById查一次是为了给出「床位不存在」和「已被占用」两种不同的友好提示真正保证正确性的不是这次查询而是后面那条update ... where id ? and status 0。这条 SQL 在 InnoDB 里会对这一行加排他锁两个事务同时进来后到的那个会等前者提交然后发现自己匹配不到status 0rows返回 0直接抛异常回滚。这就是所谓的乐观锁写法——不用显式加锁靠条件更新兜底。三个坑挨个说。第一个是rollbackForTransactional默认只在遇到RuntimeException和Error时回滚如果业务里抛的是受检异常事务会安静地提交。BizException继承RuntimeException才安全我一般直接写rollbackFor Exception.class一劳永逸。第二个是自调用失效。如果把allocate写在同一个类的另一个方法里直接调this.allocate(...)事务注解完全不生效——Spring 的事务是代理实现的this调用绕过了代理。这个 bug 最隐蔽日志里看不到任何异常只是数据不一致。第三个是「一个学生占两张床」。如果只更新床位表、不回填学生表或者回填时不带条件一个学生连续点两次不同床位两条记录都成功最后学生表里指向最后一张床前一张床永远锁着没人住。所以bindBed也要带where bed_id is null这样的条件。update idbindBed update student set bed_id #{bedId} where id #{studentId} and bed_id is null !-- 已经有床位的更新 0 行抛异常回滚 -- /update两个更新在同一个事务里第二个失败第一个会跟着回滚。这条链路的正确性完全建立在「数据库条件更新 事务」上不依赖任何应用层判断这是我唯一放心的写法。3.3 报修工单用状态机代替一堆 if-else工单有五个状态待受理、已受理、处理中、已完工、已关闭。如果按最直白的方式写代码会变成这样if (status 0 action 1) { ... } else if (status 0 action 4) { ... } else if (status 1 action 2) { ... } // 越写越长且没人说得清哪些流转是合法的状态一多这种写法就会变成维护负担。源码里用了枚举 流转表值得借鉴public enum RepairStatus { SUBMITTED(0, 待受理), ACCEPTED(1, 已受理), PROCESSING(2, 处理中), DONE(3, 已完工), CLOSED(4, 已关闭); private final int code; private final String desc; RepairStatus(int code, String desc) { this.code code; this.desc desc; } // 合法的状态流转全部集中在这里一眼看完业务规则 private static final MapRepairStatus, ListRepairStatus FLOW new EnumMap(RepairStatus.class); static { FLOW.put(SUBMITTED, Arrays.asList(ACCEPTED, CLOSED)); FLOW.put(ACCEPTED, Arrays.asList(PROCESSING, CLOSED)); FLOW.put(PROCESSING, Arrays.asList(DONE, CLOSED)); FLOW.put(DONE, Arrays.asList(CLOSED)); FLOW.put(CLOSED, Collections.emptyList()); } public boolean canTransitTo(RepairStatus target) { return FLOW.getOrDefault(this, Collections.emptyList()).contains(target); } public static RepairStatus of(int code) { for (RepairStatus s : values()) { if (s.code code) return s; } throw new BizException(非法状态 code); } }EnumMap是专门为枚举 key 优化的 Map底层是数组比HashMap快且省内存枚举做 key 时优先用它。FLOW用静态块初始化好处是新增状态时只要改这一处不用去业务代码里翻。调用侧就干净了Transactional(rollbackFor Exception.class) public void changeStatus(Long orderId, int targetCode, Long handlerId) { RepairOrder order orderMapper.selectById(orderId); if (order null) { throw new BizException(工单不存在); } RepairStatus from RepairStatus.of(order.getStatus()); RepairStatus to RepairStatus.of(targetCode); if (!from.canTransitTo(to)) { throw new BizException(不能从「 from.getDesc() 」直接变为「 to.getDesc() 」); } // 条件更新防止两个宿管同时点「受理」造成重复派单 int rows orderMapper.updateStatus(orderId, order.getStatus(), to.getCode(), handlerId); if (rows 0) { throw new BizException(工单状态已变更请刷新后重试); } }注意updateStatus传了order.getStatus()作为旧状态条件这是同一条思路的延续所有状态变更都必须带上前置状态做 CAS否则两个宿管同时点「完工」会触发两次短信通知、两份结算记录。改状态这类操作永远不要只传新状态。3.4 分页与统一返回前后端约定的那层壳所有接口返回同一个壳是个不起眼但省事的决定Data public class ResultT { private int code; // 200 成功401 未登录500 业务异常 private String msg; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.msg ok; r.data data; return r; } public static T ResultT fail(int code, String msg) { ResultT r new Result(); r.code code; r.msg msg; return r; } }页面上的 JS 只需判断res.code 200不用每个接口写一套判断。全局异常处理器兜住所有BizException把msg直接透出去——业务异常本来就是给人看的不需要再包一层「系统繁忙」。分页这块MyBatis-Plus 的Page要配上分页插件才生效Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 不注册这个插件Page 参数会被忽略永远返回全表 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }接口长这样GetMapping(/api/repair/page) public ResultIPageRepairOrderVO page( RequestParam(defaultValue 1) long current, RequestParam(defaultValue 10) long size, RequestParam(required false) String keyword, RequestParam(required false) Integer status) { PageRepairOrderVO page new Page(current, size); return Result.ok(repairService.pageWithStudent(page, keyword, status)); }current从 1 开始别传 0MyBatis-Plus 里 0 会被当成「不分页」处理直接查全表——数据量小的时候看不出来上了几千条就是一个隐形的性能问题。size也要在 Service 里做个上限比如超过 100 强制改成 100否则前端传个size999999就能拖垮数据库。4. 跑起来从建库到浏览器能点开的完整步骤架构讲完落到动手。这套系统的启动门槛不高但有几个配置项必须改不改就是一片红。4.1 环境准备与 application.yml 里必须改的四个值先确认三样东西JDK 8 或 1117 也能跑Lombok 版本要跟上、Maven 3.6、MySQL 8.0。前两个装完java -version和mvn -v能出版本号就行。配置文件server: port: 8080 servlet: session: timeout: 120m # 宿舍管理员一坐一下午30 分钟默认值太短 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/dorm_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 你的密码 thymeleaf: cache: false # 开发期关缓存改页面不用重启 mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true # 下划线转驼峰关掉的话 entity 全查不出值 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印 SQL上线前关掉四个必须动的地方username/password换成自己的url里的库名跟建库脚本一致server.port如果 8080 被占就改mybatis-plus.configuration.log-impl上线前记得注释掉它会把每条 SQL 和参数打到控制台生产环境既慢又泄露数据。serverTimezoneAsia/Shanghai这个参数不加MySQL 8 会报时区错误插入的时间还会差 8 小时。allowPublicKeyRetrievaltrue是 MySQL 8 默认加密方式下的必要参数缺了连接直接失败。4.2 建库导数据init.sql 跑错顺序会白忙一场脚本在src/main/resources/db/init.sql。执行顺序不能反# 1. 建库脚本里如果已包含 CREATE DATABASE这步可跳过 mysql -uroot -p -e CREATE DATABASE IF NOT EXISTS dorm_db DEFAULT CHARSET utf8mb4; # 2. 导表结构和初始数据 mysql -uroot -p dorm_db src/main/resources/db/init.sql # 3. 验证应该能看到 7 张表和至少 1 条管理员账号 mysql -uroot -p dorm_db -e SHOW TABLES; SELECT id,username,role FROM sys_user;脚本里造了 2 栋楼、40 间房、160 张床位以及一条管理员账号默认admin / 123456。数据量不大正好够试分页——把size设成 10翻两页就能看出分页插件有没有生效。坑在这里init.sql如果用DROP TABLE IF EXISTS开头重复执行会把已有数据清空。我一般把文件拆成schema.sql只有建表带IF NOT EXISTS和data.sql只有初始数据开发期随便跑不会误删。源码里是合在一起的单文件第一次跑没问题第二次跑之前先确认一下开头几行别顺手一执行把测试数据清了。4.3 启动与验收五个接口按顺序点一遍启动方式两种IDE 里直接跑DormApplication的 main或者命令行mvn clean package -DskipTests java -jar target/dorm-system-1.0.0.jar --spring.profiles.activedev看到Started DormApplication in 3.2 seconds就算起来了。别急着开浏览器先用 curl 把关键链路走一遍比在页面上点错要快得多# 1. 登录拿到 Cookie-c 保存-b 携带 curl -c cookie.txt -X POST http://localhost:8080/api/login \ -H Content-Type: application/json \ -d {username:admin,password:123456} # 2. 带 Cookie 查床位列表验证拦截器放行 curl -b cookie.txt http://localhost:8080/api/bed/page?current1size10status0 # 3. 不带 Cookie 再查一次应该返回 401 curl http://localhost:8080/api/bed/page?current1size10第二步返回的data.records里应该有 10 条床位第三步应该拿到{code:401,msg:登录已过期}。这两步都对了说明登录、拦截器、分页插件三个最容易出问题的环节全部正常剩下的页面基本不会有意外。注意curl 在 Windows 的 cmd 里单引号不生效要换成双引号并把内部引号转义或者直接用 Git Bash。这个小问题浪费过我一整个下午。5. 避坑与排查这套系统最常翻车的五个地方下面五条都是我实际跑这份源码时遇到的按出现频率排。5.1 启动阶段的两个硬伤现象一启动报Failed to configure a DataSource: url attribute is not specified。原因是application.yml没被加载或者文件名写成了application.yaml之外的形式比如放到了config/子目录但spring.config.location没配。解决方式确认文件在src/main/resources根目录下且pom.xml里打包时 resources 没被 exclude。如果用了多环境配置检查spring.profiles.active指向的application-dev.yml是否真的存在——指向一个不存在的 profileSpring 不会报错只会用默认配置然后就报这个。现象二页面能打开但所有接口返回 404控制台没有任何异常。八成是MapperScan的包路径写错了或者 Controller 不在启动类的同级或子包下。Spring Boot 默认只扫描启动类所在包及其子包把controller放到com.example根下、启动类放在com.example.dorm里就扫不到。解决要么把启动类上移一层要么显式加ComponentScan(basePackages com.example)。这个坑特别隐蔽因为不报错只是「东西不见了」。5.2 运行阶段的三个问题现象三列表页有数据但每个字段都是 null只有 id 有值。这是 MyBatis 的下划线转驼峰没开。数据库字段叫create_time实体属性叫createTime不配置map-underscore-to-camel-case: trueMyBatis 找不到对应关系直接给 null。解决在application.yml里打开这个开关如果某个字段确实不遵循驼峰比如bed_no映射到no在 XML 里用resultMap显式写column和property的对应关系别指望自动映射。现象四分页查出来永远是第一页传current2没反应。分页插件没注册。new Page(current, size)只是个参数对象没有PaginationInnerInterceptor的话MyBatis-Plus 不会去拼LIMITSQL 原样执行返回全量数据前端看起来「一直是第一页」。解决把MybatisPlusConfig里的拦截器加上注意 MyBatis-Plus 3.4 之后用的是MybatisPlusInterceptor而不是旧的PaginationInterceptor两者不能混用。改完重启控制台打印的 SQL 里应该能看到LIMIT ?,?。现象五报修工单点「受理」提示成功刷新后又变回「待受理」。事务没生效。常见原因是Transactional加在了 Controller 上应该加在 Service或者 Service 里方法自调用绕过了代理还有一种是把异常try-catch掉了没往外抛——事务只在异常冒泡到代理层时才回滚你自己 catch 完返回Result.fail()Spring 认为一切正常直接提交。解决注解挪到 Service 的 public 方法上异常抛出去交给全局处理器别在事务方法里吞异常。6. 二次开发三个花小钱办大事的改造点跑通之后多数人要往里加东西。按投入产出排我建议先动这三处。第一处把 MD5 换 BCrypt。改动集中在UserServiceImpl引入spring-security-crypto一个依赖加密用new BCryptPasswordEncoder().encode(raw)校验用matches(raw, encoded)。注意老数据要兼容可以在登录时判断密文长度32 位认作 MD5 走老逻辑校验通过后顺手用 BCrypt 重写一遍这样用户无感、数据平滑迁移。别图省事直接改校验逻辑否则存量账号全部登不上。第二处给工单加一条操作日志表。现在的repair_order只存当前状态谁在什么时候改的、改之前是什么状态全丢了。加一张repair_log工单 id、操作人、原状态、新状态、备注、时间在changeStatus成功之后插一条。这个改动不到 30 行但排障价值极高——学生投诉「我明明报了修没人管」翻一下日志就知道卡在哪一环。改造点涉及文件预估工作量收益MD5 换 BCryptUserServiceImpl、pom.xml半天密码安全性质变加操作日志表RepairLog 实体/Mapper、changeStatus半天工单可追溯导出 Excel新增 ExportController用 Hutool 的 ExcelWriter一天宿管科最想要的功能第三处把统计页的实时查询改成定时汇总。现在首页的「入住率」「工单完成率」是每次打开都count(*)一遍数据量小无所谓床位上千、工单上万之后首页会明显卡。做法是加一个Scheduled(cron 0 0 2 * * ?)的凌晨任务把统计结果写进stat_daily表页面直接读结果。改造时注意加EnableScheduling并且把统计逻辑单独放一个类别塞进已有的 Service 里。验证改造有没有搞坏原有功能我的习惯是每次动完都把 4.3 节那三条 curl 重跑一遍再手动点一遍「提交报修 → 受理 → 完工」的完整流程。有段时间我图快跳过了这一步结果一次改密码加密把登录拦截器顺带弄坏了别人部署的时候才发现很难解释。从那以后我每次改完鉴权相关的代码都强制走一遍「登出 → 重新登录 → 访问受保护接口」这条链路哪怕只改了一行。希望帮到你。本文还有配套的精品资源点击获取