
做影视购票这类前后端分离项目的同学应该都有过相似的经历代码在本地好不容易跑通了但要交毕设、发源码、部署到服务器的时候各种奇奇怪怪的问题全冒出来了。SpringBoot 版本选太高导致启动直接报错、Vue 里播放 .m3u8 预告片黑屏、Nginx 部署完刷新页面 404……这些坑我在完成“万象影视购票平台”这个项目的过程中全踩过。这篇文章就把整个项目从架构设计、数据库建模、后端接口、前端页面到部署文档的完整链路梳理一遍重点写清楚每个环节为什么这样设计、哪些地方容易翻车给正在做类似前后端分离项目的朋友一个可以参照的完整样本。这个项目适合谁看如果你是在做 SpringBoot Vue 的毕业设计、课程设计或者想自己动手做一个带用户端和管理端的完整业务系统这篇文章应该能帮你省不少时间。我会尽量把核心表结构、关键接口逻辑、选座并发处理、m3u8 播放、Nginx 部署这些硬骨头都讲到并标注哪些是参考代码里不会写的隐性经验。1. 项目全景这个影视购票平台到底在解决什么问题1.1 用户视角的完整业务链路万象影视购票平台从产品形态上就是一个典型的在线购票系统。用户打开首页能看到正在热映和即将上映的电影列表点进电影详情页可以看到影片介绍、预告片和覆盖的影院场次选择一个合适的场次后进入选座页面在座位图里挑座位、生成订单、完成模拟支付最后在订单列表里查看电子票信息。管理端则负责维护电影信息、影院影厅、排片计划和订单数据。这个业务链路听起来不复杂但它基本覆盖了一个前后端分离项目的所有核心知识点用户认证、RESTful 接口设计、多表关联查询、并发下单处理、前端路由管理、状态管理、组件封装、以及最后的前后端分离部署。也正因为如此这类项目在毕设和课设里一直是常青树选题。从功能模块划分来看用户端主要是电影浏览、场次查询、选座购票、订单管理管理端主要是电影管理、影院管理、影厅管理、排片管理、订单管理。实际开发时建议把用户端和管理端做成两个前端应用或者至少用路由层面严格区分开因为它们的操作入口和权限模型差别很大混在一起会让路由守卫和菜单权限判断变得非常痛苦。1.2 技术选型稳定优先别在版本上硬刚后端选 SpringBoot、前端选 Vue这个组合本身没有悬念。但版本选择上我强烈建议遵循“稳定优先”原则不要一上来就追最新版本。我一直推荐 SpringBoot 2.7.18 JDK 1.8 MyBatis-Plus 3.5.3.1 这套组合原因很简单JDK 1.8 到现在仍然是绝大多数高校机器和服务器的主流环境SpringBoot 2.7.x 是 2.x 系列的最终维护版本坑最少、资料最多逛社区搜问题基本都能找到答案。SpringBoot 3.x 虽然已经出来很久了但它强制要求 JDK 17而且包名从javax.*换成了jakarta.*很多老教程、第三方 starter 和老版本的 MyBatis-Plus 都依赖于旧的包名规范。如果你参考的项目代码是老代码直接套 SpringBoot 3.x 启动时会报ClassNotFoundException尤其是javax.annotation.PostConstruct这类注解的丢失排查起来很费劲。前端我用的是 Vue 3 Vite Vue Router 4 Pinia Element Plus Axios 这套标准组合。Vite 启动快、热更新流畅Vue 3 的组合式 API 写起来比 Options API 更清晰Element Plus 做后台管理界面几乎是现成的。唯一要注意的是 Element Plus 的按需引入配置稍后会单独讲。数据库选 MySQL 8.0如果是本地开发5.7 也可以但要注意 MySQL 8.0 默认的认证插件是caching_sha2_passwordJDBC 连接串要对应配置时区参数也建议明确指定避免后端时间数据差 8 小时。2. 数据库设计选座系统是所有表结构的核心2.1 核心表清单与字段设计逻辑影视购票平台的核心表可以分为两大块一块是基础资料包括用户表、电影表、影院表、影厅表另一块是业务数据包括排片表场次表、订单表、座位状态表。下面是我最终打磨后觉得比较合理的核心表结构表名核心字段设计说明userid, username, password, nickname, phone, avatar, role, create_time用户表和管理员共用用role字段区分角色毕设阶段不必拆两张表movieid, title, cover_url, video_url, description, duration, release_date, score, status, categorystatus区分未上映、热映、已下架video_url存预告片地址格式支持 mp4 或 m3u8cinemaid, name, address, district, phone影院基本资料district方便按区域筛选hallid, cinema_id, name, seat_rows, seat_cols影厅属于某个影院行列数决定座位图大小hall_seatid, hall_id, seat_row, seat_col, seat_type影厅的静态座位模板seat_type标记普通座、情侣座等scheduleid, movie_id, cinema_id, hall_id, start_time, end_time, price, remain_seats, status排片信息一次排片绑定电影影院影厅时间schedule_seatid, schedule_id, seat_row, seat_col, status某个场次的座位动态状态0 空闲、1 锁定、2 已售orderid, order_no, user_id, schedule_id, seat_ids, total_price, status, create_time, pay_time订单主表seat_ids存选中的座位标识方便展示ticketid, order_id, movie_id, schedule_id, seat_info, qr_code出票记录支付成功后生成可扩展二维码这里要特别解释一个细节为什么要单独建hall_seat和schedule_seat两张座位表而不是每场次直接存一个座位状态字符串因为影厅的座位布局是相对固定的。一个影厅有 8 排、每排 10 个座位这个“模板”只需要存一份。真正会变的是每个具体场次里同一个座位是否被购买。所以排片生成时程序从hall_seat这张模板表批量复制生成schedule_seat记录这样一个影厅可以有任意多个场次的独立座位状态互不干扰。实际开发中这个批量生成逻辑放在排片管理接口里用一条 insert 语句配合 select 完成代码量很小但很关键。如果你只在schedule表里存一个 JSON 字符串或逗号分隔的座位状态确实也能实现选座但后续统计、改价、状态流转都会变得很别扭而且数据库层面的约束能力基本丢失并发下单时容易出现两个用户同时买到同一个座位的脏数据。2.2 座位设计静态模板加动态状态座位体系是整个系统里最值得花时间设计的地方。我的设计思路是“静态模板 动态状态”的双层结构这也是电影院购票系统最常见的实现方案。第一步影厅创建时自动生成静态座位模板。比如新建一个 ID 为 H001 的影厅设定 8 排、每排 10 座那么hall_seat表里就插入 80 条记录标记为第 1 排第 1 座、第 1 排第 2 座……直到第 8 排第 10 座。这个操作可以在影厅管理接口中用一个双重循环完成也可以写 SQL 存储过程但最简单可靠的就是用 Java 代码生成。第二步排片创建时批量生成动态座位状态。管理员新建一个场次时程序自动查该影厅的hall_seat为这个场次生成同样数量的schedule_seat记录初始状态全部为 0。这一步的意义在于座位状态数据是独立于排片时间的你可以精确到“某一场次某个具体座位是否被锁定和售出”。第三步前端展示座位图时后端返回影厅的行列数、每个座位当前的status以及座位类型前端按二维网格渲染即可。这套设计在数据库层面确保了每个座位的状态可追踪、可恢复。比如用户选座后系统把对应schedule_seat置为 1锁定如果订单超时未支付定时任务可以把这些座位重新置为 0支付成功后置为 2已售。状态流转的每一步都有据可查。2.3 订单状态机待支付、锁定、超时释放订单状态机建议划分这几种0 待支付、1 已支付、2 已取消、3 已退款、4 已完成。核心难点在于“待支付”和“座位锁定”的联动。用户下单后系统先把选中的座位从空闲0改成锁定1同时创建一张状态为“待支付”的订单并给订单设置一个支付截止时间通常是 15 分钟。如果用户在 15 分钟内完成支付订单状态改为已支付座位状态改为已售如果超时未支付就需要把订单一键取消、座位释放回空闲状态。这个超时释放有两种常见实现。一种是定时任务扫描每隔一段时间查一次待支付订单判断创建时间是否超过 15 分钟把超时的订单取消并释放座位。另一种是懒检查用户查询订单或选座时顺便判断一下有没有该释放的座位如果过期则先执行释放逻辑再返回数据。毕设场景下建议两者结合定时任务兜底懒检查做实时性补偿。定时任务用 Spring 的Scheduled注解就能实现几分钟跑一次完全够用。如果不做超时释放会出现最典型的问题——用户占了座位不支付后面所有用户都买不到这个座位演示时非常尴尬。3. SpringBoot 后端从登录鉴权到下单并发3.1 工程结构与统一返回体约定后端工程我会按这个结构组织src/main/java/com/wanxiang/ ├── controller/ # 接口层 ├── service/ # 业务层 ├── mapper/ # MyBatis-Plus Mapper 接口 ├── entity/ # 数据库实体类 ├── dto/ # 请求/响应对象 ├── common/ # 统一返回体、常量、枚举 ├── config/ # 配置类如跨域、拦截器 ├── exception/ # 全局异常处理 └── utils/ # 工具类如 JWT、日期处理从第一个接口开始就要统一返回体。我习惯用一个ResultT类字段包含code、message、data成功时 code 为 200业务异常时返回对应错误码。这样做的好处是前端 Axios 响应拦截器可以统一判断业务状态不需要每个接口单独处理。配套的还有全局异常处理用RestControllerAdvice捕获BusinessException自定义业务异常、参数校验异常、数据库异常等统一包装成Result返回。这样哪怕后端某个环节报错了前端收到的仍然是一段完整可解析的 JSON而不是一堆不友好的堆栈信息。3.2 JWT 鉴权与角色权限控制登录认证我采用 JWT 方案这也是目前前后端分离项目的主流做法。用户登录成功后后端生成一个 token里面包含用户 ID、角色和过期时间用私钥签名后返回给前端。前端把 token 存在本地存储里每次请求在请求头Authorization带上它。后端通过拦截器统一解析 token判断请求是否合法、当前用户是谁。写 JWT 工具类时建议把密钥、过期时间等配置放到application.yml中而不是硬编码在代码里。特别注意 token 过期时间的设置一般建议 24 小时如果太短用户演示到一半被踢出去重新登录体验很差。角色权限方面用户和管理员可以用role字段区分。用户端接口只要求登录即可管理端接口额外判断role是否为管理员。因为拦截器先完成 token 解析所以可以在HandlerInterceptor的preHandle方法里把 userId 和 role 存入ThreadLocal或 request 属性后续 Service 层就能很方便地拿到当前操作人。3.3 电影、场次与选座的数据链路用户端最核心的查询链路是首页电影列表 - 电影详情 - 场次列表 - 座位状态。首页电影列表比较简单movie表按状态和上映时间过滤排序即可。电影详情页要展示的不只是电影信息还有该电影在当前城市的影院和场次。我的做法是提供GET /api/movie/{id}返回电影详情GET /api/movie/{id}/schedules?datexxx返回按日期过滤的场次列表场次数据通过关联schedule、cinema、hall表组合返回包括影院名称、影厅名称、放映时间、价格、余票数。选座接口返回的数据格式大概是这样的影厅总行数、总列数以及一个座位数组数组里每个元素包含row、col、status、seatType。前端拿到后就能直接渲染座位图。这里有个小经验剩余座位数可以直接查schedule_seat里 status 为 0 的数量但如果列表页频繁调用会对数据库造成压力。所以我在schedule表里冗余了一个remain_seats字段选座或下单时同步更新列表展示直接读它性能更好。3.4 下单接口的并发安全处理下单是业务里最需要小心的环节。核心风险是超卖两个用户同时看到某个座位是空闲的同时提交订单如果程序只是“先 select 后 update”很可能两个用户都下单成功造成座位被重复卖出。我做了一个特别简单但很有效的方案利用数据库的行级锁解决。下单接口的执行逻辑是开启事务。把前端提交的座位 ID 列表执行一条带条件更新的 SQLUPDATE schedule_seat SET status 1 WHERE id IN (?) AND status 0判断本次更新的影响行数是否等于传入座位数。如果小于传入数说明有座位已经被别人锁定或购买抛出“座位已被抢请重新选择”的异常事务回滚。更新成功则生成订单插入order表状态置为待支付。同步更新schedule表的remain_seats字段。提交事务。这条 UPDATE 语句会锁住匹配的行同一时刻其他事务对同一批座位的更新必须等它提交天然解决了并发冲突。这是数据库层幂等更新的思路代码简单、性能也够好非常适合毕设这类中小规模场景。有同学可能会问用 Redis 分布式锁是不是更好理论上可以但毕设项目往往没有引入 Redis 的必要性引入后的序列化、异常处理、锁释放等问题反而增加复杂度。我的原则是能用数据库约束解决的不额外引入中间件。3.5 管理后台的接口如何划分管理后台接口我建议和用户端接口分开前缀比如用户端用/api/user/**和/api/movie/**管理端统一加/api/admin/**前缀。这样拦截器在配置路径时特别方便管理员权限校验直接映射到/api/admin/**即可。管理端核心接口包括电影管理增删改查、上下架、影院管理、影厅管理创建影厅时顺带生成静态座位模板、排片管理新增场次时批量生成schedule_seat、订单管理查看订单、退款操作、用户管理查看用户列表、禁用用户。这些接口逻辑都不复杂就是标准的 CRUD 加一些业务约束校验比如删除电影前要检查是否存在未开始的排片。4. Vue 前端路由、选座组件与视频播放4.1 前端工程规划与依赖安装前端我用 Vite 创建项目命令是npm create vitelatest选择 Vue 模板。然后安装核心依赖vue-router、pinia、axios、element-plus。Element Plus 我建议按需引入不然打包体积会很大。按需引入有两种主流方式官方推荐的 unplugin-auto-import 和 unplugin-vue-components 插件会自动解析模板里用到的组件并只打包这部分代码另一种是手动按需引入缺点是每次要用新组件都要去写一遍 import。我建议直接配 unplugin 自动按需引入省心很多。注意配置文件是vite.config.js配好后启动项目验证一下按钮、表格组件能否正常显示。目录结构上我习惯这样分api文件夹集中管理接口请求views放页面组件components放公共组件比如选座组件、电影卡片组件router放路由配置store放 Pinia 状态utils放 Axios 封装和工具函数。4.2 路由设计带参跳转与刷新 404前端路由我分了用户端和管理端两套。用户端有首页、电影详情页、选座页、登录注册页、订单页管理端有登录页、电影管理页、排片管理页、订单管理页等。详情页和选座页需要传参。我的做法是电影详情页使用动态路径参数/movie/:id组件内通过route.params.id拿电影 ID。选座页则是先进入一个中间确认页携带scheduleId参数进入选座页后调座位接口。这里有个细节用户从选座页提交订单后跳转到订单确认页刷新页面不能丢失参数所以最好把 scheduleId 同时放进路由 query 里或者用 Pinia 暂存。说到刷新 404这是前后端分离部署最常见的坑之一。开发环境没问题打包部署到 Nginx 后如果用户直接在浏览器地址栏输入/movie/3或按 F5 刷新这个页面Nginx 会去磁盘找movie/3.html找不到就返回 404。解决办法是在 Nginx 的静态资源配置中添加try_files $uri $uri/ /index.html;让所有前端路由请求都回退到index.html由 Vue Router 接管。4.3 电影列表到详情页的数据流Axios 封装是前端工程的第一步。我的utils/request.js会创建一个 Axios 实例设置baseURL为/api开发时通过 Vite 的代理转发到后端地址部署后由 Nginx 反向代理。请求拦截器从本地存储取 token有则加到 header响应拦截器统一处理业务返回code 不为 200 时弹提示401 时跳转登录页。电影列表页的数据流很直观页面挂载时调getMovieList()把返回的数组渲染成卡片网格。卡片点击跳转/movie/:id详情页在onMounted里从route.params.id拿到 ID调详情接口和场次接口。这里建议用watch监听路由参数变化因为当用户在详情页内切换相关推荐电影时路由参数变化但组件没有重建不监听就会显示旧数据。4.4 选座组件座位渲染、选中与提交选座是前端交互最复杂的部分。我的实现思路是拿到后端返回的座位数组后按row和col组织成二维数组然后用div网格渲染每个座位。座位有四种显示状态可选、已选用户当前选中、已锁定、已售。已锁定和已售的座位不能点击可选座位点击后切换为已选已选状态再次点击则取消。组件中要维护一个数组selectedSeats记录用户当前选中的座位。同时底部栏实时显示已选座位数、总价。下单按钮只有selectedSeats.length 0时才能点击。提交时把scheduleId和选中的座位 ID 列表发送给后端下单接口。下单成功后前端可以跳转到支付确认页模拟支付后回到订单列表。这里分享一个细节用户选中座位后停留超过订单过期时间怎么办如果用户支付时座位已经被释放后端下单接口会返回“座位已被抢”的错误前端要做的是清空本地选中状态、重新拉取座位状态图提示用户重新选择。这个交互在产品体验上很重要代码上也就几行的事。4.5 预告片播放m3u8 的两种前端处理方案影视平台肯定要处理视频播放。如果后端给的视频源是.m3u8格式也就是 HLS 流媒体Chrome 原生 video 标签是不支持的需要引入 hls.js 或 video.js。用 hls.js 的话核心逻辑是判断浏览器是否原生支持 HLSSafari 支持支持的直接用 video 标签播放不支持的用new Hls()把 m3u8 地址交给 hls.js 加载然后把视频源挂接到 video 元素上。代码大致是import Hls from hls.js export function initHls(videoElement, src) { if (videoElement.canPlayType(application/vnd.apple.mpegurl)) { videoElement.src src } else if (Hls.isSupported()) { const hls new Hls() hls.loadSource(src) hls.attachMedia(videoElement) } else { // 提示浏览器不支持 } }视频地址是后端接口返回的临时地址可能存在跨域问题。比如前端部署在 80 端口m3u8 视频文件由另一个静态资源服务提供那么 hls.js 拉分片时就会因为跨域失败。解决方式是在视频文件的响应头里加Access-Control-Allow-Origin: *或者在 Nginx 里对视频目录单独配置跨域头。另外提一嘴毕设里有的同学没准备真正的 HLS 视频源就用普通 mp4 文件占位。这可以但论文里最好写清楚 m3u8 是 HLS 流媒体协议格式适合直播和长视频分段加载属于行业主流方案这样答辩时不会露怯。5. 部署与运行版本坑、跨域坑、Nginx 坑5.1 版本组合不对会引发连环报错我把部署相关的坑放在最后写是因为这些问题最容易在交付时爆发。先说版本组合见过太多同学项目代码写完了跑到服务器上一启动直接一串ClassNotFoundException或NoSuchMethodError。我给一个经过反复验证的稳妥版本组合可以直接照抄组件推荐版本说明JDK1.8兼容性最好服务器普遍支持SpringBoot2.7.182.x 最后一个版本社区资料多MyBatis-Plus3.5.3.1配套 SpringBoot 2.x 没有任何兼容问题MySQL5.7 或 8.08.0 需要指定时区参数Node.js16 或 18Vite 5 以上需要 Node 18Vue3.x Vite稳定组合Nginx1.24 或更新静态托管和反向代理SpringBoot 3.x 有同学踩过很典型的坑JDK 17 环境跑 SpringBoot 3.2MyBatis-Plus 用的还是老版本启动时找不到javax.sql.DataSource相关类原因就是 SpringBoot 3 全面切换到jakarta命名空间旧依赖的编译产物里还是javax包路径。这种问题难排查也不是改一行配置能解决的所以毕设我真心建议别用太新的版本。5.2 跨域配置与 Token 预检放行前端 3000 端口、后端 8080 端口开发环境必然遇到跨域。我的处理方式分两层。开发阶段用 Vite 代理最简单在vite.config.js里配置server.proxy把/api前缀的请求转发到http://localhost:8080这样浏览器看到的所有请求都是同源的跨域问题直接用代理消灭掉。部署阶段则用 Nginx 反向代理同样实现同源效果。如果你要用生产模式直接联调后端必须配置 CORS。SpringBoot 里可以用CrossOrigin注解加在 Controller 或方法上也可以全局配置一个WebMvcConfigurer。但要注意如果同时配置了拦截器做 token 鉴权拦截器要在 CORS 过滤器之后执行否则带Authorization头的预检请求OPTIONS请求会被拦截器拦截前端就会报跨域错误。实际处理时我习惯在拦截器里直接放行所有 OPTIONS 请求。5.3 Nginx 部署前后端分离项目的关键配置打包好前端dist目录后放到服务器的/var/www/wx目录下后端 jar 包用nohup java -jar启动在 8080 端口剩下就靠 Nginx 串起来。一个可以直接用的 server 配置节是这样server { listen 80; server_name your-domain.com; root /var/www/wx; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }第一段location /是前端页面和 Vue Router 路由回退的关键第二段location /api/是把接口请求反向代理到后端服务。注意proxy_pass后面没有斜杠时会把完整路径透传给后端有斜杠则会去掉匹配前缀这个细节很影响接口路径配置错了要排查半天。静态资源里的图片、视频文件如果也放在后端或单独目录建议再加一个location /static/或类似规则并在后端或者资源服务里配置好跨域头避免播放视频或加载海报时出现跨域拦截。5.4 部署文档应该怎么整理很多项目的部署文档写得特别随意但部署文档恰恰是交付时最容易给别人留印象的部分。我整理部署文档的固定结构是四级环境要求 - 数据库初始化 - 后端启动 - 前端构建与配置。环境要求部分把 JDK、Node、MySQL、Nginx 的版本写清楚注明“以下版本已实测可运行”。数据库初始化部分给完整的建库 SQL 和初始化数据 SQL最好附带说明脚本执行顺序。后端启动部分写清楚配置文件里哪些值需要修改数据库密码、JWT 密钥等再给启动命令。前端部分写清楚npm install、npm run build以及 Nginx 配置的完整内容。写部署文档有个小技巧每完成一步就记录一步最好是边部署边写而不是部署完了凭记忆补。这样产出的文档每一步都真正执行过不会出现“照着文档做却跑不起来”的尴尬。6. 答辩前的最后检查论文、演示数据与演示流程6.1 论文里最容易写空的那几章如果你的交付物还包括论文或设计文档有几章是特别容易写空或者被导师点名批的。需求分析章节很多同学直接抄一个笼统的“系统分管理员和用户两种角色”没有具体用例。正确做法是画用例图并结合表格描述核心用例比如“用户选座购票”的用例要写明前置条件已登录、有可选座位、基本流程选择场次、选择座位、生成订单、支付、异常流程座位被抢、支付超时。数据库设计章节除了给表结构还要画出 ER 图并解释关键表之间的关系特别是schedule_seat和order之间的状态联动逻辑。系统测试章节是最容易因为敷衍而扣分的地方。不能只写“经测试系统运行正常”要列测试用例表包括功能测试、接口测试、异常场景测试。比如“两个用户同时选择最后一个座位只有一个能下单成功”这个并发场景就非常适合写进测试报告可以实际演示给老师看。6.2 演示数据准备和演示流程演示环节翻车的常见原因有两个一是演示数据不充分电影只录了一部排片全在过去日期进系统后页面空荡荡二是演示流程没走顺现场点出了 bug 或者跳过了关键页面。我的建议是准备 6 到 8 部电影包含正在热映和即将上映两个状态至少两个影院、多个影厅排片要覆盖今天、明天、后天三个日期且不同时段都有场次。关键是把一个场次设为“即将开场但仍有大量余票”的状态方便演示时从首页一路点到选座、下单、支付。演示流程我习惯走两遍第一遍以新用户身份注册、登录走完首页-详情-场次-选座-下单-支付-订单列表这条完整链路第二遍展示管理端操作一次电影新增、排片新增、订单查询证明后台管理可用。最后分享一个实际运维中总结出来的小经验拿到任何一套参考代码第一件事往往不是看业务逻辑而是先把版本和环境理顺确认本地 run 得起来、组合没问题再进入业务开发。很多同学把时间和精力浪费在“看不懂的报错”上根源就是版本组合不验证、环境不确认。把这一步做好整个项目的开发和交付流程会顺畅非常多。