简介:面向高校计算机专业毕业设计与课程设计场景的Java校园图书馆座位预约系统程序,适合需要完整管理系统选题参考的学生。内容覆盖需求分析、系统功能与总体结构设计、数据结构及安全设计,并延伸到数据库访问、主要模块编码、关键代码和功能测试,较完整地呈现Java管理系统的开发脉络。资源包体积约12.16MB,内含可运行程序源代码一份与数据库一份,并附配置环境说明;页面未列明具体文件总数及类型明细,文件构成以实际解压为准。页面显示已有3244人学习,说明该选题在毕设与课设群体中具有一定关注度。读者可据此了解座位预约业务在系统中的组织方式,参考用户、座位、预约记录等模块的代码组织与数据库表关系,对照配置说明完成环境搭建和运行排错;测试章节也可为论文中的验证与结果分析提供写作参照。整体适合具备Java基础、希望快速形成可运行管理系统原型的本专科毕业生。
1. 项目定位:从考试周抢座的痛苦说起,这套 java校园图书馆座位预约系统 到底能跑出什么效果
每到考试周,阅览室门口六点半就有人蹲守,桌上贴满「此座已占」的纸条,管理员每天要撕几十张。这套 java校园图书馆座位预约系统 想解决的就是这个场景:座位按阅览室、时段切分,学生提前在线选座,到场签到才算生效,超时未签到系统自动释放,违约记一次信用分。它本质是一个「资源 + 时段 + 状态机」的预约系统,不是简单的增删改查玩具。
适合谁:正在做 Java Web 毕业设计、需要一个有并发点又不至于太复杂的选题的学生;想把学校纸质登记流程搬到线上的开发者;以及想拿一个完整 CRUD + 定时任务 + 权限控制工程练手的人。技术栈是老实的 Spring Boot + MyBatis + MySQL,前端可以是 Thymeleaf 也可以是 Vue,跑起来成本很低。
2. 技术选型与工程骨架:Spring Boot 三层结构怎么摆才不乱
对于一个要在答辩现场十五分钟内演示完、并且后续还要改需求的项目,工程结构比算法炫技重要得多。这套系统的骨架是典型的 Controller → Service → Mapper 三层,中间用 DTO/VO 做数据搬运,异常统一在一处兜底。下面从选型理由一路讲到建表语句,把能直接抄的部分都摆出来。
2.1 为什么用 Spring Boot 而不是原生 Servlet 或老式 SSM
毕业设计最常见的翻车点不是业务写不出来,而是环境配不起来。原生 Servlet 要手动配 web.xml、自己管连接池;老式 SSM 要写一堆 spring-dao.xml、spring-service.xml、spring-mvc.xml,改一个包名要动四个文件。带内嵌 Tomcat 的 Spring Boot 把这些都收进了一个启动类,mvn package之后java -jar就能跑,答辩机器上不需要预装 Tomcat。
我的取舍是:能用注解就不写 XML,能用自动配置就不手写 Bean。但 MyBatis 的复杂查询还是保留 XML 映射文件,因为座位可用性查询涉及多表关联和动态条件,写在注解里可读性太差,答辩老师问起来也不好讲。这个混合策略在中小型项目里是最省心的组合。
依赖清单不需要多,核心就四块:Web、MyBatis、MySQL 驱动、Lombok。定时任务用 Spring 自带的@Scheduled,不引 Quartz,少一个组件少一个坑。
<!-- pom.xml 关键依赖,版本按自己 JDK 调整 --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis 官方 starter,省去手写 SqlSessionFactory --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <!-- 批量导入座位表用,避免手写 POI 的样板代码 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.2</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这段没什么玄机,关键是<scope>runtime</scope>别写成 compile,不然打出来的 jar 里驱动会重复;EasyExcel 只在后台批量导入座位时用,如果嫌重可以直接去掉,改成手写一行行 insert,只是管理员录入一百个座位时会骂人。
2.2 目录结构:每个包只干一件事
包结构定得好,后面加需求就是往对应目录里塞文件;定得乱,两个月后自己都找不到代码在哪。我一般按职责切,不按技术层切两遍:
library-seat/ ├── src/main/java/com/example/seat/ │ ├── SeatApplication.java # 启动类,@EnableScheduling 挂这里 │ ├── common/ │ │ ├── Result.java # 统一返回体 {code,msg,data} │ │ ├── BizException.java # 业务异常 │ │ └── GlobalExceptionHandler.java │ ├── config/ │ │ ├── WebConfig.java # 登录拦截器注册 │ │ └── MybatisConfig.java │ ├── controller/ │ │ ├── SeatController.java │ │ ├── ReservationController.java │ │ └── AdminController.java │ ├── service/impl/ # 接口 + 实现分开,答辩好讲 │ ├── mapper/ # 只放接口,SQL 在 resources/mapper │ ├── entity/ # 与表一一对应 │ ├── dto/ # 入参,如 ReservationDTO │ ├── vo/ # 出参,如 SeatVO(带状态、剩余时段) │ └── task/ # SeatReleaseTask 定时释放 ├── src/main/resources/ │ ├── application.yml │ ├── mapper/*.xml │ └── static/ # 若用 Thymeleaf,页面放这 └── pom.xmlentity和vo一定要分开。entity 是数据库行的镜像,vo 是给前端看的。座位列表页需要「这个座位当前时段是否可约」,这个字段数据库里没有,只能查出来算,塞进SeatVO最自然。混着用的话,某天前端要加个字段,你会忍不住往 entity 上加一个不存在的列,然后 MyBatis 的insert直接报错。
2.3 配置文件与启动类:三个容易写错的参数
server: port: 8080 servlet: session: timeout: 30m # 学生中途去吃饭,别 15 分钟就踢下线 spring: datasource: url: jdbc:mysql://localhost:3306/library_seat?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 # 不写这行,前端拿到的时间会差 8 小时 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.seat.entity configuration: map-underscore-to-camel-case: true # seat_no -> seatNo seat: credit: init-score: 100 # 初始信用分 violate-deduct: 10 # 单次违约扣分 ban-threshold: 60 # 低于该分数禁止预约 reserve: max-days-ahead: 3 # 最多提前几天预约 checkin-window: 15 # 开始后多少分钟内必须签到(分钟)serverTimezone和jackson.time-zone这两处是血泪经验。前者不写,MySQL 8 连上去会报时区错误;后者不写,数据库里存的是正确时间,吐给前端就变成 UTC,学生看到「预约时间 00:00」会以为系统坏了。map-underscore-to-camel-case打开后,seat_no能自动映射到seatNo,省掉一堆resultMap。
启动类只需要三个注解,@EnableScheduling是给定时释放任务用的:
@SpringBootApplication @MapperScan("com.example.seat.mapper") // 不扫的话 Mapper 注入会失败 @EnableScheduling // 开启 @Scheduled 定时任务 public class SeatApplication { public static void main(String[] args) { SpringApplication.run(SeatApplication.class, args); } }@MapperScan的包路径写错是新手高频翻车点,报错长这样:Field seatMapper in ... required a bean of type ... that could not be found。看到这个先别怀疑 MyBatis 版本,先看包名。
2.4 数据库表设计:四张核心表撑起全部业务
表不在多,四张就够跑通全流程:用户、阅览室(含座位归属)、座位、预约记录。时段不做成独立表,直接用start_time/end_time存在预约记录里,因为这套系统是「固定时段制」而不是「任意时长制」。
| 表名 | 作用 | 关键字段 |
|---|---|---|
sys_user | 学生与管理员 | id、student_no、password、role、credit_score、status |
room | 阅览室/楼层 | id、name、floor、open_time、close_time、status |
seat | 座位 | id、room_id、seat_no、has_socket、status |
reservation | 预约记录 | id、user_id、seat_id、reserve_date、start_time、end_time、state |
CREATE TABLE `reservation` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL COMMENT '预约人', `seat_id` BIGINT NOT NULL COMMENT '座位ID', `reserve_date` DATE NOT NULL COMMENT '预约日期', `start_time` TIME NOT NULL COMMENT '时段开始', `end_time` TIME NOT NULL COMMENT '时段结束', `state` TINYINT NOT NULL DEFAULT 0 COMMENT '0待签到 1已签到 2已完成 3已取消 4违约', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `checkin_time` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), -- 核心:同一座位同一天同一时段只能有一条有效记录 UNIQUE KEY `uk_seat_slot` (`seat_id`, `reserve_date`, `start_time`), KEY `idx_user_date` (`user_id`, `reserve_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='座位预约记录';那个uk_seat_slot唯一索引是整个系统能防住重复预约的根。业务层再怎么判断,都可能有并发窗口,最后一道闸必须是数据库。注意它把「已取消」的记录也算进去了,所以取消逻辑不能靠删行,得改成改state——这点后面第 5 章会专门说,是个真会踩的坑。
3. 核心链路拆解:一次「选座 → 下单 → 签到」到底走了哪些代码
后台管理是体力活,真正体现设计的是学生端这条主链路。从打开座位列表,到点下预约按钮,再到第二天扫码签到,中间要处理时段过滤、并发抢座、超时释放、违约扣分四件事。这一章按请求顺序拆,每段代码都可以直接放进项目里跑。
3.1 座位列表查询:把「可用性」算在 SQL 里而不是 Java 里
最朴素的做法是把座位全查出来,再在 Java 里循环判断每个座位有没有被约。座位少的时候没问题,一个阅览室两百个座位、一天五个时段,就是一千次判断,页面还得等。我一般直接把判断下推到 SQL,用NOT EXISTS语义最清晰。
<!-- SeatMapper.xml:查询某阅览室某日某时段的可选座位 --> <select id="listAvailableSeats" resultType="com.example.seat.vo.SeatVO"> SELECT s.id, s.seat_no AS seatNo, s.has_socket AS hasSocket, r.name AS roomName, CASE WHEN res.id IS NULL THEN 1 ELSE 0 END AS available FROM seat s JOIN room r ON r.id = s.room_id LEFT JOIN reservation res ON res.seat_id = s.id AND res.reserve_date = #{reserveDate} AND res.start_time = #{startTime} AND res.state IN (0, 1) <!-- 只看待签到/已签到,已完成和已取消放行 --> WHERE s.room_id = #{roomId} AND s.status = 1 <!-- 座位本身没被管理员停用 --> ORDER BY s.seat_no </select>逻辑说明:LEFT JOIN而不是NOT EXISTS,是为了让不可用的座位也能查出来,前端把它们渲染成灰色,学生能直观看到「这间屋子还剩几个空位」,而不是看到一片空白以为系统坏了。
参数说明:#{reserveDate}是DATE类型,#{startTime}是TIME类型,注意 MyBatis 传LocalTime时要 JDK8+ 类型处理器,传String则依赖 MySQL 隐式转换,容易在边界值上翻车。res.state IN (0,1)这个条件保证已完成(2)和已取消(3)的历史记录不会挡住新预约,这条不加,学生取消一次就再也约不上同一个座位。
3.2 预约下单:唯一索引 + 事务,别指望先查后插
「先select看有没有人约,没有就insert」——这个写法在单机自测时永远是对的,一到演示现场两个人同时点就废。两个线程都查到空,都执行 insert,结果一条成功一条报错,或者更糟,两条都进去了(如果唯一索引没加对)。
正确姿势是:不做前置查询,直接插,让唯一索引拦,捕获DuplicateKeyException转成友好提示。
@Service public class ReservationServiceImpl implements ReservationService { @Resource private ReservationMapper reservationMapper; @Resource private SeatMapper seatMapper; @Resource private UserMapper userMapper; @Override @Transactional(rollbackFor = Exception.class) public Long reserve(ReservationDTO dto, Long userId) { // 1. 基础校验:座位是否存在且可用 Seat seat = seatMapper.selectById(dto.getSeatId()); if (seat == null || seat.getStatus() != 1) { throw new BizException("座位不存在或已停用"); } // 2. 时段合法性:必须在阅览室开放时间内 Room room = seatMapper.selectRoomBySeatId(dto.getSeatId()); if (dto.getStartTime().isBefore(room.getOpenTime()) || dto.getEndTime().isAfter(room.getCloseTime())) { throw new BizException("该时段不在开放时间内"); } // 3. 信用分校验:低于阈值直接拒绝 User user = userMapper.selectById(userId); if (user.getCreditScore() < creditProperties.getBanThreshold()) { throw new BizException("信用分不足,暂无法预约"); } // 4. 同一用户同一天同一时段不能约两个座位 int conflict = reservationMapper.countUserConflict( userId, dto.getReserveDate(), dto.getStartTime()); if (conflict > 0) { throw new BizException("你在该时段已有预约"); } // 5. 直接插入,并发冲突交给唯一索引 Reservation record = new Reservation(); record.setUserId(userId); record.setSeatId(dto.getSeatId()); record.setReserveDate(dto.getReserveDate()); record.setStartTime(dto.getStartTime()); record.setEndTime(dto.getEndTime()); record.setState(0); // 0 = 待签到 try { reservationMapper.insert(record); } catch (DuplicateKeyException e) { // 唯一索引 uk_seat_slot 生效,说明被别人抢先了 throw new BizException("手慢了,该座位刚被预约"); } return record.getId(); } }逻辑说明:第 4 步的「用户自身冲突」和「座位冲突」是两回事,前者查的是这个人有没有在别处占坑,后者靠数据库兜底。顺序不能反——先把轻量的业务校验跑完,最后才落库,减少无谓的锁竞争。
参数说明:@Transactional(rollbackFor = Exception.class)里必须写Exception.class,默认只回滚RuntimeException,BizException如果继承了受检异常就不会回滚。creditProperties是@ConfigurationProperties(prefix="seat.credit")绑定的配置类,阈值写在 yml 里,答辩时老师让你「把违约扣分改成 20 分」,你不用改代码只改配置,场面会好看很多。
提示:如果整套系统要顶住课间那种几百人同时点的情况,可以在第 5 步前面加一层 Redis 的
SETNX预占,key 用seat:{seatId}:{date}:{startTime},过期时间设成 5 分钟。但这属于加分项,毕设演示用唯一索引足够,加了反而多一个要维护的组件。
3.3 签到与超时释放:一个定时任务把状态机盘活
预约记录有五个状态:待签到、已签到、已完成、已取消、违约。状态流转不能随便改,得有个统一的入口。核心规则是:开始时间 + 15 分钟内没签到 → 违约释放;签到之后到结束时间 → 自动完成。
定时任务用@Scheduled每 5 分钟扫一次,别设成每分钟,没必要还费资源:
@Component @Slf4j public class SeatReleaseTask { @Resource private ReservationMapper reservationMapper; @Resource private UserMapper userMapper; @Value("${seat.reserve.checkin-window:15}") private int checkinWindow; @Value("${seat.credit.violate-deduct:10}") private int deduct; /** * 每 5 分钟执行:处理超时未签到 + 已完成预约 * cron 表达式:秒 分 时 日 月 周 */ @Scheduled(cron = "0 0/5 * * * ?") public void releaseExpired() { LocalDateTime now = LocalDateTime.now(); // 1. 待签到且已超过签到窗口 -> 违约 List<Reservation> expired = reservationMapper.selectExpiredPending(now, checkinWindow); for (Reservation r : expired) { int updated = reservationMapper.updateState(r.getId(), 0, 4); // 0->4 if (updated == 1) { // 乐观更新成功才扣分,避免重复扣 userMapper.deductCredit(r.getUserId(), deduct); log.info("预约 {} 超时未签到,已释放并扣分", r.getId()); } } // 2. 已签到且已过结束时间 -> 正常完成,不扣分 reservationMapper.finishCheckedIn(now); } }逻辑说明:updateState的 SQL 要带上原状态做条件,写成UPDATE reservation SET state = #{newState} WHERE id = #{id} AND state = #{oldState},返回影响行数。这样即使定时任务因为机器卡顿重跑一次,第二次因为state已经不是 0,影响行数为 0,就不会重复扣分。这是最便宜的幂等方案,比加分布式锁实在。
参数说明:cron = "0 0/5 * * * ?"表示每 5 分钟的第 0 秒触发,Spring 的 cron 是六位,最后一位是星期。checkinWindow从配置读,测试时改成 1 分钟就能快速验证释放逻辑,不用干等 15 分钟。
签到本身是个普通更新,但要做时间校验:太早签到(比如提前两小时)没意义,太晚已经违约了。通常是「预约开始前 15 分钟到开始后 15 分钟」这个窗口内允许签到,超出范围给明确提示而不是静默失败。
3.4 信用分与违约:规则要写死在一处,别散落各层
信用分逻辑最怕散落。今天在预约接口里扣 10 分,明天在管理员后台手动扣 5 分,后天定时任务再扣 10 分,最后没人说得清一个学生为什么被禁。我的做法是只留一个CreditService,所有加减分都走它,并且每次变动记一条流水。
| 行为 | 分值变化 | 触发点 |
|---|---|---|
| 正常完成一次预约 | +1(上限 100) | 定时任务完成状态流转时 |
| 超时未签到 | -10 | SeatReleaseTask |
| 管理员手动撤销违约 | +10 | 后台操作,需填理由 |
| 分数低于 60 | 禁止新预约 | ReserveServiceImpl前置校验 |
加分上限卡在 100,不然老用户分数越滚越高,违约惩罚形同虚设。流水表credit_log只加不删,字段就四个:user_id、change、reason、create_time,学生自己能查,纠纷时也有据可依。这套东西不复杂,但它让「信用分」从一个演示用的数字变成了有据可查的机制,答辩时讲起来比单纯说「我做了个扣分功能」有分量。
4. 后台管理与前端交互:管理员端不是摆设
学生端只有选座和签到两个动作,剩下全是管理员的活:维护阅览室、批量导入座位、停用损坏座位、看使用率、处理申诉。这部分功能的技术难度不高,但页面数量多,接口设计如果一开始不统一,写到第八个页面就会开始复制粘贴。
4.1 座位与阅览室管理:批量导入比一个个点更实用
管理员最高频的操作是「新开一间阅览室,把 120 个座位录进去」。如果只提供单条新增,录入要半小时。所以座位管理必须带 Excel 批量导入,用 EasyExcel 读,读一行插一行,失败的行收集起来最后统一返回。
@PostMapping("/admin/seat/import") public Result<ImportResult> importSeats(@RequestParam("file") MultipartFile file, @RequestParam Long roomId) { ImportResult result = new ImportResult(); try { // headRowNumber(1):第一行是表头,从第二行开始读 EasyExcel.read(file.getInputStream(), SeatImportDTO.class, new PageReadListener<SeatImportDTO>(list -> { for (SeatImportDTO dto : list) { try { Seat seat = new Seat(); seat.setRoomId(roomId); seat.setSeatNo(dto.getSeatNo()); seat.setHasSocket(dto.getHasSocket()); seat.setStatus(1); seatMapper.insert(seat); result.addSuccess(); } catch (DuplicateKeyException e) { // 座位号在同一阅览室内重复,跳过而不是中断整批 result.addFail(dto.getSeatNo(), "座位号重复"); } } })).sheet().doRead(); } catch (IOException e) { throw new BizException("文件读取失败,请检查格式"); } return Result.ok(result); }逻辑说明:PageReadListener是 EasyExcel 的分批读取回调,默认 100 条一批,避免大文件把内存撑爆。虽然一百多个座位不至于 OOM,但这个写法是标准姿势,换成几千行的学生名单也一样跑。
参数说明:headRowNumber默认就是 1,不用显式写;SeatImportDTO上用@ExcelProperty("座位号")标注表头名,模板文件由后端提供一个下载接口,保证列顺序和表头完全对得上,否则管理员自己改一列就会读空。
停用座位要小心:直接改status = 0会让已经约了该座位的学生记录变成孤儿。常见做法是停用前先查有没有未来时段的待签到预约,有就拒绝并提示「该座位还有 3 条未完成预约」,让管理员先去后台批量取消。
4.2 统计报表:使用率这条 SQL 值得单独讲
管理员最想看的是「哪个阅览室空置率高、哪个时段最挤」。这个指标叫座位使用率,算法是「已占用的座位时段数 ÷ 总可用座位时段数」。写成一条 SQL 比在 Java 里循环快得多,也更能说明你懂聚合查询。
-- 统计指定日期各阅览室的使用率 SELECT r.id, r.name, COUNT(DISTINCT s.id) AS seatCount, COUNT(res.id) AS reservedCount, ROUND(COUNT(res.id) / COUNT(DISTINCT s.id) * 100, 2) AS usageRate FROM room r JOIN seat s ON s.room_id = r.id AND s.status = 1 LEFT JOIN reservation res ON res.seat_id = s.id AND res.reserve_date = #{date} AND res.state IN (1, 2) -- 只算真正签到和完成的,待签到不算占用 GROUP BY r.id, r.name ORDER BY usageRate DESC;这里的口径要提前想清楚:待签到(state=0)算不算占用?我倾向于不算,因为随时可能违约释放,算进去会让使用率虚高。答辩时被问到「你这个使用率怎么定义的」,能答出这个取舍就很加分。导出就基于同一条 SQL 的结果,用 EasyExcel 写List<UsageVO>,三行代码搞定。
4.3 前端页面与接口约定:统一返回体省一半联调时间
前端不管用 Thymeleaf 还是 Vue,接口层一定要统一。所有接口返回{code, msg, data},code=0成功,非 0 是业务错误,HTTP 状态码始终 200(除非真的是服务器崩了)。这个约定看起来土,但它让前端只需要写一个axios拦截器就能处理全部错误提示。
| 接口 | 方法 | 说明 | 主要参数 |
|---|---|---|---|
/api/seat/list | GET | 查可用座位 | roomId、date、startTime |
/api/reserve | POST | 提交预约 | seatId、reserveDate、startTime |
/api/reserve/checkin | POST | 扫码/点击签到 | reservationId |
/api/reserve/cancel | POST | 取消预约 | reservationId |
/api/admin/seat/import | POST | 批量导入座位 | file、roomId |
/api/admin/stats/usage | GET | 使用率统计 | date、roomId |
取消预约要加时间限制:开始前 30 分钟内不允许取消(防止占着位置到最后时刻才放),取消后信用分不扣但也不加。这个规则写在前端只能防君子,后端cancel接口里必须再判一次,否则改个请求参数就绕过去了。
5. 避坑与常见问题排查:跑不起来、座位被重复预约、时间对不上
这套系统我在不同机器上部署过几次,报错翻来覆去就那么几类。下面五条是按「现象 → 原因 → 解决」整理的实录,遇到问题先对着这张单子扫一遍,能省掉大量瞎猜的时间。
5.1 现象:本地跑得好好的,打成 jar 后页面 404
原因:用了 Thymeleaf 或静态页面,但页面文件放在了src/main/webapp目录下。Spring Boot 打成可执行 jar 时不认webapp,只认classpath:/templates/和classpath:/static/。
解决:把 HTML 挪到src/main/resources/templates/,CSS/JS 挪到src/main/resources/static/。如果之前配过spring.mvc.view.prefix,检查是不是还指着老路径。另外@Controller和@RestController别混用,返回视图名的方法用@Controller,返回 JSON 的用@RestController,用错了就是白页一张。
5.2 现象:预约时间显示成 00:00 或者差 8 小时
原因:JDBC URL 没带serverTimezone,或者 Jackson 没配time-zone。MySQL 存的是DATETIME无时区,Java 侧用LocalDateTime读写时,两边对不上就偏移。
解决:URL 里加&serverTimezone=Asia/Shanghai,yml 里加spring.jackson.time-zone: GMT+8,实体类时间字段统一用LocalDate/LocalTime/LocalDateTime,不要混用java.util.Date。改完之后把数据库里已有的脏数据清一遍重新造,不然新旧混着看更晕。
5.3 现象:并发压测时座位被两个人同时约上
原因:业务层先select再insert,中间有窗口;或者唯一索引建在了(seat_id, reserve_date)上,漏了start_time,导致不同时段互相冲突。
解决:确认uk_seat_slot是(seat_id, reserve_date, start_time)三列联合唯一,并且Service里捕获DuplicateKeyException转成业务提示。如果之前表里已经有重复数据,加索引会失败,先跑一遍DELETE t1 FROM reservation t1 JOIN reservation t2 WHERE t1.id > t2.id AND ...把重复的清掉再建。
5.4 现象:学生明明签到了,定时任务还是把他标成违约
原因:签到更新了state = 1,但定时任务的查询条件写的是「start_time加窗口时间小于当前时间且state = 0」,逻辑没问题;真正出问题的是checkin_time字段用了数据库的CURRENT_TIMESTAMP,而应用服务器时间和数据库服务器差了几分钟,导致判断边界错位。
解决:统一在应用层取LocalDateTime.now()写进checkin_time,不要依赖数据库默认值;定时任务的比较也用应用层时间。顺手把窗口从 15 分钟放宽到 20 分钟,给网络延迟留余量。这个坑很阴,因为单独测永远复现不了。
5.5 现象:取消预约后,同一个座位再也约不上
原因:取消逻辑写成了DELETE FROM reservation,但唯一索引还在,如果没删干净或者用了软删除(deleted = 1)却把唯一索引建在带deleted的列上,历史记录就会挡住新预约。
解决:取消只改state = 3,绝不删行。如果确实想删,那唯一索引里必须不含已取消记录——MySQL 做不到条件唯一索引,只能靠应用层保证。所以老老实实改状态,查询时用state IN (0,1)过滤,这是最稳的。
注意:还有一种情况是取消操作没加事务,
state改了一半抛异常,记录卡在中间态。cancel方法上也补上@Transactional,别嫌它简单就不写。
6. 二次开发改造:从能演示到敢让人用,我一般动这三处
毕设交付之后如果还想继续用,或者想当成作品集项目,有三处值得改。第一处是把座位状态缓存到 Redis,key 用seat:avail:{roomId}:{date}:{startTime},列表页先读缓存,未命中再查库回填,TTL 设 60 秒。这样课间高峰时数据库压力能降一大截,代价是数据最多延迟一分钟,对选座场景完全可接受。
第二处是权限从「登录拦截器」升级成基于角色的注解校验。现在这套代码里管理员接口只是靠拦截器判断role == admin,一旦接口多了容易漏。加一个@RequireRole("ADMIN")注解加HandlerInterceptor统一校验,新增接口时靠注解声明,比在每个方法里写if可靠。
第三处是预约规则配置化。把「最多提前几天」「签到窗口」「违约扣分」「禁约阈值」全部收进seat.reserve和seat.credit两个配置节点,管理员在后台页面能改,改完通过@RefreshScope或直接重启生效都行。这样换一个学院部署时不用改代码重新打包,只改数据库里的一行配置。
| 改造点 | 涉及文件 | 收益 | 风险 |
|---|---|---|---|
| Redis 缓存座位状态 | SeatServiceImpl、application.yml | 列表页响应快 3~5 倍 | 缓存与库不一致,需短 TTL |
| 注解式权限校验 | config/、各 Controller | 新增接口不易漏权限 | 老接口要逐个补注解 |
| 规则配置化 | SeatProperties、后台页面 | 换环境零改代码 | 配置错值会导致全校不可约 |
验证方式别只看页面点得动。第一,写几个 JUnit 用例覆盖预约冲突和超时释放,mvn test能过说明核心逻辑没崩;第二,用 JMeter 起 50 个线程同时打同一个座位,看是不是只有一个成功;第三,把系统时间往前调一天,观察定时任务会不会误伤未来的预约。
从那以后我每次交付这类预约系统,都会在本地先把「并发抢同一座位」和「定时任务重跑两次」这两个用例跑一遍再打包,因为这两个地方出问题,演示现场是救不回来的。希望帮到你。
本文还有配套的精品资源,点击获取