ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot实战:高校实验室预约系统的设计与并发避坑

Spring Boot实战:高校实验室预约系统的设计与并发避坑 简介基于Spring Boot的高校实验室预约系统是一套计算机毕业设计项目面向正在做毕设的计算机专业学生及Java学习者用于解决传统实验室资源预约不便、管理效率低等现实问题。资源包总计623个文件、21.93MB主要包含171个Java后端源码、59个Vue前端组件、38个JS脚本、21个XML配置及1个SQL数据库脚本同时提供安装、构建、运行等批处理文件便于快速启动与调试。系统采用Spring Boot框架整合Spring Web与Spring Data JPA结合Vue实现前后端分离覆盖用户管理、实验室管理、预约管理、身份认证与数据持久化等核心功能并通过合理的数据库表结构设计保障数据一致性与查询效率可帮助学习者理解完整业务开发链路。目前已有70人学习压缩包内附带运行说明和数据库文档既能直接支撑毕业设计答辩也适合作为课程设计或期末大作业的实战参考。1. 高校实验室预约系统到底在解决什么问题一个几百人的学院实验室预约还在靠微信群接龙和 Excel 排班。学生反复问“某时段空了没”管理员一遍遍复制粘贴最终排出来的表还是撞车。高校实验室预约系统就是把这件事线上化学生登录查空档、提交预约管理员审核使用情况留痕月底还能直接导出使用率。用 Spring Boot 做这类系统是最顺手的选型生态成熟、部署简单单体架构能覆盖绝大多数高校的并发量。这篇文章写给两类人一是正在做相关毕设的学生需要一份能跑通全流程的实现路径二是实验室或学院的信息化负责人想低成本把预约管理从微信接龙里解放出来。2. 系统架构拆解为什么单体应用是这个题目的正解2.1 先划清业务边界三个角色与三条核心流程动手写代码之前一定要先把边界划清楚。这个系统里只有三类人学生或教师是预约人负责提交预约、取消预约、确认使用完成实验室管理员是审核人负责处理预约申请、维护实验室基本信息系统管理员负责账号、基础数据这类运维操作。角色再多比如加一个“院系超管”也只是在角色字段上再加一个值不必在架构上动刀。核心流程也就三条。第一条是预约流程预约人查实验室空档提交一条带日期、时间段、用途、人数的申请状态为待审核管理员在列表里看到这条申请后通过或驳回通过后到时间就来使用结束后确认完成。第二条是取消与爽约流程待审核的单子用户可以自己取消已通过的单子管理员可以取消超过开始时间没签到可以标记爽约。第三条是统计流程按实验室、按月份统计预约次数、使用率、爽约率这是月底管理员最需要的数据。把流程理成这样之后你就能回答一个关键问题这个系统需要多复杂的架构我见过不少毕设一上来就拆微服务注册中心、网关、配置中心全上最后连本地启动都要开五个进程。高校实验室预约的用户规模是几千人峰值 QPS 很难过百数据量在一个学期里也就是几万条预约记录。单体的 Spring Boot 应用加一个 MySQL 完全够用部署也简单一台服务器一个java -jar就起来了。很多人纠结 Spring Boot 和微服务的区别这个题目里不用纠结Spring Boot 单体本身就是微服务架构里单个服务最标准的姿势先把单体做好以后真要拆也拆得动。2.2 技术栈怎么选Spring Boot 3.2 与常用配套技术栈的选择原则是“自己熟练且社区资料够多”。我建议用 Spring Boot 3.2 加 JDK 17这是目前最稳的组合。如果你所在学校的实验环境强制 JDK 8那就退回 Spring Boot 2.7写法上差别不大。如果你用的是 Java 21 加 Spring Boot 3.5可以打开spring.threads.virtual.enabledtrue启用虚拟线程对预约这种 IO 密集型接口是加分项但升级的同时 Spring Security 这类组件的配置方式也有迁移成本不是非追不可。持久层我推荐 MyBatis-Plus因为预约系统里有大量条件查询按实验室查、按日期查、按状态查、按用户查MyBatis-Plus 的 LambdaQueryWrapper 写起来比 JPA 直观复杂 SQL 也可以手写 XML 控制。数据库用 MySQL 8.x字符集 utf8mb4建表时注意时间字段统一。缓存和分布式锁看条件有 Redis 环境就引入spring-boot-starter-data-redis用来做验证码存储和预约并发锁没有 Redis也可以用 Caffeine 做本地缓存但跨实例的锁就做不了了所以锁这块我还是建议用数据库悲观锁兜底。权限这块我一般建议用 JWT 加拦截器而不是直接上 Spring Security。不是 Spring Security 不好而是它的过滤器链配置对新手是个坎一旦配错接口 401 能排查一整天。用 JWT 拦截器的方式代码量少、逻辑透明答辩时也能讲得清楚。如果导师点名要求 Spring Security那你需要在SecurityFilterChain里放行登录接口和静态资源其他路径全部走 JWT 过滤器这个方案在第四章会讲到。2.3 项目骨架落地依赖、配置与包结构跑通第一个 Spring Boot 程序不难Spring Initializr 选好依赖写一个带RestController的类就能看到 Hello World。这个题目的难度从来不在启动而在预约业务里那些看不见的约束时间重叠、并发冲突、状态流转。所以骨架搭好后重心要放在数据模型上但先把基础打对。pom.xml里的核心依赖是这样的parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency /dependencies逻辑说明Spring Boot 3.x 走的是jakarta命名空间所以 MyBatis-Plus 必须选mybatis-plus-spring-boot3-starter选成 2.x 的 starter 会直接启动失败。MySQL 驱动在 8.x 里坐标是mysql-connector-j老写法mysql-connector-java已经被替代。Redis starter 先引进来用不用分布式锁是一回事编译环境里得有。参数说明版本号能交给 Spring Boot parent 管理的就不要手写避免依赖冲突。jjwt这类第三方库版本比较老如果你要用注意选择兼容 JDK 17 的版本。application.yml里最关键的配置是时区和日志server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/lab_reserve?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl逻辑说明serverTimezoneAsia/Shanghai和spring.jackson.time-zoneGMT8必须一致否则前端传来的2024-06-01 14:00会被 Jackson 按 UTC 解析成2024-06-01 06:00入库就错了 8 小时。这个坑在第五章会专门说。参数说明log-impl配成StdOutImpl只是开发期方便看 SQL生产环境一定要改成 Logback 文件输出否则日志刷屏不说还拖慢接口。包结构按下面这样分接口层要瘦业务都放 servicesrc/main/java/com/example/labreserve/ ├── controller/ # HTTP 接口层只做参数接收和响应封装 ├── service/ # 预约、审核、统计等业务逻辑 ├── mapper/ # MyBatis-Plus 数据访问层 ├── entity/ # 数据库实体对象 ├── dto/ # 前后端交互的请求和响应对象 ├── common/ # 统一返回体、异常处理、枚举、工具类 └── config/ # WebMvc、Redis、定时任务配置参数说明entity里的类字段尽量和数据库列一一对应DTO 则按页面需要来设计不要在 controller 里直接返回实体否则改一个页面要连带改表结构后面会很被动。3. 表结构与预约状态机数据模型定生死3.1 五张核心表的字段设计与表关系这个系统的数据模型不是越多表越好够用就行。我一般建五张表用户表、实验室表、预约单表、操作日志表再加一张预约设备关联表设备表看需求。预约单表是最核心的字段设计直接决定了冲突检测怎么写。预约单表reservation的关键字段是这样的字段名类型说明idBIGINT主键自增user_idBIGINT预约人 IDlab_idBIGINT实验室 IDreserve_dateDATE预约日期start_timeTIME开始时间end_timeTIME结束时间statusVARCHAR(20)状态PENDING / APPROVED / REJECTED / CANCELED / COMPLETED / ABSENTpurposeVARCHAR(255)预约用途people_countINT人数audit_user_idBIGINT审核人 IDaudit_remarkVARCHAR(255)审核备注audit_timeDATETIME审核时间create_timeDATETIME创建时间update_timeDATETIME更新时间把预约时间拆成reserve_date、start_time、end_time三个字段而不是用一个start_timeDATETIME 加一个duration是因为冲突检测要频繁比较“某个时间段是否重叠”拆开后 SQL 可以用start_time ? AND end_time ?直接判断走索引也方便。status用 VARCHAR 而不是 TINYINT调试的时候在数据库里看到的是APPROVED而不是2省得猜代码里再维护一个枚举常量。用户表和实验室表不要设计得太复杂。用户表有id、username、passwordBCrypt 加密后的密文、real_name、role、email、phone就够实验室表有id、name、location、capacity、equipment_desc、open_time_start、open_time_end、status就够。open_time_start和open_time_end表示这个实验室的开放时段预约的时间段不能超出这个范围。3.2 预约状态机为什么不能用删除代替状态预约单的状态必须用状态字段管理而不是用户取消就直接删记录。我见过有人把取消做成 DELETE结果月底统计数据少了一大截管理员想查历史也查不到。合理的做法是定义六个状态并且维护一张清晰的状态流转表PENDING提交预约等待审核APPROVED管理员审核通过REJECTED管理员驳回CANCELED用户自行取消或管理员取消COMPLETED使用完成正常结束ABSENT超过开始时间未签到标记为爽约流转规则是PENDING 可以转 APPROVED、REJECTED、CANCELEDAPPROVED 可以转 COMPLETED、ABSENT、CANCELEDREJECTED 和 CANCELED、COMPLETED、ABSENT 都是终态不能再改。代码里最好写一个校验方法避免谁绕过 service 直接 updatepublic enum ReserveStatus { PENDING, APPROVED, REJECTED, CANCELED, COMPLETED, ABSENT; public static boolean canTransition(ReserveStatus from, ReserveStatus to) { if (from PENDING) { return to APPROVED || to REJECTED || to CANCELED; } if (from APPROVED) { return to COMPLETED || to ABSENT || to CANCELED; } return false; } }逻辑说明这个枚举在 service 层做审核、取消、完成操作时统一调用比如管理员想驳回一个已经被取消的单子canTransition直接返回 false接口报错数据就不会被改坏。如果你后面想加状态比如“使用中”记得同步改枚举和流转方法不要只改数据库注释否则代码里判断会漏。3.3 建表 SQL 与索引设计把冲突检测做成数据库能撑住的样子建表 SQL 建议直接写进项目里的schema.sql方便别人 clone 下来就能初始化。核心两张表的建表语句如下CREATE TABLE lab ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, location VARCHAR(128) NOT NULL, capacity INT NOT NULL DEFAULT 0, equipment_desc VARCHAR(255) COMMENT 实验室自带设备说明, open_time_start TIME NOT NULL, open_time_end TIME NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, lab_id BIGINT NOT NULL, reserve_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, status VARCHAR(20) NOT NULL DEFAULT PENDING, purpose VARCHAR(255), people_count INT NOT NULL DEFAULT 1, audit_user_id BIGINT, audit_remark VARCHAR(255), audit_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_lab_date (lab_id, reserve_date), KEY idx_user_date (user_id, reserve_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明idx_lab_date用来支撑“某个实验室某一天有哪些预约”的查询这是系统里最频繁的查询idx_user_date用来支撑“我的预约记录”。注意时间重叠的冲突无法用唯一索引直接挡住因为区间是动态的所以索引只能加速查询真正的兜底要靠应用层锁和状态约束这在第四章会细讲。参数说明status字段建议预留索引如果预约单量大了按状态筛选管理列表会变慢。people_count默认 1防止前端不传导致空值。4. 核心接口实现把预约、审核、通知串起来4.1 登录鉴权用 JWT 拦截器还是引入 Spring Security我建议用 JWT 加拦截器实现代码量小且逻辑直观。用户登录成功后后端生成一个 token里面放 userId 和 role过期时间设为 24 小时前端每次请求在 Header 里带Authorization: Bearer token拦截器校验 token并把用户信息放到UserContext里controller 里直接取。一个精简的拦截器核心逻辑如下public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BizException(401, 未登录); } Claims claims JwtUtil.parse(token.substring(7)); if (claims null) { throw new BizException(401, 登录已过期); } UserContext.set(claims.get(userId, Long.class), claims.get(role, String.class)); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }逻辑说明UserContext是一个 ThreadLocal存放当前请求的用户信息请求结束时在afterCompletion里清理防止线程池复用导致串数据。业务代码里需要“当前登录用户”时直接UserContext.getUserId()不用每个接口都从 token 里解析一遍。参数说明claims.get(role, String.class)拿到的角色在后面做权限判断用比如只有ADMIN角色可以调审核接口。登录接口、注册接口和静态资源要在注册拦截器时放行否则会死循环。4.2 提交预约时间重叠检测与并发控制提交预约是系统里最容易出 bug 的接口因为它涉及两个问题时间重叠检测怎么做以及并发时怎么保证不超卖。先看时间重叠的判断逻辑public void checkConflict(ReservationCreateDTO dto) { LocalDate date dto.getReserveDate(); LocalTime start dto.getStartTime(); LocalTime end dto.getEndTime(); LambdaQueryWrapperReservation wrapper new LambdaQueryWrapper(); wrapper.eq(Reservation::getLabId, dto.getLabId()) .eq(Reservation::getReserveDate, date) .ne(Reservation::getStatus, REJECTED) .ne(Reservation::getStatus, CANCELED) .apply(start_time {0} AND end_time {1}, end, start); Long count reservationMapper.selectCount(wrapper); if (count 0) { throw new BizException(该时间段已被预约); } }逻辑说明重叠的判断条件是“已有的开始时间小于新结束时间且已有的结束时间大于新开始时间”。这个条件能同时覆盖四种情况新时间段完全包含在已有时间段内、已有时间段完全包含在新时间段内、两者交叉、两者首尾相等如10:00-12:00和12:00-14:00不算冲突因为旧结束时间12:00不大于新开始时间12:00。但这段代码在并发下是有问题的。两个请求同时执行到这里如果都查出count0就都会往下走插入预约单结果就是同一天同一时段出现两条成功预约。要解决这个问题常见做法是加数据库悲观锁。在事务里先锁住实验室这一行让同一实验室的预约请求串行执行Transactional public void createReservation(ReservationCreateDTO dto) { Lab lab labMapper.selectByIdForUpdate(dto.getLabId()); if (lab null) { throw new BizException(实验室不存在); } // 校验预约时间在实验室开放时间内 ... checkConflict(dto); Reservation reservation new Reservation(); reservation.setUserId(UserContext.getUserId()); reservation.setLabId(dto.getLabId()); reservation.setReserveDate(dto.getReserveDate()); reservation.setStartTime(dto.getStartTime()); reservation.setEndTime(dto.getEndTime()); reservation.setStatus(ReserveStatus.PENDING.name()); reservation.setPurpose(dto.getPurpose()); reservation.setPeopleCount(dto.getPeopleCount()); reservationMapper.insert(reservation); }对应的 Mapper 写法Select(SELECT * FROM lab WHERE id #{id} FOR UPDATE) Lab selectByIdForUpdate(Param(id) Long id);逻辑说明SELECT ... FOR UPDATE会锁住实验室表里这一行直到事务提交才释放。A 请求锁住实验室后B 请求再执行这条 SQL 就会阻塞等待A 的事务提交后 B 才能继续这时 B 再跑checkConflict就能看到 A 插入的记录从而抛出“该时间段已被预约”。参数说明FOR UPDATE必须放在事务里才会生效如果 service 方法没有加Transactional锁在单条 SQL 执行完就释放了等于没锁。另外锁的粒度是实验室这一行不是预约表所以同一实验室不同时段的预约也会串行等待但考虑到高校实验室的预约频率这个吞吐量完全够用。注意如果项目里已经引入了 Redis也可以改用setIfAbsent做分布式锁锁的 key 设计成lock:lab:{labId}:{reserveDate}并设置 30 秒过期时间作为兜底防止锁没有释放导致死锁。Redis 锁的方案在第五章的并发避坑里会展开。4.3 管理员审核带条件更新与消息通知审核接口的核心不是简单的 update而是“只能审核待审核状态的单子且不能被并发重复处理”。用带条件的 UPDATE 能一举解决这两个问题Transactional public void audit(Long reservationId, Long auditorId, String action, String remark) { Reservation reservation reservationMapper.selectById(reservationId); if (reservation null) { throw new BizException(预约单不存在); } ReserveStatus target APPROVED.equals(action) ? ReserveStatus.APPROVED : ReserveStatus.REJECTED; if (!ReserveStatus.canTransition(ReserveStatus.valueOf(reservation.getStatus()), target)) { throw new BizException(当前状态不能执行该审核操作); } int rows reservationMapper.updateByCondition(reservationId, target.name(), auditorId, remark); if (rows 0) { throw new BizException(该预约已被其他管理员处理); } // 审核通过或驳回后通过 WebSocket 推送结果给预约人 notifyService.sendReservationResult(reservationId); }Mapper 里的更新语句Update(UPDATE reservation SET status #{status}, audit_user_id #{auditorId}, audit_remark #{remark}, audit_time NOW() WHERE id #{id} AND status PENDING) int updateByCondition(Param(id) Long id, Param(status) String status, Param(auditorId) Long auditorId, Param(remark) String remark);逻辑说明WHERE条件里带上status PENDING就是说只有当前状态是待审核的单子才会被更新。两个管理员同时点审核数据库层面只有一个 UPDATE 能成功另一个返回 0 行接口会提示“已被其他管理员处理”。这个思路和乐观锁本质是一样的用状态字段当版本号。参数说明action参数建议在接口层做枚举校验只允许传APPROVED或REJECTED否则容易构造出非法状态。remark在驳回时是必填的前端应该做强校验让用户知道为什么被拒。审核后的通知我一般用 WebSocket 推给前端学生页面不用刷新就能看到状态变化。Spring Boot 集成 WebSocket 并不需要在 yml 里写spring.websocket这类的配置核心是引入spring-boot-starter-websocket依赖再写一个ServerEndpoint端点和一个配置类注册ServerEndpointExporter即可。5. 避坑记录预约系统最容易翻车的五个场景5.1 时区玄学前端传 14:00后端收到 06:00现象前端选的是2024-06-01 14:00后端接口收到的对象里变成了2024-06-01 06:00存进数据库也是 6 点。原因Jackson 在把 JSON 字符串反序列化成LocalDateTime时用的是 JVM 默认时区。如果服务器运行在 UTC 时区而前端传的是东八区时间就会差 8 个小时。这不是一个偶发问题而是 Spring Boot 全局配置缺失导致的必然结果。解决在application.yml里显式指定 Jackson 时区同时数据库连接串里的serverTimezone要保持一致spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 datasource: url: jdbc:mysql://localhost:3306/lab_reserve?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai参数说明date-format指定了前端传递日期字符串的格式前端也必须按yyyy-MM-dd HH:mm:ss传不要传时间戳。如果你用LocalDate接收2024-06-01那date-format要改成yyyy-MM-dd或者单独写JsonFormat注解。5.2 LocalDate 与 LocalDateTime 混用导致查询边界多一天现象管理员查“6 月 1 日所有预约”结果列表里混进了 6 月 2 日的记录或者 6 月 1 日当天的记录查不全。原因前端只传了一个日期字符串2024-06-01后端接口用LocalDate接收但在查询时新手容易把日期往时间列上套写成ge(startTime, date)或者between(startTime, date.atStartOfDay(), date.atTime(LocalTime.MAX))。预约表里的时间是分开的reserve_date和start_time如果你把日期条件写到start_time列上等于是拿date转换后的2024-06-01T00:00:00去和14:00这种 TIME 类型的值比较边界完全错乱。解决日期条件就老老实实查日期列用eq精确匹配LambdaQueryWrapperReservation wrapper new LambdaQueryWrapper(); wrapper.eq(Reservation::getReserveDate, dto.getReserveDate());逻辑说明reserve_date是 DATE 类型LocalDate和它直接比较不会有时区问题也不用拼一天的开始和结束时间。只有当你的表设计是start_time一个 DATETIME 字段时才需要ge(date.atStartOfDay())和lt(date.plusDays(1))的组合。所以建表时的字段拆分到了查询阶段就会体现优势。5.3 并发预约超卖同一时间段出现两条成功预约现象用压测工具模拟 50 个人同时抢同一实验室的同一时段结果数据库里出现了两条APPROVED状态的预约记录。原因checkConflict是先查询再插入两个并发请求同时查到count 0然后都往下执行插入。单机部署时有人想用synchronized锁住方法但Transactional加synchronized有一个经典问题锁在事务提交之前就释放了A 线程释放锁后事务可能还没提交B 线程进方法后依然查不到 A 的数据。集群部署时synchronized更是完全无效。解决用数据库悲观锁串行化同一实验室的预约请求Transactional public void createReservation(ReservationCreateDTO dto) { Lab lab labMapper.selectByIdForUpdate(dto.getLabId()); ... }如果你有 Redis也可以用分布式锁String lockKey lock:lab: dto.getLabId() : dto.getReserveDate(); Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(30)); if (!Boolean.TRUE.equals(locked)) { throw new BizException(当前有其他人正在预约该时段请稍后再试); } try { checkConflict(dto); reservationMapper.insert(reservation); } finally { redisTemplate.delete(lockKey); }逻辑说明setIfAbsent只有在 key 不存在时才能设置成功所以同一实验室同一日期同时只有一个请求能拿到锁。Duration.ofSeconds(30)是兜底过期时间防止线程在finally之前崩溃导致锁永久占用。参数说明30 秒这个值要明显大于一次预约事务的耗时但不能设置太长否则会阻塞后续预约。事务里如果还有别的远程调用要控制在合理范围内。5.4 懒加载报错LazyInitializationException现象查询预约列表时接口返回 JSON 报could not initialize proxy - no Session或者前端看到某个关联字段是 null。原因如果你用的是 Spring Data JPA关联的User、Lab对象默认是懒加载Service 层事务结束后 Session 关闭再取关联属性就报错。如果你用 MyBatis-Plus不会报这个错但会出现另一个形态预约列表里只有user_id没有用户姓名和实验室名称前端展示很不友好。解决写一个联表查询的 Mapper用 DTO 直接接收结果select idselectReservationVO resultTypecom.example.labreserve.dto.ReservationVO SELECT r.id, r.reserve_date, r.start_time, r.end_time, r.status, r.purpose, r.people_count, u.real_name AS userName, l.name AS labName FROM reservation r LEFT JOIN user u ON r.user_id u.id LEFT JOIN lab l ON r.lab_id l.id WHERE r.id #{id} /select逻辑说明把关联查询直接写在 SQL 里一次性把页面需要的数据查出来而不是在 Java 代码里循环查数据库。如果你的列表要分页记得把这段 SQL 套到Page查询里关联条件带上即可。5.5 日志像黑匣子SQL 和参数看不到现象接口 500控制台只有异常栈不知道实际执行的 SQL 是什么也不知道 MyBatis 传进去的参数值排错全靠猜。原因application.yml里没开 SQL 日志或者 MyBatis 的日志级别是默认的 info。Spring Boot 默认只会打印一点启动日志具体 SQL 不会输出。解决开发环境在application.yml里给 mapper 包单独开 debug 级别logging: level: com.example.labreserve.mapper: debug逻辑说明这样设置后MyBatis 会把预编译 SQL 和绑定的参数值全部打到控制台。排查 5.3 的并发问题时你可以直接看到两条请求的 SQL 执行顺序判断锁是否生效。参数说明这里的包名要改成你自己的 mapper 接口所在包。生产环境千万别开 debug否则一天能刷出几个 G 的日志要用 Logback 把日志按天滚动并且 SQL 日志只留warn和error级别。Spring Boot 日志配置这块logback-spring.xml是标准做法按INFO输出到一个文件、ERROR单独一个文件保留 30 天就够了。6. 进阶验证把系统从“能跑”做到“抗造”6.1 用 JMeter 模拟并发抢约系统写完不是能启动就算完预约系统的命门是并发。我的习惯是写完后用 JMeter 压一遍模拟真实抢约场景。线程组设 50 个线程Ramp-Up 设为 1 秒也就是 50 个人在 1 秒内同时发起预约请求HTTP 请求里带上登录后的 JWT token可以用 CSV 文件做参数化让每个线程用不同账号断言里检查响应体是否包含success。跑完后直接查数据库同一个时间段最多只能有一条APPROVED记录其余请求要么被锁阻塞后查到了冲突要么状态还是PENDING。如果你的接口在 50 并发下出现了重复预约回到第五章的 5.3 检查锁。这一步值得在答辩前多做几轮我见过太多系统演示时翻车都是因为没做并发验证。6.2 补上定时取消与操作日志两个“后悔药”系统上线后还会遇到一个真实问题学生提交了预约管理员忘了审核预约单一直挂着状态卡在PENDING。我的处理方式是用定时任务把超时未审核的预约单自动取消Scheduled(cron 0 */30 * * * ?) Transactional public void autoCancelTimeoutReservations() { LocalDateTime deadline LocalDateTime.now().minusHours(2); LambdaUpdateWrapperReservation wrapper new LambdaUpdateWrapper(); wrapper.eq(Reservation::getStatus, PENDING) .lt(Reservation::getCreateTime, deadline) .set(Reservation::getStatus, CANCELED) .set(Reservation::getAuditRemark, 超时未审核自动取消); int rows reservationMapper.update(null, wrapper); if (rows 0) { log.info(自动取消超时预约{} 条截止时间{}, rows, deadline); } }逻辑说明cron 0 */30 * * * ?表示每 30 分钟执行一次PENDING状态且创建时间超过 2 小时的单子会被置为CANCELED。更新条件里必须带上status PENDING防止定时任务把刚审核通过的单子覆盖掉。启动类上记得加EnableScheduling。参数说明2 小时这个阈值按学校的实际情况调比如要求管理员当天必须审核完就改成 8 小时。定时任务类要单独放一个类里不要和 service 写在同一个类中否则内部自调用会导致Transactional不生效。操作日志我建议用Aspect切面统一记录审核、取消、修改实验室信息这些都写进operation_log表出了问题能追溯到人。这个切面本身不复杂难的是别漏掉那些“看起来不重要”的操作比如管理员下架实验室这种操作不改日志后面排查“为什么约不了”会很痛苦。现在再回头看这个项目技术栈是常规的 Spring Boot难点全在业务约束上——时间重叠怎么判定、并发怎么不超卖、状态怎么不跳变、时区怎么不串。我的个人习惯是每写完一个接口先问自己三个问题并发下会不会重复、状态会不会走错、日志能不能跟上。这三个问题挡住我绝大多数翻车现场。高校实验室预约这类系统复杂度不高但正因为不高细节才决定它能不能真正被用起来。希望帮到你。本文还有配套的精品资源点击获取
返回列表