简介这是一份面向 Java 毕业设计、期末大作业与课程设计的实验室预约管理系统完整源码采用 JavaWeb 加 Spring Boot 加 MySQL 的技术组合覆盖前端页面、后端接口、数据库表结构与项目部署配置适合需要快速搭建项目或参考高分设计思路的在校生。资源压缩包内共包含 240 个文件其中有 86 个 HTML 页面、79 个 Java 源码文件、17 个 XML 配置文件和数据库 SQL 脚本同时还有 yml 配置、JAR 依赖包、CSS 样式与 JavaScript 脚本等辅助内容整体大小 16.82MB目录结构清晰便于按模块查阅、调试和二次开发。代码中加入了较为完整的中文注释从用户登录、实验室信息展示、预约申请提交到后台审核管理等核心流程均有实现即使基础一般的读者也能较快理清基于 Spring Boot 的前后端整合过程。目前已有 313 人学习下载项目为个人手打并获得导师认可的高分作品在功能划分、数据表设计、接口分层与界面交互方面都有较好的示范作用适合用于毕业设计答辩准备也可作为系统学习 JavaWeb 开发时的完整案例参考。1. 实验室预约管理系统JavaWeb 与 Spring Boot 组合里的完整业务闭环实验室预约管理系统是典型的 JavaWeb 业务系统不依赖支付、消息队列或分布式事务但预约、审核、时段冲突检测、实验室与设备维护这一串流程恰好覆盖了毕业设计最需要展示的完整业务闭环。学生登录系统选择实验室和时段提交预约管理员审核通过后该时段即被锁定同一实验室同一时段若有已通过预约后来者必须看到明确提示。这个系统用纯 Servlet 能做用 Spring Boot MyBatis MySQL 也能做但后者能让你在答辩时同时讲清楚三层架构、框架自动配置、数据库约束和事务边界。下面按大多数毕设项目的实现路径把选型、建表、核心查询和验收方式讲透。2. 选型逻辑与工程骨架Spring Boot 在这个 JavaWeb 项目里承担什么2.1 为什么 Spring Boot 是 JavaWeb 毕业设计最稳妥的底座先从面试视角看业务。如果你翻过 java 面试题和 springboot 面试题会发现考察点不是框架的某个注解而是你对 Servlet、IoC 容器、自动配置和持久层选型之间关系的理解。实验室预约管理系统挂在 JavaWeb 这个大类下实际实现通常分两派一派是纯 Servlet JSP JDBC另一派是 Spring Boot Thymeleaf MyBatis。前者能证明对底层机制的掌握但实现一个预约列表需要手动处理请求分发、参数封装、数据库连接释放和前端渲染代码量和出错概率都明显上升后者把这些基础设施交给 Spring Boot 的自动配置自己只需要写控制器、服务和 Mapper最终代码量集中在业务规则上。对毕设而言我选择后者的另一个理由是调试成本。Spring Boot 项目用一个内嵌 Tomcat 就能跑起来不需要单独配置外部 Servlet 容器修改 Java 代码后由 spring-boot-devtools 自动重启改 Thymeleaf 模板页也不用重启服务。相比之下传统 JavaWeb 项目的部署链路长遇到问题时难以判断是容器、依赖还是代码导致的。这也是为什么多数 javaweb 项目完整案例和教程型笔记都推荐用 Spring Boot 做工程化落地。2.1.1 版本选择别让 Spring Boot 版本拖慢开工我的建议是除非指导老师明确要求 JDK 17 以上否则 Spring Boot 2.7.18 是风险最低的选择。它稳定支持 JDK 8与 MySQL 5.7 和 8.0 的驱动兼容都经过了充分验证社区里踩过的坑基本都有记录不容易出现 Spring Boot 版本太高导致依赖拉取失败或自动配置行为大变的情况。下面这段 pom.xml 依赖可以作为起点!-- pom.xml 关键依赖省略公共声明 -- parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies !-- Web 模块内置 Tomcat负责控制层和静态资源 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Thymeleaf服务端渲染避免前后端分离的跨域和鉴权复杂度 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency !-- MyBatis 与 Spring Boot 的集成 starter -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency !-- MySQL 驱动运行时使用 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- JSR-303 参数校验 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- Lombok简化实体 getter/setter -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这段依赖的核心逻辑是spring-boot-starter-web 把控制器、内嵌 Tomcat、JSON 序列化一次拉齐mybatis-spring-boot-starter 负责把 SqlSessionFactory 和 Mapper 代理对象交给 Spring 容器管理mysql-connector-java 保证 MyBatis 能通过 JDBC 协议访问 MySQL。驱动版本由 Spring Boot 2.7 的依赖管理统一锁定如果你的 MySQL 是 8.0 以上建议把版本显式钉到 8.0.33避免连接握手时出现非对称认证协商或时区方面的问题。2.1.2 application.yml 的关键配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/lab_reserve?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl logging: level: com.example.labreserve.mapper: debug这套配置里有两个值得讲透的点。第一serverTimezoneAsia/Shanghai 必须显式声明否则 MySQL 8.x 驱动在 Java 8 环境中容易抛出 CST 时区歧义的连接异常这是许多 mysql 安装配置教程里不会单独提到的隐藏坑。第二map-underscore-to-camel-case 打开后数据库列 lab_name 能自动映射到实体的 labName 字段你就不需要在每一条 SQL 里写一堆列别名。配置里几个关键参数对照如下配置项推荐值作用server.port8080本地调试默认端口与系统冲突时改 8081datasource.urljdbc:mysql://localhost:3306/lab_reserve指定数据库地址库名按实际调整spring.thymeleaf.cachefalse开发时禁用模板缓存改页面立即生效mybatis.mapper-locationsclasspath:mapper/*.xml扫描 Mapper XML 文件的位置提示mapper-locations 写的是 classpath:mapper/*.xml。如果 XML 文件放在 src/main/resources 之外的其他目录启动时会出现 Invalid bound statement 错误这是毕设项目最常见的启动失败原因。2.2 工程分包与启动类毕设答辩时老师一般不会逐行读代码但会先从工程结构的层次感判断这个项目是否“像样”。我习惯按 controller、service、mapper、entity 四层分包每个包只承担一种职责这也是常见 javaweb 学习笔记里贯用的分层思路src/main/java/com/example/labreserve ├── controller │ ├── AuthController.java │ ├── LabController.java │ └── ReservationController.java ├── service │ ├── ReservationService.java │ └── ReservationServiceImpl.java ├── mapper │ ├── LabMapper.java │ └── ReservationMapper.java ├── entity │ ├── User.java │ ├── Lab.java │ └── Reservation.java └── LabReserveApplication.java启动类只需要三行代码SpringBootApplication MapperScan(com.example.labreserve.mapper) public class LabReserveApplication { public static void main(String[] args) { SpringApplication.run(LabReserveApplication.class, args); } }SpringBootApplication 本身聚合了自动配置、组件扫描和配置类声明三个能力MapperScan 让 MyBatis 在启动时扫描到所有 Mapper 接口并生成代理实现。如果不加 MapperScan就得在每个 Mapper 接口上单独写 Mapper虽然效果一致但统一扫描在项目变大后更方便维护。3. 数据模型先行实验室预约系统的 MySQL 表结构与设计要点3.1 拆解业务角色与预约状态机做实验室预约系统的第一步不是写代码而是把业务实体在纸上列清楚。这个系统至少需要四类核心数据用户、实验室、预约记录、审核记录。用户区分学生、教师和管理员实验室包含位置、容量、设备、开放时段预约记录是每次申请的数据快照审核记录则负责追溯谁在什么时间通过了哪条预约。预约状态是整个系统的核心状态机通常建议用 TINYINT 字段而不是字符串直接存中文。常见定义如下状态值含义后续动作0待审核管理员审核后变为 1 或 21已通过到达预约时间后置为 42已拒绝保留拒绝原因供用户查看3已取消用户或管理员在审核前取消4已完成终态这个状态机是所有查询、页面按钮和权限控制的依据。很多毕设做到一半出现逻辑混乱就是因为把“已通过”和“已完成”混成一个状态审核页找不到待办用户也看不到历史记录。用数字而不是字符串做状态值还能让后续的统计 SQL 直接基于数值做分组汇总。3.2 核心建表语句实验室预约系统的 MySQL 建表我建议从下面这份 DDL 起步。你可以用命令行 mysql -u root -p 执行也可以在 mysql workbench 的查询面板中直接运行。如果你还在纠结环境安装先按 mysql 安装教程装好 8.0再在工作台里执行这段脚本即可CREATE DATABASE IF NOT EXISTS lab_reserve DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE lab_reserve; CREATE TABLE t_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT BCrypt 加密后的密码, real_name VARCHAR(50) NOT NULL COMMENT 姓名, role TINYINT NOT NULL DEFAULT 1 COMMENT 1学生 2教师 3管理员, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT 用户表; CREATE TABLE t_lab ( id BIGINT AUTO_INCREMENT PRIMARY KEY, lab_name VARCHAR(100) NOT NULL COMMENT 实验室名称, location VARCHAR(200) NOT NULL COMMENT 位置描述, capacity INT NOT NULL COMMENT 最大容纳人数, equipment VARCHAR(500) DEFAULT NULL COMMENT 设备清单, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可用 0停用, open_time TIME NOT NULL DEFAULT 08:00:00, close_time TIME NOT NULL DEFAULT 22:00:00 ) ENGINEInnoDB COMMENT 实验室表; CREATE TABLE t_reservation ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL COMMENT 预约人ID, lab_id BIGINT NOT NULL COMMENT 实验室ID, reserve_date DATE NOT NULL COMMENT 预约日期, start_time TIME NOT NULL COMMENT 开始时间, end_time TIME NOT NULL COMMENT 结束时间, purpose VARCHAR(255) DEFAULT NULL COMMENT 预约用途, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待审核 1已通过 2已拒绝 3已取消 4已完成, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, audit_by BIGINT DEFAULT NULL COMMENT 审核人ID, audit_time DATETIME DEFAULT NULL COMMENT 审核时间, reject_reason VARCHAR(255) DEFAULT NULL COMMENT 拒绝原因, CONSTRAINT fk_res_user FOREIGN KEY (user_id) REFERENCES t_user (id), CONSTRAINT fk_res_lab FOREIGN KEY (lab_id) REFERENCES t_lab (id) ) ENGINEInnoDB COMMENT 预约记录表;表名前加 t_ 前缀是为了避开 MySQL 8.0 的保留字。如果你把表起名为 user 或 order在部分版本上执行会直接报语法错误。密码字段预留 100 个字符是因为 BCrypt 的散列结果固定是 60 个字符早期把它设计成 varchar(32) 的项目跑不了几次注册就会碰到 Data too long。预约单中的 reserve_date 使用 DATE 类型start_time 和 end_time 使用 TIME 类型是为了让跨天比较和天级统计更直接不要试图用一个 DATETIME 存开始、另一个 DATETIME 存结束那样后续 SQL 会平白多出很多边界判断。3.3 时间冲突检测的数据基础预约系统的难点不在增删改查而在冲突检测。同一实验室同一时段不能被两条已通过预约占用。这个问题用 Java 代码解决需要在事务里先查后插依赖隔离级别用 MySQL 唯一索引又因为区间不是单点而难以实现。所以合理做法是把冲突判断写成一个 SQL 查询在插入之前先数一下重叠数量。假设新预约是某一天的 S 到 E查询条件为 lab_id 相同、日期相同、状态为待审核或已通过且 start_time E 且 end_time SSELECT COUNT(*) FROM t_reservation WHERE lab_id #{labId} AND reserve_date #{date} AND status IN (0, 1) AND start_time #{endTime} AND end_time #{startTime};这个查询在毕设单机部署场景下已经足够。需要留意的点是status 条件不能只查已通过否则两条“待审核”状态的时间段会同时显示在页面上给用户造成冲突视觉也给管理员审核带来困扰。更严格的系统会做双重控制这里不再展开。4. 核心业务代码预约提交、冲突校验与状态流转4.1 控制层参数接收与校验预约提交是系统里使用频率最高的接口。前端页面传 labId、date、startTime、endTime、purpose 五个字段控制层用 DTO 接收并在入口处完成参数校验。这里不直接用实体类接收是为了避免浏览器的多余字段直接覆盖到数据库实体上也为了把校验规则和数据库模型解耦Data public class ReservationRequest { NotNull(message 实验室不能为空) private Long labId; NotBlank(message 日期不能为空) Pattern(regexp ^\\d{4}-\\d{2}-\\d{2}$, message 日期格式必须为 yyyy-MM-dd) private String date; NotBlank(message 开始时间不能为空) private String startTime; NotBlank(message 结束时间不能为空) private String endTime; Size(max 200, message 用途描述不能超过200字) private String purpose; }Controller RequestMapping(/reservation) public class ReservationController { Autowired private ReservationService reservationService; PostMapping(/submit) public String submit(Valid ModelAttribute ReservationRequest request, BindingResult result, HttpSession session) { if (result.hasErrors()) { return reservation/form; } User loginUser (User) session.getAttribute(loginUser); if (loginUser null) { return redirect:/login; } reservationService.submitReservation(loginUser.getId(), request); return redirect:/reservation/myList; } }这段控制层代码要做的事其实只有三件参数校验、登录断言、调用业务层。JSR-303 注解在请求进入方法之前先行拦截格式错误BindingResult 接收校验结果并回显到表单页。把 loginUser 从 session 里取出来再调用 service是为了防止有人绕过登录页直接 POST /reservation/submit 拿到未授权业务入口。4.2 业务层事务边界与冲突检测业务层需要体现真正的“业务规则”。下面给出一个标准实现注意它把开始时间晚于结束时间、冲突检测和状态初始化都放在同一个事务里Service public class ReservationServiceImpl implements ReservationService { Autowired private ReservationMapper reservationMapper; Override Transactional(rollbackFor Exception.class) public void submitReservation(Long userId, ReservationRequest request) { LocalTime start LocalTime.parse(request.getStartTime()); LocalTime end LocalTime.parse(request.getEndTime()); if (!start.isBefore(end)) { throw new BusinessException(开始时间必须早于结束时间); } int count reservationMapper.countConflict( request.getLabId(), request.getDate(), request.getStartTime(), request.getEndTime()); if (count 0) { throw new BusinessException(该时段已被预约); } Reservation reservation new Reservation(); reservation.setUserId(userId); reservation.setLabId(request.getLabId()); reservation.setReserveDate(request.getDate()); reservation.setStartTime(request.getStartTime()); reservation.setEndTime(request.getEndTime()); reservation.setPurpose(request.getPurpose()); reservation.setStatus(0); reservationMapper.insert(reservation); } }关键点是 Transactional(rollbackFor Exception.class)。Spring 声明式事务默认只回滚 RuntimeException如果自定义的 BusinessException 继承的却是 Exception事务不会回滚。把 rollbackFor 显式指定为 Exception.class才能确保冲突检测通过后插入失败时整个操作不会留下部分执行的中间状态。在 countConflict 返回大于 0 时抛出 BusinessException是最直观的冲突拦截。这里的业务规则是“先到先得”如果有人已经提交了同一时段的待审核预约后来者提交时也会被判定冲突这一点需要在答辩时主动向老师解释否则老师很容易问你“为什么待审核也算占用”。如果你的指导老师希望待审核记录不阻塞其他用户就把 SQL 中的 status IN (0, 1) 改成只查 status 1在代码里控制两条待审核记录并存的展示策略。4.3 MyBatis XML 的动态 SQL 与常见错误持久层用 MyBatis XML 维护 SQL与控制层解耦。下面这组查询覆盖了冲突计数和当前用户预约列表mapper namespacecom.example.labreserve.mapper.ReservationMapper resultMap idReservationVO typecom.example.labreserve.entity.ReservationVO id columnid propertyid/ result columnlab_name propertylabName/ result columnreal_name propertyrealName/ result columnreserve_date propertyreserveDate/ result columnstart_time propertystartTime/ result columnend_time propertyendTime/ result columnstatus propertystatus/ result columnpurpose propertypurpose/ /resultMap select idcountConflict resultTypeint SELECT COUNT(*) FROM t_reservation WHERE lab_id #{labId} AND reserve_date #{date} AND status IN (0, 1) AND start_time lt; #{endTime} AND end_time gt; #{startTime} /select select idlistByUser resultMapReservationVO SELECT r.id, l.lab_name, u.real_name, r.reserve_date, r.start_time, r.end_time, r.status, r.purpose FROM t_reservation r JOIN t_lab l ON r.lab_id l.id JOIN t_user u ON r.user_id u.id WHERE r.user_id #{userId} ORDER BY r.reserve_date DESC, r.start_time DESC /select /mapperXML 里的 和 需要写成 和 否则 MyBatis 解析 XML 时会把标签结构打乱。listByUser 联查了 t_lab 和 t_user让页面拿到 lab_name 和 real_name 而不是一串 id避免前端再做二次查询。这里给你一份从实际操作中整理的排查表报错现象常见原因处理方式Invalid bound statement (not found)Mapper XML 与接口未绑定检查 mapper-locations 与 resources/mapper 目录是否一致启动报元素类型必须后跟属性名XML 里写了裸的 或 改为 / BindingException: Invalid bound statement (not found)XML 中 id 与方法名不一致保持接口方法名与 XML id 完全一致后台报 SQLSyntaxErrorException关键字表名或列名冲突避开保留字统一使用 t_ 前缀4.4 自动建表与初始化数据的取舍有些毕设项目追求“clone 下来直接跑”会在 Spring Boot 里配置自动建表。这个想法本身没错但实验室预约系统的表之间有外键约束执行顺序错位就会初始化失败。常见做法是本地开发用一个简单的插入脚本兜底遇到表不存在时手动执行 DDL。如果你确实想省这一步可以借助 spring.sql.init 配置但要注意两点spring: sql: init: mode: always schema-locations: classpath:sql/schema.sql >mvn clean package -DskipTests java -jar target/lab-reserve-0.0.1-SNAPSHOT.jar启动前先确认本机 java 环境变量配置正确JAVA_HOME 指向 JDK 11 或 JDK 8取决于你选的 Spring Boot 版本否则 mvnw 脚本会直接报错。看到 Started LabReserveApplication 之后访问 http://localhost:8080/login 确认登录页可渲染即可。5.2 用 curl 走一遍完整业务链用 curl 模拟学生登录、提交预约、二次提交冲突三条路径# 登录并保存会话 cookie curl -X POST http://localhost:8080/login \ -d usernamestudent01password123456 \ -c cookies.txt # 第一次提交预约预期返回 302 curl -X POST http://localhost:8080/reservation/submit \ -d labId1date2025-07-20startTime09:00endTime11:00purpose数据库课程实验 \ -b cookies.txt # 第二次提交同一时段预期页面显示“该时段已被预约” curl -X POST http://localhost:8080/reservation/submit \ -d labId1date2025-07-20startTime10:00endTime12:00purpose课程设计 \ -b cookies.txt需要提醒的是redirect 到登录页和 redirect 到预约列表页都会返回 302仅凭状态码分不清业务是否真正成功。我一般会在浏览器里打开开发者工具看 Network 面板或者在后端日志里观察 SQL 语句的执行结果。debug 模式下 MyBatis 会把 countConflict 的 SQL 参数打出来看到参数符合预期基本就能断定业务链路是通的。5.3 答辩演示时的三个细节第一个细节预约列表页排序使用 ORDER BY reserve_date DESC, start_time DESC演示时先展示最新数据避免老师看到一堆乱序的历史记录。第二个细节管理员审核通过后状态从 0 变为 1 的变化需要回显在预约列表的标签颜色上前端用一个简单的枚举映射就能实现不要写死多个 if 字符串比较。第三个细节是统计口径。如果需要统计各实验室使用率直接对 t_reservation 做分组聚合即可完全不需要动用 mysql 存储过程。一条 SQL 就能算清楚SELECT lab_id, COUNT(*) AS total_count, SUM(status 1) AS approved_count FROM t_reservation WHERE reserve_date BETWEEN 2025-07-01 AND 2025-07-31 GROUP BY lab_id;SUM(status 1) 这种写法在 MySQL 中成立能在一次查询里同时拿到总数和已通过数。答辩时主动跑出这张统计表比单纯演示增删改查更能说明你对业务指标的理解。把这条 curl 链跑明白再把你对状态机的理解填进演示流程这个项目就可以进入打包交付阶段了。本文还有配套的精品资源点击获取