1. 为什么高校图书馆需要一套座位预约系统
做了几年的Java后端开发,经常有学弟学妹来找我聊毕设选题,图书馆座位预约管理系统是出现频率非常高的话题。一开始我觉得这题目都快被做烂了,但真正深入调研过后才发现,大部分同学做出来的东西只是"看起来像座位预约",实际距离能落地部署还有很长一段距离。
先说这个系统到底解决什么问题。我当年上大学的时候,图书馆占座现象非常严重,早上六点多去排队,结果发现桌上放本书就算占座了,人可能到中午才出现,真正想学习的同学反而找不到位置。管理员也很头疼,没法判断哪些座位是空置的,哪些座位是被"书占"的。座位预约系统要解决的核心矛盾就是:供需信息不对称加上履约机制缺失。你光把座位放到网上让人选号是一回事,能不能让用户"预约了就会来、来了就能坐下、坐下之后不被恶意占用"就是另一回事了。
从技术角度来说,Spring Boot 是目前做这类管理系统最稳妥的选择。它省掉了大量繁琐的配置,内置Tomcat,包一层Maven依赖就能跑起来,配上MyBatis-Plus操作数据库,前端用Vue或者Thymeleaf都行。更重要的是,图书馆座位预约天然适合拿来演示完整的软件工程流程——需求分析、数据库设计、并发处理、定时任务、消息通知,每个环节都有实打实的业务逻辑支撑,不是那种为了展示技术而强行凹出来的项目。
这篇内容适合正准备做毕设、想写一个能真正跑通的课设项目、或者在工作中接到类似工位/会议室预约需求的人参考。我会从需求建模开始讲,一路拆到表结构设计、座位扣减的并发处理、签到和信用分机制,最后谈谈部署上线时容易踩的坑。每一步都有我实际写码和调试的经验在里面,不是直接把网上的Demo拿过来抄一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 需求设计与功能边界:先想清楚"预约"和"选座"是两套逻辑
很多人拿到这个题目就开始建表,座位表、用户表、预约记录表,三张表一搭就以为完事了。真正开始做需求分析你会发现,"预约"这个词拆开来有好几层意思,而且每一层的处理逻辑都不一样。
2.1 从用户和管理员两个视角拆解核心流程
用户视角的核心流程大致是这样的:登录系统 → 查看当前可预约的座位(往往按楼层/区域筛选)→ 选择座位和时间段 → 提交预约 → 到图书馆后签到 → 学习结束后签退释放座位。这一段链路里,选座只是入口,履约才是关键。很多系统死在"预约了但人没来"这个问题上,所以必须配套签到机制和逾期释放机制。
管理员视角要处理的事情更杂:维护楼层和座位的基础信息、处理违规记录、查看实时占用率、调整座位开放状态(比如某区域临时装修要整体停用)、处理用户申诉。还有一类容易被忽略的需求是数据统计——图书馆领导通常会关心"哪个时间段上座率最高""哪个区域最受欢迎",这些数据直接决定后续是否延长开放时间、是否需要调整座位配置。
2.2 散座模式和时间段模式的选择
这里我重点说一下两种常见的业务模式,因为你会发现网上很多现成的系统在这块是混淆的。
第一种是散座模式,就是座位只能预约"今天到明天"之类的某个自然日,不区分具体时段,预约成功后在当天任意时间签到入座。这种模式实现起来简单,status字段一张表就能搞定,适合小型图书馆,但它的缺点是没法精细控制每个时段的容量。
第二种是时间段模式,把一天切成多个时段,比如上午8:00-12:00、下午14:00-18:00、晚上19:00-22:00,用户按时间段预约。这种模式更贴近真实需求,但设计和实现复杂度直线上升——你需要处理时间段重叠的校验、跨时段续约、时段开始前的座位释放等多个边界情况。
我给的建议是,如果你做的是毕设,优先选择时间段模式,理由有二:一是它更能体现你的系统设计能力,答辩时有东西可以讲;二是时间段模式天然要求你处理"预扣减"和"定时释放",这正是面试官或者评委爱问的技术点。我自己的项目里最终选择了时间段模式,后面讲并发处理的部分也是围绕这个模式展开的。
2.3 角色权限体系的最低配置
权限这块不要绕过 Spring Security,也别一上来就引入太重的权限框架。实际做的时候配置三个角色就够了:ROLE_USER(学生用户)、ROLE_ADMIN(图书馆管理员)、ROLE_SUPER_ADMIN(系统超级管理员)。用 Spring Security 加上 JWT 做无状态认证,后端拦截器校验角色和接口权限,前端根据角色动态渲染菜单。
我的建议是直接用注解式权限控制,比如 @PreAuthorize("hasRole('ADMIN')"),不要搞那种数据库动态配置的RBAC,真要做动态权限后期再加,前期会把开发节奏拖慢。毕设场景下,能讲清楚 JWT 的认证流程和 Spring Security 的过滤器链就已经超出大多数人的水平了。
3. 数据库模型与状态机设计:一张座位表抵得过十张堆砌的表
表结构设计是这类系统的地基。地基歪了,后面所有业务逻辑都会跟着别扭。很多参考项目喜欢把座位、区域、预约记录、违规记录、公告、用户表全平铺开,然后每个实体都做一套增删改查,看着功能齐全,实际上联动逻辑一塌糊涂。
3.1 核心表结构落地
我的项目中实际用到的核心表是这些:
| 表名 | 核心字段 | 职责说明 |
|---|---|---|
| seat | id, area_id, seat_no, status, type | 座位基础信息,type区分靠窗/普通/电源座 |
| seat_area | id, name, floor, open_time, close_time | 座位区域,含开放时间 |
| reservation | id, user_id, seat_id, date, start_time, end_time, status | 预约记录主表,状态流转核心 |
| check_in_record | id, reservation_id, check_in_time, check_out_time | 签到签退记录 |
| user_violation | id, user_id, type, points, create_time | 违规扣分记录 |
| system_config | config_key, config_value | 系统参数配置,如迟到时限、扣分规则 |
预约记录表我特意单独拆了签到记录表出来,而不是在 reservation 表上直接加两个时间字段。原因是签到记录可能因为用户中途换座、续约而产生多条,把它独立出来对后续统计和信用分计算都会方便很多。
3.2 座位状态机:六种状态如何流转
座位表上的状态字段不能只做"空闲/占用"二值处理,否则你会遇到一个经典问题:座位被预约了但人还没到,此时它算空闲还是占用?如果算空闲,别人看到座位显示可用就会出现重复预约;如果算占用,那座位从预约到签到的窗口期就被白白锁死。
我在项目里给 seat 表设计了六个状态:
- AVAILABLE:空闲可预约
- RESERVED:已预约待签到(被锁定但未占用)
- OCCUPIED:已签到占用中
- DISABLED:管理员禁用
- REPAIR:维修中
- TEMP_UNAVAILABLE:临时不可用(常用于活动占场)
在代码层面我会用一个状态枚举类来管理流转过程,而不是散落一堆魔法数字。审批和流转的逻辑核心是:预约创建时 AVAILABLE → RESERVED;签到成功时 RESERVED → OCCUPIED;签退或超时释放时 OCCUPIED/RESERVED → AVAILABLE;管理员手动操作时任意状态 → DISABLED。任何非法流转直接抛业务异常,日志里能看到具体是哪个环节出了问题。
3.3 为什么预约状态不能只有"预约成功"一个字段
预约记录表上的状态字段我设置了六个值:PENDING(待签到)、CHECKED_IN(已签到)、NO_SHOW(爽约)、CANCELLED(用户取消)、COMPLETED(正常完成)、EXPIRED(超时未签到自动取消)。
为什么要单独区分 NO_SHOW 和 EXPIRED?因为一个是用户主动违约(预约了没来),一个是系统自动处理(超时释放),虽然结果都是座位被释放,但信用分扣减的规则可能不同。而且从运营角度看,图书馆需要统计真实的违约率,如果这两种情况混在一起,数据口径就乱了。
这里我还做了个细节处理:预约记录的日期和时间段逻辑上需要校验重叠。一个用户不能同时预约同一时段的两个座位,一个座位在同一个时间段也不能被两个用户预约。这个约束不能只靠应用程序判断,数据库层面我用了一个联合唯一索引配合时间条件来兜底,防止并发请求穿透。
4. 并发预约与座位扣减:缓存加分布式锁,彻底解决"超卖"
做这个系统时我花时间最多的不是CRUD,而是"多人同时抢同一个座位"的处理。高校图书馆在考试周那种抢座场景下,并发量绝对不低,如果代码里用的是 if(seat.getStatus()==AVAILABLE){ seat.setStatus(RESERVED); } 这种先查后改的写法,线上一定会出问题。
4.1 从单一数据库状态更新到 Redis 预扣减
最朴素的做法是直接在数据库层面做原子更新:UPDATE seat SET status='RESERVED' WHERE id=? AND status='AVAILABLE',利用数据库行锁和更新影响行数来判断是否抢座成功。这种做法在并发量较低时完全够用,代码也清晰。它的问题在于:每次预约都要走数据库更新,座位数量一大、预约请求一多,数据库压力会明显上升;另一个问题是无法方便地做"可用座位数"的实时展示。
我最终的方案是引入 Redis 做两级校验。第一层:预约请求进来后,先到 Redis 里执行一个 Lua 脚本,原子性地检查并扣减该座位在目标时间段的可用余量;第二层:扣减成功后,异步把预约记录落库,同时更新数据库中的座位状态。这里用 Lua 脚本而不是先 get 再 set,是为了避免检查与扣减之间出现并发间隙,Lua 在 Redis 里执行是原子的。
Lua 脚本核心逻辑就是:用座位ID加时间段作为 key,记录可用余量,如果余量大于0就减1并返回成功,否则返回失败。这个思路说白了就是把"快判断"放到缓存里做,"慢确认"放异步任务做,客户端感知到的响应速度会快很多。数据库落库失败时,对应的回调任务会把 Redis 里扣掉的数量加回去,保证最终一致。
4.2 分布式锁的粒度与选型
除了缓存扣减,还需要一把分布式锁来锁住同一个座位的并发预约请求。很多人一上来就是 Redisson 的 tryLock,但容易忽略锁的粒度问题。你不能对整个座位表加锁,那等于把所有用户的预约请求串行化了。正确做法是针对"座位ID + 时间段"这个维度生成锁 key,这样不同座位的预约完全互不干扰,同一个座位同一时间段的预约才会排队。
选择 Redisson 而不是自己用 setnx 写锁,原因很简单:Redisson 的看门狗机制能自动续期,防止业务执行时间过长导致锁过期;它底层封装了可重入特性;而且 Redisson 锁在 Redis 主从切换场景下做了很多兼容处理,自己写很容易掉坑里。
不过分布式锁只解决"互斥"问题,不解决"幂等"问题。同一个用户重复提交预约请求,第一次成功了,第二次直接返回"已预约"即可,不需要再走一遍扣减流程。所以我在接口入口处还做了一个幂等校验:查 reservation 表中是否存在 user_id + date + 时间段状态为 PENDING 的记录,存在就直接返回提醒。
4.3 定时任务如何安全释放超时未签到的座位
预约成功后用户需要在规定时间内到馆签到,比如约定开放后30分钟内必须签到,否则座位自动释放。这个功能通常用定时任务扫描实现,推荐使用 Spring 自带的 @Scheduled 注解加上支持分布式部署的 ShedLock 配合使用,避免多实例部署时同一个任务被重复执行。
这里有一个边界情况必须处理:定时任务执行瞬间,用户正在签到,两者之间怎么保证不冲突? 我的处理方式是签到方法里会先做一次状态校验,只有当座位状态是 RESERVED 且预约记录状态是 PENDING 时才能签到成功。如果定时任务已经把状态改成 AVAILABLE 了,签到就会失败并给出超时提醒。这个钩子必须加在事务边界内,否则高并发下依然可能穿透。
释放流程还有一个细节:超时释放时要在 check_in_record 表里补一条 NO_SHOW 记录,同时触发信用分扣减逻辑,这一步我放到释放任务里串行执行,而不是异步丢到消息队列里。因为量不大,串行保证可追溯性,日志排错也方便。
5. 签到、暂离与信用分机制:预约系统的灵魂在"履约"
座位预约系统做得再好,如果用户预约了却不来,或者来了坐一会儿就离开两小时,系统依然形同虚设。所以我在设计的时候,把履约机制的权重看得比选座流程还高。
5.1 签到放行与签退释放的规则设计
签到有两种常见形态:一种是线上签到,用户到了图书馆后在手机上点击"签到",系统记录签到时间并更新座位状态;另一种是线下扫码签到,每个座位贴上二维码,用户扫码后由管理员在后台确认。毕设项目做线上签到就足够了,但最好预留一个扫码签到的接口,因为答辩时可以说明你考虑了线下场景的扩展。
签退逻辑我采用"手动签退为主,超时自动回收为辅"。用户离开时主动点击签退,系统立即释放座位,并计算实际使用时长。如果用户忘了签退,座位会一直显示 OCCUPIED,下一位用户无法预约,那就会出现"人走了座位却被锁死"的情况。因此我在座位状态里加了一个 "暂离" 状态:用户暂时离开时可以选择暂离,座位保留15分钟(可配置),15分钟内未回来系统自动释放。
5.2 信用分机制的完整闭环
信用分是履约机制能够长期运转的保障,也是这个系统区别于普通Demo的最大亮点。我设计的信用分规则是:
| 事件 | 分值变动 | 说明 |
|---|---|---|
| 初始分值 | 100 | 新用户默认 |
| 正常签到签退 | +0 | 不奖励,防止刷分 |
| 预约后超时未到 | -20 | 超过30分钟仍未签到 |
| 暂离超时未归 | -10 | 触发自动释放 |
| 管理员人工标记违规 | -30起 | 如恶意占用、损坏公物 |
| 连续7天无违规 | +5 | 每周任务自动扫描加分 |
信用分低于80分时,限制未来3天内的预约资格;低于60分时,限制本周内预约。这一层业务规则不复杂,但需要规划清楚。我写了一个 UserScoreService,所有扣分操作统一走这个类,内部用事务保证扣分和记录同时成功。
5.3 续约与换座的边界处理
用户在同一时间段内想续约一个座位,或者想去另一个区域换座,这些操作都有坑。
续约的规则我限定为:当前时间段结束后紧邻的下一个时间段如果该座位仍空闲,允许用户在签退时发起续约,系统直接创建一条新预约记录,状态为 PENDING。这里要注意同一座位同时间段不能被两个用户预约的约束,即使发起续约的用户就是当前使用者也不能绕过校验。
换座的处理更麻烦一点。我允许用户在"已签到"状态下更换座位,但有一个条件:新座位当前时间段必须空闲,且旧座位要立即释放。换座过程中间状态我用了一个 seat_change_log 表记录日志,方便管理员追溯。如果新座位已经被别人预约了,直接提示换座失败。
6. 关键功能模块的代码落地与实测效果
前面讲的都是设计层面,这一节我把几个核心模块的关键代码和实测效果写出来,方便你直接照着落地。
6.1 预约核心服务的基本骨架
预约创建的代码骨架大概是这样的:
java复制@Service
public class ReservationService {
@Autowired
private ReservationMapper reservationMapper;
@Autowired
private SeatMapper seatMapper;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private RedissonClient redissonClient;
public Result createReservation(ReservationDTO dto, Long userId) {
// 1. 幂等校验:同一时间段是否已有预约
if (hasPendingReservation(userId, dto.getDate(), dto.getStartTime(), dto.getEndTime())) {
return Result.error("您在该时段已有预约,请勿重复预约");
}
// 2. 构造锁key,加分布式锁
String lockKey = "SEAT_LOCK:" + dto.getSeatId() + ":" + dto.getDate() + ":" + dto.getStartTime();
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
locked = lock.tryLock(2, 10, TimeUnit.SECONDS);
if (!locked) {
return Result.error("当前预约人数过多,请稍后重试");
}
// 3. 校验座位状态
Seat seat = seatMapper.selectById(dto.getSeatId());
if (seat == null || !SeatStatus.AVAILABLE.equals(seat.getStatus())) {
return Result.error("座位当前不可预约");
}
// 4. 再查一次预约记录,防止并发穿透
if (reservationMapper.countBySeatAndTimeRange(dto.getSeatId(), dto.getDate(),
dto.getStartTime(), dto.getEndTime()) > 0) {
return Result.error("该座位此时间段已被预约");
}
// 5. 创建预约记录,更新座位状态
Reservation reservation = new Reservation();
reservation.setUserId(userId);
reservation.setSeatId(dto.getSeatId());
reservation.setDate(dto.getDate());
reservation.setStartTime(dto.getStartTime());
reservation.setEndTime(dto.getEndTime());
reservation.setStatus(ReservationStatus.PENDING);
reservationMapper.insert(reservation);
seat.setStatus(SeatStatus.RESERVED);
seatMapper.updateById(seat);
return Result.success(reservation);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Result.error("预约流程被中断,请重试");
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
这段代码里有几个细节值得说。tryLock 的第一参数2秒是等待时间,第二参数10秒是锁的自动释放时间。如果业务执行时间可能超过10秒,务必调整这个参数或者开启看门狗模式,否则锁提前释放后并发问题会重新出现。另外 lock.isHeldByCurrentThread() 这个判断必须加,防止在锁已被其他线程持有或已经过期的情况下误删别人的锁。
6.2 Redis Lua 脚本做可用余量预扣减
操作Redis的Lua脚本如下(这段脚本不是必选项,但加上对你讲并发方案更有利):
lua复制-- KEYS[1]: 座位维度key,例如 seat:count:123:2024-06-01:08:00-12:00
-- ARGV[1]: 当前可用余量上限
local current = redis.call('get', KEYS[1])
if not current then
redis.call('set', KEYS[1], ARGV[1])
current = ARGV[1]
end
if tonumber(current) <= 0 then
return 0
end
redis.call('decr', KEYS[1])
return 1
这个脚本的意义在于把"判断余量是否充足"和"扣减"合并成一个原子操作。为什么不能先 get 再 decr?因为两个操作之间可能有另一个请求插入,先 get 时余量还有1,等 decr 时已经被其他请求扣成0了,依然会产生超卖。脚本执行时 Redis 是单线程模型,整个过程不会被其他命令打断,这一步是并发扣减的关键保障。
可我必须提醒一句:预扣减的 key 需要有过期时间,一般设置成预约时间段结束时间减去当前时间再加五分钟的缓冲区。如果 key 过期了但数据库预约记录还没有进入最终状态,回调任务就要反向补偿,把余量加回来。我在回调任务里做了一个"对账补偿",每五分钟扫描一次预约记录,把数据库里已经取消或超时的记录对应的 Redis 余量加回去。
6.3 MyBatis-Plus 处理统计查询的细节
统计展示这块,我用 MyBatis-Plus 的 LambdaQueryWrapper 写了不少聚合查询。最常用的一个场景是按天统计每个时间段的预约率:
java复制public List<Map<String, Object>> getReservationRate(String date) {
return reservationMapper.selectMaps(new QueryWrapper<Reservation>()
.select("seat_id", "COUNT(*) AS cnt")
.eq("date", date)
.groupBy("seat_id")
.orderByDesc("cnt"));
}
selectMaps 返回的是 List<Map<String, Object>>,字段名直接是数据库列名,外层再用一个 VO 做转换即可。注意这里 COUNT 出来的字段别名如果含大写字母,部分数据库可能返回带引号的别名,转换时要有心理准备。我自己就在 PostgreSQL 上踩过这个坑,cnt 一切正常,但 COUNT(*) AS totalCnt 返回来的 key 变成了 totalcnt,后来统一改成小写别名才解决。
6.4 实测数据与调优效果
在我本地测试环境(8G内存的Mac,Docker跑MySQL和Redis)压过一轮:模拟200个用户同时抢同一时间段的不同座位,总请求量3000次,最终平均响应时间在260ms左右,预约成功与失败的判断全部正确,没有出现同一座位同一时间段被重复预约的情况。数据库连接池用的HikariCP默认配置,没有额外调优。
如果是在真实场景部署,建议把 Redis 的预扣减脚本作为前置校验、把事务里的 UPDATE 作为最终校验,两层都过才算预约成功。靠一层方案全扛,遇到突发流量迟早出问题。
7. 部署与运维的避坑经验:从本地跑通到上线部署有哪些坎
系统写完到部署上线之间,有一堆细节问题网上教程很少提到。我把自己实际部署过程中踩过的坑按类别整理一下,如果你准备把它部署到服务器上,可以少走很多弯路。
7.1 事务失效问题
第一类坑是 Spring 事务失效。@Transactional 注解默认只在抛出 RuntimeException 时回滚,如果你在事务方法里 catch 了异常没抛出,事务是不会回滚的。更隐蔽的是 自身调用 导致事务失效——同一个类里一个方法调用另一个带 @Transactional 的方法时,Spring 代理不生效,事务完全无效。
我的习惯是:事务方法统一放到 Service 实现类中,Controller 只做参数校验和结果封装;Service 内部不自己调用带事务的兄弟方法,需要事务组合的地方另建一个门面类,通过门面类调多个 Service。这样虽然类多了几个,但不会出现"改了代码却不知道事务为什么不生效"的玄学问题。
7.2 前端打包放进 Spring Boot 的坑
Vue 项目 npm run build 生成 dist 目录,把 dist 里的文件复制到 Spring Boot 的 src/main/resources/static 下,这一招在毕设里很常见,打包成一个 jar 直接部署很方便。但有几个坑:
一是 Vue 打包时如果没配置正确的 publicPath,部署到子路径下会出现静态资源404。需要在 vue.config.js 里设置 publicPath: './',让资源相对路径加载。二是前端路由用的 history 模式,刷新后 Spring Boot 需要把未知路径转发到 index.html,需要写一个简单的 Controller 做 forward 处理。三是 jar 包里静态资源带有缓存,更新版本后浏览器还显示旧页面,需要在资源路径上加版本号或者配置缓存策略。
7.3 Nginx 反向代理与 HTTPS 的基本配置
如果你有独立服务器,我更推荐用 Nginx 部署前端静态文件,后端 Spring Boot 单独跑在某个端口,通过 Nginx 反向代理。这种方案的好处是:前端静态文件由 Nginx 直接返回,性能更好;HTTPS 证书可以挂在 Nginx 层,不用 Java 层处理;后端只暴露给 Nginx 访问,安全系数高一些。
一份最简配置大致是这个样子:
nginx复制server {
listen 443 ssl;
server_name your.domain.com;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
location / {
root /var/www/seat-reservation/dist;
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
注意 location /api/ 里的 proxy_pass 末尾是否带 / 会直接影响路径拼接,不带 / 时会把完整 URI 传给后端,带了 / 则会剔除 /api 前缀再拼接。这两个行为不一样,配置前先想清楚自己后端的接口路径里有没有 /api 前缀。我用的是统一前缀方案,所以这里不带 /。
7.4 安全细节:密码存储、参数校验和日志脱敏
最后聊几句安全,别写到毕设阶段就完全忽略。用户密码必须用 BCrypt 加密存储,Spring Security 自带 BCryptPasswordEncoder,不要用 MD5,MD5 撞库成本太低。前端传过来的预约参数必须在后端做时间格式校验和非法字符过滤,不能只靠前端提示。还有日志方面,打印参数时不要把用户的手机号明文打进去,尤其是预约记录、登录日志这些场景,身份证号和手机号要做脱敏处理。
有一次我帮朋友排查线上问题,发现报表接口返回了大量用户手机号,检查之后发现是查询直接 select * 放大了一个完整实体,后来所有敏感字段查询都改成显式列名,避免把不需要的字段带到前端。要多啰嗦一句:凡是前端不需要展示的敏感字段,后端就不要返回,这条设计原则能省掉后患。
8. 系统扩展的几个方向:毕设加分项和真实场景增强
如果你不满足于做一个能跑通的系统,还想在答辩或展示环节多几个亮点,我建议从下面三个方向里挑一两个去做。
8.1 消息通知:预约状态变更多通道触达
预约成功的通知、签到提醒、超时释放的警告,这些场景都适合接入消息通知。最简单的方式是接入邮件,JavaMailSender 封装邮件发送,配合模板引擎生成HTML内容。如果你的项目里已经有微信生态的诉求,也可以考虑接入企业微信或者公众号模板消息,但那个需要注册开发者账号,流程较复杂,毕设阶段不是必须的。
我实际做的是邮件加站内信双通道。站内信就是一张 notice 表,用户登录后在页面右上角小红点查看;邮件是新增预约成功和超时提醒两个关键节点发送。注意邮件发送不能放在主流程里同步执行,否则拖慢接口响应,我用 Spring 的 @Async 注解异步处理,需要启用 @EnableAsync 并配置线程池。
8.2 座位推荐的简单做法
"推荐合适的座位"是一个很讨巧的加分功能。它不需要上机器学习,靠SQL就能做。逻辑是:根据用户的历史预约记录,统计用户最常去的区域、最常用的时间段、对靠窗座位的偏好权重,然后在用户打开选座页时,按"区域偏好 + 空闲状态"的组合排序推荐。
这个过程我完全用 MyBatis-Plus 查询完成,得到一个 List<SeatPreferenceVO>,前端展示时在推荐座位的卡片上打一个"推荐"标签。从技术上来说没有任何复杂度,但展示时你可以借题发挥,说这是基于用户行为的个性化推荐初版,后续可以引入协同过滤算法——答辩时这段话比单纯的功能罗列加分多了。
8.3 拼座与小组学习模式
真实图书馆场景还有一个高阶需求:小组学习。比如几个同学想预约相邻的几个座位一起自习。目前市面上多数预约系统都只支持单人预约,我做的时候设计了一个"锁定座位组"的概念。管理员预先配置某些座位属于一个组,组内的若干座位必须一起预约。实现上是在 seat 表加一个 group_id 字段,创建预约时同时校验组内所有座位的可用状态,只要有任何一个座位被占用,整组预约失败。
这个功能对并发的要求更高,因为要同时对多个座位加锁。我当时用 Redisson 的 multiLock 把组内所有座位的锁组合在一起加锁,避免锁多个 key 时出现部分成功部分失败的问题。这个设计与"购物车批量下单扣库存"是同一个模式,讲通了之后面试官会觉得你确实理解分布式锁的应用场景,而不只是背了几个API名字。
9. 部署环境的最低配置与最终打包建议
如果你准备把这个系统部署到一台云服务器上,我给出一个我在用的最低配置参考,不是官方推荐,是我实际跑过的组合:
| 组件 | 推荐配置 | 说明 |
|---|---|---|
| 服务器 | 2核4G | 价格最低档云服务器,跑得动 |
| JDK | JDK 17 | Spring Boot 3.x 要求 |
| MySQL | 8.0 | 字符集统一 utf8mb4 |
| Redis | 6.x或7.x | 单机足够 |
| Nginx | 最新稳定版 | 静态资源加反向代理 |
| 构建工具 | Maven 3.8+ | 打包 jar |
Spring Boot 3.x 相比 2.x,最大的变化是 javax.* 包全部换成了 jakarta.*,网上很多旧教程的代码直接复制过来是编译不过的。如果你从零开始建项目,直接用 Spring Initializr 生成 Spring Boot 3.x 骨架,不要用旧项目改造,省去一堆包名替换的麻烦。
后台管理界面我用了若依框架的思路来组织菜单和权限,但没有直接引入完整框架,因为完整若依初始化的代码量很大,作为毕设展示容易显得"什么都用了但说不清自己写了什么"。建议是:核心业务代码自己写,后台模板用现成的前端开源项目,比如 Vue 2 的若依或 Vue 3 的 vben admin,这样前端工作量能大幅压缩,后端逻辑保持自己可控。
最后关于打包:线上环境用 mvn clean package -DskipTests 打成 fat jar,直接 java -jar 启动。配置文件用 application-prod.yml 区分环境,数据库和 Redis 地址通过环境变量注入,不要把服务器IP和密码硬编码进仓库。MySQL 建库时指定 utf8mb4,否则中文可能出现乱码,这个坑几乎每年都有朋友踩一遍。
这套系统从前端选座界面到后端并发控制,从管理员数据统计到用户信用分治理,整体工程量和业务深度已经超过"应付毕设"的水平。如果你能把它从头到尾自己写过一遍,Spring Boot 项目构建、MyBatis-Plus 数据操作、Redis 分布式锁、定时任务调度这些高频面试技能基本就都实战过了。考试周高峰期那种"所有人同时抢座"的场景一压,系统稳不稳、设计行不行,一目了然。
