
简介面向计算机相关专业毕业设计与Java Web入门实战场景这份校园图书馆座位预约系统提供了一套可直接运行的完整实现方案。开发过程从需求分析入手梳理座位查询、预约、取消、违约记录与管理员维护等核心功能再经过总体设计与详细设计涵盖系统功能结构、数据结构、安全设计以及数据库访问实现、主要功能模块的关键代码最后配套功能测试与结果分析形成较为完整的毕设文档脉络。压缩包约12.16MB内含程序源代码一份与数据库脚本一份配置环境说明清晰按提示导入即可运行便于读者对照学习分层结构与业务逻辑的落地方式。目前已有3244人学习下载适合需要参照成熟案例搭建选题框架、补充模块实现或准备答辩材料的同学也能为Java管理系统的课程设计提供借鉴。1. 从早上六点排队说起这套 Java 校园图书馆座位预约系统到底解决什么开学第二周某高校图书馆门口六点半就排起长队管理员拿着纸质登记本喊号学生为了一个靠窗座位能争执十分钟。这套java校园图书馆座位预约系统的起点就在这里——把“谁、在哪个区域、哪个座位、哪个时间段”从纸面搬到数据库用时间片和状态机管起来让抢座从体力活变成一次数据库写入。它适合课程设计、毕业设计阶段需要一套能跑通、能答辩、能继续加功能的完整工程区域与座位管理、按时间段预约、签到签退、违约记录、后台统计。真正卡人的不是 CRUD而是并发抢座和时间片冲突下面按选型、建表、核心业务、联调、避坑、压测的顺序拆开讲。2. 技术选型与工程结构为什么放弃 Servlet 手写改用 Spring Boot 分层毕业设计里常见的两种极端一种是全手写 Servlet JDBC代码量爆炸且事务难控另一种是脚手架生成一堆用不上的模块答辩时被问“这块谁写的”就卡壳。比较稳的做法是 Spring Boot 打底、MyBatis-Plus 做单表、MySQL 存业务、Redis 处理热点与锁、JWT 管登录态。选型不是为了堆技术名词而是每个组件都要对应一个具体痛点下面把理由和工程结构一次性摆清楚。2.1 组件职责与依赖清单先看每个组件在这个系统里到底干什么组件建议版本在系统里的职责选它的理由Spring Boot2.7 系列Web 容器、自动装配、声明式事务starter 生态全本地启动快答辩环境好复现MyBatis-Plus3.5 系列单表 CRUD、条件构造、分页插件比裸 MyBatis 少写大量 XML复杂 SQL 仍可回退 XMLMySQL8.0座位、预约、用户、违约持久化唯一索引和行锁是防重复预约的最后一道闸Redis6 或 7热点座位缓存、分布式锁并发抢座时降低数据库压力锁失效也有 DB 兜底JWTjjwt0.11 系列无状态登录态前后端分离时不用维护 session联调省事Vue Element2 或 3学生端选座、管理端排座组件齐全演示效果直观提示表里的版本是选型建议实际以本地 Maven 仓库能稳定拉到的版本为准不要为了追新把 JDK 和框架版本配到互相不兼容。对应的pom.xml关键部分如下父工程统一管理版本子依赖不重复写版本号parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId !-- 示例版本按本地仓库能拉到的 2.7 小版本替换 -- version2.7.18/version relativePath/ /parent dependencies !-- Web 与参数校验 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- MyBatis-Plus单表 CRUD 与分页 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency !-- MySQL 驱动 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- Redis缓存与分布式锁 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- JWT登录态签发与校验 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency /dependencies逻辑说明spring-boot-starter-validation用来在 Controller 入参上打NotNull、Future这类注解把“预约日期不能选昨天”这种校验前置避免业务层写一堆 if。MyBatis-Plus 的版本和 Spring Boot 2.7 搭配比较稳升到 3.5.5 以上要注意分页插件包路径变化。JWT 拆成 api、impl、jackson 三个包是 jjwt 0.11 之后的标准做法少一个运行时就报NoClassDefFoundError。参数上最容易被忽略的是 MySQL 驱动坐标MySQL 8 之后mysql:mysql-connector-java逐步换成com.mysql:mysql-connector-j两者混用会出现驱动类加载不到的问题。连接串里记得加serverTimezoneAsia/Shanghai否则预约日期在跨时区机器上会整体偏移一天这个坑后面第 5 章还会展开。2.2 分层结构与包组织工程按职责分层不按“技术类型”堆平。controller只做参数接收和响应包装service承担事务边界和业务编排mapper只碰数据库entity与表一一对应dto隔离入参出参job放定时任务。这样答辩时被问“这段逻辑在哪一层”能直接指出来。seat-reservation/ ├── src/main/java/com/example/seat/ │ ├── SeatApplication.java # 启动类 │ ├── config/ # 跨域、拦截器、MyBatis-Plus 分页 │ ├── controller/ # 学生端、管理端 REST 接口 │ ├── service/ # 业务接口 │ ├── service/impl/ # 业务实现事务边界在这一层 │ ├── mapper/ # MyBatis-Plus Mapper │ ├── entity/ # 与数据库表对应 │ ├── dto/ # 请求与响应对象 │ ├── common/ # Result、异常、常量、枚举 │ └── job/ # 超时释放等定时任务 └── src/main/resources/ ├── application.yml └── mapper/ # 复杂 SQL 的 XML分层的关键约束有两条Controller 里不出现Transactional事务统一放在 Service 实现类的公开方法上Mapper 里不写业务判断只做数据访问。这样做的好处是后面加“同一用户同一天最多约两个时段”这类规则时改动集中在 Service不会牵连接口层。很多毕设项目把逻辑全塞进 Controller最后连事务回滚都测不出来答辩演示一并发就露馅。2.3 数据库表设计座位、区域、时间片、预约表设计决定了并发方案能不能成立。核心是四张表用户、区域、座位、时间片再加一张预约表。预约表上要预留一个“槽位键”字段用来解决取消后重新预约与唯一索引冲突的问题这是很多同类系统没处理好的地方。-- 用户表学生和管理员共用role 区分 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL, password VARCHAR(100) NOT NULL COMMENT BCrypt 加密后的密文, real_name VARCHAR(32) NOT NULL, role TINYINT NOT NULL DEFAULT 0 COMMENT 0学生 1管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 区域表阅览室、自习区 CREATE TABLE lib_area ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, floor INT NOT NULL, open_time TIME NOT NULL, close_time TIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 座位表 CREATE TABLE lib_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, area_id BIGINT NOT NULL, seat_no VARCHAR(16) NOT NULL COMMENT 如 A-03, has_socket TINYINT NOT NULL DEFAULT 0 COMMENT 是否带插座, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可用 0维修, UNIQUE KEY uk_area_seat (area_id, seat_no), KEY idx_area (area_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 时间片表把开馆时间切成固定段 CREATE TABLE lib_time_slot ( slot_id INT PRIMARY KEY, start_time TIME NOT NULL, end_time TIME NOT NULL, label VARCHAR(32) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO lib_time_slot VALUES (1,08:00:00,10:00:00,上午一), (2,10:00:00,12:00:00,上午二), (3,13:00:00,15:00:00,下午一), (4,15:00:00,17:00:00,下午二), (5,18:00:00,20:00:00,晚间一), (6,20:00:00,22:00:00,晚间二); -- 预约表唯一索引是防重复预约的核心 CREATE TABLE lib_reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, area_id BIGINT NOT NULL, reserve_date DATE NOT NULL, slot_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待使用 1使用中 2已完成 3已取消 4违约, sign_in_time DATETIME NULL, sign_out_time DATETIME NULL, slot_key BIGINT NOT NULL DEFAULT 0 COMMENT 占位键0表示占用取消时置为本行id释放唯一约束, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_seat_slot (seat_id, reserve_date, slot_id, slot_key), KEY idx_user_date (user_id, reserve_date), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明uk_seat_slot把“同一座位、同一天、同一时间片”绑死插入第二条相同组合时数据库直接抛DuplicateKeyException业务层捕获后返回“已被占用”不需要先查再插。slot_key默认 0 表示占用用户取消预约时把slot_key改成这一行的id唯一索引里这个组合就变成(seat, date, slot, id)与新的(seat, date, slot, 0)不再冲突历史记录也保留下来。这是取消后能重新预约又不删数据的关键。参数上reserve_date用DATE而不是DATETIME因为预约的最小粒度是天加时间片用日期字段做唯一索引更干净。sign_in_time和sign_out_time用DATETIME方便算迟到分钟数。时间片暂时固定 2 小时一段如果业务要求更细可以把slot_id扩到 30 分钟粒度表结构不用动只改数据。3. 核心业务实现时间片建模、状态机与并发抢座的三个关键点这套座位预约系统的难点集中在三处时间片怎么切、预约状态怎么流转、并发抢座怎么不超卖。前两个决定业务是否讲得通第三个决定演示时会不会翻车。下面按“先建模、再流转、最后并发”的顺序落地每步都给可执行的代码和参数说明。3.1 把一天切成固定时间片时间片建模有两种思路一种是允许用户选任意起止时间另一种是固定若干段。毕设项目建议选固定段原因很实际唯一索引能直接建在(seat_id, reserve_date, slot_id)上冲突判断变成一次数据库写入统计报表按slot_id聚合也简单前端只需要渲染几个按钮不用做时间选择器。代价是粒度粗用户想约 9:00 到 11:30 会被拆成两段但这在校园场景里可以接受。时间片的起止时间存在lib_time_slot表里业务层做预约时只接受表里存在的slot_id并且校验reserve_date不早于今天。这里有一个容易忽略的边界当天已经开始的时段不能预约。判断逻辑放在 Service用LocalDateTime.now()和slot.startTime拼出来的时间点比较不要用字符串比较否则 “9:00” 和 “10:00” 的字典序会把人坑哭。// 校验时间片是否可预约日期不早于今天且开始时间未过 public void checkSlotBookable(LocalDate date, LibTimeSlot slot) { LocalDate today LocalDate.now(); if (date.isBefore(today)) { throw new BizException(不能预约过去的日期); } if (date.equals(today)) { LocalDateTime slotStart LocalDateTime.of(date, slot.getStartTime()); // 留 5 分钟缓冲避免临界点下单失败 if (slotStart.minusMinutes(5).isBefore(LocalDateTime.now())) { throw new BizException(该时段已开始请选择后面的时段); } } }这段逻辑的作用是把“日期合法”和“时段未开始”两件事分开判断。参数date是用户选的预约日期slot是从数据库查出来的时间片对象。缓冲 5 分钟是为了避免用户在 9:58 点下单、服务器处理到 10:01 才落库结果被判断成“已开始”而失败体验很差。这个缓冲值可以配到application.yml里答辩时被问“临界点怎么处理”能直接答。3.2 预约状态机与合法流转预约状态用枚举管理避免在代码里到处写魔法数字。状态定义成待使用、使用中、已完成、已取消、违约。合法流转只有几条待使用可以签到变使用中可以取消变已取消可以超时变违约使用中可以签退变已完成其他方向一律拒绝。把流转规则集中在一个方法里任何更新状态的地方都调用它能挡住“已完成还能取消”这类脏数据。public enum ReservationStatus { WAITING(0, 待使用), USING(1, 使用中), FINISHED(2, 已完成), CANCELLED(3, 已取消), VIOLATED(4, 违约); private final int code; private final String desc; ReservationStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } // 合法流转表key 是当前状态value 是允许到达的状态 private static final MapReservationStatus, SetReservationStatus TRANSFER new HashMap(); static { TRANSFER.put(WAITING, EnumSet.of(USING, CANCELLED, VIOLATED)); TRANSFER.put(USING, EnumSet.of(FINISHED)); TRANSFER.put(FINISHED, EnumSet.noneOf(ReservationStatus.class)); TRANSFER.put(CANCELLED, EnumSet.noneOf(ReservationStatus.class)); TRANSFER.put(VIOLATED, EnumSet.noneOf(ReservationStatus.class)); } public static boolean canTransfer(ReservationStatus from, ReservationStatus to) { return TRANSFER.getOrDefault(from, Collections.emptySet()).contains(to); } }逻辑说明静态块里把每个状态能去的方向写死canTransfer在 Service 更新状态前调用不合法就抛业务异常。参数from是数据库里查出来的当前状态to是准备改成的状态。这样即使前端传了错误的操作按钮后端也不会把一条已完成的记录改成已取消。很多同类系统只在前端控制按钮显隐后端不校验联调时用 Postman 直接调接口就能造出脏数据答辩演示很容易被追问。3.3 并发抢座的正确姿势唯一索引兜底Redis 锁减压并发抢座是这类系统的核心考点。常见的错误写法是先select查有没有冲突再insert两个请求同时查都发现没有冲突然后都插入数据库里出现两条相同座位的预约。“先查后插”在并发下必然有竞态正确的做法是依赖数据库唯一索引直接insert让数据库判断冲突捕获DuplicateKeyException后返回友好提示。唯一索引是最后一道闸任何应用层锁都不能替代它。Service public class ReservationServiceImpl implements ReservationService { Resource private LibReservationMapper reservationMapper; Resource private LibSeatMapper seatMapper; Resource private LibTimeSlotMapper slotMapper; Override Transactional(rollbackFor Exception.class) public Long reserve(Long userId, ReserveDTO dto) { // 1. 校验座位存在且可用 LibSeat seat seatMapper.selectById(dto.getSeatId()); if (seat null || seat.getStatus() ! 1) { throw new BizException(座位不存在或已停用); } // 2. 校验时间片合法 LibTimeSlot slot slotMapper.selectById(dto.getSlotId()); if (slot null) { throw new BizException(时间片不存在); } checkSlotBookable(dto.getReserveDate(), slot); // 3. 直接插入唯一索引 uk_seat_slot 负责防重 LibReservation entity new LibReservation(); entity.setUserId(userId); entity.setSeatId(dto.getSeatId()); entity.setAreaId(seat.getAreaId()); entity.setReserveDate(dto.getReserveDate()); entity.setSlotId(dto.getSlotId()); entity.setStatus(ReservationStatus.WAITING.getCode()); // slotKey 默认 0表示占用中 try { reservationMapper.insert(entity); } catch (DuplicateKeyException e) { throw new BizException(手慢了该座位该时段已被占用); } return entity.getId(); } }逻辑说明整个方法在一个事务里插入成功后返回预约 id。DuplicateKeyException是从org.springframework.dao包捕获的MyBatis-Plus 会把 MySQL 的 1062 错误翻译成这个异常不要直接捕获SQLException否则拿不到统一异常处理。slotKey不显式设置走数据库默认值 0这样唯一索引才能生效。参数dto.getReserveDate()用LocalDateJackson 反序列化时前端传2024-09-01这种格式即可。Redis 锁在这里的角色是“减压”而不是“正确性保证”。热点座位被几百人同时抢时所有请求都打到数据库连接池很快耗尽。可以在reserve方法外层加一个按seatId date slotId维度的锁拿不到锁直接返回“排队中”拿到锁的请求再去走数据库唯一索引。锁的过期时间要设短比如 3 秒避免持锁线程挂掉后死锁。注意锁的粒度和唯一索引的粒度要一致否则锁了 A 座位却去抢 B 座位等于没锁。3.4 签到签退与超时释放的定时任务预约成功后用户需要到馆签到超过时间片开始 15 分钟未签到就记违约并释放座位。释放座位不是物理删除而是把slot_key改成当前行 id、状态改成违约这样唯一索引的空位让出来别人可以继续约。定时任务用Scheduled每 5 分钟扫一次扫描条件是状态为待使用、预约日期是今天、时间片开始时间已经过了 15 分钟。Component public class ReservationTimeoutJob { Resource private LibReservationMapper reservationMapper; Resource private LibTimeSlotMapper slotMapper; // 每 5 分钟执行一次cron 秒 分 时 日 月 周 Scheduled(cron 0 */5 * * * ?) public void releaseTimeoutReservations() { LocalDate today LocalDate.now(); LocalTime now LocalTime.now(); // 查出今天所有待使用的预约逐条判断是否超时 ListLibReservation list reservationMapper.selectList( new LambdaQueryWrapperLibReservation() .eq(LibReservation::getStatus, ReservationStatus.WAITING.getCode()) .eq(LibReservation::getReserveDate, today) ); MapInteger, LibTimeSlot slotMap slotMapper.selectList(null).stream() .collect(Collectors.toMap(LibTimeSlot::getSlotId, s - s)); for (LibReservation r : list) { LibTimeSlot slot slotMap.get(r.getSlotId()); if (slot null) continue; LocalDateTime deadline LocalDateTime.of(today, slot.getStartTime()).plusMinutes(15); if (now.isAfter(deadline.toLocalTime())) { // 违约并释放槽位slot_key 改成自身 id r.setStatus(ReservationStatus.VIOLATED.getCode()); r.setSlotKey(r.getId()); reservationMapper.updateById(r); } } } }逻辑说明先按状态和日期过滤避免全表扫描时间片数据量小一次性查出来转成 Map循环里不再查库。deadline是时间片开始时间加 15 分钟now.isAfter(deadline.toLocalTime())判断是否超时。把slotKey设成r.getId()是释放唯一索引槽位的关键动作少了这一步违约记录会一直占着座位后面的人永远约不上。参数上cron 表达式0 */5 * * * ?表示每 5 分钟整点触发放在单机环境没问题。如果部署多实例这个任务会重复执行同一批记录被多个实例改虽然结果幂等都改成违约但会产生重复的违约通知。多实例下的处理方式在第 5 章避坑里展开。4. 接口与鉴权REST 约定、JWT 拦截器与前后端联调的落地细节后端接口写得规不规范联调时最明显。学生端要拉座位列表、提交预约、签到签退管理端要排座、看统计、处理违约接口不多但每个都要有统一响应、权限校验和分页。这一章把响应体、鉴权、分页跨域三件事拆开给可直接抄的配置。4.1 统一响应与全局异常处理所有接口统一返回code、msg、data三段前端只判断code是否为 0不用为每个接口写不同的错误处理。业务异常继承RuntimeException全局异常处理器统一捕获把BizException转成带提示语的响应把未预期异常转成“系统繁忙”并记日志避免把堆栈暴露给前端。// 统一响应体 public class ResultT { private int code; private String msg; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 0; r.msg success; r.data data; return r; } public static T ResultT fail(String msg) { ResultT r new Result(); r.code 500; r.msg msg; return r; } // getter/setter 省略 } // 全局异常处理 RestControllerAdvice public class GlobalExceptionHandler { ExceptionHandler(BizException.class) public ResultVoid handleBiz(BizException e) { return Result.fail(e.getMessage()); } ExceptionHandler(MethodArgumentNotValidException.class) public ResultVoid handleValid(MethodArgumentNotValidException e) { // 取第一个字段校验错误提示 String msg e.getBindingResult().getFieldErrors().stream() .findFirst().map(FieldError::getDefaultMessage).orElse(参数错误); return Result.fail(msg); } ExceptionHandler(Exception.class) public ResultVoid handleOther(Exception e) { log.error(未预期异常, e); return Result.fail(系统繁忙请稍后再试); } }逻辑说明Result.ok和Result.fail是静态工厂业务代码不直接 new。RestControllerAdvice把异常处理集中MethodArgumentNotValidException是Valid校验失败时抛的取第一个字段错误返回给前端比返回一长串更有用。参数上code约定 0 成功、500 业务失败前端拦截器里统一判断不用每个请求单独写 if。4.2 JWT 鉴权与角色隔离登录成功后签发 JWTpayload 里放userId和role前端每次请求带在Authorization头里。拦截器解析 token把用户信息放进ThreadLocalController 里直接取当前用户不用每个方法都传userId。管理端接口额外校验role是否为 1学生端接口只要求登录。Component public class JwtInterceptor implements HandlerInterceptor { Resource private JwtUtil jwtUtil; // 白名单登录、注册、静态资源 private static final ListString WHITE_LIST Arrays.asList( /api/auth/login, /api/auth/register, /error ); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String uri request.getRequestURI(); if (WHITE_LIST.stream().anyMatch(uri::startsWith)) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BizException(未登录); } Claims claims jwtUtil.parse(token.substring(7)); UserContext.set(new CurrentUser( claims.get(userId, Long.class), claims.get(role, Integer.class) )); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求结束清理 ThreadLocal防止线程池复用导致串号 UserContext.clear(); } }逻辑说明WHITE_LIST用前缀匹配而不是全等方便放行静态资源。token.substring(7)去掉Bearer前缀jwtUtil.parse内部校验签名和过期时间过期抛ExpiredJwtException由全局异常处理器转成“登录已过期”。afterCompletion里清理ThreadLocal是关键Tomcat 线程池会复用线程不清理会导致下一个请求拿到上一个用户的身份这类问题在压测时才会暴露。参数上JWT 的过期时间建议设 2 小时太长不安全太短联调时反复登录很烦。密钥不要写死在代码里放到application.yml并用环境变量覆盖答辩演示时也不至于把密钥贴到 PPT 上。4.3 分页插件与跨域配置管理端查预约记录要分页MyBatis-Plus 的分页插件必须显式注册否则Page对象传进去不生效返回的还是全量数据。跨域在前后端分离部署时必配用CorsFilter统一处理不要在每个 Controller 上打CrossOrigin否则漏一个接口就联调失败。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 分页插件DbType 必须和实际数据库一致 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } } Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); // 开发期用具体前端地址不要图省事写 * config.addAllowedOrigin(http://localhost:8080); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }逻辑说明PaginationInnerInterceptor的DbType.MYSQL决定生成LIMIT还是ROWNUM写错会导致分页 SQL 语法错误。跨域配置里addAllowedOrigin写具体地址而不是*因为setAllowCredentials(true)与通配符不能同时用浏览器会直接拒绝。参数上allowCredentials设为 true 是为了让前端带上 Cookie 或自定义头如果只用 JWT 放 header可以设为 false 并放开 origin按实际联调方式选。注意分页接口的current和size要在后端做上限比如size最大 100防止前端传 10000 把数据库拖垮。这个校验写在 Controller 的 DTO 上用Max(100)即可。5. 避坑与排查这套座位预约系统里最容易翻车的五个地方同样的功能有人演示顺畅有人一压测就出问题差别往往不在代码量而在几个边界有没有处理。下面五条是这类系统里出现频率最高的翻车现场每条按“现象、原因、解决”写照着排查能省不少时间。5.1 取消预约后座位再也约不上现象用户取消了一条预约界面上显示已取消但同一个座位同一时段别人再预约时一直提示“已被占用”数据库里也查不到占用记录。原因是唯一索引uk_seat_slot还包含那条已取消的记录slot_key仍是 0和新的预约冲突。解决方法是取消时把slot_key更新为当前行 id让唯一索引组合变成(seat, date, slot, id)释放(seat, date, slot, 0)这个槽位。-- 取消预约状态改为已取消同时释放唯一索引槽位 UPDATE lib_reservation SET status 3, slot_key id WHERE id ? AND status 0;执行这条 SQL 时要注意加status 0条件防止重复取消把已完成的记录也改掉。取消接口返回成功后前端刷新座位列表该座位该时段应重新显示可约。5.2 压测时出现同一座位两条有效预约现象单机点一次没问题用 JMeter 开 50 个线程抢同一个座位数据库里出现两条状态为待使用的相同预约。原因是业务代码用了“先查后插”两个线程同时查到无冲突然后都插入。解决方法是删掉查询判断直接插入并捕获DuplicateKeyException把唯一索引作为唯一判据。还要检查唯一索引是否真的建上了。有时候开发库是手动改表加的索引测试库没同步代码以为有兜底实际没有。上线前执行SHOW INDEX FROM lib_reservation确认uk_seat_slot存在且列顺序正确列顺序错了唯一性判断也会变。5.3 定时任务在多实例下重复执行现象本地单机跑没问题部署到两台机器后同一条超时预约被改两次用户收到两条违约通知日志里两个实例同一秒都在跑任务。原因是Scheduled默认在每个实例上都会触发没有分布式协调。解决方式有三种一是用 Redis 分布式锁任务执行前抢锁抢不到直接返回二是把定时任务拆到单独的 job 服务只部署一个实例三是用数据库行锁配合SELECT ... FOR UPDATE SKIP LOCKED让每个实例处理不同记录。毕设环境通常单机但答辩时如果被问“多台服务器怎么办”要能答出锁的 key 怎么设计、过期时间怎么设。推荐的锁 key 用job:timeout:lock过期时间略大于任务执行时间避免任务没跑完锁就释放导致并发。5.4 预约日期整体偏移一天现象前端选 9 月 1 日数据库里存成 8 月 31 日或者接口返回的日期比预期少一天。原因是 JDBC 连接串没配时区或者实体类用了java.util.Date而数据库字段是DATE序列化时按 UTC 转了一次。解决方法是连接串加serverTimezoneAsia/Shanghai实体类统一用LocalDateJackson 配置spring.jackson.time-zoneAsia/Shanghai和date-formatyyyy-MM-dd HH:mm:ss。参数上LocalDate对应 MySQL 的DATELocalDateTime对应DATETIME不要混用。如果历史代码已经用了Date至少在JsonFormat上指定timezone GMT8否则前端拿到的日期字符串会差 8 小时。5.5 连接池耗尽与慢查询拖垮接口现象压测到 100 并发时接口大量超时日志里出现HikariPool-1 - Connection is not available。原因通常是事务方法里做了远程调用或循环查库连接被长时间占着或者预约列表接口没有分页一次查几万条。解决方法是把事务范围收窄到只包数据库写操作循环查库改成批量查询列表接口强制分页并加索引。排查时先看SHOW PROCESSLIST有没有长时间Sleep或Query的连接再看慢查询日志里lib_reservation的扫描行数。idx_user_date和idx_status要覆盖常用查询条件如果发现LIKE %xx%这种用不上索引的查询改成前缀匹配或加全文索引。6. 压测与验收用 JMeter 把并发预约打出原形功能跑通只是起点真正能说明问题的是并发下的表现。我一般会用 JMeter 建一个最小压测场景登录拿 token、查座位列表、提交预约三个请求串成线程组50 个线程各跑 10 次重点看预约接口的失败率和响应时间。线程组里加 HTTP 信息头管理器放Authorization预约请求的 body 用 CSV 参数化seatId和slotId避免所有线程抢同一个座位导致失败率虚高。指标上重点看三个预约接口的 99 线响应时间、DuplicateKeyException的占比、数据库连接池活跃连接数。99 线超过 500ms 说明锁竞争或 SQL 有问题重复预约异常占比高是正常的说明唯一索引在工作活跃连接数持续贴近最大值说明有连接泄漏。跑完把结果表和代码里的锁粒度、连接池配置对齐再决定要不要加 Redis 锁。验收项通过标准检查方式同一座位并发预约只有一条成功其余返回友好提示JMeter 50 线程查库确认无重复取消后可重新预约取消后同座位同时段可再次预约成功手动调接口验证超时未签到15 分钟后状态变违约且座位释放调小 cron 间隔观察分页上限size 超过 100 返回参数错误Postman 传 size1000跨域前端页面能正常调通浏览器控制台无 CORS 报错有一个习惯我保持了很久每次改完预约相关的 SQL都会先把uk_seat_slot的索引列顺序打印出来核对一遍再用两个并发请求打同一个座位确认第二条被数据库挡掉才继续往下写。早些年就是因为测试库索引漏建演示时两个同学同时约上同一个座场面很尴尬。从那以后我每次改表结构都强制走一遍“查索引、打并发、看日志”这三步希望帮到你。本文还有配套的精品资源点击获取