ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java图书馆座位预约系统毕业设计:状态机、并发控制与避坑指南

Java图书馆座位预约系统毕业设计:状态机、并发控制与避坑指南

简介:一份基于Java技术的校园图书馆座位预约系统毕业设计程序包,面向计算机相关专业毕业生、Java学习者及需要参考完整项目开发的开发者。系统围绕图书馆座位预约场景,实现座位信息管理、预约申请、取消预约、状态统计等核心功能,并遵循软件工程流程,涵盖需求分析、总体设计、详细设计、数据库设计与功能测试等环节,有助于读者掌握管理系统从分析到落地的全过程。压缩包大小约12.16MB,内含项目源代码、数据库脚本及配置说明等文件,源代码可直接导入IDE运行,数据库脚本用于初始化表结构与测试数据,配置说明帮助快速完成环境搭建与部署。该资源已有3244人学习下载,作者验证可完美运行,并附有运行配置说明,对于毕业设计选题、系统设计与JavaWeb开发技能提升具有较高参考价值。

1. Java校园图书馆座位预约系统毕业设计:为什么同题不同命,差距全在流程设计上

如果你正在做一个“Java校园图书馆座位预约系统”的毕业设计程序,大概率已经搜到了一堆源码包和视频教程,复制下来能跑通一个新增、查询、删除的 CRUD 流程,但一到答辩演示,老师追问“同时段两个人都预约同一个座位怎么办”“超过签到时间没来怎么算违约”“统计报表的数据从哪来”,项目就哑火了。这个题目的价值不在“图书馆管理”这层外壳,而在预约业务本身:座位是有限资源,用户在同一时间只能占一个座位,所有规则都围绕时间边界和状态流转展开。下面按“业务建模→后端接口→前端页面→踩坑排错→答辩准备”的顺序,给出一套能自己讲清楚、能现场演示、经得起追问的落地路径,适合准备毕业设计、课设升级和技术面试的 Java 学习者。

2. 先把预约状态机想明白:三张表和一个“谁触发谁消费”的规则

拿到这个题目先别急着建项目、引依赖。我见过不少同学把座位表里加一个 status 字段表示“是否被占”,被占就置为 1,释放就置为 0。这么做在单条演示路径里没问题,但一旦加入日期和时段,座位上午有人、下午没人、明天又有人,单个布尔字段根本存不下,最后硬生生拆出十几张表。正确的切入点是先把业务规则转成状态机,再让数据库表为状态机服务。

2.1 核心实体拆解:座位、用户、预约记录,外键怎么留

这个系统最少需要三张表:座位表、用户表、预约记录表。座位表只放座位的物理信息,比如楼层、区域、座位编号,再加一个“是否停用”的状态,用来标记维修、暂停使用的座位。“这个座位现在有没有被人占”不应该放在座位表里,而应该通过查询预约记录实时算出:如果在某一天某个时段存在一条状态为“待签到”或“已签到”的预约,就认为它被占。

用户表我建议直接做单表,加一个 role 字段区分学生和管理员,不要拆成学生表、管理员表。对于毕业设计来说,拆表的代价是登录、个人信息维护、权限判断都要写两套,工作量翻倍但答辩收益很低。管理员和学生共用一张表,role=1 是学生,role=2 是管理员,接口里根据 role 判断是否能访问后台管理接口。

预约记录表是整个系统的核心,所有业务规则都落在它的 status 字段上。外键这里有个取舍:物理外键能保证数据一致性,但后续造测试数据、批量清理、分页查询时会被外键约束卡得非常难受。我一般会保留用户 ID、座位 ID 作为逻辑外键,不建物理外键,在应用层做存在性校验,同时给这些字段建普通索引保证查询速度。这个做法在答辩时也能讲清楚:外键约束交给应用层,数据库索引负责查询性能。

2.2 预约状态流转:0 待签到、1 已签到、2 已取消、3 违约,谁触发谁消费

预约的状态不能随便定义,最好一开始就把状态值、触发动作、触发方写清楚,后面写接口和定时任务时才不会混乱。这里我给出一个适合毕设的简化状态表:

