游泳馆预约管理系统这种题目,在计算机毕设里属于典型的“看着简单,做起来全是细节”的类型。很多同学拿到题目第一反应是“不就是个增删改查吗”,真动手才发现,光一个“同一泳道同一时段不能被两个人同时约上”就能折腾掉好几天。我手上这套Spring Boot实现的游泳馆预约管理系统(项目编号39899)就是这么个情况:功能模块完整,代码结构清晰,但如果你不懂背后的设计逻辑,直接照搬代码,换个场地、改个需求,照样会翻车。这篇文章我会把整个项目的需求拆解、表结构设计、核心功能实现、踩坑记录完整过一遍,尽量让零基础的人能看懂,让有基础的人能直接拿去用。
先说这个项目到底是干什么的:游泳馆运营方需要在后台维护场地(比如标准泳池、训练池、儿童池)、开场次(每天分早中晚几个时段)、定价格;用户在前台注册登录后,能看到某一天某个场地还有没有名额,选中时间段下单预约,到馆后由管理员核销签到。整套流程里涉及的核心问题有三个,一个是怎么设计表结构才能避免时间冲突,二是怎么在高并发下保证一个场次不会被超卖,三是前后端分离场景下权限控制怎么做才不漏风。全文围绕这三个问题展开,代码层面的关键位置我会直接贴出来讲。
- 适用人群
- 计算机相关专业,正在为毕设选题发愁的学生
- 想快速上手Spring Boot实战、但不想看教程视频硬磨的人
- 已经写完基础增删改查,想补充业务复杂度的开发者
- 项目形态
- 后端:Spring Boot + MyBatis Plus + MySQL
- 前端:Vue / Thymeleaf 二选一,取决于是否前后端分离
- 部署形态:单体应用为主,预约占内存和配置成本低,适合毕设演示
1. 项目核心需求拆解:游泳馆预约系统到底“难”在哪
1.1 表面上在做增删改查,实际上在做资源调度
很多同学拿到题目,第一件事就是建五张表:用户表、场地表、场次表、订单表、公告表,然后开始写接口。真按这个思路做下去,会发现整个系统“能跑,但很假”。为什么?因为游泳馆预约的本质不是“用户下了一个订单”,而是“在一个有限资源池子里做时间片分配”。场地数量有限,每个场地每天的可用时段有限,每个时段能容纳的人数有限,三个“有限”叠加在一起,订单表就不只是记录购买行为,而是要承担“资源占用”的职责。
我做需求拆解的时候,习惯把角色捋一遍,再列业务流程,最后才落到表结构。这个系统有三类角色,不是两类:
- 普通用户:注册、登录、浏览场次、提交预约、取消预约、查看个人预约记录
- 场馆管理员:维护场地信息、配置每日场次、查看预约列表、执行签到核销、管理公告
- 系统管理员(超级管理员):管理员账号管理、数据统计、基础参数配置
三类角色对应的核心业务动作差异很大。用户关心的是“今天下午三点有没有空位”,管理员关心的是“三点这场来了多少人、谁还没签到”,超级管理员关心的是“整个月哪个时段的预约率最高”。如果你的表结构设计得不够灵活,后面做统计的时候会发现查个数据要join五张表,效率极低。
1.2 几个必须提前想清楚的业务规则
预约类系统的难点从来不是CRUD,而是业务规则。游泳馆预约有几条规则必须落地在代码里,否则答辩时会被问住:
- 同一场地、同一日期、同一时段,一个用户只能有一条有效预约(防止恶意刷单)
- 同一场地的同一时段,预约人数不能超过该场地容量(防超卖的核心)
- 用户只能取消“未签到”状态的预约,已经签到的不允许取消
- 超过开场时间未签到,系统应自动标记为爽约,爽约次数影响后续预约资格
- 价格可以按场次类型区分(普通时段/高峰时段),也可以在后台手动调整
这几条规则听起来简单,每一条落到代码里都有对应的校验逻辑。第一条需要联合唯一索引或者先查后插,第二条需要加锁或者版本号控制,第三条是状态机流转,第四条需要定时任务兜底,第五条是字段设计问题。后面我会逐一展开。
1.3 为什么选Spring Boot:省心,但省心不等于可以不懂原理
毕设选技术栈,大多数人第一反应是Spring Boot,这没毛病,但我建议还是搞清楚它省心在哪。相比传统的SSH(Struts+Spring+Hibernate)或者纯Servlet写法,Spring Boot最大的价值是“自动配置+起步依赖”。你引入spring-boot-starter-web,内嵌Tomcat就有了;引入mybatis-plus-boot-starter,数据访问层的模板代码就少了大半。对毕设来说,这意味着你可以在两周内把主要精力放在业务逻辑上,而不是配置XML。
但省心不等于可以不懂原理。有几个底层机制是绕不开的,比如Spring MVC的请求处理流程(DispatcherServlet怎么分发到Controller)、MyBatis的Mapper代理机制、Spring事务的传播行为。答辩的时候老师很容易从“你项目里用了事务吗”切入,你要是只会说“加个@Transactional”,大概率会被追问“那事务失效的场景有哪些”,后面我会专门把这些问题列出来。
2. 技术选型解析:为什么是Spring Boot + MyBatis Plus + MySQL
2.1 技术栈对比,不搞“信仰之争”,只看适不适合
游泳馆预约管理系统这种业务,属于典型的“中小型Web应用”:并发量不会特别大(一个游泳馆的峰值预约量撑死几百QPS)、数据量不会特别大(一天几千条订单)、部署环境是学生自己的笔记本或者一台云服务器(内存2G4G居多)。这种场景下的技术选型,核心诉求是开发效率高、维护成本低、排错容易。
我对比过几条常见路线:
| 技术路线 | 开发效率 | 学习曲线 | 排错难度 | 毕设适配度 |
|---|---|---|---|---|
| Spring Boot + MyBatis Plus + MySQL | 高 | 平缓 | 低 | 很高,资料多,模板多 |
| Spring Boot + JPA/Hibernate | 较高 | 中等 | 中等,SQL不可见,问题隐蔽 | 中高,但外键和复杂查询让人头疼 |
| Django + DRF + SQLite | 高 | 平缓 | 低 | 高,但不是Java方向 |
| SSM(Spring+SpringMVC+MyBatis) | 低 | 较陡 | 中 | 过时,XML配置量大 |
| 纯Servlet + JDBC | 极低 | 平缓 | 高 | 不推荐,写起来折磨 |
最终定Spring Boot + MyBatis Plus不是因为它“流行”,而是因为它在“减少样板代码”和“SQL可控性”之间取得了最好的平衡。MyBatis Plus的BaseMapper帮你把单表CRUD全干掉,但你要写复杂多表查询的时候,又可以自己写XML,SQL是你自己的,出了性能问题你能直接看。JPA那种“自动生成SQL”的黑盒,在这种带资源冲突校验的业务里简直是灾难——你都不知道它什么时候帮你做了乐观锁校验。
2.2 数据库为什么选MySQL而不是PostgreSQL或SQLite
这个选择题其实没什么悬念。SQLite只适合单机Demo,PostgreSQL功能确实更强,但考虑到毕设环境是Windows笔记本或Linux小内存服务器,MySQL的安装、运维、可视化工具(Navicat、DataGrip)生态都更成熟,出了问题一搜就有答案。而且游泳馆预约系统用到的都是基础SQL能力:索引、联合唯一约束、事务、简单的聚合统计。这些MySQL完全够用,还不需要额外调优。
2.3 前端部分:模板渲染还是前后端分离?
这是很多毕设团队会纠结半天的点。我的建议很直接:如果你只有自己一个人写,时间又紧,优先选服务端渲染(Thymeleaf)。理由很实在——前后端分离意味着你至少需要维护两套工程、处理跨域、联调接口、部署时还要解决静态资源路径问题。这个东西不是不能做,而是它会让你的工作量至少增加40%,而答辩时老师并不会因为你用了Vue而多给分。
如果你已经会用Vue,或者组里有人负责前端,那用前后端分离我也支持。但要注意,分离模式下你需要额外处理几个事情:
- 跨域配置(CORS),Spring Boot里用@CrossOrigin或者WebMvcConfigurer统一处理
- Token认证(JWT或者Session,推荐JWT,状态无关,适合前后端分离)
- 接口返回格式统一(约定code、message、data三层结构)
- 前端的路由守卫,未登录跳登录页
2.4 登录鉴权的两种方案对比
游泳馆预约系统涉及用户和管理员两类人群,权限隔离是必须的。我在代码里提供的是JWT(JSON Web Token)方案。对比传统的Session方案,JWT的好处是服务端不保存会话状态,适合前后端分离;缺点是Token一旦签发,在过期之前无法主动失效,所以我会把用户角色信息写进Token的claims里,然后写一个拦截器统一解析。
Session方案则是Java Web经典做法,实现简单,但跨域和分布式扩展时比较麻烦。两者各有适用场景,下面用表格对比关键差异:
| 对比项 | Session + Cookie | JWT + Header |
|---|---|---|
| 服务端存储 | 需要,占用内存 | 不需要,无状态 |
| 跨域支持 | 需要配置CORS配合Cookie | 天然支持 |
| 主动踢人下线 | 可以删除Session | 较难,需要额外黑名单机制 |
| 移动端/小程序适配 | 差 | 好 |
| 毕设实现复杂度 | 低 | 中 |
我实际选择的是JWT,因为项目本身就是前后端分离的形态,而且JWT的实现代码也就几十行,核心是靠一个拦截器校验请求头里的Authorization字段,解析出 userId 和 role,存到 ThreadLocal 或者直接放请求参数里,后续逻辑就能用了。
3. 数据库设计与核心表结构详解
3.1 五张核心表,外加一张扩展表,为什么这么拆
数据库设计是这类预约系统的灵魂,后面所有功能都长在表结构上。我拆分的时候遵循两个原则:一是避免把业务状态硬编码成字段的“魔法值”,能用字典表的用字典表;二是把“场地”和“场次”分开,预留扩展性。
完整的表清单如下:
- t_user:用户表,包括普通用户和管理员,用role字段区分
- t_venue:场地表,存储游泳馆内的物理泳池(标准池、训练池、儿童池)
- t_session:场次表,存储某场地某日期的时段安排
- t_order:预约订单表,存储用户和场次的绑定关系
- t_announcement:公告信息表
- t_dict:字典表,用于存状态枚举等基础数据(扩展用)
3.2 核心表设计之一:场地表和场次表的关系
场地表和场次表的关系是“一对多”:一个场地(venue)在一天内可以有多个场次(session)。设计场次表的时候,有一个点特别关键——不要把“时段”设计成单独的时段时间表,而是直接把“开始时间”和“结束时间”作为字段存进场次表。原因很简单:游泳馆的运营方经常调整时段,比如冬天下午场是14:00到16:00,夏天改成14:00到17:00,如果时段是固定表,改一次就要动结构,存成字段则只需改一行数据。
场次表关键字段如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| venue_id | bigint | 关联场地ID |
| session_date | date | 场次日期 |
| start_time | time | 开始时间 |
| end_time | time | 结束时间 |
| total_slots | int | 总容量(名额数) |
| booked_slots | int | 已预约名额数 |
| price | decimal | 场次价格 |
| status | tinyint | 场次状态:1正常 0停用 |
这里booked_slots字段就是防超卖的关键。每次新增预约时,我给这个字段做“原子化更新”,类似下面的SQL:
UPDATE t_session SET booked_slots = booked_slots + 1 WHERE id = #{sessionId} AND booked_slots < total_slots这条SQL走的是数据库行锁,同一时刻只有一个事务能更新这条记录,第二个事务会被阻塞,等前一个事务提交后再执行,此时booked_slots已经变了,如果等于total_slots就更新不到数据,影响行数为0,代码就能据此判断“预约失败,名额已满”。这就是防超卖的核心机制,比“先select再update”的Java代码方式靠谱太多。
3.3 核心表设计之二:预约订单表的状态机
订单表是用户与场次发生关系的纽带,我把状态字段设计成tinyint,配合字典表解释含义:
- 0:已取消(用户主动取消,名额释放)
- 1:已预约(付款或确认预约成功,待签到)
- 2:已签到(到馆核销完成)
- 3:已爽约(未在开场前签到,系统自动标记)
- 4:已完成(场次结束,正常结束)
状态机之间的流转,我全部封装在Service层,不允许在Controller里直接改状态。比如取消预约的逻辑,必须先判断当前状态是1还是0,如果是0就直接提示“订单已取消”,如果是1才能做取消并释放名额。释放名额要跟“更新场次已预约数”放在同一个事务里,否则会出现订单取消了、名额没释放的脏数据。
t_user表和t_order表之间是“一对多”,t_session表和t_order表之间也是“一对多”。用户查“我的订单”时,联表查询拿场次信息和场地信息,一次查询就能展示出“某天某个时段在哪个泳池,价格多少,状态如何”,不用来回查三次数据库。
3.4 联合唯一索引:一个用户不能重复约同一个场次
除了防超卖,另一个容易忽略的问题是“同一用户重复预约同一个场次”。如果不在数据库层面兜底,仅靠Java代码里select查一下再insert,高并发下两个请求同时通过检查,就会插入两条重复订单。
解决办法是在订单表上加联合唯一索引:
ALTER TABLE t_order ADD UNIQUE KEY uk_user_session (user_id, session_id);这样即使代码层校验漏了,数据库也会在插入时直接报DuplicateKeyException,再在Service层捕获这个异常并转成“您已预约该场次”的提示即可。很多自学项目省掉这一步,最后数据一多就出现脏数据,排查起来非常痛苦。
3.5 索引设计总结
预约类系统的查询模式很固定,几乎全是“按用户查订单”“按日期场次查可预约列表”,索引不需要建太多,建多了反而拖慢插入速度。我最终只用了几组索引:
- t_session:venue_id + session_date 联合索引
- t_order:user_id 单列索引,session_id 单列索引
- t_order:user_id + session_id 联合唯一索引(也是唯一性兜底)
- t_user:username 唯一索引
4. 核心功能模块实现实录
4.1 模块一:用户登录注册与JWT鉴权
登录这块我单独讲一下,因为它是整个系统的“门禁”,写不好后面所有接口都是裸奔的。我用的方案是:用户拿用户名密码请求登录接口,后端校验通过后生成JWT返回给前端;前端把Token存在localStorage,每次请求在Authorization头里带上这个Token;后端写一个拦截器(HandlerInterceptor),对所有非放行接口做Token解析。
Token里只放三个信息:userId、username、role。不能放密码,也不能放太多业务数据,否则Token会变得很长。生成逻辑大概长这样:
String token = Jwts.builder() .setSubject(user.getUsername()) .claim("userId", user.getId()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();拦截器解析Token后,把userId和role放进ThreadLocal。ThreadLocal是每个线程独立的变量副本,请求结束后要记得remove,否则线程池复用线程时会读到上一个请求的用户信息,这是很经典的坑。
权限控制上,我只写了两个角色,所以用了最简单的方案:在拦截器里判断请求路径前缀,以 /admin/ 开头的接口必须role为1(管理员),否则返回403。角色与路径的映射维护在配置里,以后要加角色,只需改配置和拦截器判断逻辑。
4.2 模块二:场次查询列表,让用户挑得上
用户端首页的核心功能是“查场次”。我设计的查询接口是:
POST /api/session/list 入参:sessionDate(日期)、venueId(可选,场地ID)、pageNum、pageSize 返回:场次列表,包括场地名、时间段、总名额、剩余名额、价格为了不让用户每次都看一堆已满的场次,查询SQL里做了过滤:booked_slots < total_slots 的才展示,同时status=1。剩余名额字段用 total_slots - booked_slots 计算得出。这里有一个细节:如果你把剩余名额存成独立字段,每次预约和取消都要更新它,很容易和booked_slots不一致;所以我只维护booked_slots,剩余名额查出来动态算,保证数据只有一个数据源。
4.3 模块三:预约提交,事务与并发的关键一搏
提交预约是整个系统最核心的接口,没有之一。完整流程包括:
- 校验用户Token,拿到userId
- 校验场次状态status=1
- 校验当前日期是否晚于场次日期(不能预约过去的时间)
- 校验用户是否已预约该场次(联合唯一索引兜底)
- 原子更新t_session的booked_slots(防超卖核心)
- 插入t_order订单记录,状态为1(已预约)
- 以上全部包在同一个事务里,任一失败则整体回滚
第5步和第6步的顺序不能反。如果先插入订单再更新场次名额,可能出现订单插入成功但名额更新失败,导致用户有订单、场地没锁名额,后续别人还能约,直接超卖。Spring默认事务隔离级别是可重复读(REPEATABLE READ),如果两个事务同时读同一行场次记录,然后都去更新,会出现更新等待,但数据库行锁会保证最终只有一个成功。
代码里的核心逻辑就是用MyBatis Plus的UpdateWrapper实现原子更新:
boolean updated = sessionMapper.update( null, new UpdateWrapper<Session>() .eq("id", sessionId) .eq("status", 1) .lt("booked_slots", totalSlots) .setSql("booked_slots = booked_slots + 1") ) == 1; if (!updated) { throw new BizException("该场次名额已满或已停用"); }这比自己先select出来、加一、再update要安全得多,因为“先查再改”之间有时间窗口,两个并发请求可能同时查到booked_slots=9,然后同时更新成10,而total_slots只有10,就超卖了。用原子SQL直接把这个过程压缩成一步,行锁天然保证只有一个请求能更新成功。
4.4 模块四:取消预约,别忘了解锁名额
取消预约的代码逻辑相对简单,但有一个隐藏的重要点:释放名额的原子更新,必须与更新订单状态放在同一个事务里。且加一个前置条件,订单只能是“已预约”状态,才能取消。如果订单已经签到,就不能取消;如果场次已经结束,也不能取消。
对应操作是先把订单状态改成0(已取消),再对t_session做booked_slots - 1 的原子更新。如果只改订单状态不释放名额,预约系统运行几天后你会发现场次明明没人约,名额却都满了,这就是典型的“幽灵占用”。
4.5 模块五:后台管理功能
后台管理功能主要包括场地管理、场次管理、订单管理、公告管理、数据统计。
其中场地管理是相对独立的CRUD,注意删除场地时要检查是否已经有场次关联。场次管理要求管理员可以批量生成某段时间的场次,比如“7月1日到7月10日每天下午14:00-16:00”,这里我用了一个循环插入的逻辑,代码不复杂,但要注意别在循环里逐条提交事务,会慢。正确做法是一次性收集数据后批量插入。
订单管理是查看、筛选、签到核销。签到用“核销码”逻辑,每个订单生成一个随机的6位核销码,用户到馆后报码,管理员输入核销码完成签到。这样比单纯报手机号更清晰,也贴合现实场景。
数据统计我用了几条聚合SQL,比如统计某月每日预约量:
SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*) AS cnt FROM t_order WHERE status IN (1, 2, 4) AND create_time BETWEEN #{start} AND #{end} GROUP BY day ORDER BY day图表展示可选ECharts,后端只负责出数据,前端负责画图。
4.6 模块六:定时任务处理爽约订单
爽约检测是预约系统里容易忽略的一环。游泳馆的规则是开场时间到了还没签到,订单自动标记为爽约,爽约次数超过3次限制预约。这个功能我用Spring Schedule实现,写一个定时任务,每分钟扫描一次:
UPDATE t_order SET status = 3 WHERE status = 1 AND session_id IN ( SELECT id FROM t_session WHERE CONCAT(session_date, ' ', start_time) < NOW() )注意这里的子查询不能直接更新同一张表的子查询问题,实际实现时可以先查出过期sessionId集合,再去更新订单。定时任务加上@Scheduled(cron = "0 0/5 * * * ?")即可。这个功能看似简单,但答辩时如果能主动讲出来,会加分不少,因为它说明你考虑了业务的完整闭环,而不只是做CRUD。
5. 实操过程中踩过的坑与排查技巧实录
5.1 坑一:LocalDate和LocalDateTime参数接收报400
用Spring Boot接收前端传来的日期参数,最常见的问题是前端传的是字符串“2025-07-01”,但后端Controller用LocalDate接收,会直接报400 Bad Request,错误信息是Failed to convert property value of type 'java.lang.String' to required type 'java.time.LocalDate'。
解决办法有两种。第一种是给每个Controller方法参数加@DateTimeFormat(pattern = "yyyy-MM-dd"),麻烦但明确。第二种是全局配置,在配置类里加入:
@Bean public Converter<String, LocalDate> localDateConverter() { return new Converter<String, LocalDate>() { @Override public LocalDate convert(String source) { return LocalDate.parse(source, DateTimeFormatter.ofPattern("yyyy-MM-dd")); } }; }我实际用的是后者,因为项目里有多个接口要传日期,全局配置一劳永逸。另外,用JSON传LocalDate给前端时,默认序列化格式可能是数组格式,需要在application.yml里配一下:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+85.2 坑二:MyBatis Plus的Mapper接口扫描不到
这是新手最容易撞的墙。Mapper接口写了,XML文件也建了,启动项目就报Invalid bound statement (not found)。大部分原因是Mapper接口没被Spring扫描到。
解决办法是确保启动类上有@MapperScan("com.xxx.mapper"),或者每个Mapper接口上加@Mapper注解。两个都加也没问题,但不要都不加。还有一个小坑:如果用了XML文件,确保application.yml里配置了mapper-locations:
mybatis-plus: mapper-locations: classpath*:mapper/*.xml而且XML文件目录要放在resources/mapper下面,不是放在java目录里。如果XML文件的namespace写错,也会报类似的错。
5.3 坑三:并发测试发现超卖,原因竟在事务注解
有一个现象很迷惑人:单线程测试没问题,用JMeter并发20个请求预约同一天最后一个名额,结果超卖了。问题出在代码里事务注解加错了方法。如果只给Service实现类的方法加了@Transactional,但该方法内部调用了同一个类里的另一个方法,事务会失效。Spring的事务是基于AOP代理的,同类内部调用不会走代理,注解失效。
解决办法之一是把需要事务的方法拆到不同的Service类里互相调用,或者自己注入自己(Self-injection),或者在同一个方法内完成所有数据库操作。我更推荐后者,简单直白:把“校验+更新名额+插入订单”全部写在同一个@Service方法里,加上@Transactional,声明为REQUIRED传播行为,就能保证要么全部成功要么全部回滚。
5.4 坑四:JWT拦截器放行了不该放行的路径
很多人写拦截器的时候,放行路径配置写得太粗,导致未登录用户也能访问预约接口。常见的错误是只放行了/login和/register,然后忘了接口路径前缀是/api/,后续接口全部被拦截,登录状态一直存不进去。
我的做法是明确区分三类路径:
- 完全放行:/api/auth/login、/api/auth/register、/api/session/list(浏览场次不需要登录)
- 仅需登录:/api/order/**(预约、取消、我的订单)
- 需要管理员:/admin/**(管理后台所有功能)
拦截器实现里判断request.getRequestURI(),先判断是否在放行列表,再解析Token,再判断角色权限。漏掉任何一环,都会出现“登录了但接口403”或者“没登录也能下单”两个极端。
5.5 坑五:部署到云服务器后的时区问题
本地开发一切正常,部署到云服务器后,订单时间全部早了8小时,或者日期对不上。这是MySQL连接串没指定时区导致的。解决办法是在数据库连接URL里加上serverTimezone=Asia/Shanghai:
spring: datasource: url: jdbc:mysql://localhost:3306/swim_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai还有JVM的时区配置,启动参数可以加-Duser.timezone=Asia/Shanghai,双保险。这类问题一旦遇上了很难排查,因为日志里的时间全是错的,容易让人误以为是代码逻辑问题。
5.6 坑六:前端跨域CORS配置
如果选择前后端分离,本地联调时一定会遇到跨域问题。Spring Boot后端需要允许前端地址访问。我用的是全局CORS配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:5173") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true); } }注意allowedOrigins和allowCredentials不能同时使用通配符“*”,否则预检请求会失败。如果前端用了带Cookie的Session方式,这里很容易踩坑,用JWT的话会省心很多。
6. 从开发到答辩:一套能落地的实战建议
6.1 演示环境的准备比想象中更重要
项目开发完,一定要准备一套干净的演示环境,不要用你自己本地乱糟糟的数据。我的做法是单独跑一套演示数据库,灌入“看起来真实”的数据:
- 场地:标准泳池、训练泳池、儿童戏水池
- 场次:今天、明天、后天各若干时段,包括已满的、可约的、已停用的
- 订单:覆盖所有状态(已预约、已签到、已爽约、已取消),每种状态建两三笔
演示的时候按“用户操作路径”来走,先注册,再浏览场次,挑一个明天的可预约场次下单,然后去后台签到。这套路径提前演练三遍以上,每一步大概点哪里、出什么提示、数据库怎么变,都要烂熟于心。如果演示到一半发现一个接口报错,场面会很难看。
6.2 答辩老师的经典追问清单
我帮不少人模拟过答辩,总结下来,老师最喜欢围着这几个点问:
- 为什么要用Spring Boot而不是SSH?答:自动配置降低开发成本,内嵌容器方便部署,生态成熟。
- 你的预约系统如何避免超卖?答:原子更新SQL + 事务 + 唯一索引三重保障。
- 数据库出现了死锁怎么办?答:这是开放题,可以从减少事务粒度、保持锁定顺序一致、合理设计索引切入。
- 如果预约量翻一百倍,你的系统哪里最先扛不住?答:数据库连接池和场次表的行锁竞争,可以引入Redis预减库存、消息队列削峰。
- 用户密码怎么存储的?答:BCrypt加密,不能明文保存。
回答的时候不需要长篇大论,但每个答案都要有依据,最好能顺手写一两行伪代码或SQL片段佐证。
6.3 这个项目还能怎么扩展,给想冲高分的同学
如果时间和精力允许,有几个扩展方向比“加个导出Excel”更有含金量:
- 引入Redis缓存热门场次列表,减少数据库压力
- 引入RabbitMQ削峰,预约高峰时先写消息队列再异步落库
- 用WebSocket做预约结果实时通知
- 用AOP实现操作日志,记录谁在什么时候改了什么数据
- 把签到环节升级为人脸识别或二维码扫码核销
这些扩展不用全做,选一个跟自己的理解深度最匹配的,把它做扎实,答辩时讲透一个亮点,比堆十个半吊子功能有用。
最后再分享一个小技巧:预约系统这类“资源分配型”项目,最容易出彩的地方就在并发控制和状态流转上。写代码之前先画一张状态机图(不用很正式,纸笔就行),把用户和管理员的每个操作会触发什么状态变化、要不要释放资源、要不要校验前置条件全部标清楚。照着图写代码,比边写边想清晰得多,出错率也低得多。这个习惯不仅对游泳馆预约系统有用,以后换做会议室预约、车位预约、实验室预约,思路都是完全相通的。