站在一个踩过无数毕设坑的人的角度说一句:很多人选“游泳馆预约管理”这种题目,不是因为它简单,而是因为它“刚好卡在舒服的位置”——业务链条完整、技术点覆盖经典、工作量可控,但又不是一眼就能抄完的增删改查。
我手头上正好有一套完整跑通的源码项目,编号39899,是用 Spring Boot 做的游泳馆预约管理系统。这套系统从头到尾改过三轮,从数据库设计到并发防超卖都踩过真实的坑。这篇文章就把整个项目的设计思路、核心代码、实操过程和避坑记录完整拆开,讲的都是可以直接复现的东西。
1. 项目定位与需求拆解
1.1 业务场景与核心痛点
先想清楚一个问题:游泳馆为什么需要预约系统?
游泳馆的容量不是无限的,泳道数量、水深区域划分、同一时段的安全员配比都决定了“某个时段最多能放多少人进来”。高峰期泳道紧张,以前靠前台人工登记,客户到了现场才发现约满了,白跑一趟;淡季时段又没人来,泳道闲置,馆方也看不到数据。
站在游泳馆管理者角度,痛点是三件:一是场次容量不透明,客户体验差;二是预约和收费靠人工,容易出错扯皮;三是没有数据支撑,不知道哪些时段热门、哪些时段该做活动引流。
站在用户角度,痛点是:预约不方便,到场还要排队核验身份,临时想改也改不了。
所以这套系统的核心价值,就是把“场次发布→在线预约→余额扣费→签到核销→统计报表”串成一条完整链路。这也是它适合做毕设的根本原因——不是某个单点功能炫技,而是整个业务闭环能讲清楚、能写进论文、能在答辩时被追问不慌。
1.2 角色权限与模块划分
系统用了三个角色,权限边界必须提前划清楚:
| 角色 | 核心权限 | 典型操作 |
|---|---|---|
| 管理员 | 全部权限 | 场馆管理、场次排期、订单查看、数据统计 |
| 前台员工 | 受限权限 | 查看预约列表、扫码/报号签到核销 |
| 注册用户 | 个人权限 | 场次查询、在线预约、余额充值、取消预约 |
模块划分在代码层面是六个核心包:认证模块处理注册登录,场馆模块维护基础信息,场次模块负责生成每日可预约时段,预约模块处理占位和扣费,账户模块管充值与退款,统计模块产出营收和热门时段数据。
这里想给一个非常实用的建议:动手写代码之前,先把包结构定下来。很多同学一上来就堆文件,后面改需求的时候连自己都找不到对应的类,心态直接崩。按照 controller / service / mapper / entity / common / config 分层包结构,后面加功能会很顺手。
1.3 业务流程总览
一个普通用户的完整预约流程:注册登录 → 查看今天或明天的场次列表 → 选一个有余位的场次 → 系统校验余额和余位 → 扣费 → 生成预约订单 → 到馆后工作人员核销 → 状态变更为已签到。
管理员侧流程:维护场馆信息 → 按日期批量生成场次(比如每天分早、中、晚三场) → 实时查看各场次预约人数和营收 → 遇到紧急情况可下架异常场次。
把流程画出来就会发现,状态流转是整个系统的灵魂。预约单至少要有“待使用 / 已签到 / 已取消 / 已过期”四种状态,场次也要有“可预约 / 约满 / 已结束”等状态。状态设计不清晰,后面的取消、退费、统计全会乱。
2. 技术选型与方案取舍
2.1 为什么是 Spring Boot,而不是 SSM 或微服务
直接说结论:这套系统用 Spring Boot 单体应用是最稳的选择。
前几年毕设主流还是 SSM(Spring + Spring MVC + MyBatis)三件套,配置文件特别多。Spring Boot 把自动配置、内嵌容器、起步依赖这些问题全解决了,一个启动类就能跑起来,开发重心回到业务代码本身。对毕设而言,这意味着你把时间花在预约逻辑、事务控制这些有讲头的地方,而不是花在环境配置上。
为什么不建议上微服务?预约管理系统本质上就是一个单体业务,硬拆成服务注册中心、网关、分布式事务,除了增加答辩时解释不清的复杂度,没有任何收益。技术选型是为了解决问题,不是为了炫技。如果“用微服务解决了什么问题”这个问题答不上来,那就不该用。
版本上,建议用 Spring Boot 2.7.x。毕业设计场景下资料最多、踩坑案例最全,遇到任何问题基本都能搜到现成答案。3.x 虽然也稳定了,但很多教程插件还停留在 2.x 生态,查问题会多花时间。
2.2 持久层方案:MyBatis-Plus 的实用体验
持久层这套项目用的是 MyBatis-Plus,理由非常实在:单表的增删改查样板代码全免了。继承一个 BaseMapper 就有基础 CRUD,复杂条件用 LambdaQueryWrapper 两行写完,不用手写重复 SQL。
查某个场次已预约人数,一行代码的事:
Integer booked = reservationMapper.selectCount( new LambdaQueryWrapper<Reservation>() .eq(Reservation::getSessionId, sessionId) .eq(Reservation::getStatus, 0) );以前写 SSM 这种查询要在 XML 里配 resultMap,现在直接方法调用。当然 MyBatis-Plus 不是万能的,多表关联的复杂统计务必要写自定义 XML SQL,性能和可读性都更好。这套系统里的营收统计报表就是用 XML 自定义 SQL 实现的。
2.3 前端方案:Thymeleaf 还是前后端分离
前端方案是个大分水岭。很多同学一上来选 Vue + Spring Boot 前后端分离,看着潮流,实际工作量翻倍,还要处理跨域、Token 失效、联调等问题,折腾到怀疑人生。
我的明确建议:如果毕设重点在后端业务逻辑,前端老老实实用 Thymeleaf + Bootstrap。服务端渲染的好处是,没有跨域问题、不用纠结接口鉴权怎么传给前端,页面直接用 th:each 循环渲染数据,单个 Spring Boot 应用打成一个 jar 包就能部署,省心得多。
要是确实想体现一点现代感,可以把 Vue 的构建产物放到 static 目录下直接同源部署,也能绕开跨域。但我个人认为对于这套系统,写清楚业务逻辑比前端技术栈好看重要得多。
2.4 环境版本与依赖管理细节
- JDK:1.8 或 11,Spring Boot 2.7 都支持,JDK 8 最稳
- Maven:3.6 以上,换国内镜像源,否则依赖下载慢到崩溃
- MySQL:8.0,字符集用 utf8mb4
- 连接池:默认的 HikariCP 就够
- 工具类:Hutool,处理日期、生成订单号非常省事
- Redis:可选,但建议不引入,理由后面细说
这里有个原则想强调:能用数据库事务解决的,就不要为了简历好看而引入中间件。很多同学喜欢一上来把 Redis、RabbitMQ 全塞进去,但对预约场景,单机事务完全够用,加 Redis 做锁反而引入缓存一致性、缓存穿透一系列新问题。架构越简单,越容易把自己的核心亮点讲清楚。
3. 数据库设计:预约系统的地基
3.1 核心表结构总览
预约系统的表不算多,但每一张都有设计讲究。直接过一遍:
用户表 sys_user:id、username、password、nickname、phone、balance(余额)、role、create_time、update_time。
场馆表 swimming_pool:id、name、address、lane_count、capacity、open_time、close_time、status。
场次表 pool_session:id、pool_id、session_date(日期)、start_time、end_time、max_count(总容量)、booked_count(已预约数)、price、status。
预约表 reservation:id、order_no(订单号)、user_id、session_id、amount、status、create_time、checkin_time(签到时间)。
流水表 account_log:id、user_id、type(1充值、2预约扣款、3退款)、amount、balance_before、balance_after、remark、create_time。
最容易犯的一个设计错误:把场次直接塞在场馆表里,比如给场馆表加一个“时间段”字段。这样设计之后,同一日期多时段的数据处理会非常痛苦。正确做法是把“场次”当成独立的核心实体,一个场馆每天可以有多条场次记录,预约表直接关联场次而不是场馆。
3.2 场次容量与状态字段设计
场次的容量有两种建模方式:按“总人数”算,或者按“泳道 × 时段”算。真实游泳馆里泳道还有功能区分,比如深水训练道和儿童区。但毕设项目不用一上来就建那么细的模型,用 max_count 和 booked_count 两个字段表达即可:booked_count 表示已预约人数,预约成功加 1,取消或过期回退减 1。
场次状态字段我设了这些值:
- 0:未开始
- 1:可预约
- 2:约满,即 booked_count >= max_count
- 3:已结束
这里有一个小优化需要提一下:不要依赖定时任务去改场次状态。查询列表时直接用当前时间和场次结束时间动态判断是否已结束,少一套定时任务,也就少很多状态不同步的 bug。
3.3 余额、充值与流水表设计
用户账上的钱我直接放 balance 字段在用户表里,配合 account_log 表记录每一笔资金变动。你可能会问为什么不单独设计账户表?从业务规模看,余额字段足够,毕设不需要引入复杂的账户体系。但流水表一定要有,这是对账和退款追溯的依据。
流水表的关键设计是 balance_before 和 balance_after 两个字段,记录每一笔资金变动前后的余额。这个设计在答辩时非常加分,因为你能讲清楚“审计”和“可追溯性”——任何一笔资金变动都能从流水里完整还原来龙去脉。
3.4 索引与防并发设计
数据库设计阶段就要把索引考虑进去,而不是等数据多了再补:
- 预约表建 (user_id, session_id) 联合唯一索引,这是防重复预约的关键
- 预约表 status、create_time 建索引,统计签到数和查询历史用得到
- 场次表 (pool_id, session_date) 建索引,查询某天某馆场次列表必备
- 流水表 user_id 建索引,查个人消费历史效率高
一个我用真金白银换来的经验:业务约束要下沉到数据库层面。比如“一个用户一个场次只能预约一次”,不能只靠 Service 层查一遍再判断。两个请求同时进来,Service 层的 if 判断存在并发窗口,只有数据库唯一索引能兜底。
4. 核心业务逻辑实现:预约流程拆解
4.1 一次预约的完整调用链
预约是高频操作,同时牵涉余额和场次名额。后端收到预约请求后,Service 层的执行顺序很关键,我的实现是:
- 查询场次信息,校验场次存在、未结束、状态为可预约
- 查询用户信息,校验余额充足
- 原子更新预占名额:
update pool_session set booked_count = booked_count + 1 where id = ? and booked_count < max_count - 扣减用户余额
- 插入预约订单,状态为待使用
- 写入扣费流水
这六步必须在同一个事务里,任何一步失败,前面的操作全部回滚。
@Transactional(rollbackFor = Exception.class) public ReservationVO book(Long userId, Long sessionId) { // 1. 场次校验 // 2. 用户余额校验 // 3. 原子预占名额 // 4. 扣款 // 5. 生成订单 // 6. 写流水 }这里有一个新手几乎必踩的坑:rollbackFor 必须写清楚。Spring 的 @Transactional 默认只回滚 RuntimeException 和 Error,如果你代码里抛出的是受检异常,事务不会回滚。所以业务异常最好统一使用自定义运行时异常,配合全局异常处理器返回友好提示。
4.2 原子更新与并发方案选择
第三步的原子更新本质上是一种乐观锁思路:通过 where 条件保证只有满足剩余容量条件才更新成功。如果 update 返回影响行数 0,说明场次已满或状态不对,直接抛“该场次已满”。
对比一下常见方案:
- 悲观锁(SELECT FOR UPDATE):简单直接,但对数据库连接占用时间长,并发一高就锁等待
- 版本号乐观锁:每次更新带 version,失败后重试,适合读多写少场景
- 条件更新(本系统方案):一条 SQL 完成检查和更新,天然并发安全
预约场景用原子 update 最省事也最可靠。因为 booked_count < max_count 这个条件本身就是并发安全的,数据库行锁保证同一时刻只有一个事务能更新成功。很多秒杀系统的库存扣减用的就是这个思路。
4.3 防重复预约:唯一索引兜底
刚才强调的业务约束下沉,落实到 SQL 上就是在建表时直接加唯一索引:
ALTER TABLE reservation ADD UNIQUE KEY uk_user_session (user_id, session_id);用户第二次对同一场次发起预约时,插入被数据库拒绝,抛出 DuplicateKeyException。在 Service 层捕获这个异常,转成“您已预约过该场次”的提示即可。
但这里有个细节容易踩坑:如果用户先预约后取消,数据库里仍留着一条取消状态的记录,唯一索引会拦住用户再次预约。所以处理方式是把 status 也加进唯一索引:
ALTER TABLE reservation ADD UNIQUE KEY uk_user_session_status (user_id, session_id, status);注意 status=已取消(2)的记录不参与唯一约束,因为用户取消之后可以重新预约。这个业务约定一定要在代码注释里写清楚,不然过两个月再读代码,没人记得当初为什么这么设计。
4.4 取消预约与退款逻辑
取消预约同样在一个事务里完成四件事:
- 查询预约单,校验归属人正确、状态为待使用
- 更新预约单状态为已取消,记录取消时间
- 场次表 booked_count 原子减 1
- 用户余额退回,写入退款流水(type=3)
用余额支付就直接退到余额。实际生产环境对接微信或支付宝的话需要调用支付平台退款接口,毕设一般用余额模拟支付就够了,但代码里可以预留 pay_type 字段,说明未来可对接第三方支付,答辩时这是一个加分点。
还有一个规则设计值得提:临近开场是否允许取消。真实游泳馆通常会有截止时间,比如开场前 1 小时不允许取消。这个规则建议做成配置项 cancel_deadline_minutes 放进系统参数表,而不是硬编码。硬编码一时爽,改需求的时候谁都找不到入口。
5. 实操记录:从环境搭建到跑通全流程
5.1 环境准备清单
我把这套项目从零跑起来的完整操作过一遍,跟着走基本不会卡壳。
第一步,装 JDK 8 和 Maven。JDK 配置好 JAVA_HOME 环境变量,Maven 的 settings.xml 里把镜像源换成国内常用源,默认中央仓库在国内网络环境下经常卡死在下载 jar 包的环节。
第二步,装 MySQL 8.0。安装完后做两件事:确认服务启动,把数据库账号密码记好。遇到连接失败,百分之七八十是时区或账号问题,不是代码问题。
第三步,导入项目到开发工具。用工具自带的 Maven 面板刷新依赖,首次导入会慢一点,等右下角进度条走完再运行,别着急。
第四步,建库。执行项目附带的 SQL 脚本前,先手动执行 create database,确认库名与连接串一致。执行完脚本后,再核对配置文件里的用户名、密码、库名有没有笔误,这是启动报错的高发区。
5.2 配置文件的几个关键点
application.yml 里这几个配置值得展开讲:
spring: datasource: url: jdbc:mysql://localhost:3306/swim_pool_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true server: port: 8080serverTimezone=Asia/Shanghai 解决的是 MySQL 8.0 默认时区与本地时区不一致导致的启动报错。map-underscore-to-camel-case 让数据库的 user_name 自动映射到实体的 userName,省去手动指定列映射。log-impl 配成 StdOutImpl 可以在控制台打印 SQL,开发调试必需,但部署演示时建议去掉,避免 SQL 日志暴露表结构细节。
5.3 核心代码实现片段
场次列表查询是首页核心接口,用 MyBatis-Plus 的 Wrapper 就能写明白:
public List<SessionVO> listSessions(Long poolId, LocalDate date) { LocalTime now = LocalTime.now(); return sessionMapper.selectList( new LambdaQueryWrapper<PoolSession>() .eq(PoolSession::getPoolId, poolId) .eq(PoolSession::getSessionDate, date) .ge(PoolSession::getEndTime, now) .orderByAsc(PoolSession::getStartTime) ).stream().map(this::toVO).collect(Collectors.toList()); }预约接口的 Controller 入口:
@PostMapping("/api/reservation") @PreAuthorize("hasRole('USER')") public Result<?> createReservation(@RequestBody ReservationRequest request) { ReservationVO vo = reservationService.book( SecurityUtils.getCurrentUserId(), request.getSessionId() ); return Result.success(vo); }这个 @PreAuthorize 注解做的接口级权限控制,配合启动类上的方法级安全配置,比手写 if 判断优雅很多。答辩时如果能把“认证”和“授权”是两个层次这件事讲清楚,就是一个很好的亮点。
签到核销的接口逻辑也值得看:工作人员输入用户的订单号,后端校验订单状态为待使用,然后把状态改成已签到,写入签到时间。这个操作同样要加事务,因为签到和场次人数的数据联动不能出错。
6. 常见问题排查与避坑指南
6.1 我踩过的三个典型坑
第一批坑是时区与日期问题。开发时本地运行一切正常,打包部署后就发现预约日期全部不对,差 8 个小时。原因是服务器默认时区不同,MySQL 连接串没加 serverTimezone,或者 Jackson 序列化时没指定时区。排查方法很直接:前端传一个日期,后端打印出来对比,差 8 小时基本就是时区问题,按上面配置文件改就行。
第二批坑是事务不生效。Service 方法加了 @Transactional,数据异常时却没有回滚。排查发现是同类内部方法调用:一个方法调用同一个类里另一个带事务的方法,Spring 的代理机制没有介入,事务自然不生效。这就是典型的自调用问题,解决办法是把事务注解加到外部入口,或者注入自身代理对象。
第三批坑是懒加载异常。在 Service 里查完关联的懒加载字段,返回 DTO 给前端时才去访问,发现 Session 已经关闭。处理方式有两种:一是事务内把字段拷贝到 VO,事务关闭后不再访问实体;二是给整个查询方法加事务。我更推荐前者,职责清晰,而且能避免把多余字段暴露给前端。
6.2 高频报错速查表
整理成一张速查表,照着排查就行。
| 报错场景 | 现象 | 解决方案 |
|---|---|---|
| 启动无法连接数据库 | Could not create connection to database server | 检查 MySQL 服务是否启动、连接串、账号密码、serverTimezone |
| 权限被拒绝 | Access denied for user | 核对配置文件用户名密码,注意是否有多余空格 |
| 端口占用 | Port 8080 was already in use | 改端口,或查进程占用并清理 |
| Mapper 方法找不到 | Invalid bound statement | 检查 Mapper 接口全限定名、XML namespace、MapperScan 路径 |
| SQL 语法错误 | SQLSyntaxErrorException | 多半是字段名用了保留字,加反引号处理 |
| 重复预约提示 | DuplicateKeyException | Service 层捕获并转为友好提示 |
| 驱动找不到 | ClassNotFoundException: com.mysql.cj.jdbc.Driver | pom.xml 引入 mysql-connector-j 依赖 |
6.3 答辩前容易被追问的点
最后聊几个导师大概率会问的问题,提前准备好,现场不慌。
“并发场景下如何防止超卖?”回答思路:预约时不先查再改,而是用一条原子更新 SQL 在 where 条件里加上 booked_count < max_count,数据库行锁保证同一场次只有一个事务能扣减名额,事务回滚保证数据一致。
“预约和扣费不是一个操作,怎么保证一致性?”回答思路:用声明式事务把预占名额、扣费、生成订单、写流水放在同一个事务里,任何一步失败全部回滚。这是本地事务保证的强一致,如果未来拆分分布式,再考虑分布式事务方案。
“余额不足但场次还有空位,点击预约会发生什么?”回答思路:事务内第一步校验余额,不足则抛业务异常,整个事务回滚,场次名额不会被占用。关键是要强调校验发生在事务内部,而不是事务外部。
“为什么用 Session 做认证,不换 JWT?”回答思路:Session 简单、服务端可控,单体部署没有会话共享问题;JWT 无状态但过期控制难、无法主动失效。毕设单体场景 Session 足够,代码里留了扩展空间。
最后再说几句实话
这套项目做完,我最真实的体会是:毕设选题的价值不在于技术新,而在于你能否把一个完整业务闭环讲透、做稳。预约管理系统的每个环节——数据库设计、事务控制、并发防超卖、状态流转——都能对应到真实业务场景,用心做了,答辩和简历上都有的写。
如果你准备照着这个思路自己写,我给三个建议。第一,数据库脚本先设计好再写代码,改表结构的成本远高于改代码。第二,核心预约接口第一版就带上事务和并发控制,别抱着“先实现功能再加锁”的想法,后面补并发处理要重构一大片。第三,写注释顺手一点,两周后你自己回头看代码,能省大量回忆成本。
这套系统后续还能扩日历视图、教练预约、短信通知、真实支付对接,从游泳池的预约管理拓展成通用的按时段预约平台,也不是没可能。不过那是后话,眼下最要紧的是先把手头这条业务链路跑通、跑稳。