简介:这是一套基于Spring Boot的高校实验室预约系统完整毕业设计资源,面向计算机相关专业正在做毕设的学生以及需要Java项目实战练习的学习者,也可用于课程设计与期末大作业。系统围绕用户管理、实验室管理、预约管理等核心模块展开,采用前后端分离架构,后端整合Spring Web与Spring Data JPA,前端使用Vue构建交互界面,并实现用户身份验证与授权机制,帮助读者理解从数据库设计到接口开发的完整流程。资源包共623个文件,约21.93MB,其中171个Java源文件构成后端主体,59个Vue组件与38个JavaScript文件支撑前端页面,另含SQL建库脚本、XML配置、运行说明文档及图片样式等静态资源,目录结构清晰,便于按模块查阅。已有71人学习下载。读者可获得可直接运行的源码、数据库脚本与部署说明,对照调试排错,快速掌握Spring Boot应用开发与数据持久化知识。
1. 从一张排课冲突表说起:高校实验室预约系统到底要解决什么
每到学期初,实验室管理员最头疼的不是设备不够,而是同一台示波器被三个班级同时预约。我见过最离谱的一次,某高校电子实验室的预约登记还停留在共享 Excel 阶段,结果两个老师带着学生同时站在门口,谁都不肯让。这类问题的根子不在设备数量,而在预约这件事本身缺少状态管理——谁在什么时间段占了哪台设备,系统里没有唯一答案。
基于 Spring Boot 的高校实验室预约系统,要解决的就是把「人、实验室、时间段、设备」这四个变量锁进一套可校验、可追溯的流程里。它适合两类人:一类是正在做课程设计或毕业设计的同学,需要一套结构完整、能跑通的后端方案;另一类是真的在给学院做信息化改造的开发者,关心并发冲突、权限边界和后续维护成本。这篇文章不讲空泛的架构图,而是把表结构、冲突检测、状态流转和部署踩坑一条条拆开,让你看完能直接动手搭出一版可用的系统。
2. 需求拆解与数据建模:预约系统最容易翻车的地方
2.1 三类角色与核心用例
高校实验室预约系统的角色划分比一般预约系统更细,常见做法是分成学生、教师、管理员三层。学生只能预约开放时段的公共实验室,教师可以预约自己带的课程所需设备,管理员负责审批、释放和维护实验室资源。这个划分直接决定了后面权限拦截器的粒度。
核心用例其实就五个:查空闲时段、提交预约、审批预约、取消预约、生成使用记录。很多教程一上来就画一堆 UML,结果代码里连「同一实验室同一时段只能有一条有效预约」都没保证。我一般会先把这五个用例的输入输出写清楚,再倒推表结构。
| 用例 | 输入 | 输出 | 关键约束 |
|---|---|---|---|
| 查空闲时段 | 实验室 ID、日期 | 可用时间段列表 | 排除已审批和待审批记录 |
| 提交预约 | 用户 ID、实验室 ID、起止时间 | 预约单号 | 起止时间不能重叠 |
| 审批预约 | 预约 ID、审批结果 | 状态变更 | 仅管理员可操作 |
| 取消预约 | 预约 ID | 状态变更 | 开始前 2 小时可取消 |
| 生成使用记录 | 预约 ID | 使用日志 | 审批通过后自动生成 |
2.2 表结构设计与字段含义
数据建模是这类系统的地基。我见过太多项目把预约时间存成两个字符串,结果查询时空闲判断全靠 Java 循环,数据量一上来就崩。正确做法是用datetime存起止时间,并在数据库层加唯一索引兜底。
-- 实验室表 CREATE TABLE lab ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT '实验室名称', location VARCHAR(128) COMMENT '位置', capacity INT DEFAULT 0 COMMENT '容纳人数', open_time TIME COMMENT '开放开始时间', close_time TIME COMMENT '开放结束时间', status TINYINT DEFAULT 1 COMMENT '1开放 0关闭' ); -- 预约表 CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, lab_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT DEFAULT 0 COMMENT '0待审批 1已通过 2已拒绝 3已取消', purpose VARCHAR(255) COMMENT '使用目的', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_lab_time (lab_id, start_time, end_time) );idx_lab_time这个联合索引是必须的,因为冲突检测的查询条件永远围绕lab_id和时间区间展开。status字段用 tinyint 而不是字符串,是为了后续做状态机流转时比较方便。注意open_time和close_time存的是实验室的开放窗口,预约时间必须落在这个窗口内,这个校验放在 Service 层做。
2.3 时间冲突检测的 SQL 写法
冲突检测是整个系统最核心的一段逻辑。判断两个时间段是否重叠,标准条件是new_start < existing_end AND new_end > existing_start。这个条件覆盖了包含、相交、被包含三种情况。
SELECT COUNT(*) FROM reservation WHERE lab_id = #{labId} AND status IN (0, 1) AND start_time < #{endTime} AND end_time > #{startTime};参数说明:labId是目标实验室,startTime和endTime是本次预约的起止时间。status IN (0, 1)表示只检查待审批和已通过的记录,已拒绝和已取消的不参与冲突判断。返回数量大于 0 就说明有冲突,直接抛业务异常。
提示:这段 SQL 必须配合数据库事务使用,否则两个请求同时查到 0 再同时插入,依然会产生重叠记录。后面第 4 章会讲具体的锁方案。
3. 用 Spring Boot 把预约流程跑通:从接口到状态机
3.1 项目分层与依赖选择
一个能维护的 Spring Boot 预约系统,分层不需要太花哨,Controller、Service、Mapper 三层足够。依赖上我一般只加四样:spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、lombok。热搜里常出现的 spring boot + mybatis 组合在这里完全够用,没必要上 JPA,因为预约查询涉及大量自定义 SQL,MyBatis 的 XML 映射更直观。
<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>版本号这里写的是我本地验证过的组合,Spring Boot 3.x 需要把 MyBatis starter 换成对应的 3.x 版本,否则启动时会报NoClassDefFoundError。如果你用的是 IntelliJ IDEA 社区版,创建项目时选 Maven 而不是 Spring Initializr,后者在社区版里有时会卡在依赖下载。
3.2 预约提交接口的完整实现
提交预约的 Service 方法要按顺序做四件事:校验实验室是否开放、校验时间是否在开放窗口内、检测冲突、写入记录。顺序不能乱,否则会出现「先写库再发现时间非法」的脏数据。
@Service public class ReservationService { @Autowired private ReservationMapper reservationMapper; @Autowired private LabMapper labMapper; @Transactional(rollbackFor = Exception.class) public Long submit(ReservationDTO dto) { Lab lab = labMapper.selectById(dto.getLabId()); if (lab == null || lab.getStatus() == 0) { throw new BizException("实验室不存在或未开放"); } // 校验开放窗口 LocalTime start = dto.getStartTime().toLocalTime(); LocalTime end = dto.getEndTime().toLocalTime(); if (start.isBefore(lab.getOpenTime()) || end.isAfter(lab.getCloseTime())) { throw new BizException("预约时间超出实验室开放时段"); } // 冲突检测 int conflict = reservationMapper.countConflict( dto.getLabId(), dto.getStartTime(), dto.getEndTime()); if (conflict > 0) { throw new BizException("该时段已被预约"); } Reservation entity = new Reservation(); entity.setUserId(dto.getUserId()); entity.setLabId(dto.getLabId()); entity.setStartTime(dto.getStartTime()); entity.setEndTime(dto.getEndTime()); entity.setStatus(0); reservationMapper.insert(entity); return entity.getId(); } }@Transactional注解保证了冲突检测和插入在同一个事务里。BizException是自定义业务异常,配合全局异常处理器返回统一格式。注意countConflict的查询和insert之间如果有其他事务提交了重叠记录,仍然可能出问题,这就是下一章要讲的并发场景。
3.3 审批状态流转与权限拦截
预约状态从 0 到 1 或 2 的流转只能由管理员触发。我一般用 Spring 的HandlerInterceptor做角色校验,而不是在每个 Controller 里写 if-else。
public class AdminInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object role = request.getSession().getAttribute("role"); if (!"admin".equals(role)) { response.setStatus(403); response.getWriter().write("{\"code\":403,\"msg\":\"无审批权限\"}"); return false; } return true; } }注册拦截器时只拦截/api/reservation/approve和/api/reservation/reject两个路径,不要全局拦截,否则学生查空闲时段也会被挡。状态流转本身建议用枚举加 switch 控制,拒绝从「已取消」再变回「已通过」这种非法跳转。
4. 并发预约与冲突排查:那些让你半夜爬起来改代码的坑
4.1 避坑:五个真实踩过的坑
现象一:两个请求同时提交,数据库里出现两条重叠记录。原因:冲突检测的 SELECT 和 INSERT 之间没有锁,两个事务都查到 0 条冲突。解决:在countConflict的 SQL 后面加FOR UPDATE,或者对lab_id做 Redis 分布式锁。单机部署用SELECT ... FOR UPDATE就够了,注意要放在事务里才生效。
现象二:审批通过后学生说看不到自己的预约。原因:查询接口只查了status = 1,但学生提交后状态是 0,审批前也应该能看到自己的待审批记录。解决:学生端查询条件改成user_id = 当前用户 AND status IN (0,1),管理员端才只看 0。
现象三:取消预约后时段没有释放。原因:取消操作只改了status = 3,但冲突检测的 SQL 里写的是status != 3,把已拒绝的 2 也算进去了。解决:统一用status IN (0,1)作为有效预约的判断条件,拒绝和取消都不参与冲突。
现象四:跨天预约时间计算错误。原因:LocalTime比较只看了时分秒,22:00 到次日 02:00 的预约被判定超出开放窗口。解决:跨天场景要么禁止,要么把开放窗口判断改成基于完整LocalDateTime的比较,别用LocalTime。
现象五:MySQL 时区不对导致预约时间差 8 小时。原因:连接串没配serverTimezone,JDBC 驱动按 UTC 解析。解决:连接串加?serverTimezone=Asia/Shanghai,同时确认数据库和 JVM 时区一致。
4.2 用乐观锁替代悲观锁的取舍
FOR UPDATE简单直接,但会把行锁住直到事务结束,高并发下容易排队。如果实验室数量多、冲突概率低,可以用乐观锁:在lab表加一个version字段,更新时带上版本号。
UPDATE lab SET version = version + 1 WHERE id = #{labId} AND version = #{version};返回影响行数为 0 就说明有人抢先改了,让前端重试。这种方案适合预约分散的场景,但如果某个热门实验室被疯抢,重试次数会飙升,反而不如悲观锁稳定。我的经验是:实验室数量少于 20 个就用FOR UPDATE,超过 50 个再考虑乐观锁或队列削峰。
4.3 接口幂等与重复提交
学生手抖连点两次提交按钮,会产生两条待审批记录。前端禁用按钮只能防君子,后端必须做幂等。最简单的做法是用user_id + lab_id + start_time做唯一索引,插入重复时捕获DuplicateKeyException返回友好提示。
ALTER TABLE reservation ADD UNIQUE KEY uk_user_lab_start (user_id, lab_id, start_time);注意这个唯一索引只防同一用户对同一实验室同一开始时间的重复提交,不同用户抢同一时段仍然要靠冲突检测。两者是互补关系,不是替代关系。
5. 部署上线前值得做的三件事:压测、监控与数据清理
5.1 用 JMeter 压出冲突检测的瓶颈
上线前我一定会做一轮并发压测,重点不是看 QPS 有多高,而是看冲突检测在并发下会不会漏判。用 JMeter 开 50 个线程同时提交同一实验室同一时段的预约,正常结果应该是 1 条成功、49 条返回「该时段已被预约」。如果成功数大于 1,说明锁没生效,回去检查@Transactional和FOR UPDATE是否配对。
压测时把日志级别调到DEBUG,观察 SQL 执行顺序。常见问题是countConflict走了全表扫描,因为idx_lab_time没被命中。用EXPLAIN看一下执行计划,type列应该是range而不是ALL。
5.2 用 Spring Boot Admin 盯住运行时状态
热搜里提到的 spring boot admin 在这里很实用。引入spring-boot-admin-starter-server后,你可以实时看到预约接口的调用次数、平均耗时和异常率。我一般会重点关注两个指标:reservation_submit的 P99 耗时,以及冲突检测抛出的BizException数量。后者突然飙升,往往意味着有实验室被恶意占位,需要人工介入。
配置上注意把 Admin Server 和业务服务分开部署,别让监控拖慢主服务。如果只是课程设计,用 Actuator 的/actuator/health和/actuator/metrics也够用,不必强上 Admin。
5.3 历史预约数据的归档策略
预约表是典型的只增不改的表,一个学期下来轻松几十万行。冲突检测的查询虽然走了索引,但数据量太大时依然会变慢。我的习惯是每学期末把status IN (2,3)且end_time超过半年的记录迁移到reservation_history表,主表只保留有效和近期记录。
INSERT INTO reservation_history SELECT * FROM reservation WHERE status IN (2, 3) AND end_time < DATE_SUB(NOW(), INTERVAL 6 MONTH); DELETE FROM reservation WHERE status IN (2, 3) AND end_time < DATE_SUB(NOW(), INTERVAL 6 MONTH);这两条语句要放在同一个事务里执行,并且先备份。归档后记得对主表做一次OPTIMIZE TABLE,回收碎片空间。这个操作我一般放在凌晨低峰期,用定时任务触发,避免手动误删。
5.4 一个容易被忽略的细节:实验室临时关闭
管理员临时关闭某个实验室时,如果只改了lab.status = 0,已经审批通过的预约不会自动失效。正确做法是在关闭实验室的 Service 方法里,把该实验室未来所有status = 1的预约批量改成status = 2,并给对应用户发一条站内通知。这个逻辑不复杂,但十个人做预约系统有八个会漏掉。
@Transactional(rollbackFor = Exception.class) public void closeLab(Long labId) { labMapper.updateStatus(labId, 0); reservationMapper.rejectFutureByLab(labId); // 通知逻辑省略,按项目实际的消息组件实现 }rejectFutureByLab的 SQL 条件是lab_id = #{labId} AND status = 1 AND start_time > NOW()。只处理未来的预约,已经开始的不管。这个细节处理好了,能省掉大量学生跑到实验室发现门锁了的投诉。
我做这类系统最大的教训是:别在第一次写的时候就想着支持多校区、多级审批、设备级预约。先把单校区、单级审批、实验室级预约跑通,把冲突检测和状态流转做扎实,后面加维度只是改查询条件的事。反过来,地基没打好,功能堆得越多,半夜被叫起来修数据的概率就越大。希望帮到你。
本文还有配套的精品资源,点击获取