
简介面向计算机相关专业毕设学生及Java学习者的基于Spring Boot的高校实验室预约系统是一套经过严格调试、可直接运行的完整项目解决传统实验室管理预约不便与资源利用率低的问题。资源包共623个文件、约21.93MB以171个Java源码、59个Vue前端文件、38个JavaScript脚本、21个XML配置及SQL数据库文件为主辅以PNG/JPG图片、CSS样式、批处理脚本和说明文档便于按模块查阅部署。系统实现用户管理、实验室管理、预约管理等核心功能采用Spring Boot与Vue前后端分离架构集成身份验证与授权机制运行说明与数据库文档分别给出配置部署步骤、表结构设计及数据字典有助于理解真实项目开发全流程。已有70人学习适合希望掌握Spring Boot全栈开发、数据库设计与工程化项目结构的读者。1. Spring Boot 高校实验室预约系统从业务梳理到毕设答辩的一次性落地做高校实验室预约系统难点从来不是写代码而是把导师口中的「需求」翻译成一张张表、一个个状态机。Spring Boot 的优势在于它把所有繁琐的配置按约定收拢到一个 yml 文件里让开发者把精力放在业务逻辑上——预约流程、时间冲突检测、角色权限这三件事占据了整个系统至少 70% 的代码量。这套设计思路对计算机毕业设计尤其适用既有技术深度可写又有数据流可画论文里每一章都能落到实际代码。适合正在选题或中期检查阶段的学生也适合想快速搭一套可演示原型、拿去做课设扩展的从业者。下面从数据库设计开始逐步拆到答辩细节。2. 数据库设计先行六张核心表撑起整个预约闭环2.1 角色-权限模型为什么不做 RBAC 而是用字段区分实验室预约系统涉及的成员只有三类学生、教师、管理员。如果引入完整的 RBAC基于角色的访问控制五表模型会额外多出角色表、权限表、用户-角色关联表、角色-权限关联表对毕业设计而言除了凑页数之外没有实际收益。我的处理方式是用户表里直接放role字段用 0/1/2 区分管理员、教师、学生。CREATE TABLE user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 学号/工号, password varchar(255) NOT NULL COMMENT BCrypt加密后的密码, real_name varchar(50) NOT NULL, role tinyint(4) NOT NULL DEFAULT 2 COMMENT 0-管理员 1-教师 2-学生, email varchar(100) DEFAULT NULL, phone varchar(20) DEFAULT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表 SQL 里最关键的是role字段和uk_username唯一索引。密码字段我用BCrypt加密而不是 MD5原因很直接毕设答辩时老师大概率会问「密码是明文存储吗」BCrypt 可以让你给出一个经得起追问的答案。登录验证时用BCryptPasswordEncoder.matches(rawPassword, encodedPassword)比对不要自己写解密逻辑。2.2 预约记录表状态字段是你的整个业务核心预约记录表是整个系统的数据中枢。我见过不少同学把预约状态设计成布尔值——要么预约成功要么失败结果做到实验室管理员审核这一步就卡住了。实际业务里一条预约记录至少要经历「待审核 → 已通过 → 已使用」或「待审核 → 已拒绝」的生命周期还可能涉及学生主动取消。CREATE TABLE reservation ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL COMMENT 预约人ID, lab_id bigint(20) NOT NULL COMMENT 实验室ID, reservation_date date NOT NULL COMMENT 预约日期, start_time time NOT NULL COMMENT 开始时间, end_time time NOT NULL COMMENT 结束时间, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0-待审核 1-已通过 2-已拒绝 3-已取消 4-已完成, purpose varchar(500) DEFAULT NULL COMMENT 实验用途说明, teacher_id bigint(20) DEFAULT NULL COMMENT 审核教师ID, review_comment varchar(500) DEFAULT NULL COMMENT 审核意见, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_lab_date (lab_id, reservation_date), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;status我用 0 到 4 五个值表示完整状态流这里有个容易被忽视的坑学生取消预约只能发生在状态为待审核或已通过时已拒绝的记录不该允许取消否则前端会多出很多无意义的按钮判断。索引设计上联合索引(lab_id, reservation_date)是为后面查冲突准备的——按实验室和日期过滤是最高频的查询路径。2.3 实验室与设备扩展预留的度怎么把握实验室表本身不复杂但建议加一个equipment字段存JSON字符串。毕设场景下不需要单独建设备表把一个实验室里的设备清单放在 JSON 里查询时直接透传给前端就行了。如果你为了扩展性单独建三张关联表写论文时这确实是个亮点但实现工作量会多出将近一天。CREATE TABLE lab ( id bigint(20) NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL COMMENT 实验室名称, location varchar(100) DEFAULT NULL COMMENT 所在位置, capacity int(11) NOT NULL DEFAULT 30 COMMENT 容纳人数, equipment text DEFAULT NULL COMMENT 设备列表(JSON数组), open_time time DEFAULT 08:00:00, close_time time DEFAULT 22:00:00, is_available tinyint(1) NOT NULL DEFAULT 1 COMMENT 是否开放预约, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;open_time和close_time是容易被忽略的字段——实验室不是 24 小时开放的做时间冲突检测时必须把它们纳入边界条件。is_available用于管理员临时关闭某间实验室比如设备检修关闭后前台预约入口直接置灰。3. Spring Boot 工程骨架与核心配置用 Maven 聚合模块还是单工程3.1 工程结构划分controller-service-mapper 三层到底怎么分实验室预约系统有一个特点业务复杂度集中在预约状态流转上其他模块用户管理、实验室管理都是标准 CRUD。对于一个毕业设计级别的项目建议直接用单工程 三层分包不要用 Maven 多模块。理由很务实多模块拆分的预期收益在大型微服务项目里才能体现在单机项目里反而增加调试成本而且答辩时老师如果问「为什么不分模块」单模块完全能站得住——项目规模不需要。lab-reservation-system/ ├── src/main/java/com/example/labresv/ │ ├── controller/ # 接口层只做参数接收和结果封装 │ ├── service/ # 业务层预约流程和状态流转的核心 │ ├── mapper/ # MyBatis 数据访问层 │ ├── model/ # 实体类和 DTO │ ├── config/ # 配置类拦截器、WebMvc、CORS │ └── common/ # 统一返回结果、异常处理、工具类 ├── src/main/resources/ │ ├── mapper/ # MyBatis XML 文件 │ └── application.yml └── pom.xml我在实际项目里见过一种翻车写法把controller里的代码写得极短所有业务逻辑全部堆在service层然后 service 方法超过三百行答辩时老师问某一段在做什么自己都要翻半天。合理做法是 service 只处理业务流程编排具体的校验逻辑抽成私有方法。3.2 configuration 配置WebMvc 拦截器与统一返回体登录拦截是一个绕不开的模块。我的方案是写一个JwtInterceptor拦截所有接口但用注解放行部分端点。这个设计比用 Servlet Filter 更贴合 Spring Boot 的习惯也方便在论文里写「基于 AOP 思想实现」。Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/**) .excludePathPatterns( /user/login, /user/register, /error, /doc.html, /webjars/**, /v3/api-docs/** ); } }这里我使用拦截器而不是 AOP 切面的原因是拦截器天然工作在 Spring MVC 的 Handler 执行前后可以拿到HttpServletRequest做 token 解析而 AOP 更适合做方法级的耗时监控、日志记录这类横切关注点。拦截器忽略静态资源和 Swagger 相关路径调接口时不会被认证挡住。建议用CrossOrigin在Controller层做跨域处理不要在拦截器里手动写跨域响应头否则会碰到「预请求被拦截」这种排查半小时的问题。3.3 日志与 MyBatis 配置把 SQL 打印开关留到排查时再关yml 文件里有一个经常被忽略但极其实用的配置项就是日志级别。开发阶段必须能看到完整的 SQL 和参数否则 MyBatis 写错了条件根本无从查起。logging: level: com.example.labresv.mapper: debug mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl spring: datasource: url: jdbc:mysql://localhost:3306/lab_reservation?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 server: port: 8080第一段日志配置把 mapper 接口的日志级别设为 debugSpring Boot 启动后会打印每个 Mapper 方法的 SQL 执行情况。MyBatis 配置里的map-underscore-to-camel-case必须开启否则reservation_date这种字段映射不到reservationDate属性上查询结果全是 null。serverTimezoneAsia/Shanghai是 MySQL 8 连接的老坑少了这个参数直接报时间区间错误。4. 核心业务模块的实现要点登录认证、预约流程与冲突检测4.1 登录认证JWT 方案与角色信息传递JWT 登录在毕业设计里属于「性价比最高」的技术选型——实现简单、答辩讲得出原理、代码量少。核心逻辑是登录成功后生成一个 token把userId和role塞进 Claims后续请求通过拦截器解析 token 获得当前用户身份。Service public class AuthService { Autowired private UserMapper userMapper; Autowired private PasswordEncoder passwordEncoder; public String login(String username, String rawPassword) { User user userMapper.findByUsername(username); if (user null) { throw new BusinessException(用户不存在); } if (!passwordEncoder.matches(rawPassword, user.getPassword())) { throw new BusinessException(密码错误); } // 生成JWT有效期2小时 String token Jwts.builder() .setSubject(user.getId().toString()) .claim(role, user.getRole()) .claim(realName, user.getRealName()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 2 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, jwtSecret) .compact(); return token; } }注意这里我使用jwtSecret这个常量作为签名密钥实际项目中应该放到配置文件中。密码比对用PasswordEncoder.matches()而不是自己把明文和密文做字符串比较因为 BCrypt 每次生成的盐都不同。token 有效期为 2 小时如果学生做实验超过两小时需要重新登录可以提醒用户不需要做 refresh token。4.2 预约提交事务与时间冲突检测预约流程是这套系统的核心也是最容易翻车的地方。我在前置检查阶段看到很多同学只判断「结束时间晚于开始时间」这是一个必要条件而非充分条件。正确做法是先查目标实验室在当天是否存在状态为「待审核」或「已通过」且时间区间重叠的预约记录。Transactional(rollbackFor Exception.class) public Long createReservation(ReservationRequest request, Long userId) { // 1. 基础参数校验 if (request.getStartTime().isAfter(request.getEndTime())) { throw new BusinessException(开始时间不能晚于结束时间); } Lab lab labMapper.selectById(request.getLabId()); if (lab null || lab.getIsAvailable() 0) { throw new BusinessException(该实验室暂不可预约); } // 2. 时间边界校验实验室开放时间 if (request.getStartTime().isBefore(lab.getOpenTime()) || request.getEndTime().isAfter(lab.getCloseTime())) { throw new BusinessException(预约时间不在实验室开放时段内); } // 3. 核心冲突检测同一实验室、同一天、时间区间有交集 ListReservation conflictList reservationMapper.findConflictingReservations( request.getLabId(), request.getReservationDate(), request.getStartTime(), request.getEndTime()); if (!conflictList.isEmpty()) { throw new BusinessException(该时间段已被预约请选择其他时间); } // 4. 插入预约记录 Reservation reservation new Reservation(); reservation.setUserId(userId); reservation.setLabId(request.getLabId()); reservation.setReservationDate(request.getReservationDate()); reservation.setStartTime(request.getStartTime()); reservation.setEndTime(request.getEndTime()); reservation.setStatus(ReservationStatus.PENDING); reservation.setPurpose(request.getPurpose()); reservationMapper.insert(reservation); return reservation.getId(); }findConflictingReservations的 SQL 是这段逻辑的关键所在。我踩过的坑是直接用(startTime newStart AND endTime newEnd)判断重叠把四种冲突情况新预约在前、在后、包含、被包含只覆盖了其中一种。正确的 SQL 条件只有一个start_time newEnd AND end_time newStart。select idfindConflictingReservations resultTypecom.example.labresv.model.Reservation SELECT * FROM reservation WHERE lab_id #{labId} AND reservation_date #{reservationDate} AND status IN (0, 1) AND start_time lt; #{endTime} AND end_time gt; #{startTime} /select这段 SQL 是一个典型的半开区间重叠判断对新时间段[newStart, newEnd)只要已有记录的start_time newEnd且end_time newStart就说明两个区间有交集。事务注解Transactional(rollbackFor Exception.class)保证冲突检测和插入要么同时成功、要么同时回滚——没有事务的话两个用户同时提交时可能出现都通过检测的情况。4.3 状态流转用枚举收口可维护状态预约状态的变更散落在多个 Service 中如果直接对status字段做整数赋值代码会越写越乱。我一般会在项目中定义一个枚举类把可能的状态流转路径写死public enum ReservationStatus { PENDING(0, 待审核, Arrays.asList(CANCEL, APPROVE, REJECT)), APPROVED(1, 已通过, Arrays.asList(CANCEL, COMPLETE)), REJECTED(2, 已拒绝, Collections.emptyList()), CANCELED(3, 已取消, Collections.emptyList()), COMPLETED(4, 已完成, Collections.emptyList()); private final int code; private final String desc; private final ListString allowedActions; }allowedActions可以理解为状态机的合法性校验比如REJECTED状态的记录不允许任何操作。在 Service 层写一个通用的changeStatus方法每次变更前校验动作是否允许。这样做的好处是答辩时可以直接画出状态转换图作为论文的一张插图——「基于状态枚举的受限状态机设计」听起来就是加分项。5. 避坑指南这套系统最常见的四类问题排查5.1 数据库时间与 JVM 时间不一致预约日期偏移 8 小时现象前端选择了 2025 年 5 月 10 日后端数据库存的却是 5 月 9 日 16 点。原因MySQL 连接参数没加serverTimezoneAsia/Shanghai默认使用服务器本地时区或者 JDBC URL 里写了 GMT8 但 MySQL 服务端全局时区是 UTC。解决统一三处时区——JVM 启动参数加-Duser.timezoneAsia/ShanghaiMySQL 连接串加serverTimezoneAsia/Shanghai数据库连接池初始化 SQL 执行SET time_zone 8:00。调完记得重启应用不是刷新页面就能生效。5.2 时间冲突检测漏掉状态为「已取消」的记录现象学生 A 预约了周一 10:00-12:00 后主动取消学生 B 在同一时段预约却被系统提示「时间段已被占用」。原因冲突查询 SQL 没有过滤状态把已取消的记录也算进了冲突集合。业务上「取消」意味着时间段应被释放。解决在findConflictingReservations的查询条件里加status IN (0, 1)只排除已拒绝、已取消、已完成三种状态既保住了通过状态的有效性又不会让历史数据产生干扰。我见过一个做法是给记录加is_active逻辑删除字段本质上一样但改造成本更高。5.3 前端格式化日期传到后端变成 null现象接口文档里写的是yyyy-MM-dd HH:mm:ss前端传了2025-05-10T08:00ISO 格式后端直接报参数解析异常。原因Jackson 默认的日期解析格式是yyyy-MM-ddTHH:mm:ss.SSSXXX和前端约定的格式不一致。解决在application.yml里设置spring.jackson.date-format: yyyy-MM-dd HH:mm:ss和time-zone: GMT8。如果前端用的是 Element UI 的DatePicker保持 value-format 和后端约定一致使用LocalDateTime接收需要额外加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)注解。这部分很容易被忽略调试时先看请求参数是否完整到达了 Controller再往前端查。5.4 拦截器放行路径写错导致 Swagger 看不到接口文档现象用 Swagger 调试接口点「Try it out」直接 401。原因拦截器excludePathPatterns里只放行/user/login和/user/registerknife4j 的静态资源路径/doc.html、/webjars/**没被排除。解决在WebMvcConfig的放行列表里至少要包含/doc.html、/webjars/**、/v3/api-docs/**和/swagger-resources/**。如果项目引入了 knife4j还需要把/favicon.ico也放行否则浏览器控制台会报一个无关痛痒的 404干扰排查节奏。6. 答辩验证三步走从演示数据到压测报告的闭环系统做完了不等于可以答辩至少要走完下面三个验证步骤这直接决定老师问「系统健壮性怎么样」时你能不能给出有说服力的回答。第一步准备一套完整的演示数据覆盖所有状态分支。数据集应当包括一名管理员、一名教师、三名学生、六间实验室预约记录至少造出「待审核」「已通过」「已拒绝」「已取消」「已完成」五种状态。演示预约冲突时提前准备两条时间重叠的记录展示系统拒绝第二条预约的提示。这一步看似简单但特别容易被忽略很多同学演示到「查看历史预约」时数据只有一两行根本展示不出状态流转的逻辑。第二步开启 MyBatis SQL 日志梳理一遍核心接口执行了几条 SQL。老师在答辩时经常会问「预约页面加载需要几次数据库查询」如果你能说出「实验室列表一次查询、预约记录一次关联查询、冲突检测一次条件查询」这种精确结论比空谈 MyBatis-plus 性能深刻得多。顺手在 Service 方法上加一个Timed注解或用 Spring AOP 打印耗时把耗时数据截个图论文的测试章节素材就有了。第三步用一个轻量级压测工具打一下预约接口。不需要复杂链路只用JMeter开 50 个线程循环 20 次提交预约请求重点观察两个指标吞吐量和冲突时拒绝率。单机 MySQL 场景下核心预约接口的 TP99 应该能控制在 100ms 以内冲突拒绝率接近 100%。这一组数据放进论文「系统测试」章节基本就能扛住老师关于性能的发问。最后再说一个所有方向通用的隐藏加分项给项目写一个 README.md用五到八行说明「本地启动步骤、演示账号、默认密码、核心接口地址」。这不仅是一个交付习惯也相当实用——答辩当天如果要用自己的电脑演示照着 README 最快速度把环境拉起来不至于现场启动失败。这种事我吃过亏当时为了把 MySQL 的 root 密码重置让三位评委干等了六分钟从那以后我每套毕业设计项目都会强制走一遍「干净环境拉代码 → 照 README 启动 → 关键接口冒烟测试」的流程。希望这一套流程能帮你也避开那些现场演示翻车的时刻。本文还有配套的精品资源点击获取