资讯动态

SpringBoot+Vue前后端分离:剧本杀预约平台开发实战

发布时间:2026/9/10 7:08:32 来源:尧图企业网站定制
1. 项目整体设计与思路拆解1.1 剧本杀行业背景与平台定位先聊聊这个项目到底解决什么问题。剧本杀这几年确实火线下门店越开越多但绝大多数门店的预约和管理还停留在微信群里接龙、手工记表格的阶段。玩家想约一场本得先加群、翻聊天记录、找客服确认场次体验很差店家想管理剧本库存、统计场次上座率也缺乏趁手的工具。所以做一个“剧本杀服务平台”核心就是把拼车、预约、剧本展示、场次管理这些线下流程搬到线上让玩家能快速找到想玩的本、约上合适的场次也让店家能高效管理门店运营。这个项目用SpringBoot Vue来做前后端分离在同类毕设或个人项目中是非常主流且稳妥的选型。SpringBoot负责提供RESTful接口处理业务逻辑和数据持久化Vue负责页面交互和接口调用两者通过JSON格式的数据通信。相比传统的JSP Servlet方案前后端分离的架构更贴近企业真实开发场景也方便后续扩展移动端或小程序端——只要复用后端接口即可。对于想找工作或考研复试的同学来说这个项目完整覆盖了“用户登录鉴权、核心业务CRUD、预约状态流转、后台管理”这些高频考点写在简历上是能拿出来讲的。1.2 技术选型为什么是SpringBoot Vue而不是其他组合很多人问过我为什么选SpringBoot Vue而不是SpringBoot Thymeleaf或者用若依这类快速开发框架我分几点说清楚。第一SpringBoot解决的是后端“配置地狱”问题。传统SSM整合要写大量的XML配置SpringBoot通过自动配置和起步依赖把数据源、事务、Web容器这些底层细节封装好开发时只需要在application.yml里配置数据源、端口等必要参数即可。一个简单的剧本列表接口从创建项目到跑通十几分钟就能完成。这对个人开发者非常友好能把精力集中在业务代码本身。第二Vue在前端生态里是渐进式的上手曲线平滑。项目不需要一开始就用TypeScript、Pinia这些进阶方案直接从选项式API Vue Router Axios起步就能完成全部页面功能。而且Vue的组件化开发模式天然适合“剧本卡片”“场次列表”“预约弹窗”这类高复用界面。我后面会在前端模块里详细拆解组件怎么划分。第三前后端分离带来的部署灵活性。后端打成jar包跑在服务器上前端打包成静态资源用Nginx托管两者互不干扰。本地开发时通过代理转发解决跨域线上部署时通过Nginx统一入口既简洁又不容易出问题。至于为什么不直接用若依这类脚手架我的理解是毕设或项目实战的价值在于你把每个环节都亲手实现一遍能讲清楚JWT鉴权怎么做的、拦截器拦截了什么、订单状态怎么流转的。直接用脚手架虽然快但面试官一问底层细节就容易露馅。当然若依的代码结构值得参考尤其是权限管理部分后面我会提到可以借鉴的思路。1.3 功能模块设计与数据库建模思路平台的核心用户是两类C端玩家和B端店家/管理员。围绕这两类角色功能模块可以拆成六个主要部分模块功能说明主要角色用户认证注册、登录、JWT签发与校验、用户信息维护游客、玩家剧本管理剧本列表、详情、类型筛选、搜索玩家、管理员门店与场次管理门店信息、场次排期、座位数管理玩家、店家预约拼车创建预约、加入拼车、取消预约、状态流转玩家订单管理订单列表、订单状态跟踪、结算信息玩家、管理员后台管理用户管理、剧本上下架、数据统计管理员数据库建模是这一步的重头戏。我的建议是不要一上来就追求字段多先保证核心表的关系清晰。平台最少需要这几张表user用户表字段包括id、username、passwordBCrypt加密存储、nickname、phone、role区分玩家/管理员、create_time。script剧本表字段包括id、name、type还原本/阵营本/情感本等、difficulty、duration、min_players、max_players、price、description、cover_url、status上架/下架。store门店表字段包括id、name、address、phone、business_hours。session场次表也有叫arrangement的核心是脚本–门店–时间三个维度的绑定字段包括id、script_id、store_id、start_time、end_time、max_seats、booked_seats、status。appointment预约/拼车表字段包括id、session_id、user_id、seats、status待成团/已成团/已完成/已取消、create_time。这里有一个设计细节值得注意appointment表和session表之间既要记录预约行为又要维护场次剩余的座位数。我在开发时采用的是“预约主表 座位数字段冗余”的方式每次新增预约时校验并更新对应session的booked_seats。虽然牺牲了一点规范化但查询效率更高也不用频繁做聚合计算。后文我会专门讲这个并发控制问题。2. 核心技术细节解析与实操要点2.1 后端分层架构从Controller到Mapper的职责划分后端代码我习惯按标准的四层结构组织Controller接口层、Service业务层、Mapper数据访问层、Entity/DTO模型层。这样做的好处是职责单一出了问题能快速定位是参数校验的锅、业务逻辑的锅还是SQL的锅。先看一个查询剧本列表的接口设计RestController RequestMapping(/api/script) public class ScriptController { Resource private ScriptService scriptService; GetMapping(/list) public ResultListScriptVO list(RequestParam(required false) String type, RequestParam(required false) String keyword, RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize) { return Result.success(scriptService.getScriptList(type, keyword, pageNum, pageSize)); } }Controller层只负责接收参数、调用Service、包装返回结果。具体的参数校验可以交给Validated注解配合实体类上的NotBlank等校验规则来做而不是在Controller里写一堆if-else。这一点在代码评审时是加分项。Service层承载核心业务逻辑。比如预约模块里的“创建预约”方法就涉及好几步操作根据sessionId查出场次信息、检查当前场次是否还有余座、检查当前用户是否已经预约过该场次、生成预约记录、更新场次已订座位数。每一步都需要在Service层明确编排而不是把业务逻辑散落在Controller或Mapper里。再往下是Mapper层。我用的持久层框架是MyBatis-Plus它最强的地方是提供了内置的CRUD方法单表操作基本不用写SQL直接调用selectById、insert等方法即可。复杂的多表关联查询再配合Select注解或XML文件编写SQL。例如查询剧本详情时需要连带返回门店名称和平均评分就可以写一条联表SQL处理。2.2 JWT鉴权与拦截器登录状态怎么安全地保持前后端分离场景下Session机制并不好用因为前端和后端不在同一个域下SessionId的传递和共享比较麻烦。实际项目中更普遍的做法是JWTJSON Web Token。JWT的原理可以理解成一张“带签名的通行证”。用户登录成功后后端把用户id、角色等信息放进Token的载荷部分用密钥签名生成一串字符串返回给前端。前端每次请求时把这个Token放到请求头Authorization字段里后端拦截器校验签名和有效期通过后放行请求并从Token中解析出当前用户信息。登录接口核心代码逻辑public String login(LoginDTO dto) { User user userMapper.selectByUsername(dto.getUsername()); if (user null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { throw new BusinessException(用户名或密码错误); } // 生成JWT有效期设置为24小时 String token JwtUtil.createToken(user.getId(), user.getRole()); return token; }密码存储一定要用BCrypt加密千万不能明文存。BCrypt每次加密的结果是随机的但checkpw方法可以校验原文是否匹配安全性比MD5加盐要好很多。我见过不少项目还用MD5这在简历上写出来会被面试官追问到很难看。JWT拦截器的核心逻辑public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !JwtUtil.verify(token)) { response.setStatus(401); return false; } return true; } }拦截器在SpringBoot中注册时还需要排除掉登录接口、静态资源路径等白名单。我建议使用一个自定义的WebMvcConfigurer来配置Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new JwtInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/user/login, /api/user/register); } }这里有个很关键的坑拦截器放行路径的写法。如果你把/api/**都拦截了却忘了排除登录接口那么登录请求也会被拦截导致前端调登录接口直接报401还查不出原因。我第一次整合时就踩了这个坑排查了半天才发现是路径配置的问题。2.3 Vue前端架构组件化划分与路由设计Vue前端的目录结构我按照功能来组织而不是按页面来堆砌。目录结构如下src/ api/ // 接口请求封装 script.js user.js appointment.js assets/ // 静态资源 components/ // 通用组件 NavHeader.vue ScriptCard.vue router/ // 路由配置 index.js views/ // 页面组件 home/HomeView.vue script/ScriptListView.vue script/ScriptDetailView.vue appointment/AppointmentView.vue user/LoginView.vue user/RegisterView.vue admin/AdminDashboard.vue store/ // 状态管理可选本项目可用可不用 user.js utils/ request.js // axios封装组件化设计要遵循“高内聚、低耦合”的原则。我把ScriptCard抽成通用组件因为无论首页推荐、剧本列表还是搜索结果页都需要展示剧本的封面、名称、类型、难度、价格等信息。只需要把剧本对象通过props传进去组件内部负责展示即可。路由方面我采用了Vue Router的createWebHistory模式并在路由守卫里处理登录控制router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }); } else { next(); } });requiresAuth标记需要登录才能访问的页面比如“我的预约”页面。游客未登录时访问会被重定向到登录页并带上跳转前路径登录成功后可以自动跳回原页面这个体验细节很实用。2.4 Axios封装与前后端联调接口对接的标准姿势前后端分离项目最容易出问题的环节就是接口联调所以我建议把Axios统一封装在一个request.js里统一处理请求头携带Token、响应拦截、错误提示。import axios from axios; import { message } from ant-design-vue; import router from /router; const request axios.create({ baseURL: /api, timeout: 10000 }); // 请求拦截器自动携带Token request.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization token; } return config; }); // 响应拦截器统一处理错误 request.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { message.error(登录已过期请重新登录); localStorage.removeItem(token); router.push(/login); } else { message.error(error.response?.data?.message || 请求失败); } return Promise.reject(error); } ); export default request;这里有一个设计要注意的地方后端返回的Result结构统一为{ code, message, data }所以响应拦截器直接return response.data即可业务代码里只需要关注res.code 200就能拿到res.data不用每次写response.data.data这种冗长代码。开发阶段的跨域问题我在vue.config.js里配置了代理module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } };这样前端的请求都走相对路径/api/...由开发服务器代理到后端的8080端口避开了浏览器同源策略的限制。线上部署时则由Nginx统一处理静态资源转发和API反向代理我在第4节会详细展开。3. 实操过程与核心环节实现3.1 环境准备与项目初始化项目初始化阶段后端需要准备好Java 8、Maven 3.6、MySQL 5.7推荐8.0前端需要Node.js 16和npm。这一步没什么捷径但我有一个实际经验Node版本不要盲目追新。Vue CLI项目在Node 18下可能存在依赖兼容问题而Node 20在某些老项目中会更明显。如果遇到安装依赖报错先用Node 16的LTS版本试试很多问题能迎刃而解。后端初始化时我推荐直接去Spring Initializr官网生成基础工程选择Spring Web、MyBatis-Plus、MySQL Driver、Lombok这些依赖。这里有个小建议Lombok确实能减少getter/setter代码但用了IDE编译报错时记得先检查是不是没装Lombok插件。这个问题特别常见尤其是用IDEA的新手。前端初始化用Vue CLI或Vite都可以。Vue CLI的生态成熟、资料多出问题了容易查到解决方案Vite启动速度快但部分老组件库可能存在兼容问题。如果你是第一次做完整项目我建议用Vue CLI稳妥优先。3.2 剧本管理模块前后端完整实现剧本管理是最能体现CRUD基本功的模块从后端到前端走通一遍其他模块基本就心里有底了。后端部分实体类对应的数据库表Data TableName(script) public class Script { TableId(type IdType.AUTO) private Integer id; private String name; private String type; private String difficulty; private Integer duration; private Integer minPlayers; private Integer maxPlayers; private BigDecimal price; private String description; private String coverUrl; private Integer status; private LocalDateTime createTime; }TableName注解用来指明实体类对应的数据库表名TableId用来标注主键。这些是MyBatis-Plus的约定只要实体类和表的字段对应上了单表操作全都可以靠内置方法解决。Service层写了剧本新增和分页查询两个核心方法public PageScript getScriptList(String type, String keyword, Integer pageNum, Integer pageSize) { LambdaQueryWrapperScript wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(type)) { wrapper.eq(Script::getType, type); } if (StringUtils.hasText(keyword)) { wrapper.like(Script::getName, keyword) .or().like(Script::getDescription, keyword); } wrapper.orderByDesc(Script::getCreateTime); return scriptMapper.selectPage(new Page(pageNum, pageSize), wrapper); }这里用到了MyBatis-Plus的LambdaQueryWrapper它的好处是类型安全。如果你把字段名写成字符串重构实体类字段时很容易漏改Lambda表达式可以尽量避免这个问题。前端的剧本列表页就是一个经典的“搜索条件 卡片列表 分页”布局。搜索区域用el-form内联布局剧本类型用el-select下拉选择关键字用el-input输入门店或难度等过滤条件按需增加。列表区域用el-card循环展示点击卡片跳转到详情页。详情页要调用详情接口GetMapping(/detail/{id}) public ResultScriptDetailVO detail(PathVariable Integer id) { ScriptDetailVO vo scriptService.getDetailById(id); return Result.success(vo); }详情接口返回的不只是剧本基础信息还包括关联的门店场次列表因此VO里会嵌套一个场次列表字段。后端用一个ScriptDetailVO接收多表查询结果前端拿到后分开渲染剧本信息区和可选场次区。对这种“主表 子表”的场景前端一定要处理好子表为空时的空状态避免渲染报错。3.3 预约拼车模块并发控制与状态流转预约模块是整个项目里业务复杂度最高的部分因为它涉及到并发场景下的数据一致性。举个实际场景某个热门剧本的场次只剩下1个座位这时候A和B两个玩家同时点击预约谁能成功如果代码逻辑是先查询booked_seats max_seats然后执行insert再更新booked_seats存在一个典型的时间窗口——两个请求同时通过了查询然后都执行了insert最后座位数就超卖了。解决超卖问题有几种方案我按由易到难排序第一个是乐观锁。在session表增加一个version版本号字段更新座位数时通过SQL条件WHERE id ? AND version ?来保证只有版本号匹配才能更新成功并且每次更新版本号加1。如果更新影响行数为0说明版本冲突返回“手速慢了座位已被抢走”。这是性能和复杂度都比较均衡的方案。Update(UPDATE session SET booked_seats booked_seats #{seats}, version version 1 WHERE id #{sessionId} AND version #{version} AND booked_seats #{seats} max_seats) int lockSeats(Param(sessionId) Integer sessionId, Param(seats) Integer seats, Param(version) Integer version);第二个是数据库悲观锁使用SELECT ... FOR UPDATE锁定行记录事务结束后释放锁。这种方式最保险但并发性能略差且需要谨慎控制事务边界否则容易导致长事务锁表。第三个是分布式锁用Redis的SETNX实现。这属于锦上添花的方案单机部署的项目其实用不到但面试时可以提一下你了解。预约状态流转也值得花心思设计。我定义的预约状态有四种待成团、已成团、已完成、已取消。当某个场次预约人数达到最低开本人数时系统把预约状态改成“已成团”场次时间结束后定时任务或管理员手动操作将“已成团”改成“已完成”。虽然业务逻辑不复杂但前后端都要对这个状态做对应处理——前端展示状态标签时要有不同的颜色和文案后端接口要根据不同状态判断允许的操作比如已成团后玩家不能再取消。3.4 后台管理文件上传与数据统计后台管理模块给管理员提供剧本上下架、用户管理、场次管理、数据看板等功能。这个模块的技术难度主要在文件上传和图表渲染上。剧本封面上传是一个高频需求。前端用el-upload组件后端提供一个接收MultipartFile的接口将文件保存到服务器本地或者对象存储服务中。PostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { String originalFilename file.getOriginalFilename(); String extName originalFilename.substring(originalFilename.lastIndexOf(.)); String newFileName UUID.randomUUID() extName; String datePath new SimpleDateFormat(yyyy-MM-dd).format(new Date()); File dir new File(uploadPath / datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, newFileName)); String url /files/ datePath / newFileName; return Result.success(url); }文件上传有几个需要特别注意的细节文件名一定要重命名不能直接用原始文件名否则会遇到中文乱码、路径穿越、文件名冲突等问题。文件保存路径建议用日期分目录便于后期清理和排查。上传文件时要在Nginx或后端配置文件访问映射。比如后端把文件放在了/data/files目录Nginx需要配置location /files/ { alias /data/files/; }才能访问到图片。限制上传文件大小和类型防止被别人传恶意脚本文件上去。数据统计方面可以用el-card展示核心指标注册用户数、今日预约数、剧本总数、门店总数用Apache ECharts的折线图展示近7天预约趋势用饼图展示剧本类型分布。后端统计接口主要就是几个GROUP BY查询再按日期补0填充这部分代码不难但要注意SQL中的日期处理要兼容数据库类型。3.5 全流程部署从jar包到Nginx静态资源开发完项目后部署上线是一个必经环节也经常被新手忽略。我的部署方案是后端jar包运行在服务器上比如8000端口前端打包后的dist目录交给Nginx托管Nginx监听80端口统一对外提供服务同时把/api开头的请求反向代理到后端服务。Nginx关键配置server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /var/www/script-platform/dist; try_files $uri $uri/ /index.html; } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 上传文件访问 location /files/ { alias /data/script-platform/files/; } }这里的try_files $uri $uri/ /index.html;非常关键。Vue使用History模式时如果用户直接在浏览器地址栏输入某个子路由地址如/script/2服务器上其实没有这个物理路径Nginx需要把所有路径都回退到index.html由前端路由接管。如果不加这一行刷新页面就会报404。后端打jar包的命令mvn clean package -DskipTests然后通过nohup java -jar script-platform.jar app.log 21 启动服务。到这里一个完整的前后端分离项目就算真正落地了。4. 常见问题与排查技巧实录4.1 SpringBoot版本与依赖冲突问题SpringBoot版本的选择直接影响后续依赖的可用性。常见的坑是SpringBoot 3.x和SpringBoot 2.x在javax与jakarta命名空间上的变化。SpringBoot 3.x要求JDK 17并且把javax.servlet替换成了jakarta.servlet很多老教程里的代码在3.x下会直接编译失败。如果用JDK 8那只能选择SpringBoot 2.7.x及以下版本。所以我的建议是先确定JDK版本再选SpringBoot版本最后根据SpringBoot版本选配套的MyBatis-Plus和SpringCloud版本顺序不能乱。依赖冲突的另一个高发点是pagehelper和mybatis-plus同时引入两者都会操作MyBatis的插件机制容易出现分页查询不稳定或直接报错。如果用了MyBatis-Plus就不要引入PageHelper分页直接用selectPage即可。同理不要同时引入mysql-connector-java和mysql-connector-j两个坐标版本不一致时会报莫名其妙的驱动类错误。排查这类问题先看启动日志里报错的第一行堆栈而不是看最后一行。第一行通常会告诉你“jar包版本冲突”或“找不到某个类”根据提示在pom.xml中排除传递依赖或统一版本号。4.2 Vue依赖安装与启动问题前端项目最常见的报错是Module not found: Error: Cant resolve element-plus这种问题一般有三个原因一是没有执行npm install二是package.json里没有声明这个依赖三是node_modules目录损坏。解决办法依次是确认package.json依赖声明、删除node_modules目录后重新执行npm install、检查npm镜像源是否正常。还有一类很典型的问题是el-message使用时提示未定义。在Element Plus中ElMessage是一个独立的API使用前需要先引入import { ElMessage } from element-plus;如果你是通过插件方式全局引入Element Plus那么在组件里必须显式import。但如果是用自动导入插件如unplugin-vue-components unplugin-auto-import就要额外配置AutoImport的resolvers否则ElMessage还是找不到。这个坑比较隐蔽很多新手在全局引入了Element Plus后仍然报错就是因为自动导入插件把组件按需导入了但API级别的方法没有被自动注册。4.3 前后端联调中的跨域与Cookie问题联调阶段的跨域问题很常见。虽然我前面提到在vue.config.js配置了开发代理但有些同学的代码里把axios的baseURL写成了绝对地址如http://localhost:8080这样的话代理配置就不生效了因为代理只匹配相对路径/api开头的请求。正确的做法是baseURL统一写成/api由代理转发不要写死IP地址。如果你在生产环境遇到了跨域最稳妥的方案是交给Nginx处理而不是在后端加CrossOrigin注解或CORS全局配置。后端放开CORS看似方便但会导致所有域名都能调用你的接口存在安全隐患。比较好的做法是Nginx只允许指定域名访问后端保持接口纯净。4.4 敏感信息泄露与安全加固最后说一个很多人忽视的安全问题。SpringBoot项目如果以默认配置启动Actuator监控端点可能暴露系统信息、环境变量甚至导致堆内存信息被不明访问者下载。如果你的pom.xml里引入了spring-boot-starter-actuator务必在application.yml中限制端点暴露范围或者干脆去掉这个依赖。再一个就是数据库密码、Redis密码等敏感配置不能明文写在application.yml里。可以用Jasypt对配置项做加密在配置文件中使用ENC(加密串)占位运行时由Jasypt解密。比如将数据库密码加密后配置spring: datasource: url: jdbc:mysql://localhost:3306/script_platform username: root password: ENC(加密后的密文)然后在启动类或配置类上启用JasyptSpringBootApplication EnableEncryptableProperties public class ScriptPlatformApplication { public static void main(String[] args) { System.setProperty(jasypt.encryptor.password, 你的密钥); SpringApplication.run(ScriptPlatformApplication.class, args); } }密钥可以通过启动命令参数传入避免写死在代码里。这个处理在简历上可以作为一个安全亮点来写面试官会认为你有安全意识。这个项目做完之后最大的感受是剧本杀服务平台虽然业务不算复杂但麻雀虽小五脏俱全从前端交互到后端并发控制都涉及了做完一遍对整个前后端分离开发流程会有一个非常完整的认知。如果你打算在这个基础上继续扩展可以试试增加微信小程序端或者引入Redis缓存热点剧本数据、用消息队列处理预约通知往这些方向深化项目质量会有明显的提升。我个人在实际操作中的体会是不要一上来就追求大而全先把核心链路跑通再逐步迭代这才是做项目最踏实的节奏。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价