简介:这是一套面向Java与Vue全栈初学者及课程设计者的电影院订票系统实战源码,聚焦前后端分离架构落地,解决从环境搭建、接口联调到数据库集成的完整开发闭环问题。资源含300个文件,主体为82个Java后端业务与控制器代码、41个Vue组件页面及状态管理逻辑、80张界面截图与图标资源(jpg/jpeg/png/svg),辅以17个JS工具脚本、6个CSS样式文件及1个SQL建库脚本,包体仅7.95MB,轻量易部署。已有77人学习下载,适合用于毕业设计、实训项目或SpringBoot+Vue技术栈入门实践。读者可直接获取可运行的完整工程结构——含标准Maven模块划分、Vue Router路由配置、Axios请求封装、MySQL 8.0兼容的DDL/DML脚本,以及前后端启动所需的详细配置要点与常见报错提示,大幅降低环境适配与调试门槛。
1. 为什么电影院订票系统是前后端分离练手的“黄金标本”:SpringBoot + Vue 落地不翻车的关键在边界切分
你不是第一次看到「电影院订票系统」这个标题——它高频出现在课程设计、毕业设计、求职简历项目栏,甚至招聘 JD 的“加分项”里。但真正跑通一个能查影厅、选座位、锁库存、生成订单、支持退改的完整流程,90% 的人卡在三个地方:前端 Vue 页面跳转时路由状态丢失、后端 SpringBoot 处理并发选座时超卖、前后端联调时跨域配置反复失效。这不是代码写得不够多,而是没把「前后端分离」这五个字真正拆解成可执行的契约:接口怎么定义才不返工?数据库事务边界划在哪才能既保证一致性又不拖垮性能?Vue 的路由守卫和 SpringBoot 的拦截器如何协同做权限?本文不讲 MVC 模式有多老、Vue3 Composition API 有多优雅,只聚焦一件事:用一套真实可运行的源码结构(含建库 SQL、启动脚本、角色权限表设计),把电影院订票这个场景里最常踩的坑、最易忽略的配置、最容易被面试官追问的细节,一条条摊开给你看。适合刚学完 SpringBoot 基础、Vue 组件通信、MySQL 事务的同学,目标明确——跑起来、调通、能讲清每一步为什么这么写。
2. 从零搭建:SpringBoot 后端服务的最小可行骨架与关键配置
2.1 创建 SpringBoot 工程并引入核心依赖(Maven)
新建 Maven 项目,pom.xml中必须包含以下四类依赖,缺一不可。特别注意spring-boot-starter-web是 Web 层基础,mybatis-spring-boot-starter是 ORM 核心,spring-boot-starter-validation用于接口参数校验(比如座位号格式、手机号正则),而spring-boot-starter-data-redis是后续解决高并发选座超卖问题的伏笔:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>3.0.3</version> <!-- 注意:MyBatis 3.5+ 对应此版本 --> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- MySQL 驱动(8.0+) --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> </dependencies>提示:不要用
spring-boot-starter-jdbc替代 MyBatis,后者提供动态 SQL 和结果映射能力,对影院系统中「按日期+影厅查场次」「按场次查空闲座位矩阵」这类复杂查询必不可少;Redis 版本需与 SpringBoot 3.x 兼容(推荐 Redis 7.x),避免Lettuce连接池初始化失败。
2.2 数据库建模:三张核心表的设计逻辑与字段取舍
影院系统数据模型看似简单,实则暗藏陷阱。我们只保留最精简且生产可用的三张表:movie(电影)、hall_schedule(场次排期)、seat_order(座位订单)。不建user表——实际项目中用户体系应对接统一认证中心,此处用token模拟登录态即可;不建payment表——支付环节由第三方 SDK 完成,后端只记录支付状态回调。
-- 电影表:仅存基础信息,海报 URL 存 CDN 地址 CREATE TABLE `movie` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '片名', `director` varchar(50) DEFAULT NULL COMMENT '导演', `duration_min` int NOT NULL COMMENT '时长(分钟)', `poster_url` varchar(255) DEFAULT NULL COMMENT '海报地址', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 场次排期表:关键字段是 start_time 和 hall_id,用于查空闲座位 CREATE TABLE `hall_schedule` ( `id` bigint NOT NULL AUTO_INCREMENT, `movie_id` bigint NOT NULL COMMENT '关联电影', `hall_id` int NOT NULL COMMENT '影厅编号(1-5)', `start_time` datetime NOT NULL COMMENT '开始时间', `end_time` datetime NOT NULL COMMENT '结束时间', `price` decimal(6,2) NOT NULL COMMENT '票价', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1-正常,0-取消', PRIMARY KEY (`id`), KEY `idx_movie_hall_time` (`movie_id`,`hall_id`,`start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 座位订单表:重点在 seat_row/seat_col + order_status + lock_time CREATE TABLE `seat_order` ( `id` bigint NOT NULL AUTO_INCREMENT, `schedule_id` bigint NOT NULL COMMENT '场次ID', `user_token` varchar(64) NOT NULL COMMENT '用户标识(非真实ID)', `seat_row` tinyint NOT NULL COMMENT '行号(A=1,B=2...)', `seat_col` tinyint NOT NULL COMMENT '列号(1-20)', `order_status` tinyint NOT NULL DEFAULT '0' COMMENT '0-锁定中,1-已支付,2-已取消,3-超时释放', `lock_time` datetime DEFAULT NULL COMMENT '锁定时间(用于超时清理)', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_schedule_seat` (`schedule_id`,`seat_row`,`seat_col`), KEY `idx_schedule_status` (`schedule_id`,`order_status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;说明:
seat_order表的联合唯一索引uk_schedule_seat是防止重复下单的核心保障;order_status字段必须支持「锁定中」状态,这是实现乐观锁+定时任务释放的基础;lock_time不用TIMESTAMP而用datetime,避免时区转换导致超时判断错误。
2.3 接口契约设计:RESTful 风格下的 5 个核心端点及其语义约束
前后端分离成败,70% 取决于接口定义是否清晰。本系统只暴露以下 5 个端点,全部遵循 RESTful 规范,且每个端点都隐含业务约束:
| 端点 | 方法 | 语义 | 关键约束 |
|---|---|---|---|
/api/movies | GET | 查询所有上映中电影 | status=1且start_time > now() |
/api/schedules?date=2024-06-15&hall=2 | GET | 查某日某厅场次 | date必须为 YYYY-MM-DD 格式,hall为整数 1-5 |
/api/schedules/{id}/seats | GET | 查指定场次空闲座位 | 返回二维数组[ [1,1],[1,2],... ],已售/锁定座位不返回 |
/api/orders | POST | 提交座位订单 | 请求体含scheduleId,seats: [{row:1,col:5},{row:1,col:6}],必须校验座位未被占用 |
/api/orders/{id}/pay | PUT | 支付订单 | 仅允许order_status=0的订单调用,成功后更新为1 |
注意:
/api/schedules/{id}/seats不返回「已售」座位,而是只返回「当前空闲」列表——这避免前端渲染时做大量状态判断,也降低网络传输量;POST /api/orders的入参seats是数组,而非单个座位,支持连坐选座,这是影院系统区别于普通电商的关键交互。
3. Vue 前端工程搭建与关键交互逻辑实现
3.1 使用 Vue CLI 创建项目并配置代理解决开发期跨域
执行npm install -g @vue/cli后,运行vue create cinema-web,选择Manually select features,勾选Babel, Router, Vuex, CSS Pre-processors (Sass)。创建完成后,在vue.config.js中配置开发服务器代理,不要在 Vue 组件里写http://localhost:8080这种硬编码地址:
// vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8081', // SpringBoot 默认端口 changeOrigin: true, pathRewrite: { '^/api': '/api' // 保持路径前缀一致 } } } } }说明:
changeOrigin: true是关键,它修改请求头中的Host为 target 域名,避免 SpringBoot 的CorsConfiguration因 Origin 不匹配而拒绝;pathRewrite确保前端请求/api/movies时,代理后实际发往http://localhost:8081/api/movies,而非http://localhost:8081/movies。
3.2 路由设计:嵌套路由 + 动态参数承载影院业务流
影院订票是典型的多步流程:选电影 → 选场次 → 选座位 → 提交订单 → 支付完成。使用 Vue Router 的嵌套路由实现页面状态隔离,避免组件内堆砌大量v-if:
// router/index.js const routes = [ { path: '/', name: 'Home', component: () => import('@/views/Home.vue') }, { path: '/movies', name: 'MovieList', component: () => import('@/views/movie/MovieList.vue'), children: [ { path: ':id', name: 'MovieDetail', component: () => import('@/views/movie/MovieDetail.vue'), props: true } ] }, { path: '/schedules', name: 'ScheduleList', component: () => import('@/views/schedule/ScheduleList.vue'), children: [ { path: ':scheduleId/seats', name: 'SeatSelect', component: () => import('@/views/seat/SeatSelect.vue'), props: true } ] }, { path: '/orders/:id/pay', name: 'PayPage', component: () => import('@/views/order/PayPage.vue'), props: true } ]关键点:
/schedules/:scheduleId/seats这个路径中,:scheduleId是动态参数,它会作为props传入SeatSelect.vue组件,组件内部直接通过this.$route.params.scheduleId获取,无需额外query或state传递;嵌套路由让「选座页」天然继承「场次列表页」的导航上下文,用户点击浏览器后退按钮时,能正确回到上一页而非首页。
3.3 座位选择组件:Canvas 渲染 + 状态管理 + 实时校验
SeatSelect.vue是整个系统的交互核心。不用第三方 UI 库,用原生<canvas>绘制影厅座位图,优势在于:1)精准控制行列坐标;2)避免 DOM 节点过多导致渲染卡顿;3)方便添加「连坐高亮」「禁选区域」等定制效果。
<template> <div class="seat-container"> <canvas ref="seatCanvas" width="800" height="400"></canvas> </div> </template> <script> export default { name: 'SeatSelect', props: ['scheduleId'], data() { return { seats: [], // 从 API 获取的空闲座位数组,如 [[1,1],[1,2],[2,3]] selectedSeats: [] // 当前选中的座位 } }, mounted() { this.loadSeats() }, methods: { loadSeats() { this.$http.get(`/api/schedules/${this.scheduleId}/seats`) .then(res => { this.seats = res.data this.renderCanvas() }) }, renderCanvas() { const canvas = this.$refs.seatCanvas const ctx = canvas.getContext('2d') const rowHeight = 40 const colWidth = 40 const startX = 50 const startY = 50 // 清空画布 ctx.clearRect(0, 0, canvas.width, canvas.height) // 绘制座位(空闲为绿色,已选为蓝色) this.seats.forEach(([row, col]) => { const x = startX + (col - 1) * colWidth const y = startY + (row - 1) * rowHeight const color = this.selectedSeats.some(s => s.row === row && s.col === col) ? '#1890ff' : '#52c418' ctx.fillStyle = color ctx.fillRect(x, y, 30, 30) ctx.fillStyle = '#fff' ctx.font = '12px sans-serif' ctx.fillText(`${String.fromCharCode(64 + row)}${col}`, x + 5, y + 20) }) }, handleCanvasClick(e) { const rect = this.$refs.seatCanvas.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top const col = Math.floor((x - 50) / 40) + 1 const row = Math.floor((y - 50) / 40) + 1 if (row >= 1 && row <= 10 && col >= 1 && col <= 20) { // 影厅最大 10×20 const seat = { row, col } const index = this.selectedSeats.findIndex(s => s.row === row && s.col === col) if (index > -1) { this.selectedSeats.splice(index, 1) // 取消选择 } else if (this.selectedSeats.length < 4) { // 最多选 4 个 this.selectedSeats.push(seat) } } this.renderCanvas() } } } </script>说明:
handleCanvasClick中的行列计算基于像素坐标反推,比监听<div>的click事件更精准;selectedSeats数组长度限制为 4,这是影院系统常见的连坐上限;renderCanvas每次重绘都清空画布再绘制,避免残留图形——这是 Canvas 渲染的铁律,新手常在此翻车。
4. 前后端联调避坑指南:5 个真实发生过的血泪问题与解法
4.1 现象:Vue 页面刷新后路由丢失,直接跳转到 404
原因:Vue Router 使用history模式(默认),依赖服务端对所有前端路由返回index.html。但开发期用vue-cli-service serve时,代理已接管/api,而其他路径(如/schedules/123/seats)未被代理,导致 404。
解决:在vue.config.js中添加fallback: true,或更稳妥的做法是——开发期用hash模式:
// router/index.js const router = createRouter({ history: createWebHashHistory(), // 改为 hash 模式 routes })上线部署时再切回history,并在 Nginx 配置try_files $uri $uri/ /index.html;。
4.2 现象:SpringBoot 接口返回406 Not Acceptable
原因:Vue 发送请求时Accept头为application/json,text/plain,*/*,而 SpringBoot 控制器方法未声明produces = "application/json",或返回对象未加@ResponseBody。
解决:全局配置@RestController类,并确保每个@GetMapping方法都显式标注:
@GetMapping(value = "/movies", produces = "application/json;charset=UTF-8") public Result<List<Movie>> listMovies() { ... }同时在application.yml中添加:
spring: mvc: contentnegotiation: favor-parameter: false4.3 现象:选座提交后,数据库出现重复记录
原因:前端多次快速点击「确认下单」按钮,导致同一请求被发送多次;后端未做幂等性控制,INSERT IGNORE或ON DUPLICATE KEY UPDATE未覆盖所有冲突场景。
解决:
- 前端按钮添加
:disabled="submitting"并在请求开始时设为true,响应后恢复; - 后端在
seat_order表上建联合唯一索引uk_schedule_seat(已建); - 业务层增加 Redis 分布式锁:
String lockKey = "order:lock:" + scheduleId; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (!locked) { throw new BusinessException("操作过于频繁,请稍后再试"); } try { // 执行下单逻辑 } finally { redisTemplate.delete(lockKey); }4.4 现象:日期查询参数?date=2024-06-15在 SpringBoot 中接收为空
原因:@RequestParam String date无法自动转换字符串为LocalDate,且未配置@DateTimeFormat。
解决:在 Controller 参数上加注解:
@GetMapping("/schedules") public Result<List<Schedule>> listByDate( @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date, @RequestParam Integer hall) { ... }同时确保application.yml中有:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+84.5 现象:Vue DevTools 显示组件状态正常,但页面无渲染
原因:Vue 3 的响应式原理要求对象必须是 reactive 的,而从 API 获取的座位数组res.data是普通数组,未被ref()或reactive()包裹。
解决:在data()中定义时即用ref([]):
import { ref } from 'vue' export default { setup() { const seats = ref([]) const selectedSeats = ref([]) // ... return { seats, selectedSeats } } }若用 Options API,则data()返回对象中字段必须是响应式值:
data() { return { seats: [], // Vue 2 自动代理,但 Vue 3 + Composition API 必须显式 ref selectedSeats: [] } }5. 高并发选座的终极防线:Redis + MySQL 事务的协同方案
5.1 为什么不能只靠数据库唯一索引?
uk_schedule_seat确保最终一致性,但无法解决「查空闲→提交」之间的竞态窗口。例如:用户 A 查到座位 [1,1] 空闲,用户 B 同时查到同样结果,两人几乎同时提交,其中一人必然失败(Duplicate entry),体验极差。真正的解决方案是:在查座位阶段就预占资源,把「查」和「锁」合并为原子操作。
5.2 Redis 预占座位:Lua 脚本保证原子性
在SeatOrderService中,下单前先执行 Lua 脚本,将待选座位写入 Redis Set,并设置过期时间(如 15 分钟):
// Lua 脚本:预占座位 String script = "local key = KEYS[1]\n" + "local seats = ARGV\n" + "for i=1,#seats do\n" + " redis.call('SADD', key, seats[i])\n" + "end\n" + "redis.call('EXPIRE', key, tonumber(ARGV[#seats+1]))\n" + "return redis.call('SCARD', key)"; String lockKey = "seat:lock:" + scheduleId; Long result = (Long) redisTemplate.execute( new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockKey), Stream.concat(seats.stream().map(s -> s.getRow() + "_" + s.getCol()), Stream.of("900")) // 900秒 = 15分钟 .toArray(String[]::new) ); if (result == null || result != seats.size()) { throw new BusinessException("座位已被他人预占,请刷新重试"); }说明:Lua 脚本一次性执行
SADD+EXPIRE,避免网络往返导致的原子性破坏;SEAT_LOCK_KEY格式为seat:lock:123,其中123是场次 ID;SCARD返回集合元素数量,用于校验是否全部预占成功。
5.3 MySQL 写入与 Redis 清理的事务边界
预占成功后,进入 MySQL 写入阶段。此时必须保证:MySQL 插入成功,Redis 锁必须清除;MySQL 插入失败,Redis 锁必须保留(供超时清理)。因此不能用@Transactional包裹两者,而要手动控制:
@Transactional(rollbackFor = Exception.class) public void createOrder(Long scheduleId, List<Seat> seats, String userToken) { try { // 1. 先插入数据库(利用唯一索引防重) seats.forEach(seat -> { SeatOrder order = new SeatOrder(); order.setScheduleId(scheduleId); order.setUserToken(userToken); order.setSeatRow(seat.getRow()); order.setSeatCol(seat.getCol()); order.setOrderStatus(0); // 锁定中 seatOrderMapper.insert(order); }); // 2. 插入成功,删除 Redis 预占锁 redisTemplate.delete("seat:lock:" + scheduleId); } catch (DuplicateKeyException e) { // 3. 唯一索引冲突,说明已被他人下单,抛业务异常 throw new BusinessException("座位已被占用,请刷新座位图"); } catch (Exception e) { // 4. 其他异常,不删 Redis 锁,留给定时任务清理 log.error("下单失败,Redis 锁保留", e); throw e; } }关键逻辑:只有
insert成功才deleteRedis 键;若insert抛DuplicateKeyException,说明别人已抢到,当前请求失败;若抛其他异常(如网络中断),Redis 锁保留,由后续的「超时清理定时任务」处理。
5.4 超时清理定时任务:兜底保障
创建@Scheduled任务,每 5 分钟扫描所有seat:lock:*键,检查对应场次在seat_order表中是否有order_status=0的记录,若无,则认为该预占已失效,主动释放:
@Scheduled(fixedRate = 300_000) // 5分钟 public void cleanExpiredSeatLocks() { Set<String> keys = redisTemplate.keys("seat:lock:*"); if (keys == null || keys.isEmpty()) return; for (String key : keys) { String scheduleIdStr = key.replace("seat:lock:", ""); Long scheduleId = Long.parseLong(scheduleIdStr); // 查询该场次是否有锁定中的订单 int lockedCount = seatOrderMapper.countByScheduleAndStatus(scheduleId, 0); if (lockedCount == 0) { redisTemplate.delete(key); log.info("清理过期预占锁:{}", key); } } }注意:
countByScheduleAndStatus是 MyBatis 的@Select("SELECT COUNT(*) FROM seat_order WHERE schedule_id = #{scheduleId} AND order_status = #{status}"),必须走索引idx_schedule_status,否则全表扫描会拖垮数据库。
6. 部署与验证:从本地 jar 到线上 Nginx 的全流程闭环
6.1 SpringBoot 打包与 JVM 参数调优
执行mvn clean package -Dmaven.test.skip=true生成target/cinema-backend-0.0.1-SNAPSHOT.jar。不要直接java -jar运行,必须指定 JVM 参数:
nohup java -Xms512m -Xmx1024m \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=200 \ -Dfile.encoding=UTF-8 \ -Dspring.profiles.active=prod \ -jar cinema-backend-0.0.1-SNAPSHOT.jar \ > app.log 2>&1 &说明:
-Xms512m -Xmx1024m避免堆内存动态扩容导致 GC 暂停;-XX:+UseG1GC是 JDK8u212+ 推荐的垃圾收集器,适合低延迟场景;-Dspring.profiles.active=prod激活生产配置,关闭 H2 Console 等调试端点。
6.2 Vue 打包与 Nginx 静态资源配置
执行npm run build生成dist/目录。Nginx 配置如下,重点在location /api代理到后端,location /指向静态文件:
server { listen 80; server_name cinema.example.com; # 前端静态资源 location / { root /var/www/cinema-web/dist; index index.html; try_files $uri $uri/ /index.html; # 解决 history 模式 404 } # API 代理 location /api { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # 防止敏感文件被访问 location ~* \.(git|htaccess|ini|log|sh|bak|swp)$ { deny all; } }注意:
proxy_pass末尾不加/,否则会导致proxy_pass http://127.0.0.1:8081/将/api/movies代理为http://127.0.0.1:8081/movies,丢失api前缀。
6.3 真实环境验证 checklist:5 个必测场景
部署完成后,必须逐项验证以下场景,缺一不可:
| 场景 | 验证方式 | 预期结果 | 失败信号 |
|---|---|---|---|
| 1. 页面加载 | 访问http://cinema.example.com | 正常显示首页,Network 面板无 404 | index.html加载失败,或app.js404 |
| 2. 跨域请求 | 在浏览器控制台执行fetch('/api/movies') | 返回 200 + JSON 数据 | CORS错误,或502 Bad Gateway |
| 3. 座位预占 | 两人同时打开同一场次选座页,各选不同座位提交 | 两人均成功,数据库seat_order有两条记录 | 一人失败,或数据库出现重复主键错误 |
| 4. 超时释放 | 选座后不支付,等待 15 分钟 | Redis 中seat:lock:*键消失,座位重新可选 | 键长期存在,或座位一直不可选 |
| 5. 支付回调 | 模拟支付成功回调PUT /api/orders/{id}/pay | order_status更新为1,且seat_order记录不再出现在空闲座位列表中 | 状态未更新,或空闲座位列表仍包含该座位 |
我带过 3 届实习生,他们第一次独立部署影院系统时,80% 卡在第 2 条(跨域)和第 4 条(超时释放),因为本地开发用代理,上线后 Nginx 配置漏了proxy_set_header;而超时清理任务没加@EnableScheduling注解,导致定时任务根本没启动。现在每次新项目,我都会在Application.java顶部加一行注释:// ⚠️ 必须加 @EnableScheduling!——这就是血换来的习惯。希望帮到你。
本文还有配套的精品资源,点击获取