Spring Boot班车预约管理系统设计与实现:从并发扣减到权限隔离全解析
每年到了毕业设计选题季“Spring Boot班车预约管理系统”这个题目总会出现在各种推荐列表里计算机毕业设计源码库里也常年挂着类似编号。说实话我第一次看到这个题时也觉得它平平无奇不就是普通的管理系统用户预约、管理员维护增删改查一套下来完事。直到自己动手做才发现预约逻辑、余票扣减、权限隔离、状态流转每一个细节都藏着坑。这篇文章我就把这个题目完整拆一遍从需求定义、技术选型、数据库建表到预约并发处理、接口设计、打包部署和答辩准备把我做这个项目时的真实思路和踩过的坑全部写出来。哪怕你手上有类似编号18530的完整参考源码不理解这些设计决策答辩一样会露馅。1. 这个题目到底在做什么需求拆解与功能边界1.1 班车预约系统的本质一个受约束的资源预约平台把班车预约管理系统的本质想清楚整个设计就会顺很多。它本质上是一个“受约束的资源预约平台”班车座位是有限的一个座位不能被两个人同时占班次是动态的只有在有效时间范围内的班次才可以预约用户预约之后还可能取消取消后座位要重新释放。它和论坛、博客这类纯信息展示系统的最大区别就是系统里有一个真实存在、且数量有上限的资源在流动。只要有资源在流动就绕不开两个核心问题状态的正确性和并发场景下的安全性。这也是这个毕设最有技术含量的地方答辩时老师最可能盯住不放的也是这两点。从角色上看系统至少要支持两类用户普通用户和管理员。普通用户的核心操作是预约座位、查看班次、取消预约管理员的核心操作是维护线路、车辆、生成班次、查看预约数据。再往上可以加公告管理、用户管理、数据统计这些辅助模块但核心引擎始终是“班次-预约-座位”这条链。1.2 两类角色的用例拆分先把用户端的用例写死后面开发就不会跑偏。用户端需要注册登录、按日期和线路查询班次、查看班次余票、提交预约、查看我的预约列表、取消预约、修改个人资料和密码。这些用例是主线一个都不能少。管理端则需要路线管理增删改查车辆与司机信息管理班次生成把一条路线、一辆车和一个日期组合成一个可预约的班次预约名单查询异常预约手动处理以及最基础的用户管理。统计报表属于加分项能做的做做不了可以用一条SQL查出来展示不用太复杂。用例拆分最大的价值在于你一眼就能看出哪些功能是“必须做”的哪些是“可以不做”的。我见过太多同学需求阶段什么都想加最后代码写了一万多行答辩却连主流程都讲不清楚就是因为没有先划边界。1.3 画好功能边界有些事宁可不做这里我想认真劝一句不要因为觉得功能多就显得厉害。以班车预约这个场景来说在线支付完全没必要做因为大多数毕设选这个题背景都是企业内部班车、园区通勤车或者校内班车本来就是免费预约。做了支付反而要处理对账、退款、支付回调这些完全超出毕设范畴的问题。更建议把精力花在预约闭环上预约、扣减、取消、释放、状态一致性。这个闭环做扎实了整个项目的质量已经超过大多数同类毕设。还有一个常见的纠结是“要不要加司机角色”。如果开发时间充足加一个司机角色让司机查看自己某天负责的班次和乘客名单确实能丰富系统但如果时间很紧我建议保留两个角色把管理端的操作质量做高比堆一堆半成品角色更有说服力。2. 技术选型复盘Spring Boot这层底座为什么够用2.1 技术栈清单与选择逻辑我做的这套班车预约管理系统核心技术栈非常标准Spring Boot 2.7.x做框架主体MyBatis-Plus做数据访问层MySQL 8.x做存储Thymeleaf做服务端页面渲染Maven做构建Lombok简化实体类再配上Hutool处理订单号、日期格式化这类日常工具逻辑Spring Validation做参数校验。为什么选MyBatis-Plus而不是纯MyBatis因为毕业设计周期就这么长手写单表CRUD的XML非常耗时而且写多了容易错。MyBatis-Plus自带的BaseMapper已经覆盖了大部分单表操作分页查询也封装好了几乎不用写SQL。省下来的时间全部可以投入到预约业务逻辑上这才是这个项目的核心难点。Spring Boot的好处不用多说自动配置省去一堆xml内置Tomcat直接跑jar生态里什么都有。对毕业设计来说“能快跑好讲解易部署”就是最合适的组合不需要为了炫技引入复杂中间件。2.2 前后端分离还是单体模板一个务实的判断现在网上的毕设教程一上来就让你搭Vue加Spring Boot分离项目搞得好像不做前后端分离就不够高级。但分离项目要处理的跨域、token刷新、前端路由守卫、两套工程联调每一项都是时间黑洞。如果你对Vue不熟一个月的时间很可能全耗在前端脚手架上。我的判断是如果目标是稳稳当当完成毕业设计用Thymeleaf单体项目就是最优解。一个Application工程里Controller既返回页面也返回JSON管理端和用户端在templates目录下分开组织后端跑起来就能访问页面不折腾任何构建工具链。对大多数人来说这条路最稳妥。当然如果你确实会Vue想给简历加点分也可以做前后端分离。我试过Vue3加Element Plus的做法页面美观、答辩演示效果好但代价是联调时间翻倍。选择分离路线的话至少预留两周时间给前端联调接口文档要提前定义清楚否则前后端两边各敲各的很容易对着空气联调。2.3 版本选型和环境问题先解决再开发这个项目里最容易翻车的就是版本兼容。Java建议用8或11不要图新鲜上Java 17甚至21旧版本的Lombok和MyBatis-Plus在更高版本JDK上会出现稀奇古怪的问题。MySQL用8.x的话JDBC连接串里一定要带上时区和编码参数我一般这样写spring.datasource.urljdbc:mysql://localhost:3306/shuttle_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai不配serverTimezone常见的表现就是数据库时间和系统时间差8小时预约记录的时间看起来总是不对。建库时指定utf8mb4字符集否则中文正常但遇到特殊字符容易乱码。Maven配置一个国内的镜像源不然下载依赖可以卡到你怀疑人生IDEA里装好Lombok插件否则实体类一堆getter和setter标红。环境问题一定要先解决再写业务不要像我当年一样代码写了几千行才发现Lombok和JDK版本不兼容改配置改到崩溃。3. 数据库设计是从“路线”到“班次”再到“预约单”的思考链3.1 六张核心表的字段设计数据库是这类系统的地基。我见过不少同学一上来就建了十几张表关系乱成一锅粥最后越写越痛苦。班车预约这个场景六张表足够了user、bus、route、schedule、reservation再加一张可选的site站点表。user表存放账号信息id、username、password、real_name、phone、roleUSER或ADMIN、status禁用启用、create_timepassword必须存BCrypt哈希不要存明文。bus表车辆信息id、bus_no、plate_no、driver_name、driver_phone、seat_count、status。route表线路信息id、route_name、origin、destination、depart_time、duration、status。schedule表是班次表也是整个系统最关键的动态表id、route_id、bus_id、depart_date、depart_time、available_seats、status。最后是reservation预约单表id、reservation_no、schedule_id、user_id、seat_no、status、create_time、cancel_time。这里最核心的字段就是schedule.available_seats它存的是当前剩余座位数。有人问为什么不通过预约表count一下就算出来那是因为在并发场景下count的效率低而且无法支撑原子扣减。用一个冗余字段存余票才能配合数据库的原子update做扣减。3.2 最容易做错的关系建模路线、班次和车辆很多同学会把“路线”和“班次”合并成一张表或者把车辆信息塞进路线表里结果一条路线一天只能发一班想换台车就得改路线数据这就是没有把静态和动态分开。这个错误几乎是同类毕设里最常见的。正确的关系是这样的route是静态配置它描述“从A到B这条线路存在8点出发预计开多久”bus是另一组静态配置描述“有一辆车55个座位司机是谁”schedule是运行实例描述“某年某月某日这条路线用这辆车发一班当前还剩多少座位”。所以schedule要同时关联route_id和bus_iddepart_date和depart_time决定的是具体某一次发车。对应到界面上用户看到“今天8点从A到B的班次还有5个座位”这个对象就是schedule记录而不是route记录。把这一层想明白后面写预约时join哪张表、更新哪条记录就非常清楚了。这也是我觉得这个题目比普通增删改查管理系统更有价值的地方。3.3 状态字段与索引从数据层面挡住重复预约先定业务规则一个用户对同一个班次只能预约一次。如果这个规则只在Java代码里判断并发下一定会出现两个请求同时通过校验的情况因为两个事务都还没提交谁也看不到谁插入的数据。所以要在数据库层面加唯一索引挡住它ALTER TABLE reservation ADD UNIQUE INDEX uk_schedule_user (schedule_id, user_id);MySQL在唯一索引冲突时会拒绝第二条插入这是最硬的一层防线。加上它之后Java代码里即便是并发重复提交也最多是多一次插入异常程序捕获后提示“你已经预约过这个班次”即可。这个点在答辩时值得重点讲老师一听就知道你是真正理解数据库约束的。状态字段建议用TINYINT存数字编码不要用VARCHAR存“已预约”“已取消”这种中文。用数字可以让数据库更轻、查询更快也更便于在代码里用枚举统一管理。索引方面schedule表depart_date加普通索引reservation表user_id加普通索引schedule_id和user_id加联合唯一索引这三个索引基本够用不要贪多索引过多反而拖累写入性能。4. 预约扣减与取消释放核心业务逻辑的并发正确性4.1 一次完整预约的完整代码路径预约是系统的核心动作它的代码路径必须很清晰。第一步校验当前用户是否登录、是否被禁用第二步根据scheduleId查出班次记录确认它处于可预约状态同时检查发车时间是否在允许预约的窗口内比如发车前30分钟内就应该禁约第三步执行一条原子SQL扣减余票第四步插入预约单记录第五步返回成功。这里最关键的是第三步和第四步必须放在同一个事务里。如果先扣座位再插预约单插入失败时座位已经白白扣掉用户会看到余票变少但自己没预约上如果先插预约单再扣座位扣减失败时数据库里会留着一张没有座位的预约单。两种情况都会破坏数据一致性。最简单的方案就是在Service方法上加Transactional让扣减和插入要么一起成功要么一起回滚。4.2 并发抢座不超卖三套方案与我的最终选择并发抢座是这类系统绕不开的话题也是答辩老师的天然考点。我把常见方案归纳成三种。第一种是先select余票再update这也是新手最容易写出来的版本。两个请求同时读到余票为1都判断有座位然后先后执行update余票最终变成负数。这个方法在并发下必挂不用考虑。第二种是悲观锁select ... for update先把班次记录锁住其他事务必须等它提交后才能再查。逻辑最简单但锁等待时间可能很长而且一不小心容易把表锁住对高并发不友好。第三种是乐观锁的实战变体直接用一条带条件的原子更新UPDATE schedule SET available_seats available_seats - 1 WHERE id #{scheduleId} AND status 1 AND available_seats 0这条SQL在执行时InnoDB会锁定命中的那一行同一时刻只有一个事务能把余票减掉。available_seats 0这个条件就是天然的互斥判断谁先执行成功谁就抢到座位。如果影响行数为0说明别人抢先一步把最后一张票拿走了直接提示“座位已满”。我最推荐第三种。代码最少、性能足够、逻辑严密对毕业设计场景绰绰有余。如果老师追问“高并发再大怎么办”你可以答当前数据库原子更新已经能满足演示需求如果未来规模扩大到每秒上千次预约再引入Redis预扣减或消息队列削峰。能说出这个演进路径就已经是高水平回答了。4.3 取消预约、状态机与座位回流取消预约看似简单但同样藏坑。第一只能取消自己的预约不能传一个别人的reservationId就取消成功所以更新条件里必须带上user_id。第二已经取消过或者已完成的记录不能再取消要用条件更新约束UPDATE reservation SET status 2, cancel_time NOW() WHERE id #{reservationId} AND user_id #{userId} AND status 1影响行数为1才代表取消成功为0则说明记录不存在、不是自己的、或状态不允许取消统一返回失败即可。第三取消成功后要立刻把座位加回去同样用原子SQLUPDATE schedule SET available_seats available_seats 1 WHERE id #{scheduleId}取消和加座位这两步也必须在一个事务里完成否则就会出现用户取消了预约余票却没有恢复的bug。状态机方面预约单我设计了四种状态已预约、已取消、已完成、已过期。已完成的定义是发车时间过去之后定时任务把对应预约批量更新为已完成已过期则是用户一直没乘车也没取消按规则处理。定时任务如果做触发频率每分钟一次就够不要设置成几秒钟一次纯属浪费数据库连接。4.4 一个容易被忽略的细节预约单号怎么生成预约单号看着不起眼实际开发时一定会用到。我建议不要直接暴露数据库自增主键也不要用无规律UUID。我的做法是生成“yyyyMMddHHmmss 班次ID 用户ID 四位随机数”的拼接单号大约二十多位。这样光看单号就能知道是哪个时间、哪个班次、哪个用户提交的预约排查问题非常直观。生成单号的工具方法放在一个独立Util类里。两个请求在同一毫秒并发时四位随机数基本可以避免冲突如果觉得不稳在插入失败捕获到唯一键冲突时再重新生成一次。这个细节虽然不大但写代码时能考虑到代码质量会明显高一个档次。5. 登录鉴权与接口权限管理端和用户端如何安全共存5.1 Session和JWT怎么选取决于你是怎么做的前端登录鉴权最常见的两种方案是Session和JWT。它们的适用场景完全不同选错会把自己坑死。如果是Thymeleaf单体项目Session是最自然的选择。登录成功后用户信息放session后续请求通过拦截器从session里取用户不需要额外引入任何依赖服务端随时可以让session失效安全模型非常直观。实现简单答辩也好解释。如果是Vue分离项目JWT会更顺手。登录成功返回token前端存起来每次请求在Authorization头里带上token后端通过拦截器解析。JWT的好处是服务端无状态坏处是签发后不好主动吊销只能等过期时间。针对毕设系统token过期时间可以设成2小时配合前端401拦截跳转登录页就够了。我的建议很直接单体用Session分离用JWT千万不要两边混着来。有的项目明明用Thymeleaf还硬套JWT拦截器里既要解析token又要管session逻辑拧巴到后面自己都看不懂。5.2 用自定义注解做单元级鉴权比一堆拦截器干净多了登录后普通用户和管理员能访问的接口必须隔离。最粗暴的做法是拦截器写死路径比如/admin开头的都要管理员。但这样路径一变就炸。我后来用了一个更优雅的方式自定义RequireRole(ADMIN)注解标在Controller方法上然后在统一拦截器里读取当前请求方法上的注解和当前用户角色做比较不一致就返回403。这样权限规则直接写在接口旁边代码即文档。用户接口不标注解或者标RequireRole(USER)管理接口标ADMIN一目了然。实际开发中用起来很顺手答辩讲到权限设计时也能讲得有层次。还有两个安全细节值得提密码存储用BCrypt绝不存明文登录接口要做失败次数限制比如连续错5次就锁定15分钟防止被脚本爆破。用户被禁用后session要能及时失效最简单的做法是拦截器每次都从数据库查一次用户状态虽然多一次查询但对毕设访问量来说完全没影响。5.3 接口路径规划与统一返回格式接口路径要有清晰的约定。用户端统一用/api前缀管理端统一用/admin前缀这样拦截器只需要判断前缀就能决定走哪套校验。基本规划是这样的POST /api/auth/register注册POST /api/auth/login登录GET /api/schedule/list查询可预约班次POST /api/reservation提交预约GET /api/reservation/my查看我的预约POST /api/reservation/{id}/cancel取消预约管理端则是POST /admin/schedule/save新增修改班次GET /admin/reservation/list查看预约名单DELETE /admin/bus/{id}删除车辆。所有接口统一返回Result包装对象包含code、message、data三个字段。前端只认code判断成败失败信息统一走全局异常处理器输出到message。这个约定可以让Controller层非常干净不用到处写try-catch代码风格也统一。6. 交付前夜环境初始化、演示数据与答辩高频题清单6.1 开发环境初始化与项目启动顺序拿到空项目后建议按这个顺序操作能少踩很多坑。第一步创建数据库字符集选utf8mb4第二步改application.yml里的连接串、用户名密码第三步在pom.xml里配好依赖和国内镜像源第四步先写一个空的Controller跑起来确认环境没问题再做业务开发。别一上来就写一堆代码然后才发现环境跑不通排查起来非常痛苦。确认启动没问题后再建表、建实体类。MyBatis-Plus的实体类建议用TableName指定表名驼峰和下划线对应好。启动成功后用初始管理员账号登录验证登录拦截器和权限注解是否生效。打包阶段用一条命令mvn clean package -DskipTests生成的可执行jar在target目录下java -jar就能跑。如果要在云服务器演示提前把MySQL装好连接串里的地址改成服务器内网地址端口安全组记得放行。6.2 演示数据设计数据要能撑起一段完整故事很多同学的测试数据都是随手造的几条演示时一说就穿帮。我的建议是准备一套能讲15分钟的数据故事3条路线覆盖早高峰、通勤、跨城三种场景每条路线配2到3个班次其中一个当天可约、一个已满员两到三辆车座位数不同一个管理员账号两个普通用户账号预约记录里把已预约、已取消、已完成、已过期四种状态都覆盖上。演示顺序也值得设计。先以管理员身份登录展示路线、车辆、班次维护切到用户端展示查询班次、提交预约预约成功后回到班次列表展示余票从5变成4再取消预约展示余票恢复最后切回管理端展示预约名单和统计数据。这一套下来核心流程全都被演示到了而且整个逻辑闭环很漂亮。答辩的时候常用账号密码提前抄在一张纸上或者放在PPT第一页不要现场敲键盘非常浪费时间还容易手抖。6.3 答辩高频问题提前把答案准备好根据我的经验班车预约管理系统这类毕设老师最爱问的问题就这几个提前准备好能省一半紧张。为什么选Spring Boot答生态成熟、内置Tomcat免部署、自动配置开发效率高适合模块清晰的管理系统。余票并发怎么保证不超卖答数据库原子更新利用InnoDB行锁保证同一时刻只有一个事务能扣成功。一个用户重复预约怎么办答数据库层的schedule_id加user_id唯一索引兜底。取消预约后座位怎么释放答同一个事务里改预约状态再加回余票。密码安全答BCrypt哈希存储加登录失败次数限制。测试做了什么可以回答用了Postman联调接口用并发测试工具模拟200个请求同时抢一个班次最终观察到入库预约数刚好等于座位数没有超卖余票也没有变成负数。整理到这里我自己还有一个小习惯要分享答辩之前把数据库重置成演示初始状态浏览器清掉缓存完整走一遍预约流程。很多人代码写得好结果现场演示时发现之前测试留下的脏数据把状态搞乱了当场卡壳。毕业设计这东西代码质量重要把系统讲明白同样重要。你要是按照前面这些步骤把每个设计决策都能讲出理由这个项目的分数不会差。