状态值含义触发动作触发方
0待签到用户提交预约成功用户
1已签到到馆扫码或手动签到用户
2已取消用户主动取消或管理员取消用户/管理员
3违约超过开始时间 30 分钟未签到定时任务
4已结束预约时段结束后系统自动归档定时任务

这 5 个状态已经能满足绝大多数毕业设计需求。有人会问,用户离开座位要不要做“签退”?签退会引入新的状态和统计口径,比如“按时签退”“超时占用”,对基础版来说没必要。你可以在“已签到”状态下,约定预约结束时间即释放座位,后台统计违约时只看“待签到但没签到”的记录,一样能讲清楚规则。

状态流的关键规则有 4 条:用户只能预约未来某天且开馆时间内的时段;用户在同一个时间段只能有一条有效预约,有效指的是状态为 0 或 1;开始时间之前可以取消,开始时间之后不允许取消;超过开始时间 30 分钟未签到,系统自动把状态置为违约。这些规则要落到 Service 层校验和定时任务里,而不是散落在 Controller 层各处。

2.3 建表 SQL 骨架:字段、索引、注释一次到位

下面给出我常用的建表语句。字符集统一用 utf8mb4,主键用 BIGINT 自增,状态字段用 TINYINT,时间字段区分 DATE、TIME、DATETIME 三种类型,尽量不要全部用 DATETIME 一把梭,否则“每天同一时段”这种规则会很难写。

