☰
Spring Boot剧本杀预约系统:场次排期与并发防超卖实战解析
2026/10/22 5:10:15 网站建设 项目流程

我做过的Spring Boot项目里,剧本杀预约管理系统算是比较有代表性的一个。它并不复杂,但刚好把真实业务里最容易踩坑的“场次排期、并发预约、状态流转”都覆盖到了。不管你是准备拿它当毕业设计,还是想帮一家实体剧本杀店做一套预约工具,这套Springboot剧本杀预约管理系统都能省掉很大一部分从零开发的工作量。

项目包含程序、源码、数据库脚本、调试部署步骤、开发环境配置,还附带一万字以上的设计文档。接下来我不打算把文档内容复述一遍,而是从设计者的角度,把这套系统的关键决策、核心实现、部署过程和测试结果讲清楚。哪怕你刚接触Spring Boot,跟着后面的步骤也能把项目跑起来。

1. 剧本杀门店的预约困境:这个系统到底解决什么问题

1.1 线下预约的三个典型痛点

剧本杀门店的预约场景,比影院卖票还要特殊一点。一方面,一个剧本对应一个房间,房间有容量上限,同一时间段不能安排两场;另一方面,玩家动辄五六人一组,临时取消很常见。早期很多门店靠微信群接龙或者前台手写登记,问题非常明显。

第一个痛点是信息不实时。玩家在群里问“今晚还有没有位”,老板得翻聊天记录、查本子,回复不及时就流失客人。第二个痛点是重复占位。同一组客人可能在群里接龙后又打电话确认,或者两个玩家各帮全队订了同个场次,后台根本没有校验能力。第三个痛点是经营数据一团乱。每个剧本被预约过几次、取消率多少、哪个房间利用率高,全靠脑子记,月底盘点对不上账。

这套系统要解决的,就是这三件事:让玩家能实时看到可约场次,让门店能管理剧本和房间,让订单和统计都能自动沉淀下来。

1.2 系统角色的划分与使用场景

项目按使用场景拆成了三类角色。

玩家端面对的是C端流程:注册登录、浏览剧本列表、查看某个剧本的场次、提交预约、查看自己的预约记录、取消预约。商家端面对的是门店运营:维护剧本信息、管理房间、设置场次、审核或取消订单、看经营数据。管理员端负责基础配置和用户管理,确保系统本身不会出乱子。

这种角色划分不是拍脑袋。预约业务天然需要“用户-场次-订单”三条主线,商家要在中间承接,管理员做兜底。后续如果要接小程序、接支付,也只需要在这三个角色上做增量,不需要推倒重来。

1.3 为什么技术栈选Spring Boot全家桶

这个项目选型时,我一开始也纠结过要不要做成前后端分离。后来还是决定用Spring Boot + Thymeleaf + MyBatis-Plus + MySQL的传统单体方案。原因很直接:门店规模就那么多,不需要独立的前端工程和Nginx集群;单体应用部署简单,一个jar包加一个数据库就能跑;对于学习或毕业设计来说,Controller-Service-Mapper三层结构特别清晰,写论文也好画架构图。

Spring Boot 2.7搭配JDK 8是目前最稳的组合。MyBatis-Plus负责单表CRUD和条件构造器,省掉大量XML。Thymeleaf做服务端渲染,页面直接用Bootstrap修一下样式,整个开发周期可以控制在一周左右。这套组合不是“最潮”的,但绝对是“最容易跑通+最容易解释”的。

2. 整体架构与功能模块:一套标准单体应用的正确姿势

2.1 技术栈与项目结构

项目代码结构比较常规,但分层清晰,方便二次开发。我的项目结构如下:

src/main/java/com/xxx/jubensha ├── common // 统一返回结果、全局异常、常量 ├── config // 拦截器、跨域、WebMvc配置 ├── controller // 前端接口层 ├── service // 核心业务逻辑层 ├── mapper // MyBatis-Plus Mapper接口 ├── entity // 数据库实体 └── util // 订单号、日期处理等工具

common层我放了一个Result,所有接口返回封装成{code, msg, data}三字段结构;业务异常统一用BusinessException抛出,再由全局异常处理器转成JSON。这样做的好处是,前端不管是页面跳转还是AJAX请求,处理响应都只用关心一个格式。config层的拦截器做登录鉴权,不引入Spring Security——对预约系统来说,拦截器已经够用,安全框架反而增加学习成本。

2.2 前端页面与服务端渲染的分工

前端页面用Thymeleaf模板放在src/main/resources/templates下,静态资源放在static。页面跳转走Controller返回视图名,涉及异步操作(提交表单、取消订单)就走axios或fetch调接口,拿到Result再更新页面。整体交互体验不输给前后端分离。

