ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

自习室预订系统开发实战:Spring Boot并发控制与管理端统计

自习室预订系统开发实战:Spring Boot并发控制与管理端统计 简介基于Spring Boot的自习室预订系统毕业设计资料包面向需要完成Java课程设计或毕业设计的计算机专业学生提供一套完整可运行的座位预订解决方案。系统围绕学生自习场景涵盖需求分析、数据库建表、后端接口开发、前端管理页面以及部署说明可帮助理解Spring Boot整合MyBatis、Vue组件化开发等常用技术栈。压缩包共738个文件以Java源码、Vue页面、JavaScript脚本、SQL脚本与Word说明文档为主另含PPT、演示视频以及一键安装、运行等批处理脚本整体大小32.84MB便于直接下载部署。资源内部后端、前端、数据库脚本和论文相关材料分层存放目录结构清晰读者可参照论文与操作视频完整走通项目流程节省自建环境的时间。已有318人学习使用适合需要快速完成毕业设计演示、系统学习Spring Boot项目实践的在校学生和自学者。1. 先把自习室预订系统的题眼想清楚并发占座与管理端统计才是灵魂考研季的图书馆早上六点半排队抢座是常态这个 java 毕业设计题目就是把「谁在什么时间占哪个座位」这件事彻底管起来。用 Spring Boot 写整套源码时你会发现增删改查只占工作量的一小半真正决定项目含金量的是三件事座位与时间段怎么建模、两个学生同时点同一个座位时怎么只放行一个、管理员能不能看到每天每间教室的真实利用率。这也是 java 后端面试里场景题的常见变体。这套方案面向 Spring Boot 2.7 MyBatis-Plus MySQL 8.0 的组合。选 2.7 而不是 3.x 的原因是当前毕设资源、教程和踩坑记录最密集的就是这个版本线springboot 版本太高时很多旧模板引擎和拦截器配置会不兼容没有必要在版本问题上消耗时间。前端无论接 Vue 还是直接上 Thymeleaf后端接口设计都可以完全复用。下面按「表结构 → 预订核心接口 → 管理端 → 验收」四个阶段把关键决策讲清楚每个参数都会给出默认值和改它的理由。2. Spring Boot 数据模型与座位状态机三张表把自习室预订系统拆干净2.1 三张核心表自习室、座位、预订单怎么拆很多初次做这个题的人会问是不是一张「自习室表 预订表」就够了我一般不建议这么干。把预订直接挂在自习室上意味着一个自习室里所有座位共享同一份预订记录后续想统计「哪个座位最受欢迎」就无从下手。常见做法是拆成 study_room、seat、reservation 三张表座位才是真正被预订的资源粒度。CREATE TABLE study_room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT 自习室名称如 A栋301, location VARCHAR(128) COMMENT 位置描述, open_time TIME NOT NULL DEFAULT 08:00:00, close_time TIME NOT NULL DEFAULT 22:00:00, capacity INT NOT NULL DEFAULT 0 COMMENT 座位总数冗余字段, status TINYINT NOT NULL DEFAULT 1 COMMENT 1开放 0停用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id BIGINT NOT NULL, seat_no VARCHAR(16) NOT NULL COMMENT 座位编号如 A-01, status TINYINT NOT NULL DEFAULT 0 COMMENT 0空闲 1占用 2维护中, UNIQUE KEY uk_room_seat (room_id, seat_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, reserve_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待签到 1已签到 2已结束 3已取消 4爽约, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_seat_date (seat_id, reserve_date), KEY idx_user_date (user_id, reserve_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里面有两个容易忽略的细节。第一seat 表的联合唯一键 uk_room_seat 保证同一个自习室里不会出现两个相同编号的座位这个约束在管理员批量导入座位时能兜住重复数据。第二reservation 表没有直接存 room_id因为 seat_id 已经能关联到房间多存一列反而可能在写入时出现「房间和座位不一致」的脏数据。capacity 在 room 表里是冗余字段只用于列表页展示真实数量以 seat 表为准管理员有没有把座位录齐全可以用 COUNT(seat) 去对账。时段字段建议用 TIME 而不是 DATETIME因为 reserve_date 已经承担了「哪一天」的职责时间字段只描述「几点到几点」。如果图省事拼成一个 DATETIME跨天时段比如 22:00 到次日 02:00会变得极难判断先按日 起止时间拆开后面的冲突查询会省很多麻烦。2.2 座位状态机从待签到爽约的流转规则表结构定下来后下一步是把状态流转画清楚。这个项目的核心状态不在 seat.status而在 reservation.status。seat.status 只表达物理状态空闲/占用/维护reservation.status 表达一次预订的生命周期两者不要混用。一次预订的流转规则如下当前状态触发动作下一状态校验点0 待签到学生在开始时间前取消3 已取消未开始才能取消0 待签到开始后 30 分钟内签到1 已签到超过窗口则失败0 待签到超过签到窗口未操作4 爽约定时任务批量处理1 已签到到达结束时间2 已结束定时任务统一收尾把迁移规则集中在一个 ReservationStatusService 里每个方法只做一件事cancel、checkIn、complete、markNoShow。这样就不会在业务代码里到处散落 if (status 0) 的判断。例如取消方法必须判断当前时间是否早于 start_time否则管理员会看到一场已经开始的预订还能被学生取消。Service public class ReservationStatusService { Transactional public boolean cancel(Long reservationId, Long userId) { Reservation r reservationMapper.selectById(reservationId); if (r null || !r.getUserId().equals(userId)) { return false; } // 预留 10 分钟缓冲防止学生踩着开始时间取消 if (LocalTime.now().isAfter(r.getStartTime().minusMinutes(10))) { return false; } r.setStatus(3); // 已取消 reservationMapper.updateById(r); return true; } }这里的 minusMinutes(10) 是缓冲参数意思是即使开始时间已经过了只要还在 10 分钟宽限期内也允许取消。不同学校的规则不一样有的要求 0有的要求 30建议把它做成 application.yml 里的配置项而不是写死在常量里。答辩时你很可能需要现场改这个值演示效果配置化之后只改一行配置就能看到行为变化。2.3 MyBatis-Plus 下的 DAO 层冲突查询必须手写 SQLMyBatis-Plus 的 BaseMapper 能省掉大量单表 CRUD但预订系统的核心查询——时间段冲突判断——必须手写 SQL因为条件构造器表达不了区间重叠条件。Mapper public interface ReservationMapper extends BaseMapperReservation { Select(SELECT COUNT(*) FROM reservation WHERE seat_id #{seatId} AND reserve_date #{date} AND start_time #{endTime} AND end_time #{startTime} AND status IN (0, 1)) long countConflict(Param(seatId) Long seatId, Param(date) LocalDate date, Param(startTime) LocalTime startTime, Param(endTime) LocalTime endTime); }重叠条件的写法是固定的一段区间 [a, b) 和另一段 [c, d) 存在交集等价于 a d 且 c b。翻译成 SQL 就是新时段的开始时间小于已有时段的结束时间新时段的结束时间大于已有时段的开始时间。注意只统计 status IN (0, 1)即待签到和已签到已取消和已结束的记录不构成冲突。这个过滤条件漏掉的话会出现「取消后想重订同一时段却被挡住」的假象。分页方面MP 的 Page 对象直接放在 controller 参数里前端传 current 和 size 即可不用自己写 LIMIT。但有个默认值要改MP 的 size 默认是 10而列表页经常一屏要展示 50 条座位记录建议在配置里统一调大或者每个接口显式传 size避免前端表格看起来只出了一页数据还找不到原因。3. 预订接口的并发控制唯一索引 Redis 锁让占座不超卖3.1 为什么 if save 的常规写法在抢座时必翻车预订接口是这个系统里最容易暴露问题的地方因为它的天然场景就是「很多人同时抢一个座位」。先看一个看起来没问题的版本Transactional public Result reserve(Long seatId, LocalDate date, LocalTime start, LocalTime end) { long cnt reservationMapper.countConflict(seatId, date, start, end); if (cnt 0) { return Result.fail(该座位这个时间段已被预订); } Reservation r new Reservation(); r.setUserId(currentUserId()); r.setSeatId(seatId); r.setReserveDate(date); r.setStartTime(start); r.setEndTime(end); r.setStatus(0); reservationMapper.insert(r); return Result.ok(); }这个版本单用户测试永远是对的。但两个用户 A、B 同时提交请求时T1 时刻 A 的 countConflict 查到 0T2 时刻 B 的 countConflict 也查到 0然后 A 插入成功B 也插入成功——两个人都以为自己抢到了。原因是 countConflict 和 insert 之间不是原子操作默认隔离级别下两个查询互相看不到对方未提交的数据。解决思路分两层第一层是让数据库自己拒绝冲突第二层是在应用层用 Redis 锁降低冲突概率。对毕设而言两层都写上是最好的源码里有可讲的东西面试官追问到哪一层你都能接住。3.2 数据库兜底INSERT ... SELECT 配合唯一约束让冲突插不进去先做数据库层的强约束。如果业务允许把时间段固定成整点或半小时的槽位可以在 reservation 表加联合唯一索引 (seat_id, reserve_date, start_time)数据库直接拒绝第二个相同槽位的插入。ALTER TABLE reservation ADD UNIQUE KEY uk_seat_start (seat_id, reserve_date, start_time);如果做自由时间段就要把「先查再插」改成「条件插入」。下面这条 SQL 把冲突判断放进 INSERT 的 WHERE 子句MySQL 执行时会锁住相关索引区间并发事务里只有一个能插入成功INSERT INTO reservation (user_id, seat_id, reserve_date, start_time, end_time, status) SELECT #{userId}, #{seatId}, #{date}, #{start}, #{end}, 0 FROM dual WHERE NOT EXISTS ( SELECT 1 FROM reservation WHERE seat_id #{seatId} AND reserve_date #{date} AND start_time #{end} AND end_time #{start} AND status IN (0, 1) );3.2.1 固定时段还是自由时段先定业务规则再选方案这两条路线的选择本质是业务规则问题。固定时段每小时一个槽位实现简单、唯一索引直接兜底适合学校自习室的真实管理习惯自由时段交互更好但必须依赖条件插入的原子性。我的建议是毕设默认做固定时段参数做成配置可调比如 slotMinutes 60。这样数据库层的唯一索引能真正生效演示也稳定。判断结果用影响行数MyBatis 里 Insert 返回 int affected如果 affected 0 说明有人抢先一步Insert(script INSERT INTO reservation (user_id, seat_id, reserve_date, start_time, end_time, status) SELECT #{userId}, #{seatId}, #{date}, #{start}, #{end}, 0 FROM dual WHERE NOT EXISTS (SELECT 1 FROM reservation WHERE seat_id #{seatId} AND reserve_date #{date} AND start_time #{end} AND end_time #{start} AND status IN (0,1))/script) int insertIfNoConflict(Param(userId) Long userId, Param(seatId) Long seatId, Param(date) LocalDate date, Param(start) LocalTime start, Param(end) LocalTime end); Transactional public Result reserve(Long seatId, LocalDate date, LocalTime start, LocalTime end) { if (reservationMapper.insertIfNoConflict(userId, seatId, date, start, end) 0) { return Result.fail(手慢了座位刚被别人预订); } return Result.ok(); }这个方案有个底层行为值得写进设计文档条件插入依赖 InnoDB 的 gap lock高并发下可能锁住一个索引区间而不是单条记录。对毕设级别的并发量完全够用但面试聊到「为什么不用悲观锁」时你能说出 gap lock 这个词就已经比大多数人强了。3.3 Redis 锁做第一道闸门锁粒度与过期时间怎么设数据库兜底虽然可靠但每次冲突都走一次写操作浪费连接。常见做法是在前面加一层 Redis 分布式锁让真正走到数据库的请求变少// 锁粒度座位 日期 开始时间 String lockKey seat:lock: seatId : date : start; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(30)); if (!Boolean.TRUE.equals(locked)) { return Result.fail(当前操作人数较多请重试); } try { // 锁拿到后再走条件插入做最终一致性校验 return doReserve(seatId, date, start, end); } finally { redisTemplate.delete(lockKey); }锁粒度是「座位 日期 开始时间」这是合理的不同座位之间互不干扰同一座位不同时段也不该互相阻塞。如果偷懒写成 seat:lock:{seatId}一个人订了上午的座位另一个人订下午也会被挡住这是最常见的锁粒度过大问题。过期时间设 30 秒基于「一次业务操作应该远快于 30 秒」的假设如果锁内代码要调用文件上传或邮件通知这种慢操作应该把慢逻辑移出锁外而不是把过期时间调大。还有一个答辩高频追问finally 里直接 delete 锁如果当前线程的锁已过期被别的线程重新拿到会把别人的锁删掉。严谨做法是 setIfAbsent 时存一个 UUID 作为 value删除前先 GET 比对再用 Lua 脚本保证「比对 删除」原子执行。这个细节写进 RedisConfig 的注释里就是加分项。3.4 用 JMeter 验证并发控制到底拦没拦住写完并发控制后用 JMeter 建一个线程组20 个线程、1 秒内全部启动全部打到同一个 seatId 和同一个 startTime。正确结果是 20 个请求只有 1 个成功其余返回业务失败提示。观察项正常值异常时排查方向成功请求数1唯一索引没生效或状态过滤写成了 IN (0)失败响应统一业务提示检查 DuplicateKeyException 是否被吞掉MySQL 慢日志无 countConflict 慢查询(seat_id, reserve_date) 索引是否缺失Redis 锁命中部分请求秒回「人数较多」锁 key 是否漏掉 startTime 参数4. 管理端权限、定时统计与报表接口自习室预订系统的另一半工作量4.1 用拦截器 JWT 做角色控制先别急着上 Spring Security管理端的工作量容易被低估。学生端只需要预订、取消、查看我的预订管理端至少要覆盖自习室和座位的增删改查、查看全部预订记录、处理违规把频繁爽约的学生限制预订、查看统计报表。如果一上来就配 Spring Security 的过滤器链光是角色继承和放行规则就能折腾一整天毕设时间不划算。常见做法是用 JJWT 生成 token登录接口区分角色然后用一个拦截器统一校验Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } Claims claims; try { claims JwtUtil.parse(token.substring(7)); } catch (Exception e) { response.setStatus(401); return false; } String role claims.get(role, String.class); if (request.getRequestURI().startsWith(/admin) !ADMIN.equals(role)) { response.setStatus(403); return false; } request.setAttribute(userId, claims.get(userId)); return true; } }设计点默认做法说明管理端识别URI 以 /admin 开头比注解更直观新手也好维护角色存储写进 token 的 role 字段免去每次请求查数据库当前用户request attribute 传递controller 直接取不层层传参这里的取舍是role 存进 token 后改了角色必须重新登录才生效毕设场景完全可以接受。把 userId 放进 request attribute 后后续每个 controller 都从 request 拿当前用户业务方法签名会干净很多。注册拦截器到 WebMvcConfigurer 时记得排除 /api/login别把自己的登录接口拦住。4.2 定时任务每天 23:30 自动生成当日上座率管理员最关心的报表是「每个自习室今天的上座率」。手动统计不现实用 Spring Boot 的 Scheduled 即可。启动类上加 EnableScheduling再写一个统计任务Component public class RoomStatScheduler { // cron每天 23:30 执行 Scheduled(cron 0 30 23 * * ?) public void generateDailyRoomReport() { LocalDate today LocalDate.now(); ListRoomStatVO stats reservationMapper.selectRoomStats(today); for (RoomStatVO vo : stats) { DailyRoomReport report new DailyRoomReport(); report.setRoomId(vo.getRoomId()); report.setStatDate(today); report.setReservedCount(vo.getReservedCount()); report.setTotalSeats(vo.getTotalSeats()); // 利用率 预订数 / 座位总数保留一位小数 report.setUtilizationRate(Math.round(vo.getReservedCount() * 1000.0 / vo.getTotalSeats()) / 10.0); reportMapper.insertOrUpdate(report); } } }对应统计 SQL 用 GROUP BY 按房间聚合SELECT s.room_id, COUNT(DISTINCT r.id) AS reserved_count, (SELECT COUNT(*) FROM seat WHERE room_id s.room_id) AS total_seats FROM reservation r JOIN seat s ON r.seat_id s.id WHERE r.reserve_date #{date} AND r.status IN (0, 1, 2) GROUP BY s.room_id;状态过滤写成 IN (0, 1, 2) 是因为「预订过」就算有效使用即使已结束也是今天的真实占用只有已取消和爽约不计入上座率。如果你希望「签到才算上座」把状态改成只统计 status 1这会直接改变报表口径建议把这个口径做成页面下拉选项让管理员自己选而不是写死在代码里。4.3 报表接口返回结构前端 ECharts 怎么消费最省事除了房间利用率常见的还有「近 7 天趋势」和「按小时热度」。后端返回统一的 VO 结构前端直接对接 EChartsData public class HourlyStatVO { private Integer hour; // 8 表示 8 点 private Long count; // 该时段预订数 private String roomName; // 自习室名称 }GetMapping(/admin/stats/hourly) public ResultListHourlyStatVO hourlyStat(RequestParam LocalDate date) { ListHourlyStatVO list reservationMapper.selectHourlyStats(date); return Result.ok(list); }SQL 核心是 HOUR(start_time) 分组SELECT HOUR(r.start_time) AS hour, COUNT(*) AS count, sr.name AS room_name FROM reservation r JOIN seat s ON r.seat_id s.id JOIN study_room sr ON s.room_id sr.id WHERE r.reserve_date #{date} AND r.status IN (0, 1, 2) GROUP BY HOUR(r.start_time), sr.id ORDER BY sr.id, hour;返回协议里 hour 用整型count 用 Long前端拿到数组后 xAxis 格式化一下series 直接填 count。这里要克制住「帮前端拼字符串」的冲动比如返回 08:00-09:00 这种字符串。返璞归真地返回原始结构前端用 formatter 自己拼展示文案后端的这套接口还能复用到其他图表上。提示管理端接口全部走 /admin 前缀后前端路由也要对应加一层权限判断不然拦截器挡得住接口挡不住页面文件。Vue 路由里写一个 beforeEach 检查本地 token 的 role 字段即可。5. 验收演示前必查的 4 个边界并发模拟、N1、跨天与爽约回收5.1 现场演示前先压一遍预订接口上讲台最怕的是两个浏览器同时点预订结果两个都成功。交代码前必须跑一遍并发验证JMeter 建线程组20 个线程 1 秒内全部启动全部打到同一个 seatId 和同一个 startTime。正确结果只有一个成功其余都是业务失败响应。如果出现两个成功优先查 countConflict 的状态过滤是不是写成了 IN (0)——漏掉已结束状态会导致次日同一时段被重复预订。5.2 列表接口查一遍 N1 查询「我的预订列表」是 N1 高发区。最省事的写法是查出 10 条预订记录循环里逐条查 seat 和 room。数据量小没感觉记录超过几百条后翻页明显变慢。用一条 SQL join 出 username、seat_no、room_name或者把列表查询写成带关联的 VO 查询演示时翻页速度的差距一眼就能看出来。MP 的 selectPage 只解决分页不解决关联这块必须手写。5.3 跨天时段和 close_time 的边界校验很多实现只校验了 start end没校验 end close_time。比如自习室 22:00 关门学生提交 20:00 到 23:00 的预订系统照样通过。建议在预订接口里加一条校验链reserve_date 不早于今天、start end、end close_time、签到窗口不能早于 open_time。四个条件缺一个现场演示都可能被老师试出来。5.4 爽约状态必须由定时任务兜底状态流转表里写了「超时未签到 → 爽约」这个动作不能依赖学生端触发否则没人签到就永远卡在待签到。建议每 5 分钟跑一次定时任务把 status 0 且 start_time 已经过去 30 分钟以上的记录更新为 4。顺手把该学生当天的爽约次数加一连续三次自动禁止预订一周这条规则写进论文的业务规则说明里答辩时是有实际深度的加分内容。提示验收前把日志级别从 DEBUG 调回 INFO关闭 MyBatis-Plus 的 SQL 日志输出。否则演示时控制台刷出满屏参数绑定日志反而看不清真正的业务输出放在 application.yml 里改一行即可logging.level.your.mapper.package: info。本文还有配套的精品资源点击获取
返回列表