-- 座位表 CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, seat_no VARCHAR(20) NOT NULL COMMENT '座位编号,如 A-101', floor INT NOT NULL COMMENT '楼层', area VARCHAR(50) NOT NULL DEFAULT '' COMMENT '区域', status TINYINT NOT NULL DEFAULT 0 COMMENT '0可预约 1停用', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1删除', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_seat_no (seat_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书馆座位表'; -- 用户表 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后存储', student_no VARCHAR(20) NOT NULL COMMENT '学号', role TINYINT NOT NULL DEFAULT 1 COMMENT '1学生 2管理员', status TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1黑名单', deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username), UNIQUE KEY uk_student_no (student_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 预约记录表 CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, reserve_date DATE NOT NULL COMMENT '预约日期,只存日期', start_time TIME NOT NULL COMMENT '开始时间,只存时分秒', end_time TIME NOT NULL COMMENT '结束时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待签到 1已签到 2已取消 3违约 4已结束', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, checkin_time DATETIME NULL COMMENT '实际签到时间', cancel_time DATETIME NULL COMMENT '取消时间', deleted TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_seat_time (seat_id, reserve_date, start_time, end_time), KEY idx_user_date (user_id, reserve_date), KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约记录表';

这里有个非常关键的细节:uk_seat_time 唯一索引的四个字段都必须 NOT NULL。MySQL 的唯一索引对 NULL 不去重,只要有一列是 NULL,就能插入多条看似相同的记录,这个索引就失去了兜底意义。reserve_date 用 DATE 类型,start_time 和 end_time 用 TIME 类型,能让 SQL 里的时间比较更直接。deleted 字段是为 MyBatis-Plus 逻辑删除预留的,后面配置里会用到。

3. Spring Boot + MyBatis-Plus 后端骨架:从依赖到预约接口

表结构定好后,后端搭建就顺了。很多同学在这个阶段翻车,不是因为业务难,而是依赖版本不兼容、序列化配置不对、分页插件没加,导致项目能启动但接口一调就报错。这一章我会把从 pom.xml 到核心预约接口的完整路径走一遍。

3.1 项目初始化:spring-boot-starter-parent 版本选型与三个依赖坑

Spring Boot 版本我建议固定在 2.7.x,不要一上来就选 3.x。3.x 把 javax 包迁移到了 jakarta,网上大量旧教程、拦截器示例、验证码工具类直接编译不过,你会在改包名上耗费大量时间。2.7.18 是 2.x 的最后一个维护版本,稳定且教程资源最丰富,毕业设计完全够用。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

MyBatis-Plus 3.5.3.1 与 Spring Boot 2.7 兼容性很好,不用额外加 mybatis-spring 版本。mysql-connector-j 是 MySQL 8.x 驱动的新坐标,旧坐标 mysql-connector-java 虽然在 Spring Boot 2.7 中也能用,但会有废弃警告。Lombok 要标记 optional,否则打包时会把注解处理器打进 jar 包,部分环境会出现奇怪错误。

分页是预约列表、后台管理列表的刚需,而 MyBatis-Plus 的分页插件需要单独注册,不注册的话 Page 对象返回的记录是对的,但 total 永远是 0。原因是分页插件没有生成 count 查询,这个坑几乎每个人都踩过。

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

PaginationInnerInterceptor 会在执行分页 SQL 时自动拼接 LIMIT,并在查询 total 时生成 count 语句。DbType.MYSQL 指定数据库方言,避免分页 SQL 语法差异。如果你在项目里发现分页列表接口返回的 total 为 0 但 rows 有数据,优先检查这个类有没有配。

3.2 application.yml 配置:端口、数据源、驼峰映射、时间格式化一次配好

application.yml 是后端最容易出问题的地方,尤其是时间序列化和时区。下面这份配置我用了很多次,能覆盖大部分预约系统的常见需求。

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/library_seat?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mvc: pathmatch: matching-strategy: ant_path_matcher mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

serverTimezone=Asia/Shanghai 解决 MySQL 8.x 驱动连接时的时区警告,保证 Java 的 LocalDateTime 按东八区读写。map-underscore-to-camel-case 让数据库的 reserve_date 自动映射到实体类字段 reserveDate,但如果你在自定义 XML 里写了 resultType="java.util.Map",这个配置不会生效,后面避坑章节会单独说。jackson.date-format 对 java.util.Date 生效,对 LocalDateTime 不生效,所以实体类里的 LocalDateTime 字段我建议额外加 @JsonFormat 注解。

mvc.pathmatch.matching-strategy=ant_path_matcher 是给集成 Swagger、Knife4j 等接口文档工具准备的,Spring Boot 2.6 以上默认改用 PathPatternParser,会导致部分文档工具启动报错。如果项目里没引入文档工具,这行不写也行,但写了能省掉一个未知报错。逻辑删除这里,三个表都加了 deleted 字段,配置后所有 select 会自动追加deleted=0条件,删除操作变成 UPDATE。

3.3 预约核心接口:Controller、Service、Mapper 三层代码与参数说明

后端代码我按分层写,Controller 只做参数接收和结果包装,Service 放业务校验,Mapper 管 SQL。先看座位查询和创建预约的接口。

@RestController @RequestMapping("/api/seat") public class SeatController { @Resource private ReservationService reservationService; @GetMapping("/available") public Result<List<SeatVO>> available(@RequestParam String date, @RequestParam String startTime, @RequestParam String endTime) { return Result.ok(reservationService.listAvailable(date, startTime, endTime)); } @PostMapping("/reservation") public Result<ReservationVO> create(@RequestBody CreateReservationReq req, @RequestAttribute Long userId) { return Result.ok(reservationService.createReservation(req, userId)); } @PostMapping("/reservation/{id}/cancel") public Result<Void> cancel(@PathVariable Long id, @RequestAttribute Long userId) { reservationService.cancel(id, userId); return Result.ok(); } }

这里 userId 我直接用 @RequestAttribute 接收,由拦截器在解析登录态后放入 request 属性。毕业设计如果觉得 JWT 复杂,可以用 HttpSession 替代,但设计文档里要讲清楚登录态如何传递,不要每个接口都从请求头里手撕 userId。创建预约是核心,Service 实现如下。

@Transactional(rollbackFor = Exception.class) public ReservationVO createReservation(CreateReservationReq req, Long userId) { // 校验预约日期不能是过去 LocalDate date = LocalDate.parse(req.getDate()); if (date.isBefore(LocalDate.now())) { throw new BizException("不能预约过去的时间"); } // 同一座位同时段冲突校验:条件用区间重叠判断 Integer count = reservationMapper.countConflict( req.getSeatId(), req.getDate(), req.getStartTime(), req.getEndTime()); if (count != null && count > 0) { throw new BizException("该座位此时段已被预约"); } // 同一用户同时段是否已有有效预约 Integer userConflict = reservationMapper.countUserConflict( userId, req.getDate(), req.getStartTime(), req.getEndTime()); if (userConflict != null && userConflict > 0) { throw new BizException("你在此时段已有一个有效预约"); } Reservation reservation = new Reservation(); reservation.setUserId(userId); reservation.setSeatId(req.getSeatId()); reservation.setReserveDate(date); reservation.setStartTime(LocalTime.parse(req.getStartTime())); reservation.setEndTime(LocalTime.parse(req.getEndTime())); reservation.setStatus(0); // 即使上面校验通过,并发下仍可能冲突,靠唯一索引兜底 try { reservationMapper.insert(reservation); } catch (DuplicateKeyException e) { throw new BizException("手速慢了,该座位此时段刚刚被预约"); } return ReservationVO.from(reservation); }

@Transactional 保证冲突校验和插入在同一个事务里,避免校验通过后事务回滚不一致。countConflict 的 SQL 是区间重叠判断,Mapper XML 里这样写。

<select id="countConflict" resultType="java.lang.Integer"> SELECT COUNT(*) FROM reservation WHERE seat_id = #{seatId} AND reserve_date = #{date} AND start_time &lt; #{endTime} AND end_time &gt; #{startTime} AND status IN (0, 1) </select> <select id="countUserConflict" resultType="java.lang.Integer"> SELECT COUNT(*) FROM reservation WHERE user_id = #{userId} AND reserve_date = #{date} AND start_time &lt; #{endTime} AND end_time &gt; #{startTime} AND status IN (0, 1) </select>

区间重叠条件start_time < #{endTime} AND end_time > #{startTime}能覆盖两段完全重叠、部分重叠、包含关系这几种情况。XML 里小于号必须转义成 <,否则 XML 解析直接报错。status IN (0,1) 很关键,已取消和违约的记录不能参与冲突计算。前端传入的时间字符串必须是 “HH:mm” 格式,LocalTime.parse 才能解析,如果你传了 “08:00:00” 或带时区偏移的 ISO 字符串,这里会抛 DateTimeParseException。

再补一个取消预约的方法,规则是只有待签到状态能取消,且必须在开始时间之前操作。

@Transactional(rollbackFor = Exception.class) public void cancel(Long id, Long userId) { Reservation reservation = reservationMapper.selectById(id); if (reservation == null || !reservation.getUserId().equals(userId)) { throw new BizException("预约不存在"); } if (reservation.getStatus() != 0) { throw new BizException("当前状态不可取消"); } if (LocalTime.now().isAfter(reservation.getStartTime())) { throw new BizException("已超过开始时间,无法取消"); } reservation.setStatus(2); reservation.setCancelTime(LocalDateTime.now()); reservationMapper.updateById(reservation); }

判断“当前状态不可取消”用的是 status != 0,比只判断 status == 0 更安全,避免状态为 null 时直接通过。LocalTime.now().isAfter 有一个边界问题:用户恰好在 09:00:00 前后几毫秒提交,判断结果可能不一致。实际项目里我会把取消截止时间提前 10 分钟,前端也做同样限制,否则卡点操作很容易引发“我明明取消了怎么还违约”的投诉。

4. Vue 3 + Element Plus 前端:座位图、预约记录和后台管理三屏

前端这部分,我推荐 Vue 3 + Element Plus + Vite。Vue 2 + Element UI 的旧教程很多,但面试时聊 Vue 3 组合式 API 更占优势,而且 Vite 启动速度比 Webpack 快一个量级,改代码热更新几乎无感。

4.1 前端初始化与开发代理:为什么页面直接请求后端会跨域

前后端分离开发时,前端跑在 5173 端口,后端跑在 8080 端口,浏览器会认为这是两个源,直接请求会触发跨域拦截。开发环境的跨域不要在后端硬解,前端用 Vite 代理更干净。做好代理后,浏览器看到的请求路径没变,但 Vite 会把它转发到后端,浏览器全程感知不到跨域。

// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

target 指向后端服务地址,changeOrigin: true 会让转发后的请求头里 Host 变成 localhost:8080,避免某些后端框架根据 Host 做过滤时误判。这个代理只在 Vite 开发服务器生效,线上部署时用 Nginx 的location /api { proxy_pass ... }做同样的转发,前后端代码不需要改。

4.2 座位选择页:座位图动态渲染与提交的数据结构

座位选择页的核心不是样式,而是“哪些座位被占”的数据来源。打开页面时请求/api/seat/available?date=...&startTime=...&endTime=...,后端返回座位列表,每个座位带一个 occupied 布尔字段。前端根据这个字段控制能不能点击,提交预约时再传一遍日期和时段,后端会再次校验,防止页面停留太久状态过期。

<template> <div class="seat-map"> <div v-for="seat in seats" :key="seat.id" :class="['seat', { 'seat-occupied': seat.occupied, 'seat-selected': selectedId === seat.id }]" @click="selectSeat(seat)"> {{ seat.seatNo }} </div> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { getAvailableSeats, createReservation } from '@/api/seat' import { ElMessage } from 'element-plus' const seats = ref([]) const selectedId = ref(null) const query = ref({ date: '2024-05-20', startTime: '08:00', endTime: '10:00' }) async function loadSeats() { seats.value = await getAvailableSeats(query.value) } function selectSeat(seat) { if (seat.occupied) return selectedId.value = seat.id } async function submit() { if (!selectedId.value) { ElMessage.warning('请先选择一个座位') return } await createReservation({ seatId: selectedId.value, date: query.value.date, startTime: query.value.startTime, endTime: query.value.endTime }) ElMessage.success('预约成功') await loadSeats() } onMounted(loadSeats) </script>

occupied 的判断在后端,前端只读取结果。如果你在前端做“自己选中一个后,其他人不能点”的逻辑,刷新页面状态就丢了,属于自己给自己挖坑。提交成功后重新 loadSeats 是为了让当前用户与其他正在预约的人的状态同步,至少在下一次操作前看到最新结果。

4.3 预约记录与后台表格:分页参数、状态标签与操作列

预约记录页和后台管理页的表格写法几乎一样,区别只在查询接口和操作按钮。分页组件通常要维护页码 pageNum、每页条数 pageSize,以及总数 total。后端用 MyBatis-Plus 的 Page 时,返回结构一般是{ records, total },这里建议后端统一包装成{ rows, total },前端不用关心分页插件内部字段名。

// src/api/seat.js import request from '@/utils/request' export function getAvailableSeats(params) { return request.get('/api/seat/available', { params }) } export function createReservation(data) { return request.post('/api/seat/reservation', data) } export function getReservationPage(params) { return request.get('/api/seat/reservation/page', { params }) } export function cancelReservation(id) { return request.post(`/api/seat/reservation/${id}/cancel`) }

表格页的操作列里,取消按钮要按状态显示,状态为 0 才显示,其他状态不显示。这样最直接,也避免用户重复点击。状态文本建议用一个 map 维护,写在独立的 status.js 文件里,而不是在模板里堆 if/else。

<template> <el-table :data="rows" v-loading="loading"> <el-table-column prop="seatNo" label="座位" width="100" /> <el-table-column prop="reserveDate" label="日期" /> <el-table-column label="时段"> <template #default="{ row }"> {{ row.startTime }} - {{ row.endTime }} </template> </el-table-column> <el-table-column label="状态"> <template #default="{ row }"> <el-tag :type="statusTypeMap[row.status]"> {{ statusTextMap[row.status] }} </el-tag> </template> </el-table-column> <el-table-column label="操作" width="120"> <template #default="{ row }"> <el-button v-if="row.status === 0" type="danger" size="small" @click="handleCancel(row.id)" >取消</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" layout="total, prev, pager, next" @current-change="loadPage" /> </template>

分页查询的常见问题是:切换页码后,筛选条件被重置。解决方法是把筛选表单字段和 pageNum、pageSize 放在同一个响应式对象 query 里,点击搜索时强制把 pageNum 重置为 1,否则你在第 5 页筛选时,接口可能返回空数据。

5. 避坑手册:这 5 个问题让预约系统频繁翻车

这一章写的每一个坑都是实际调试时反复折磨过人的问题,有些表面上看是“玄学”,实际是数据库约束、序列化规则或并发时序没搞清楚。每条按“现象 → 原因 → 解决”来写,方便你直接对照排查。

5.1 同一座位同时段被重复预约:冲突校验通过了却插入两条记录

现象:两个用户几乎同时提交预约请求,后端日志里两个请求都执行了冲突查询,都返回 count=0,最后数据库里插入两条 status=0 且时段重叠的记录。

原因:这就是典型的 check-then-act 竞态。Service 层先查再插,两个并发请求在查询阶段都看不到对方未提交的数据,于是都通过了校验。单纯靠应用层代码无法完全避免这个问题。

解决:数据库唯一索引兜底,插入时捕获 DuplicateKeyException。uk_seat_time 索引已经约束了seat_id, reserve_date, start_time, end_time四个维度,并发插入时后提交的那条会直接报主键冲突,再把异常翻译成“该座位此时段刚刚被预约”。如果你的表里相关字段允许 NULL,这个兜底就失效了,务必确保四个字段都是 NOT NULL。

5.2 时间字段格式不统一:前端传 ISO 字符串后端直接报错

现象:预约提交接口返回 400,控制台报 DateTimeParseException,错误信息里出现类似2024-05-20T08:00:00.000+08:00的字符串。

原因:前端日期时间选择器没有指定 value-format,组件默认输出 Date 对象,JSON 序列化后变成 ISO 8601 格式。后端用 LocalTime.parse 接这种字符串当然解析失败。有的同学偷懒把接口参数全部定义成 String,再手动截取,结果截取规则前后端没对齐,照样出问题。

解决:前后端统一格式,前端日期选择器加value-format="YYYY-MM-DD",时间选择器加value-format="HH:mm";后端直接用 LocalDate 和 LocalTime 接收,让框架自动完成字符串到时间对象的转换。约定写清楚后,联调时间能省下一大半。

5.3 查询结果 reserveDate 为 null:驼峰映射没生效还是 resultMap 没写

现象:预约记录列表接口能返回数据,但实体类的 reserveDate 字段是 null,座位编号等字段正常。

原因:数据库列名是 reserve_date,实体字段是 reserveDate,如果 MyBatis 的 mapUnderscoreToCamelCase 没开启,下划线列名不会自动映射到驼峰属性。还有一种情况是全局配置开了,但自定义 XML 里写了 resultMap,resultMap 没覆盖 reserve_date。

解决:全局配置里加上map-underscore-to-camel-case: true;如果用了自定义 resultMap,检查每个字段是否都有<result column="reserve_date" property="reserveDate" />。这里特别注意,resultType 是 Map 时驼峰转换不生效,需要给 SQL 列名起别名,比如SELECT reserve_date AS reserveDate。

5.4 前端请求跨域被拦截:OPTIONS 预检、拦截器与 CORS 配置要一起处理

现象:页面用 axios 发 POST 请求,浏览器提示 blocked by CORS policy,且报错发生在 OPTIONS 预检请求阶段。

原因:带 JSON 的 POST 请求属于非简单请求,浏览器会先发一个 OPTIONS 预检。如果后端拦截器把这个 OPTIONS 请求也当成普通业务请求处理,要求登录态、校验 Token,就会直接返回 401 或 403,预检失败,真正的 POST 请求根本不会发出。

解决:后端配置 CORS 过滤器,并且拦截器放行 OPTIONS 方法。开发环境我优先用 Vite 代理,线上用 Nginx,都不存在跨域问题。如果你一定要在后端放开跨域,注意 Access-Control-Allow-Origin 不能和 allowCredentials 同时滥用,允许携带凭证时不能用*通配,要用具体的源列表或 allowedOriginPatterns。

5.5 定时任务把已签到用户改成违约:批量更新不是 selectAll 再 updateById

现象:定时任务跑完后,后台发现很多已签到的记录状态变成了违约,用户明明按时到馆,却被记了一次违规。

原因:定时任务先查出“开始时间超过 30 分钟”的预约,再在 Java 代码里逐条判断并更新。但任务执行有延迟,比如本来该 10:31 跑的任务拖到 10:40 才执行,期间用户已经签到了,Java 里拿到的是查出来的旧状态,执行 updateById 时把状态覆盖成了违约。

解决:用一条带条件 UPDATE 的 SQL 直接完成,条件里加AND status = 0,只有状态仍然是待签到的记录才会被更新为违约。这样即使任务延迟执行,已签到记录也不会被误改。

UPDATE reservation SET status = 3 WHERE reserve_date = CURDATE() AND start_time < NOW() - INTERVAL 30 MINUTE AND status = 0;

这条 SQL 是幂等的,跑多少遍结果都一样。定时任务再用@Scheduled(cron = "0 */10 8-22 * * ?")控制执行频率,就不需要担心并发重复执行导致状态二次覆盖。

6. 答辩前最后一步:造演示数据、跑通验证脚本,再做两个加分项

系统跑通只能说明代码没报错,离“能答辩”还差一步。我的习惯是提前准备三件事:一批符合真实分布的演示数据、一个能快速回归核心流程的验证脚本、一个能现场演示的加分功能。这三件事花的时长不会超过半天,但能让你在答辩时从“演示的人”变成“讲设计的人”。

演示数据别靠手点页面生成,那只能造出当前用户几条记录。写一个CommandLineRunner,项目启动时自动检测预约表,如果为空就批量插入过去 7 天到未来 2 天的数据。日期过去的记录,状态分布在待签到、已签到、已取消、违约之间;未来日期的记录,状态统一为待签到。这样打开统计图表时,能看到每天的预约量有起伏,而不是清一色重复数字。

@Component public class DemoDataRunner implements CommandLineRunner { @Resource private ReservationMapper reservationMapper; private static final String[] SLOTS = { "08:00-10:00", "10:00-12:00", "14:00-16:00", "16:00-18:00" }; @Override public void run(String... args) { if (reservationMapper.selectCount(null) > 0) { return; } for (int d = -7; d <= 2; d++) { LocalDate date = LocalDate.now().plusDays(d); for (int seatId = 1; seatId <= 20; seatId++) { for (int s = 0; s < SLOTS.length; s++) { String[] parts = SLOTS[s].split("-"); Reservation r = new Reservation(); r.setSeatId((long) seatId); r.setUserId((long) (1 + seatId % 10)); r.setReserveDate(date); r.setStartTime(LocalTime.parse(parts[0])); r.setEndTime(LocalTime.parse(parts[1])); r.setStatus(d < 0 ? (seatId + d + 7) % 4 : 0); reservationMapper.insert(r); } } } } }

这里(seatId + d + 7) % 4是为了让状态分布在 0 到 3 之间,避免负数取模。数据量是 9 天乘 20 个座位乘 4 个时段,共 720 条,足够支撑图表展示和分页效果。

回归验证脚本用 curl 写成一个 bash 文件,每次答辩前跑一遍,确认核心链路没被改坏。脚本覆盖四个动作:查可用座位、创建预约、查预约列表、取消预约。只要这条链路通,演示过程就不会卡壳。

#!/bin/bash BASE=http://localhost:8080/api/seat TOMORROW=$(date -d "+1 day" +%F) curl -s "$BASE/available?date=$TOMORROW&startTime=09:00&endTime=10:00" curl -s -X POST "$BASE/reservation" \ -H "Content-Type: application/json" \ -d "{\"seatId\":1,\"date\":\"$TOMORROW\",\"startTime\":\"09:00\",\"endTime\":\"10:00\"}"

加分项我建议做两个,工作量都不大。第一个是违约黑名单:用户表已经有 status 字段,后端统计该用户近 30 天违约次数,超过 3 次创建预约时直接拦截,后台管理员可以手动解除。第二个是预约热度统计:按日期分组SELECT reserve_date, COUNT(*) FROM reservation GROUP BY reserve_date,前端用图表展示,这是答辩现场最直观的页面。这两个功能加起来不超过 200 行代码,但能让整个项目的复杂度上一个台阶。

我评审项目时有个习惯,不看 happy path,专门改参数看系统怎么接招。把预约时间改成过去、同时段重复提交、把取消按钮卡在开始前一秒,这些瞬间最能体现一个人是不是真的理解了自己写的代码。所以你在答辩前,也按这个思路自己折腾一遍系统,把每个边界情况的提示语记熟。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进