这套做法的好处是有问题好排查:F12看到请求发出,后端日志一打,就知道是哪一层出了错。页面上的数据回显用Thymeleaf表达式,例如${script.name},不用单独为接口联调写文档。如果你打算改成Vue或小程序,后端接口其实已经具备,直接复用/api/**接口即可。

2.3 三大端点的功能与API设计

下面列一下主要接口,后面调试的时候可以直接对照:

功能请求方式路径说明
用户注册POST/api/auth/register用户名、密码、手机号
用户登录POST/api/auth/login返回登录状态,写入Session
剧本列表GET/api/script/list分页、按类型/价格筛选
剧本详情GET/api/script/detail含剧本基本信息与场次
场次列表GET/api/session/list按剧本、日期查询可约场次
提交预约POST/api/order/create创建预约订单
我的预约GET/api/order/my当前用户订单列表
取消预约POST/api/order/cancel玩家侧取消
商家审核POST/api/admin/order/confirm确认或拒绝订单
房间维护POST/api/admin/room/save新增或编辑房间

这套接口是前后端约定的基准。实现顺序建议先做剧本、房间、场次这些基础模块,再做预约单,否则你会发现自己连测试数据都没法造。

3. 预约业务的核心逻辑:场次、排期与并发防超卖

3.1 场次模型设计的关键

预约的核心不是直接约“剧本”,而是约“场次”。一个场次代表某天某个时间点、在某个房间、开某个剧本,并且定义了这场能坐几个人。如果没有场次模型,就会出现“有人约了下午三点的《某某本》”这种很模糊的描述,商家还得自己去推算房间和人数。

在表结构上,场次表session持有script_id、room_id、start_time、end_time、capacity、booked_count。查询可约场次时,条件就是booked_count < capacity且start_time > now()。因为同一房间同一时间段只能开一场,我会在业务层对新增场次做重叠校验:查一下该房间是否已存在start_time <= new_end AND end_time >= new_start的记录。这个区间重叠判断是排期系统里最常见的公式。

3.2 创建预约的完整流程

玩家从场次列表进入详情页,看到剩余可约人数,点击预约后填写联系人和手机号,提交到后端。后端处理流程分四步:

  1. 校验场次是否存在且在可预约时间范围内;
  2. 校验当前用户是否已经约过这个场次;
  3. 原子更新booked_count,确认没有超出容量;
  4. 插入预约订单,返回订单号。

核心代码简化后是这样:

@Transactional(rollbackFor = Exception.class) public Result createOrder(CreateOrderRequest req) { Session session = sessionMapper.selectById(req.getSessionId()); if (session == null || session.getStatus() != 1) { return Result.error("场次不存在或已下架"); } if (session.getStartTime().isBefore(LocalDateTime.now())) { return Result.error("该场次已开始,无法预约"); } Integer count = orderMapper.selectCount( new LambdaQueryWrapper<AppointmentOrder>() .eq(AppointmentOrder::getUserId, req.getUserId()) .eq(AppointmentOrder::getSessionId, req.getSessionId()) .in(AppointmentOrder::getStatus, "PAID", "CONFIRMED")); if (count > 0) { return Result.error("你已经预约过这个场次"); } int updated = sessionMapper.incrBookedCount(req.getSessionId(), session.getCapacity()); if (updated == 0) { return Result.error("手慢了,这个场次刚刚被约满"); } AppointmentOrder order = new AppointmentOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(req.getUserId()); order.setSessionId(req.getSessionId()); order.setContactName(req.getContactName()); order.setContactPhone(req.getContactPhone()); order.setStatus("CONFIRMED"); orderMapper.insert(order); return Result.success(order.getOrderNo()); }

这里的@Transactional保证原子更新和插入订单在同一次事务里,要么全成功,要么全回滚。这个接口就是整套系统最核心的代码,建议仔细读一遍。

3.3 并发预约如何避免超卖

很多初学者会把预约写成“先查剩余人数,再插入订单”,我在第一次写的时候也是这么干的。结果用JMeter模拟20个人同时抢一个只剩1个名额的场次,出现了超卖。原因很简单,两个请求同时查到booked_count=7 < 8,然后都去插入订单,各自都认为自己是第8个。

解决方式我选的是数据库原子更新,而不是加Java锁。MyBatis-Plus里写一个自定义更新方法:

<update id="incrBookedCount"> UPDATE session SET booked_count = booked_count + 1 WHERE id = #{id} AND booked_count &lt; #{capacity} </update>

这条SQL在数据库层面用行锁保证同一时刻只有一个事务能成功更新,影响行数为1才代表预约成功。在此基础上再给订单表加一个(user_id, session_id)唯一索引,彻底防止同一用户重复预约。对单店项目来说,这套方案比引入Redis分布式锁简单得多,也足够可靠。

4. 数据库设计实战:核心表与关键索引

4.1 六张核心表的关系梳理

整个系统的表不多,核心就六张。为了让没看过源码的人也能快速上手,我把每张表的作用列出来:

表名作用关键字段
user用户表username, password, phone, role
script剧本表name, type, difficulty, price, min_players, max_players
room房间表name, capacity, status
session场次表script_id, room_id, start_time, end_time, capacity, booked_count
appointment_order预约订单表order_no, user_id, session_id, contact_name, contact_phone, status
evaluation评价表order_id, user_id, score, content

关系上,一个剧本可以开设多个场次,一个房间可以承载多个场次,一个场次对应一个剧本和一个房间。预约订单挂在场次下面,同时记录预约人。评价表再挂到订单上,保证只有真正到场的玩家才能评价。

4.2 关键字段与索引设计

有几个字段需要特别说清楚。

order_no我建议用“时间戳+随机数”生成,比如202506141530001234,并且一定要建唯一索引。订单号一旦重复,后面所有对账、客服查询都会出乱子。

预约订单表的(user_id, session_id)建唯一索引是防重复预约的底线。就算业务代码里漏了判断,数据库也能兜住。场次表的(script_id, start_time)建普通索引,因为按剧本查可约场次是最常用的操作。再给start_time建索引,后台按时间范围统计会快很多。

为什么不建物理外键?我在实际项目里基本都是逻辑外键。物理外键在插入、删除时会有额外校验开销,而且订单表这种高频写入表很容易和外键锁打架。只要应用层维护好关联关系,逻辑外键完全够用。

4.3 经营统计SQL是怎么写的

商家端要看的报表基本离不开三张表:订单、场次、剧本。

统计近7天预约量:

SELECT DATE(create_time) AS d, COUNT(*) AS cnt FROM appointment_order WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE(create_time) ORDER BY d;

热门剧本Top5:

SELECT s.script_id, sc.name, COUNT(*) AS cnt FROM appointment_order o JOIN session s ON o.session_id = s.id JOIN script sc ON s.script_id = sc.id WHERE o.status IN ('CONFIRMED','PAID') GROUP BY s.script_id, sc.name ORDER BY cnt DESC LIMIT 5;

写这种SQL最需要注意的是别漏掉状态过滤。取消的订单如果也算进销量,报表会明显失真。另一个容易被忽略的是日期函数的使用,建议测试环境先造一批不同日期的数据,验证分组条件和时区是否符合预期。

5. 调试部署全记录:从本地环境到服务器上线

5.1 开发环境准备清单

拿到项目后,第一件事不是急着打开代码,而是先把环境对齐。项目默认的开发环境是:

  • JDK 1.8(建议用8u202之后的版本,可以正常支持Maven编译)
  • Maven 3.6.3 或更高版本
  • MySQL 8.0(5.7也能跑,但要留意SQL语法差异)
  • IntelliJ IDEA 2021 以上版本,社区版也可以
  • Git,用来跟踪代码改动

数据库初始化脚本放在doc/sql目录下,先执行create_database.sql建库,再执行init_data.sql导入初始账号和测试数据。本地开发可以直接用root账号连接MySQL,不过如果要部署到服务器,建议单独建一个数据库账号,只授予业务库权限。

5.2 配置文件中最容易翻车的三个点

我见过太多人在这三个地方卡住。

第一是数据库连接地址。application.yml里的spring.datasource.url必须配serverTimezone=Asia/Shanghai和useUnicode=true&characterEncoding=utf8,否则会出现中文乱码或者时区报错。8.0的驱动还强制要求driver-class-name=com.mysql.cj.jdbc.Driver。

第二是端口冲突。Spring Boot默认8080端口,如果本地有别的进程占用,启动会直接报端口被占用。要么关掉占用进程,要么改server.port,比如改成8081。

第三是文件上传路径。如果商家要维护剧本封面图,项目里通常配置了本地上传目录,比如D:/upload/(Windows)或/usr/local/upload(Linux)。不存在的目录会导致上传接口报错,部署时记得先创建目录并检查写入权限。

5.3 打包部署与常见异常排查

部署流程其实很短。进入项目根目录,执行:

mvn clean package -DskipTests

构建完成后,target目录下会生成jubensha-0.0.1-SNAPSHOT.jar。把jar包、数据库脚本传到服务器,先导入数据库,再启动:

nohup java -jar jubensha-0.0.1-SNAPSHOT.jar > app.log 2>&1 &

启动日志正常情况下会有一个Spring Boot Banner,最后出现“Started SpringBootApplication”。如果没有出现,去app.log看异常。我这里列几个常见问题和处理办法:

报错现象原因处理方法
Access denied for user 'root'@'...'数据库账号密码不对检查application.yml与数据库用户授权
Unknown database 'jubensha'数据库没建执行建库脚本
Port 8080 was already in use端口被占用换端口或释放占用
Failed to configure a DataSource驱动或URL配置缺失检查spring.datasource配置
ClassNotFoundException: Lombok编译环境缺Lombok依赖检查pom,IDEA装Lombok插件

部署到服务器还有一个细节,如果用的是云服务器,记得在安全组放行项目端口。这不是代码问题,但是漏掉的人非常多。

6. 测试与实测结果:预约接口到底扛不扛得住

6.1 功能测试用例设计

先别急着看性能,功能测试过了再说。我按业务主流程列了一张用例表,测试时逐一核对:

用例操作预期结果
用户注册填用户名/密码/手机号注册成功,可登录
重复用户名注册用同一个用户名再注册提示用户名已存在
剧本列表筛选按类型/价格筛选返回符合条件的数据
正常预约选一个未满场次下单生成订单号,库存+1
重复预约同一用户再约同一场次提示已预约过
约满场次满员后继续下单提示约满
取消预约商家/玩家取消状态更新,库存-1
未登录访问不登录直接调接口拦截器返回未登录

我在实际测试中发现,最容易出问题的反而是“取消预约”。因为刚开发时只处理了订单状态,忘了把booked_count减回去。这个Bug如果不测,商家端很快就会发现场次明明有空位却约不进去。

6.2 并发压测的实测表现

性能测试我用JMeter跑了一个场景:一个场次容量8人,已经预约7人,然后用50个并发线程同时请求预约接口。正常情况应该只有1个请求成功,其余全部返回“已约满”。

实测结果是:成功1个,失败49个,数据库里没有出现超过容量的记录。这说明原子更新方案在单机数据库下能扛住这个量级的并发。对一个单店预约系统来说,这个结果已经够了。如果你非要追求更大的并发,再考虑Redis预扣库存,但那些都是后话。

6.3 我在测试中发现的两个隐性Bug

第一个Bug是用户快速双击提交按钮,产生了两个重叠请求。由于唯一索引的兜底,没有被写入重复订单,但是接口返回给用户的提示却是“系统繁忙”。后来我在前端增加了按钮禁用,后端也加了一个幂等判断:如果订单表中已经存在该用户对应场次的有效订单,直接返回“你已经预约过”。双保险之后体验才正常。

第二个Bug和状态流转有关。商家拒绝订单时,如果订单状态是“待确认”,可以直接拒绝;但如果是“已确认”,拒绝前需要先减回库存。一开始我在两个分支里都写了减库存逻辑,结果导致数据错乱。最后我把状态机明确画出来,每个状态只允许特定的下一跳,才算真正稳定下来。

7. 拿到项目之后怎么快速跑通和二次开发

7.1 跑起来的五个步骤

如果你刚拿到源码,不想看文档,按下面五步走:

  1. 用IDEA打开Maven项目,等待依赖下载完成。
  2. 创建空数据库,执行doc/sql/create_database.sql和doc/sql/init_data.sql。
  3. 修改application.yml里的数据库密码和上传目录。
  4. 启动JubenshaApplication,浏览器访问http://localhost:8080。
  5. 使用初始账号登录,玩家账号和商家账号在初始化脚本里都有备注。

这五步做完,你应该能看到系统首页、剧本列表和管理后台。如果哪一步卡住,参照第5节的异常排查表。

7.2 代码结构与二次开发建议

代码结构在前面已经讲过。接下来怎么二次开发,我给几个方向:

想做小程序端,直接复用现有/api接口,写一套微信小程序前端即可。后端需要补充接口返回的JSON字段,比如把每个场次的剩余座位数提前算好。想接在线支付,可以对接微信支付或支付宝支付,订单增加“待支付”状态,配合支付回调更新状态。想做短信提醒,在订单确认和临近开场时调用短信服务商接口。

需要注意的是,任何一次改动都先改数据库脚本,再改代码。别在本地库里手工加字段,回头部署到服务器就会漏掉。

7.3 完整包内容与最后一点经验

这个项目整理好后,源码、数据库脚本、调试部署说明、开发环境配置、系统界面截图,以及一万字以上的设计文档,都在同一个包里。需要用到的朋友可以看文末获取方式。

最后说一点我自己的体会:做预约系统,最重要的不是页面多好看,而是把“场次、订单、状态”这三者的关系想清楚。状态机画明白了,代码怎么写都不会乱。文档和截图都在包里,先把整体流程走一遍,再动手改代码,比上来就东改一个页面、西改一个字段要高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询