温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系名片!
一、项目背景与意义
剧本杀、密室逃脱等剧本娱乐业态近年来快速扩张,门店数量增多、场次排布复杂、玩家组队需求频繁,传统依靠微信群接龙、电话预约、纸质排期表的管理方式已经难以支撑。门店普遍面临排期冲突、场次空置、玩家爽约、订单对账困难等问题。
本文设计并实现一个基于 Spring Boot + Vue3 的剧本娱乐门店预约平台,面向玩家、门店店长和平台运营三类角色,提供剧本浏览、场次排期、在线预约、组队拼场、订单支付与核销、评价互动等能力,帮助门店提升场次利用率,降低人工排期成本。
二、需求分析
2.1 角色与权限
| 角色 | 核心诉求 | 主要功能 |
|---|---|---|
| 玩家 | 快速找到合适的剧本和场次 | 剧本浏览、场次查询、在线预约、拼场组队、订单管理、评价 |
| 门店店长 | 管理门店资源与排期 | 门店信息维护、剧本上架、房间管理、场次排期、订单核销、数据统计 |
| 平台运营 | 审核与全局运营 | 门店审核、剧本审核、用户管理、投诉处理、运营数据看板 |
2.2 核心业务流程
flowchart TD A[玩家浏览剧本] --> B[选择门店与场次] B --> C{是否拼场} C -- 是 --> D[加入拼场队伍] C -- 否 --> E[整场预约] D --> F[提交订单并支付] E --> F F --> G[门店确认排期] G --> H[到店核销] H --> I[评价与积分]2.3 非功能性需求
- 并发控制:同一场次座位有限,需防止超卖,采用数据库乐观锁或 Redis 分布式锁。
- 响应性能:剧本列表、场次查询等高频接口引入 Redis 缓存,热点数据响应控制在 200ms 内。
- 安全性:基于 JWT 的无状态鉴权,接口按角色做细粒度权限校验。
- 可扩展性:前后端分离,模块化分层,便于后续接入小程序端。
三、系统架构设计
3.1 总体架构
系统采用前后端分离架构。前端使用 Vue3 + Vite + Element Plus 构建管理端与用户端,后端使用 Spring Boot 提供 RESTful 接口,数据层采用 MySQL 存储业务数据、Redis 缓存热点数据与分布式锁、MinIO 存储剧本封面与门店图片。
flowchart LR U[玩家端 Vue3] --> G[Nginx 网关] M[门店管理端 Vue3] --> G G --> S[Spring Boot 应用] S --> R[(Redis)] S --> D[(MySQL)] S --> O[(MinIO 对象存储)] S --> P[支付/短信第三方服务]3.2 技术选型
| 层次 | 技术栈 | 说明 |
|---|---|---|
| 前端框架 | Vue3 + Vite + TypeScript | 组合式 API,按需加载 |
| UI 组件 | Element Plus | 管理端与用户端统一组件库 |
| 状态管理 | Pinia | 用户信息、购物车式预约单管理 |
| 后端框架 | Spring Boot 3.x | 自动装配、内嵌 Tomcat |
| 持久层 | MyBatis-Plus | 简化 CRUD,支持分页插件 |
| 数据库 | MySQL 8.0 | 业务主库 |
| 缓存 | Redis 7 | 热点缓存、分布式锁、验证码 |
| 鉴权 | Spring Security + JWT | 无状态认证 |
| 对象存储 | MinIO | 图片与剧本资料存储 |
四、数据库设计
核心表围绕门店、剧本、房间、场次、订单、拼场队伍展开,主要表结构如下。
| 表名 | 说明 | 关键字段 |
|---|---|---|
| sys_user | 用户表 | id、username、password、phone、role、status |
| store | 门店表 | id、name、address、phone、business_hours、status |
| script_info | 剧本表 | id、title、type、player_count、duration、cover_url、difficulty |
| room | 房间表 | id、store_id、name、capacity |
| session_schedule | 场次表 | id、store_id、room_id、script_id、start_time、end_time、price、total_seats、booked_seats、status |
| booking_order | 预约订单表 | id、order_no、user_id、session_id、seats、amount、pay_status、verify_code |
| team_room | 拼场队伍表 | id、session_id、leader_id、current_size、max_size、status |
| comment | 评价表 | id、order_id、user_id、script_id、score、content |
其中session_schedule.booked_seats是并发敏感字段,预约时通过带条件的更新语句保证不超卖:
UPDATE session_schedule SET booked_seats = booked_seats + #{seats} WHERE id = #{sessionId} AND booked_seats + #{seats} <= total_seats AND status = 1;当影响行数为 0 时说明座位不足,直接返回预约失败,避免加锁带来的性能损耗。
五、后端核心实现
5.1 项目结构
script-booking ├── script-common 公共模块:统一响应、异常、工具类 ├── script-security 鉴权模块:JWT、权限校验 ├── script-admin 平台运营端接口 ├── script-store 门店端接口 ├── script-portal 玩家端接口 └── script-job 定时任务:场次状态刷新、超时订单关闭5.2 统一响应与全局异常
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> fail(String message) { Result<T> r = new Result<>(); r.setCode(500); r.setMessage(message); return r; } }@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public Result<Void> handleBusiness(BusinessException e) { return Result.fail(e.getMessage()); } @ExceptionHandler(Exception.class) public Result<Void> handleException(Exception e) { log.error("系统异常", e); return Result.fail("系统繁忙,请稍后重试"); } }5.3 JWT 鉴权
@Component public class JwtTokenProvider { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") private long expire; public String createToken(Long userId, String role) { Date now = new Date(); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(now) .setExpiration(new Date(now.getTime() + expire)) .signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))) .compact(); } public Claims parse(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))) .build() .parseClaimsJws(token) .getBody(); } }5.4 预约下单服务
@Service @RequiredArgsConstructor public class BookingService { private final SessionScheduleMapper scheduleMapper; private final BookingOrderMapper orderMapper; private final RedisTemplate<String, Object> redisTemplate; @Transactional(rollbackFor = Exception.class) public String book(Long userId, Long sessionId, Integer seats) { String lockKey = "lock:session:" + sessionId; Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, userId, Duration.ofSeconds(10)); if (Boolean.FALSE.equals(locked)) { throw new BusinessException("当前场次预约火爆,请稍后重试"); } try { int updated = scheduleMapper.increaseBookedSeats(sessionId, seats); if (updated == 0) { throw new BusinessException("剩余座位不足"); } SessionSchedule schedule = scheduleMapper.selectById(sessionId); BookingOrder order = new BookingOrder(); order.setOrderNo(OrderNoGenerator.next()); order.setUserId(userId); order.setSessionId(sessionId); order.setSeats(seats); order.setAmount(schedule.getPrice().multiply(BigDecimal.valueOf(seats))); order.setPayStatus(0); order.setVerifyCode(RandomUtil.randomNumbers(6)); orderMapper.insert(order); return order.getOrderNo(); } finally { redisTemplate.delete(lockKey); } } }5.5 拼场组队
拼场是剧本娱乐场景的特色功能。玩家可加入未满员的队伍,队伍满员后自动转为待开场状态。加入队伍时同样需要校验人数上限,使用与预约一致的乐观更新思路:
UPDATE team_room SET current_size = current_size + 1 WHERE id = #{teamId} AND current_size < max_size AND status = 0;六、前端核心实现
6.1 请求封装
import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 10000 }) request.interceptors.request.use((config) => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = Bearer ${userStore.token} } return config }) request.interceptors.response.use( (res) => { const { code, message, data } = res.data if (code === 200) return data ElMessage.error(message) return Promise.reject(new Error(message)) }, (error) => { if (error.response?.status === 401) { useUserStore().logout() } return Promise.reject(error) } ) export default request6.2 场次列表与预约
<template> <div class="session-list"> <el-card v-for="item in sessions" :key="item.id" class="session-card"> <div class="title">{{ item.scriptTitle }}</div> <div class="meta"> <span>{{ item.startTime }} - {{ item.endTime }}</span> <span>剩余 {{ item.totalSeats - item.bookedSeats }} 位</span> </div> <div class="footer"> <span class="price">¥{{ item.price }}</span> <el-button type="primary" :disabled="item.bookedSeats >= item.totalSeats" @click="handleBook(item)">立即预约</el-button> </div> </el-card> </div> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue' import { getSessionList, createBooking } from '@/api/booking' const sessions = ref<any[]>([]) const loadSessions = async () => { sessions.value = await getSessionList({ status: 1 }) } const handleBook = async (item: any) => { await createBooking({ sessionId: item.id, seats: 1 }) ElMessage.success('预约成功') loadSessions() } onMounted(loadSessions) </script>6.3 路由与权限控制
const routes = [ { path: '/', component: () => import('@/views/home/index.vue') }, { path: '/script/:id', component: () => import('@/views/script/detail.vue') }, { path: '/store', component: () => import('@/layout/StoreLayout.vue'), meta: { roles: ['STORE'] }, children: [ { path: 'schedule', component: () => import('@/views/store/schedule.vue') }, { path: 'order', component: () => import('@/views/store/order.vue') } ] } ] router.beforeEach((to) => { const userStore = useUserStore() const roles = to.meta.roles as string[] | undefined if (roles && !roles.includes(userStore.role)) { return { path: '/403' } } })七、关键难点与解决方案
7.1 场次超卖
采用「Redis 分布式锁 + 数据库条件更新」双重保障。锁用于削峰,条件更新作为最终一致性兜底,即使锁失效也不会出现超卖。
7.2 场次状态自动流转
使用 Spring Task 定时扫描场次表,将开始时间已过的场次置为进行中,结束时间已过的置为已完成,并释放未支付订单占用的座位。
@Scheduled(cron = "0 */5 * * * ?") public void refreshSessionStatus() { scheduleMapper.finishExpiredSessions(); orderMapper.closeTimeoutOrders(LocalDateTime.now().minusMinutes(15)); }7.3 拼场与整场预约的座位一致性
拼场队伍人数与场次已订座位数需要保持同步。方案是在同一事务内先更新队伍人数,再更新场次座位,任一失败整体回滚,避免出现「队伍满员但场次仍有余位」的脏数据。
八、系统测试与部署
8.1 测试要点
- 使用 JMeter 对预约接口做 500 并发压测,验证无超卖且响应时间稳定。
- 对 JWT 过期、越权访问、重复提交等场景编写接口测试用例。
- 前端使用 Vitest 对核心工具函数与状态管理做单元测试。
8.2 部署方案
version: '3.8' services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: script_booking ports: - "3306:3306" redis: image: redis:7 ports: - "6379:6379" minio: image: minio/minio command: server /data --console-address ":9001" ports: - "9000:9000" - "9001:9001" app: build: . depends_on: - mysql - redis ports: - "8080:8080"前端通过npm run build产出静态资源,交由 Nginx 托管并反向代理后端接口,实现前后端同域部署,简化跨域配置。
九、总结与展望
本文完成了剧本娱乐门店预约平台的需求分析、架构设计、数据库设计与核心功能实现,重点解决了场次超卖、拼场座位一致性和状态自动流转三个关键问题。后续可从以下方向继续演进:
- 接入微信小程序端,降低玩家使用门槛。
- 引入推荐算法,根据玩家历史偏好推荐剧本与场次。
- 增加门店经营看板,提供上座率、复购率等多维分析。
- 引入消息队列处理支付回调与短信通知,提升系统解耦程度。