简介:这是一份面向 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <!-- Web 模块:内置 Tomcat,负责控制层和静态资源 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Thymeleaf:服务端渲染,避免前后端分离的跨域和鉴权复杂度 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- MyBatis 与 Spring Boot 的集成 starter --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <!-- MySQL 驱动,运行时使用 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- JSR-303 参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- Lombok,简化实体 getter/setter --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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这套配置里有两个值得讲透的点。第一,serverTimezone=Asia/Shanghai 必须显式声明,否则 MySQL 8.x 驱动在 Java 8 环境中容易抛出 CST 时区歧义的连接异常,这是许多 mysql 安装配置教程里不会单独提到的隐藏坑。第二,map-underscore-to-camel-case 打开后,数据库列 lab_name 能自动映射到实体的 labName 字段,你就不需要在每一条 SQL 里写一堆列别名。
配置里几个关键参数对照如下:
| 配置项 | 推荐值 | 作用 |
|---|---|---|
| server.port | 8080 | 本地调试默认端口,与系统冲突时改 8081 |
| datasource.url | jdbc:mysql://localhost:3306/lab_reserve | 指定数据库地址,库名按实际调整 |
| spring.thymeleaf.cache | false | 开发时禁用模板缓存,改页面立即生效 |
| mybatis.mapper-locations | classpath: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 或 2 |
| 1 | 已通过 | 到达预约时间后置为 4 |
| 2 | 已拒绝 | 保留拒绝原因供用户查看 |
| 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 ) ENGINE=InnoDB 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' ) ENGINE=InnoDB 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) ) ENGINE=InnoDB 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 > S:
SELECT 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 namespace="com.example.labreserve.mapper.ReservationMapper"> <resultMap id="ReservationVO" type="com.example.labreserve.entity.ReservationVO"> <id column="id" property="id"/> <result column="lab_name" property="labName"/> <result column="real_name" property="realName"/> <result column="reserve_date" property="reserveDate"/> <result column="start_time" property="startTime"/> <result column="end_time" property="endTime"/> <result column="status" property="status"/> <result column="purpose" property="purpose"/> </resultMap> <select id="countConflict" resultType="int"> SELECT 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} </select> <select id="listByUser" resultMap="ReservationVO"> 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> </mapper>XML 里的 < 和 > 需要写成 < 和 >,否则 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 "username=student01&password=123456" \ -c cookies.txt # 第一次提交预约,预期返回 302 curl -X POST http://localhost:8080/reservation/submit \ -d "labId=1&date=2025-07-20&startTime=09:00&endTime=11:00&purpose=数据库课程实验" \ -b cookies.txt # 第二次提交同一时段,预期页面显示“该时段已被预约” curl -X POST http://localhost:8080/reservation/submit \ -d "labId=1&date=2025-07-20&startTime=10:00&endTime=12:00&purpose=课程设计" \ -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 链跑明白,再把你对状态机的理解填进演示流程,这个项目就可以进入打包交付阶段了。
本文还有配套的精品资源,点击获取