简介:这份源码资源面向计算机专业学生与Java Web开发者,提供一套基于Spring Boot与MVC架构的自习室管理与预约系统完整实现,可用于课程设计、毕业设计或全栈练手。系统分为前后台:前台支持用户注册登录、自习室预约及座位与开放时段查询;后台提供管理员登录、自习室信息增删改、用户管理以及预约记录的查看、修改与取消,业务闭环较为完整。压缩包共828个文件,约30.6MB,包含121个Java后端源码、63个Vue组件、159个JavaScript脚本、47个HTML页面,以及大量svg、gif、jpg、png等界面素材和css、scss样式文件,另有sql建库脚本、yml配置、xml映射与bat启动脚本,前端资源与后端分层结构清晰。目前已有52人学习下载,适合希望理解预约类业务建模、权限划分与前后端联调流程的读者参考复用。
1. 自习室预约系统:从抢座乱象到一套能跑通的 Spring Boot 源码
每到考试季,图书馆门口排长队、座位被书本占着却没人来的场景,几乎每个高校都上演过。自习室管理与预约系统要解决的就是这件事:把座位状态、预约时段、违约记录这些原本靠人工登记的信息,用一套后端服务管起来。这个标题指向的是一份基于 Spring Boot 框架的完整源码,适合两类人——想拿一个真实业务练手 Spring Boot 全链路的开发者,以及需要给学校或小型自习室快速搭一套预约后台的团队。它涉及的核心技术点包括 Spring Boot 的 REST 接口设计、MyBatis 数据访问、预约时段冲突校验、座位状态机,以及定时任务处理超时未签到。下面按「先搞懂业务模型,再动手跑通,最后避开那些必踩的坑」的顺序展开,中间会给到能直接抄的配置和代码片段。
2. 预约业务建模:座位、时段、订单三张表怎么设计才不返工
2.1 先想清楚座位状态机,再动手建表
很多人拿到这类源码第一反应是打开 IDEA 直接跑,结果发现预约逻辑对不上自己的场景。问题出在业务模型没对齐。自习室预约的本质是一个「有限资源 + 时间窗口 + 独占占用」的问题,核心实体只有三个:座位(Seat)、时段(TimeSlot)、预约订单(Reservation)。座位有物理属性(所属自习室、座位编号、是否靠窗、有无电源),时段定义了可预约的时间粒度(常见是 1 小时或 2 小时一段),订单则把用户、座位、时段三者绑定在一起。
座位状态机是设计里最容易翻车的地方。一个座位在任意时刻只可能处于四种状态之一:空闲、已预约未签到、使用中、维护中。状态流转必须由订单的生命周期驱动,而不是让管理员手动改。常见做法是在 Reservation 表里存 status 字段(0 待签到、1 已签到、2 已完成、3 已取消、4 违约),座位本身的可用性通过查询「当前时段内是否存在有效订单」实时计算,而不是在 Seat 表里冗余一个 is_occupied 字段。后者在并发场景下几乎必然出现状态不一致。
提示:如果你的场景允许跨时段连续预约,时段表要设计成可拼接的,否则用户约了 8:00-10:00 和 10:00-12:00 会被系统当成两个独立订单,签到逻辑要额外处理。
2.2 三张核心表的字段与索引
下面给出我一般会用的建表结构,字段命名贴近源码里常见的驼峰转下划线风格,方便和 MyBatis 映射对上。
-- 自习室表 CREATE TABLE study_room ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT '自习室名称', location VARCHAR(128) COMMENT '位置描述', open_time TIME NOT NULL DEFAULT '08:00:00', close_time TIME NOT NULL DEFAULT '22:00:00', status TINYINT NOT NULL DEFAULT 1 COMMENT '1开放 0关闭' ); -- 座位表 CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, room_id BIGINT NOT NULL, seat_no VARCHAR(16) NOT NULL COMMENT '座位编号如 A-01', has_power TINYINT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT '1可用 0维护', UNIQUE KEY uk_room_seat (room_id, seat_no) ); -- 预约订单表 CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, slot_date DATE NOT NULL COMMENT '预约日期', 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, sign_in_time DATETIME DEFAULT NULL, KEY idx_seat_date (seat_id, slot_date, status), KEY idx_user_date (user_id, slot_date) );idx_seat_date这个联合索引是冲突校验的性能关键。判断某座位某天某时段是否已被占用时,查询条件会同时命中 seat_id、slot_date 和 status,走这个索引能把扫描行数压到个位数。idx_user_date则用于限制「同一用户同一天最多约几个时段」这类规则。注意 status 放在索引第三位,是因为前两个字段的选择性已经足够高,status 主要起过滤作用。
2.3 时段冲突校验的 SQL 写法
预约接口最核心的一步是判断目标时段是否和已有订单重叠。时间重叠的判定条件是「新开始 < 旧结束 且 新结束 > 旧开始」,这个条件对开区间和闭区间都成立,不需要额外处理边界。
SELECT COUNT(*) FROM reservation WHERE seat_id = #{seatId} AND slot_date = #{slotDate} AND status IN (0, 1) -- 待签到和已签到都算占用 AND start_time < #{endTime} AND end_time > #{startTime};返回大于 0 就说明冲突,直接抛业务异常。这里有个细节:已取消(3)和违约(4)的订单不参与占用判断,所以 status 的 IN 列表要写准。我见过有人图省事写成status != 3,结果违约订单也把座位锁死了,用户投诉到怀疑人生。
3. 用 Spring Boot + MyBatis 把预约接口跑起来
3.1 项目分层与依赖配置
这类源码通常采用 Controller-Service-Mapper 三层结构,配合 MyBatis 做数据访问。先看 pom.xml 里必须有的几个依赖,版本按你本地仓库能拉到的稳定版填,不要盲目追最新。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <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> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> </dependencies>spring-boot-starter-validation容易被忽略,但预约接口的入参校验(日期不能是过去、时段必须在开放时间内)靠它省很多手写 if。MyBatis starter 的版本要和 Spring Boot 主版本匹配,2.3.x 对应 Spring Boot 3.x,用错了启动直接报 NoSuchMethodError。
application.yml 里除了数据源,还要配 MyBatis 的映射文件位置和驼峰转换:
spring: datasource: url: jdbc:mysql://localhost:3306/study_room?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true default-fetch-size: 100map-underscore-to-camel-case: true让 seat_no 自动映射到 seatNo,省掉大量 resultMap 配置。serverTimezone必须显式指定,否则 MySQL 8 下时间字段会差 8 小时,预约日期直接错位。
3.2 预约创建接口的完整实现
下面这段 Service 方法是整个系统的核心,包含冲突校验、用户限额、订单落库三个步骤。
@Service public class ReservationService { @Autowired private ReservationMapper reservationMapper; @Autowired private SeatMapper seatMapper; private static final int MAX_DAILY_SLOTS = 3; // 每人每天最多约3个时段 @Transactional(rollbackFor = Exception.class) public Long createReservation(Long userId, Long seatId, LocalDate date, LocalTime start, LocalTime end) { // 1. 校验座位是否可用 Seat seat = seatMapper.selectById(seatId); if (seat == null || seat.getStatus() == 0) { throw new BizException("座位不存在或维护中"); } // 2. 校验时段合法性 if (!start.isBefore(end)) { throw new BizException("结束时间必须晚于开始时间"); } // 3. 冲突校验 int conflict = reservationMapper.countConflict(seatId, date, start, end); if (conflict > 0) { throw new BizException("该时段已被预约"); } // 4. 用户当日限额 int userCount = reservationMapper.countByUserAndDate(userId, date); if (userCount >= MAX_DAILY_SLOTS) { throw new BizException("当日预约次数已达上限"); } // 5. 落库 Reservation r = new Reservation(); r.setUserId(userId); r.setSeatId(seatId); r.setSlotDate(date); r.setStartTime(start); r.setEndTime(end); r.setStatus(0); reservationMapper.insert(r); return r.getId(); } }@Transactional加rollbackFor = Exception.class是必须的,因为冲突校验和插入之间存在并发窗口。两个请求同时通过校验再同时插入,就会产生重复预约。要彻底解决得靠数据库唯一约束或分布式锁,但加事务至少能保证单次请求内的原子性。MAX_DAILY_SLOTS这类业务参数建议抽到配置中心或数据库,硬编码在代码里改一次要重新打包。
对应的 Mapper XML 里,countConflict 就是前面那条重叠查询,countByUserAndDate 则是按 user_id 和 slot_date 统计 status 为 0 或 1 的记录数。两个查询都很简单,但索引必须建对,否则并发一上来数据库 CPU 直接飙满。
3.3 超时未签到的定时处理
预约了不来,是自习室管理最头疼的问题。常见做法是加一个定时任务,把超过开始时间 30 分钟仍未签到的订单标记为违约,释放座位。
@Component public class NoShowTask { @Autowired private ReservationMapper reservationMapper; // 每10分钟执行一次 @Scheduled(cron = "0 0/10 * * * ?") public void markNoShow() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(30); int affected = reservationMapper.markNoShow(deadline.toLocalDate(), deadline.toLocalTime()); if (affected > 0) { // 记录日志,便于排查 System.out.println("标记违约订单数: " + affected); } } }对应的 SQL 是UPDATE reservation SET status = 4 WHERE status = 0 AND CONCAT(slot_date, ' ', start_time) < #{deadline}。注意这里用 CONCAT 拼出完整时间做比较,比分开比较日期和时间更直观。定时任务的 cron 表达式0 0/10 * * * ?表示每 10 分钟触发,频率别设太高,否则频繁扫表影响正常查询。启动类上要加@EnableScheduling,这个注解漏了任务不会报错,只是静默不执行,排查起来很费时间。
4. 并发预约与状态一致性:那些让座位「凭空消失」的坑
4.1 冲突校验的并发漏洞与补救
前面提到,先查冲突再插入的写法在并发下会失效。假设两个用户同时预约 A-01 座位 14:00-16:00,两个请求都查到 conflict=0,然后都执行 insert,结果就是同一座位同一时段出现两条有效订单。用户到现场发现座位被占,系统里却显示两个人都约成功了。
补救方案有三个层次。最轻量的是在 reservation 表上加唯一索引,把 seat_id、slot_date、start_time 组合成唯一键,插入冲突时数据库直接报错,Service 层捕获 DuplicateKeyException 转成友好提示。这个方案改动最小,但只能防完全相同的时段,对部分重叠无效。中等方案是用SELECT ... FOR UPDATE锁住座位行,把并发串行化,代价是吞吐下降。最重的是引入 Redis 分布式锁,按 seat_id 加锁,适合多实例部署。我一般先用唯一索引兜底,业务量上来再考虑锁。
4.2 签到接口的幂等处理
签到接口被重复调用是常态,用户手抖点两下、网络重试都会触发。如果签到逻辑写成「查到订单就更新状态并记录时间」,重复调用会把 sign_in_time 覆盖成第二次的时间,违约判定就乱了。
正确做法是在 UPDATE 语句里带上状态条件:UPDATE reservation SET status = 1, sign_in_time = NOW() WHERE id = #{id} AND status = 0。返回影响行数为 0 就说明订单不是待签到状态,直接返回「请勿重复签到」。这种「条件更新 + 判断影响行数」的模式是幂等处理的通用套路,比先查后改可靠得多。
4.3 座位释放的时机
订单取消或违约后,座位什么时候重新可约?如果靠定时任务批量释放,会有延迟,用户看到座位还是灰的。更好的做法是座位可用性完全由实时查询决定,不存冗余状态。前端查可约座位时,直接查「该时段内没有有效订单的座位」,取消订单后下一次查询立刻就能看到。代价是查询稍复杂,但避免了状态同步问题。如果非要缓存座位状态,缓存过期时间要设短,且取消订单时主动失效对应缓存。
5. 部署与联调避坑:从本地跑通到给别人用
5.1 常见问题排查
现象:启动报错Failed to configure a DataSource。原因通常是 application.yml 没被加载,或者数据源配置写在了错误的层级。检查 yml 缩进,spring.datasource 必须是 spring 的直接子级。如果用了多环境配置,确认spring.profiles.active指向的文件存在。
现象:预约时间存入数据库后差了 8 小时。原因是 JDBC URL 没加 serverTimezone,或者加了但值写成了 UTC。MySQL 8 的驱动默认按服务器时区解析,国内环境统一写serverTimezone=Asia/Shanghai。另外实体类里用 LocalDateTime 比 Date 更省心,不受时区隐式转换影响。
现象:MyBatis 查询返回字段全是 null。先看map-underscore-to-camel-case是否开启,再看 Mapper XML 里的 resultType 是否写成了实体类全限定名。如果 SQL 里用了别名,别名要和实体属性名一致,否则映射不上。
现象:定时任务不执行。检查启动类有没有@EnableScheduling,方法所在类有没有@Component,方法本身是不是 public。三者缺一,任务都会静默失效,没有任何报错。
现象:并发压测时出现重复预约。回到 4.1 的方案,先加唯一索引,再考虑锁。压测工具用 JMeter 或 wrk 都行,重点看同一座位同一时段的并发请求是否只有一条成功。
5.2 接口对外提供时的放置策略
热搜里有人问 Spring Boot 对外接口该单独放一个服务还是放在对应模块里。对这个预约系统来说,如果只是给学校内部的前端和管理后台用,接口放在同一个应用里完全够,按/api/reservation、/api/seat分路径就行。只有当第三方系统(比如校园一卡通、门禁)需要对接时,才考虑把对外接口抽成独立模块,用不同的鉴权方式和限流策略,避免内部接口的权限模型被外部调用污染。抽离的时机是「外部调用方超过两个且鉴权需求不同」,过早拆分只会增加部署复杂度。
6. 把预约系统用起来:几个让代码更耐用的技巧
源码跑通只是起点,真正投入使用还要处理一些边界。第一个技巧是给预约接口加防重放。用户快速点击提交按钮会产生多个相同请求,除了前端置灰按钮,后端可以用「用户 ID + 座位 ID + 时段」做短时缓存键,60 秒内相同请求直接返回上一次的结果。这个用 Redis 的 setIfAbsent 一行就能实现,比数据库唯一索引更早拦截。
第二个技巧是把时段配置做成可调的。不同自习室的开放时间、时段粒度可能不一样,硬编码在代码里后期维护很痛苦。建一张 time_slot_config 表,存 room_id、start_time、end_time、slot_minutes,预约时按配置生成可选时段。这样新增一个自习室只需要插数据,不用改代码重新发版。
第三个技巧是违约记录的软处理。直接标记违约会让用户反感,可以做成「违约累计 3 次才限制预约一周」,给一次申诉机会。实现上在 user 表加 violation_count 字段,定时任务标记违约时累加,预约接口校验时判断阈值。规则要能配置,别写死。
最后一个习惯:每次改预约相关的逻辑,先在本地用两条并发请求打同一个座位,确认只有一条成功再提交。这个动作花不了两分钟,但能挡住大部分状态一致性问题。我早期跳过这一步,上线后被用户投诉座位冲突,回头查日志才发现是并发插入,血泪经验。希望帮到你。
本文还有配套的精品资源,点击获取