1. 项目背景与毕设选题价值
1.1 为什么选了自习室座位预约这个题目
每到考研季、期末周,高校图书馆和自习室里一座难求的现象几乎成了常态。我见过太多同学早上六点去排队占座,结果发现桌子上一摞书放了三天人没来;也见过有人在门口徘徊半小时找不着空位,而热门区域的座位利用率其实不到一半。自习室座位预约系统解决的正是这个真实痛点:把座位的分配从“人肉占座”变成“线上预约”,通过数据把闲置座位释放出来,让有限的资源服务更多人。
这个题目作为计算机毕业设计,性价比非常高。一方面它属于典型的信息管理系统(MIS)范畴,需求边界清晰、业务逻辑不复杂,非常适合用 Spring Boot 这种主流框架来落地;另一方面它的业务场景贴近校园生活,需求分析、原型设计、数据库建模都有大量现成素材可以参考,答辩时评委一听就懂,不会出现“你这个题目到底解决什么问题”的尴尬局面。更重要的是,系统的核心流程——用户注册登录、座位信息展示、预约创建、取消、签到、违约判定——覆盖了一个 Web 系统从数据建模到接口设计的完整链路,写进简历里也是能讲清楚的项目。
1.2 这类系统能扩展到哪些场景
座位预约只是“预约类系统”的一个缩影。同样的架构和流程,改改业务表就能延伸到很多场景:图书馆阅览室座位、高校实验室机位、驾校场地训练时段、自习室付费时段、会议室工位共享……核心都是“资源 + 时间段 + 用户 + 状态流转”这四件事。做毕设选这样一个有扩展空间的题目,后续不管是写论文还是答辩扩展提问,你都有的聊。
另外,“计算机毕业设计源码36958”这个编号说明它是一套完整交付的源码工程。对做毕设的同学来说,拿到源码不是终点,能读懂结构、能跑起来、能讲清楚每个模块为什么这么设计,才是答辩过关的关键。这篇文章我就把整套系统从技术选型到核心代码实现,按我实际做项目的思路完整拆解一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与整体架构拆解
2.1 为什么用 Spring Boot 而不是 SSH 或 SSM
很多学校的课程还停留在 Spring MVC + Spring + MyBatis 的 SSM 组合,但毕设这个节点,我强烈建议直接用 Spring Boot。原因很实际:SSM 里大量的 XML 配置、jar 包版本冲突、Tomcat 部署步骤,每一项都在消耗你本来该花在业务代码上的时间。Spring Boot 通过自动配置把“约定大于配置”做到了极致,一个 SpringApplication.run() 就能把 Web 容器、数据源、MyBatis、事务管理全部拉起来,这对学生党来说是降维打击。
选型时要注意版本匹配。Spring Boot 2.x 和 3.x 的差异不小,3.x 要求 JDK 17+,部分老教程里的 javax.* 包要改成 jakarta.*。如果学校机房环境还停留在 JDK 8,建议老老实实用 Spring Boot 2.7.x;如果自己的电脑是 JDK 17/21,那直接用 3.x 也没毛病。做毕设千万不要追求“最新”,稳定跑起来比什么都强。
技术栈清单大概是这样的:
| 层次 | 选型 | 说明 |
|---|---|---|
| 后端框架 | Spring Boot 2.7.x / 3.x | 提供 RESTful API,内置 Tomcat |
| 持久层 | MyBatis Plus | 比原生 MyBatis 省掉大量 SQL 编写 |
| 数据库 | MySQL 8.0 | 开源稳定,支持事务和行级锁 |
| 前端 | Vue 2/3 + Element UI | 后台管理界面开发效率高 |
| 构建工具 | Maven | 依赖管理和项目构建 |
| 鉴权 | JWT + Spring Security 或拦截器 | 简单的 Token 校验方案 |
| 缓存(可选) | Redis | 用于座位状态缓存、预约计数 |
2.2 系统功能模块怎么划分
座位预约系统的功能模块,我按用户角色拆成两端:管理员端和学生/普通用户端。这个划分方式符合绝大多数校园场景的真实权限模型。
- 用户模块:注册、登录、个人信息维护、密码修改。
- 座位管理:管理员维护自习室楼层、区域、座位编号,座位有状态(可用/占用/维护中)。
- 预约管理:用户查看座位、选定时间段、提交预约;管理员可查看全部预约记录、处理违约记录。
- 签到管理:预约生效后用户在自习室扫码或输入预约号签到,超时未签到自动释放座位并计入违约。
- 公告模块:管理员发布自习室开放时间、节假日安排、系统维护通知。
- 统计模块:座位使用率、预约频次、高峰时段分析,用柱状图或折线图展示。
数据库层面,核心表我建议至少设计这六张:user(用户表)、room(自习室表)、seat(座位表)、reservation(预约记录表)、check_in(签到记录表)、violation(违约记录表)。如果要做公告,再增加 notice 表;要做时段管理,可以加 time_slot 表。
2.3 核心流程与状态机设计
预约系统的核心不是 CRUD,而是状态流转。座位和预约都有状态,状态之间怎么跳转,决定了系统会不会出现“座位被别人约了还能约”“预约了不去也没事”这类逻辑漏洞。
预约记录的状态字段我定义为:PENDING(待签到)、CHECKED_IN(已签到)、COMPLETED(已完成)、CANCELLED(已取消)、EXPIRED(已过期/爽约)、RELEASED(超时释放)。
流程大致是这样的:
- 用户选择自习室座位和时间段,提交预约,生成预约记录(状态 PENDING)。
- 系统锁定座位在当前时间段(座位状态为 OCCUPIED)。
- 用户在预约时间段开始前后一段时间内签到,状态变 CHECKED_IN。
- 时间段结束,系统定时任务将 CHECKED_IN 转为 COMPLETED,释放座位。
- 用户主动取消,且离预约开始还有足够时间,状态变 CANCELLED,释放座位。
- 预约开始后用户未签到,超过宽限期(比如 15 分钟),状态变 EXPIRED,座位释放,违约记录 +1。
这个状态机想清楚了,后面的 service 层代码就是在“正确的时间做正确的状态迁移”。
3. 数据库设计详解与建表 SQL
3.1 表结构设计的核心思路
数据库设计是毕设答辩提问的高频区。很多同学建表就是随手一写,字段能跑就行,可一旦被问“为什么这个字段要加唯一索引”“为什么预约表要冗余座位号”,就答不上来。所以表设计必须有意识地去体现一些规范性的思考。
设计座位相关表时,核心思路是“自习室 → 区域/楼层 → 座位”这样一个层级关系。room 表和 seat 表用 room_id 关联,seat 表里存 seat_number(座位编号)、seat_type(普通座/靠窗座/静音区座)、status(0占用 1空闲 2维护)。预约表不要直接存一个 JSON 时间段字符串,而是拆成 reserve_date(预约日期)、start_time(开始时间)、end_time(结束时间)三个字段,这样做时间段冲突检测时 SQL 才能写得出来。
用户表我通常会增加 user_no(学号/工号)、role(1管理员 0普通用户)、credit_score(信用分)字段。信用分机制是预约系统的点睛之笔:每次违约扣 10 分,信用分低于 60 分限制预约,这样既惩罚了恶意占座,又不至于一棍子打死,答辩讲到这个地方评委会觉得你有思考。
3.2 核心建表 SQL 示例
下面给出一份可运行的 MySQL 建表 SQL,字段注释已经写得比较完整,拿到可以直接用。
sql复制CREATE DATABASE IF NOT EXISTS study_room DEFAULT CHARACTER SET utf8mb4;
USE study_room;
-- 用户表
CREATE TABLE `user` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_no` VARCHAR(32) NOT NULL COMMENT '学号/工号',
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
`password` VARCHAR(100) NOT NULL COMMENT '密码(MD5/BCrypt加密)',
`real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`role` TINYINT NOT NULL DEFAULT 0 COMMENT '角色 0-用户 1-管理员',
`credit_score` INT NOT NULL DEFAULT 100 COMMENT '信用分',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1-正常 0-禁用',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_no` (`user_no`)
) ENGINE=InnoDB COMMENT='用户表';
-- 自习室表
CREATE TABLE `room` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`room_name` VARCHAR(100) NOT NULL COMMENT '自习室名称',
`floor_no` INT DEFAULT NULL COMMENT '所在楼层',
`open_time` VARCHAR(10) DEFAULT '08:00' COMMENT '开放时间',
`close_time` VARCHAR(10) DEFAULT '22:00' COMMENT '关闭时间',
`total_seats` INT DEFAULT 0 COMMENT '座位总数',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1-开放 0-关闭',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='自习室表';
-- 座位表
CREATE TABLE `seat` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`room_id` BIGINT NOT NULL COMMENT '所属自习室ID',
`seat_no` VARCHAR(20) NOT NULL COMMENT '座位编号',
`seat_type` TINYINT DEFAULT 0 COMMENT '座位类型 0-普通 1-靠窗 2-静音区',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 0-占用 1-空闲 2-维护',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_room_seat` (`room_id`, `seat_no`)
) ENGINE=InnoDB COMMENT='座位表';
-- 预约记录表
CREATE TABLE `reservation` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`seat_id` BIGINT NOT NULL COMMENT '座位ID',
`room_id` BIGINT NOT NULL COMMENT '自习室ID(冗余)',
`seat_no` VARCHAR(20) DEFAULT NULL COMMENT '座位号(冗余)',
`reserve_date` DATE NOT NULL COMMENT '预约日期',
`start_time` VARCHAR(10) NOT NULL COMMENT '开始时间',
`end_time` VARCHAR(10) NOT NULL COMMENT '结束时间',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 0-待签到 1-已签到 2-已完成 3-已取消 4-已过期 5-超时释放',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_seat_id` (`seat_id`),
KEY `idx_reserve_date` (`reserve_date`)
) ENGINE=InnoDB COMMENT='预约记录表';
-- 签到记录表
CREATE TABLE `check_in` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`reservation_id` BIGINT NOT NULL COMMENT '预约记录ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`check_in_time` DATETIME NOT NULL COMMENT '签到时间',
`check_in_type` TINYINT DEFAULT 0 COMMENT '签到方式 0-二维码 1-预约号',
PRIMARY KEY (`id`),
KEY `idx_reservation_id` (`reservation_id`)
) ENGINE=InnoDB COMMENT='签到记录表';
-- 违约记录表
CREATE TABLE `violation` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`reservation_id` BIGINT NOT NULL COMMENT '关联预约ID',
`reason` VARCHAR(200) DEFAULT NULL COMMENT '违约原因',
`deduct_score` INT DEFAULT 10 COMMENT '扣除信用分',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB COMMENT='违约记录表';
这里两个冗余字段(seat_no、room_id)是故意加的。查询用户的历史预约列表时,如果用 seat_id 去 join 两张表也能查出座位号,但每次多一次关联查询;冗余之后直接查预约表就能展示完整信息,这在列表页高频接口上性能提升明显。毕设阶段表量小可能看不出差距,但设计思路上讲得通。
3.3 唯一约束和索引设计的坑
seat 表里那个 uk_room_seat 唯一索引(room_id + seat_no)很容易被忽略,但它是防重的关键。如果没有这个唯一约束,通过页面连续点两次新增座位,或者管理员导入数据时手误,就会出现同一个自习室里两个相同编号的座位,预约逻辑立刻乱套。
预约表的时间段查询必须用索引。一个典型 SQL 是“查询某座位在某日期是否已被预约”,如果表里没有 reserve_date 的索引,全表扫描在数据量上千条的时候还无所谓,上万条就会明显变慢。另外我建议加一个组合索引(seat_id + reserve_date),因为这种查询几乎都带座位和日期两个条件。
注意:MySQL 的 utf8mb4 字符集一定要用,别图省事用 utf8。座位编号、用户名里出现生僻字或 emoji 时,utf8mb3 会直接报错或乱码,utf8mb4 是兼容所有字符的。
4. 后端核心业务逻辑实现
4.1 工程结构到底怎么分层
Spring Boot 工程的分层,我见过太多同学把所有类塞进一个 controller 里,service 层形同虚设。这样做运行没问题,但代码量一上来就“牵一发动全身”,改一个预约状态要把整个 controller 翻一遍。规范的分层应该是这样的:
code复制com.example.studyroom
├── controller # 接收请求、参数校验、返回结果
├── service # 业务逻辑层,处理状态流转、事务
├── mapper # MyBatis 接口,操作数据库
├── entity # 数据库实体类
├── dto # 数据传输对象,接收前端参数
├── vo # 视图对象,返回前端数据
├── config # 配置类,如 CORS、拦截器
├── common # 通用返回结果、异常处理、工具类
└── task # 定时任务
common 包里有两个东西我特别推荐写:一个是统一返回体 Result,包含 code、message、data 三个字段,所有接口统一返回这个对象,前端处理异常就非常统一;另一个是全局异常处理器 @RestControllerAdvice,把参数校验异常、业务异常、系统异常分别返回给前端友好的提示,而不是一长串堆栈。
4.2 预约座位:并发与状态校验怎么写
预约接口是整个系统最核心、也是最容易写错的接口。很多人的第一版代码长这样:先查座位状态,如果是空闲就 update 成占用,然后 insert 预约记录。看似没问题,但两个用户同时提交时,后一个请求可能在“查状态”阶段读到同一个“空闲”,两个预约就都成功了。解决并发问题有两把锁:数据库悲观锁和乐观锁。
在 MySQL 里最简单的做法是 SELECT * FROM seat WHERE id = ? FOR UPDATE,把座位这行锁住,再执行状态判断和插入。加了 FOR UPDATE 之后,第二个事务会阻塞到第一个事务提交,从而保证同一时刻只有一个预约请求在操作这个座位。注意,这个方法只有在事务里才生效,所以 service 方法必须标注 @Transactional。
我的核心预约逻辑代码可以这样写:
java复制@Override
@Transactional(rollbackFor = Exception.class)
public Result createReservation(CreateReservationDTO dto, Long userId) {
// 1. 校验用户信用分
User user = userMapper.selectById(userId);
if (user.getCreditScore() < 60) {
return Result.error("信用分不足,无法预约");
}
// 2. 悲观锁锁定座位,防止并发预约同一座位
Seat seat = seatMapper.selectByIdForUpdate(dto.getSeatId());
if (seat == null || seat.getStatus() != 1) {
return Result.error("座位不存在或不可预约");
}
// 3. 校验时间段冲突
LambdaQueryWrapper<Reservation> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Reservation::getSeatId, dto.getSeatId())
.eq(Reservation::getReserveDate, dto.getReserveDate())
.in(Reservation::getStatus, Arrays.asList(0, 1)); // 待签到和已签到视为占用
// 时间重叠判断:已有开始时间 < 新结束时间 且 已有结束时间 > 新开始时间
wrapper.and(w -> w.lt(Reservation::getStartTime, dto.getEndTime())
.gt(Reservation::getEndTime, dto.getStartTime()));
Long conflictCount = reservationMapper.selectCount(wrapper);
if (conflictCount > 0) {
return Result.error("该时段已被预约");
}
// 4. 创建预约记录,座位状态置为占用
Reservation reservation = new Reservation();
reservation.setUserId(userId);
reservation.setSeatId(seat.getId());
reservation.setRoomId(seat.getRoomId());
reservation.setSeatNo(seat.getSeatNo());
reservation.setReserveDate(dto.getReserveDate());
reservation.setStartTime(dto.getStartTime());
reservation.setEndTime(dto.getEndTime());
reservation.setStatus(0);
reservationMapper.insert(reservation);
seat.setStatus(0);
seatMapper.updateById(seat);
return Result.success(reservation.getId());
}
时间重叠的判断条件是这段代码里的精髓。lt(start, newEndTime) and gt(end, newStartTime) 这条规则能覆盖所有重叠场景:新时段完全被包含、旧时段完全包含新时段、只有头尾交叉,这四种情况都能识别出来。我周围有人用 between 写,边界条件总是漏,最后还是老老实实回到这种经典的区间重叠写法。
4.3 取消预约的边界条件
取消预约看起来简单,实际上有一个“什么时候允许取消”的业务规则要想清楚。我做的规则是:预约开始时间前 30 分钟以上允许用户自行取消;30 分钟以内不可取消,否则用户可以通过“先约后取消”的方式把一个座位占住直到最后一刻,其他人永远抢不到。这条规则在写接口时很容易忽略,但它恰恰是预约系统公平性的保障。
取消的逻辑:
java复制public Result cancelReservation(Long reservationId, Long userId) {
Reservation reservation = reservationMapper.selectById(reservationId);
if (reservation == null || !reservation.getUserId().equals(userId)) {
return Result.error("预约记录不存在");
}
if (reservation.getStatus() != 0) {
return Result.error("当前状态不可取消");
}
LocalDateTime reserveStart = LocalDateTime.of(
reservation.getReserveDate(),
LocalTime.parse(reservation.getStartTime()));
if (LocalDateTime.now().isAfter(reserveStart.minusMinutes(30))) {
return Result.error("距离预约开始不足30分钟,无法取消");
}
reservation.setStatus(3); // CANCELLED
reservationMapper.updateById(reservation);
// 释放座位
Seat seat = seatMapper.selectById(reservation.getSeatId());
if (seat != null && seat.getStatus() == 0) {
seat.setStatus(1);
seatMapper.updateById(seat);
}
return Result.success();
}
4.4 签到与定时释放的配合
签到接口做的事很简单:根据预约号或二维码查预约记录,判断当前时间是否在签到窗口内(我设定为预约开始前后 15 分钟内),把预约状态改为已签到,插入签到记录。这里要注意的是,同一个预约只能签到一次,所以签到前要检查状态是否为 PENDING,防重复签到。
定时任务是另一个关键环节。预约开始后没人来,座位不能一直占着,所以需要一个定时任务每 5 分钟扫一次所有状态为待签到、且当前时间已超过预约开始时间 15 分钟的预约记录,把它们标记为过期(EXPIRED),释放座位,插入违约记录,扣信用分。
java复制@Component
public class ReservationScheduledTask {
@Resource
private ReservationMapper reservationMapper;
@Resource
private SeatMapper seatMapper;
@Resource
private ViolationMapper violationMapper;
@Scheduled(cron = "0 */5 * * * ?")
@Transactional(rollbackFor = Exception.class)
public void releaseExpiredReservations() {
LocalTime nowTime = LocalTime.now();
LocalTime expireThreshold = nowTime.minusMinutes(15);
LambdaQueryWrapper<Reservation> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Reservation::getStatus, 0)
.eq(Reservation::getReserveDate, LocalDate.now())
.lt(Reservation::getStartTime, expireThreshold.toString());
List<Reservation> expiredList = reservationMapper.selectList(wrapper);
for (Reservation r : expiredList) {
r.setStatus(4); // EXPIRED
reservationMapper.updateById(r);
Seat seat = seatMapper.selectById(r.getSeatId());
if (seat != null && seat.getStatus() == 0) {
seat.setStatus(1);
seatMapper.updateById(seat);
}
Violation v = new Violation();
v.setUserId(r.getUserId());
v.setReservationId(r.getId());
v.setReason("超时未签到");
v.setDeductScore(10);
violationMapper.insert(v);
// 扣信用分,下限为0
userMapper.updateCreditScore(r.getUserId(), -10);
}
}
}
这里用到了 Spring Boot 内置的 @Scheduled 注解,配合启动类上的 @EnableScheduling 即可。不需要引入 Quartz,对毕设项目来说定时任务的精度完全够用。
5. 前端页面与接口联调要点
5.1 简单高效的前端方案
做毕设的前端,我推荐 Vue 2 + Element UI(或者 Vue 3 + Element Plus),选它不是因为技术新,而是因为组件库齐全,表格、表单、日期选择器、弹窗都是现成的,改一改样式就能用。你不需要从零写 CSS 布局,重点精力放在页面逻辑上。
前端项目的页面结构大致分为:登录注册页、用户端首页(座位分布图/列表)、预约页(时间选择、座位筛选)、我的预约(预约列表、取消入口)、签到页(二维码或预约号)、管理员后台(座位管理、用户管理、预约记录、统计报表)。
座位分布图算是这个系统里最有“产品感”的页面。可以用 CSS Grid 画一个简易的座位网格,每个格子是一个 div,根据座位状态显示不同颜色:绿色空闲、红色占用、灰色维护。点击绿色格子弹窗选择时间段,提交预约。数据通过 GET /api/seat/available?roomId=xx&date=xxx 接口获取,前端拿到数组循环渲染。这种可视化方式虽然比 3D 建模简陋,但足够直观,答辩演示效果很好。
5.2 接口返回结构统一这件事不能省
前后端联调最容易出问题的就是接口返回结构不一致。这个接口返回 {code:200, data:{list:[]}},那个接口返回 {success:true, data:{}},前端拦截器根本没法统一处理错误。所以我在后端定义了 Result 类:
java复制@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("success");
result.setData(data);
return result;
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}
}
前端用 axios 拦截器统一判断 code,非 200 直接弹出 message,后端错误码设计简单但够用。所有 Controller 返回值都统一用 Result 包装,前端链路清爽干净。
5.3 跨域与 Token 鉴权的配置
前后端分离开发最绕不开的是跨域。后端在配置类里加一个 CorsFilter 或者实现 WebMvcConfigurer 的 addCorsMappings,允许前端开发服务器(比如 http://localhost:5173)跨域访问本地的 8080 端口。用小技巧说就是“后端放行一次,前端不用管”。
鉴权方面我用 JWT 方案:登录成功后后端生成 token 返回给前端,前端每次请求在 Header 里带 Authorization: Bearer token,后端写一个拦截器拦截除登录注册外的所有接口。拦截器里解析 token,把 userId 放入 request attribute,Controller 里取出来当当前登录用户。这个方案比 Session 方案更适合前后端分离,也比 Spring Security 那套繁琐流程更适合毕设节奏。
java复制@Component
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if ("OPTIONS".equals(request.getMethod())) {
return true;
}
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
String realToken = token.substring(7);
try {
Claims claims = JwtUtil.parseToken(realToken);
request.setAttribute("userId", claims.get("userId"));
return true;
} catch (Exception e) {
response.setStatus(401);
return false;
}
}
response.setStatus(401);
return false;
}
}
注意:
OPTIONS请求要直接放行,否则浏览器预检请求被拦截,前端所有带自定义 Header 的请求都会报跨域错误。这个坑我当初调了一个多小时,网上很多教程都没提。
6. 常见问题排查与答辩避坑指南
6.1 项目跑不起来的五个高频原因
每次帮同学看毕设代码,跑不起来的原因基本集中在几个点上:
- JDK 版本和 Spring Boot 版本不匹配:Spring Boot 3.x 必须 JDK 17+,用 JDK 8 编译直接报错。反过来 Spring Boot 2.x 用 JDK 17 也可能有兼容问题。
- 端口被占用:8080 被别的进程占着,Spring Boot 启动失败。改
application.yml里的 server.port 换成 8081 就行。 - 数据库连接不上:
url写成localhost:3306但 MySQL 装在 Docker 里或远程服务器上,或者密码写错,报 Communications link failure。 - Maven 依赖下载慢/失败:用阿里云镜像仓库替换中央仓库,速度立竿见影。在
~/.m2/settings.xml里配置 mirror。 - 时区问题:MySQL 连接 URL 少加了
serverTimezone=Asia/Shanghai,会报 CST 时区错误。
数据库连接串我一般推荐这样写:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/study_room?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
6.2 并发测试整出脏数据怎么处理
我在自己做测试的时候,用 Jmeter 模拟 50 个并发同时抢同一个座位,发现预约记录出现了两条。原因就是最初代码没用 FOR UPDATE。这提醒你:乐观锁和悲观锁在这个场景下一定要二选一。
用悲观锁会锁行,如果座位表更新频繁,效率略低;但毕设场景座位量几百个,更新频率不高,行锁完全够用。如果你担心锁的粒度太粗,可以用乐观锁方案:seat 表加 update_time 或 version 字段,update 时 SET status=0 WHERE id=? AND status=1,如果 update 影响行数为 0,说明别人已经抢先了。这个方案也能防并发,而且不用锁行,要我说其实更推荐这条路。
6.3 答辩时老师最爱追问哪些点
这个过程我见过太多次了,老师提问的套路其实是可以预测的:
- “你的预约并发问题是怎么解决的?”——回答 FOR UPDATE 悲观锁/乐观锁,顺便说明为什么不用 ReentrantLock(单机锁不垮服务、锁代码块范围不好控制)。
- “如果用户预约了不来怎么办?”——定时任务 + 违约记录 + 信用分。
- “座位状态什么时候释放?”——取消释放、超时释放、时间段结束自动释放,三种释放路径缺一不可。
- “你的系统怎么防止一个人占多个座位?”——在预约逻辑加校验:同一个用户在同一个日期存在未完成预约(状态为待签到或已签到)时不允许再次预约。这个校验我在上面代码里没列完整,但实际项目里一定要有,不然一人占十个座的漏洞在演示时会被当场打死。
另外老师很可能问“为什么用 Redis 还是没用 Redis”。你可以说:当前项目用数据库锁已经满足需求,Redis 可以用来做热门座位缓存和分布式锁,是后续扩展方向。这个回答既诚实又展示了技术视野,比硬吹自己用了 Redis 结果被细节问倒强得多。
6.4 论文结构怎么配合源码讲
毕设论文的目录结构和系统模块最好一一对应。我看过不少同学源码里写的类和论文里画的设计图对不上,答辩时老师翻一下源码就能发现。写论文的章节建议为:绪论(背景意义、国内外现状)、需求分析(用例图、业务流程)、系统设计(架构图、功能模块、数据库表)、系统实现(每个模块核心代码加截图)、系统测试(功能测试、并发测试、测试用例表)。
源码里一定要留测试数据。预置几个管理员、用户账号,自习室 3 间,每间 30-50 个座位。这样演示时不会出现“点开页面一个座位都没有”的尴尬。测试账号记得在 README 里写清楚,评委不是来看你登录流程的。
7. 部署上线与日常维护的实用经验
7.1 本地跑和服务器跑有什么区别
很多同学开发时用的是 spring-boot:run 或 IDE 启动,但到最终需要部署演示时,直接用 jar 包跑是最干净的。用 Maven 打包:
bash复制mvn clean package -DskipTests
打完包在 target 目录下会生成 studyroom-0.0.1-SNAPSHOT.jar。放到服务器上或者演示用的电脑上,执行:
bash复制java -jar studyroom-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
如果想把服务放到后台,用 nohup:
bash复制nohup java -jar studyroom-0.0.1-SNAPSHOT.jar > studyroom.log 2>&1 &
查看日志就 tail -f studyroom.log。前端 Vue 项目打包后生成 dist 目录,可以单独部署到 Nginx,也可以直接把 dist 里的静态文件放到 Spring Boot 的 static 目录下,实现“前后端一体”的单服务部署,演示时只需要开一个 8080 端口,非常省事。
7.2 数据备份与初始化数据的技巧
毕设答辩前一天最怕数据库被弄坏。我建议每天导出一份 SQL 备份:
bash复制mysqldump -u root -p study_room > study_room_backup.sql
需要恢复时:
bash复制mysql -u root -p study_room < study_room_backup.sql
另外在 resources 目录下放一份 init-data.sql,里面写好管理员账号和基础自习室座位数据,这样新环境上跑起来不用手动一条条录数据。用 Spring Boot 的 SQL 初始化功能,在 application.yml 里配置 spring.sql.init.mode=always 和 schema/data 路径,首次启动自动建表和导入数据,效率翻倍。
7.3 日志排查三板斧
系统跑起来报错,千万不要干瞪眼。我排查问题的顺序是:先看控制台或 nohup 输出的错误堆栈,定位异常类型(ClassNotFoundException 通常是依赖缺失,NullPointerException 大概率是查不到数据或前端没传参);再用 Postman 直接测接口,手动构造参数看返回,排除前端问题;最后打开 SQL 日志,MyBatis 配置里把 mapper 的 SQL 打印出来,看实际执行的 SQL 和参数对不对。
application.yml 里加上这两行,SQL 和参数就会打到控制台:
yaml复制mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
每次看到日志里 Preparing: SELECT * FROM seat WHERE id=? 和 Parameters: 23(String),我基本就能判断出问题出在查询条件还是数据本身。这个习惯能帮你省掉大量瞎猜的时间。
8. 总结一下我能给你的建议
做这套自习室座位预约系统的过程,我最大的体会是:毕设项目不需要炫技,但每一步都要有逻辑闭环。从需求里“为什么要有信用分”,到数据库里“为什么时间重叠判断要这样写”,到并发场景“为什么用 FOR UPDATE”,每一个细节都能讲清楚,你的答辩基本就立于不败之地了。
最后再分享一个小技巧:拿到任何毕设源码,第一件事不是急着跑,而是先把数据库脚本打开看一遍表结构,再对照 readme 里的功能清单梳理模块。能看懂表关系,整个系统的骨架就装进脑子里了;跑起来之后,先走通“注册→预约→签到→完成”这条主流程,再把边缘流程(取消、过期、违约)逐个触发一遍,你对系统的掌控程度会远超只跑通主流程的同学。
这套代码里唯一我想提醒你加强的,是预约接口里“同一用户同时段重复预约”的校验。很多现成源码里只有座位维度的时间冲突判断,没有用户维度的防重复规则,导致一个用户可以同时占多个座。把这个场景补上,代码质量会明显高一个档次。希望这篇拆解能帮你把源码吃透,答辩顺利过关。
