这个选题我太熟了。带实训项目这几年,基于SpringBoot + Vue的电影院购票系统可以说是学生项目里的“常青树”,但正因为做的人多,反而暴露了几个共性问题:表结构抄来抄去、座位锁定没考虑并发、前端一拿到接口就不知道怎么组织代码。这篇文章我把这个项目的完整设计思路、核心模块的实现方式、前端Vue的组织套路,以及实测中踩过的坑全部展开说一遍。不管是拿来做毕业设计、简历项目,还是刚入职练手,都能直接对着改。
先说明一下,这套方案的定位是“适合学习和二次改造”的版本,不是那种堆了十几个微服务、上K8s的过度设计。项目本身不复杂,但它牵扯到的技术点其实不少:SpringBoot基础整合、MyBatis-Plus的CRUD与复杂查询、JWT登录鉴权、Vue Router和Pinia的状态管理、Axios的二次封装、ECharts数据可视化,以及一个很关键但又容易被忽略的点——座位并发预订的数据一致性处理。把这些都理顺了,这个项目写在简历上才有底气。
1. 项目整体设计与技术选型思路
1.1 这个项目到底该解决什么问题
动手之前,先花点时间想清楚一件事:电影院购票系统的核心业务链路是什么。很多人一上来就开建表、写接口,结果做到一半发现业务逻辑各种别扭,回头改表结构,前端也跟着重写,非常痛苦。
电影院购票系统的核心链路其实很清晰:用户浏览影片 → 查看某部影片的场次 → 选择一个场次并查看座位图 → 选座并提交订单 → 支付(或模拟支付) → 生成电子票(二维码)→ 检票入场。后台管理端的链路则是:管理员维护影片信息 → 维护影厅和座位布局 → 排片(生成场次)→ 查看订单和统计数据。所以整个系统的核心业务对象是:用户(User)、影片(Movie)、影厅(Hall)、座位(Seat)、场次(Session/Screening)、订单(Order)、订单明细(Ticket)。
如果你只是照着网上的代码抄一份,可能感觉不到这中间有什么难度。但你要认真考虑两个问题:第一,同一场次的同一个座位,两个人同时选座提交,如何保证只有一个人能买成功?第二,下单之后用户迟迟不支付,座位是长期锁定还是定时释放?这两个问题直接决定了你的系统是“玩具”还是“真正可用”的水平,也是面试时最容易被问到的地方。
选这套技术栈还有一个非常现实的原因:招聘市场对Java后端 + Vue前端的岗位需求量一直很大。你写一个电影院购票系统,虽然业务场景比电商简单,但“用户 + 商品(影片场次) + 库存(座位) + 订单 + 支付(模拟) + 管理后台”这个模型非常完整,几乎覆盖了企业级应用中最常见的开发场景。简历上写这个项目,面试官一眼就能看懂,也容易顺着项目问出深层次问题。
1.2 为什么是SpringBoot + Vue这套组合
先说后端部分。SpringBoot在2025年依然是Java后端开发的绝对主流,无废话配置、内嵌Tomcat、生态成熟,配合MyBatis-Plus可以极大减少单表CRUD的重复代码。而且SpringBoot对Redis、RabbitMQ、Elasticsearch这些中间件的整合非常顺滑,将来你想在这个项目里加入缓存、消息队列,扩展成本不高。
有一说一,网上对SpringBoot的吐槽也不少,主要集中在“版本更新太快”上。确实,SpringBoot 3.x要求JDK 17+,javax包名迁移到了jakarta,很多老教程里的代码直接复制会报红。我的建议是,如果你是新起步做项目,直接使用SpringBoot 2.7.x或3.2.x都行,但务必要锁定JDK版本——SpringBoot 2.x配JDK 8或11,SpringBoot 3.x配JDK 17或21,千万别混用。我自己在实际项目中用过SpringBoot 3.2.5 + JDK 17的组合,跑了几年的老项目也还在用SpringBoot 2.7 + JDK 8,都稳定。你要做毕设就直接SpringBoot 3.x,简历上写出来更好看;但如果你要接手的是一个老实训基地的代码,那大概率是2.x版本,差别主要在依赖坐标和jakarta命名空间上。
前端的选型,我更倾向于Vue 3 + Vite + Pinia + Element Plus。说实话,现在再推荐Vue 2给新人已经没必要了,Vue 3的Composition API在逻辑复用上确实比Options API舒服太多,而且Vite的启动速度比Webpack那是天壤之别。Vite配Vue 3几乎是零配置就能跑起来,开发体验非常好。如果你在本地无法访问某些镜像源,就使用国内npm镜像(registry.npmmirror.com),装依赖的速度会快很多。
这套前后端分离的结构下,后端只需要暴露RESTful接口,前端用Axios请求数据,通过JWT做身份认证,处理跨域即可。业务复杂度适中,既能锻炼前后端的完整能力,又不会因为技术栈太杂导致掌握不了。这不是最炫技的方案,但绝对是最稳、最适合大多数人的方案。
2. 数据库建模与会话/订单的核心细节
2.1 数据表设计:不是简单把字段填满就行
数据库是一个项目的底盘。电影购票系统的表设计,第一版你可能会设计出五张表:用户表、影片表、场次表、订单表、座位表。看起来够用了,但实际写代码时会发现几个问题:字段之间缺少关联、座位状态无法与场次关联、订单与座位是多对多的关系无法表达。所以至少需要这样几张核心表。
用户表(user):
- id,username,password(BCrypt加密存储),nickname,phone,avatar,balance(余额,用于模拟支付),role(0普通用户 / 1管理员),status,create_time
影片表(movie):
- id,title,poster(封面图URL),description,duration(片长分钟),director,actors,genre,release_date,status,create_time
影厅表(hall):
- id,name,row_count(行数),col_count(列数),seat_layout(可用JSON或直接用行列数来生成座位)
场次表(session):
- id,movie_id,hall_id,start_time,end_time,price(每个座位的单票价),status(未开始/已开演/已结束)
座位表(seat):
- id,hall_id,row_num,col_num,seat_type(普通座/情侣座等)
订单表(orders):
- id,order_no(业务单号,方便查询和展示),user_id,session_id,total_amount,status(0待支付 / 1已支付 / 2已取消 / 3已退票),create_time,pay_time,expire_time(订单过期时间)
票务明细表(ticket):
- id,order_id,session_id,seat_id,qr_code(二维码内容或URL),status
关键点在于:ticket表承担了“订单”和“座位”之间的关联。一个订单可以包含多张票,每张票对应一个具体的场次座位。这样“某个场次的某个座位是否已被购买”就变成了“ticket表中是否存在session_id + seat_id的记录”。这是整个系统最核心的判断逻辑。
另外,站点风格的细节也不可忽略:座位状态需要根据“物理座位 + 场次”来判断。也就是说,座位表存的是影厅的物理座位布局,而一个座位在某场次是否可售,则需要看ticket表(已售出的票)和一张“座位锁定表”来确定。加一张seat_lock表(或者用Redis的过期key)来存“已锁定但未支付”的座位,这个设计非常重要。
2.2 座位并发锁定:这个坑十个人有九个踩
继续展开说明座位锁定的处理思路,这也是我审美上一个必须讲清楚的问题。
假设同一场次只剩一个座位A,用户甲和用户乙同时点了“提交订单”。如果系统不做任何控制,两个人都有可能生成包含座位A的订单,但网络世界里没有后悔药——这个座位最后只能属于一个人。如何保证?
第一个思路是在创建订单时对座位行加锁。MySQL的SELECT ... FOR UPDATE锁住对应的票务明细记录,或者给整个场次的座位资源加一把分布式锁(Redis的SET NX)。但问题是:如果用户还没提交订单只是先“选了座”占着页面,不创建订单,那加锁就没有意义,因为锁的是“下单”那一步。所以更常见的做法是两步走:
第一步,“锁定座位”接口。用户在前端点选座位后,前端调用后端接口,传入session_id和seat_id列表,后端在Redis中使用一个带过期时间的key来记录这些座位被哪个用户锁定(比如lock:session:101:seat:12_5 -> userId,过期时间5分钟)。若发现seat已经被别人锁了,直接返回不可选。
第二步,“创建订单”接口。用户提交订单时,后端需要先确认Redis中的锁仍然存在且属于当前用户,然后创建订单记录和ticket明细,写入数据库。写入成功后,删除Redis中的锁定key。
如果项目不想引入Redis(很多同学为了省事不装Redis),也有办法:在seat_lock表里做唯一约束(session_id + seat_id + user_id),锁座位时插入记录,创建订单后删除记录;同时给seat_lock表加一个expire_time,定时任务扫描过期记录做清理。但用Redis会更优雅,因为天然支持过期时间、性能好、代码量少。你若还没装Redis,用Docker一行命令就可以搞定:docker run -d -p 6379:6379 redis。
实操提示:不管用哪种方案,“带过期时间”的锁是必不可少的一环。如果忘记设置过期时间,用户选座后直接关掉浏览器,座位会被锁到天荒地老,其他人永远买不了这个座位。这在实际项目中属于重大事故级别的问题。
3. 后端SpringBoot核心模块落地实现
3.1 项目结构划分和基础整合
SpringBoot后端项目的结构,我建议用比较清晰的分层方式:controller / service(impl) / mapper / entity / dto / config / common / utils。不要搞那种一锅炖在一个包里的写法,一个中大型项目根本维护不了;也不要过度分层到每个业务都搞出五个类,纯粹是给自己增加工作量。我实际项目的包结构如下:
com.example.cinema ├── common // 统一返回结果、异常处理、常量 ├── config // CORS、MyBatis-Plus分页、Redis序列化等配置 ├── controller // 接收HTTP请求 ├── service // 业务接口及实现 ├── mapper // MyBatis-Plus的mapper接口 ├── entity // 数据库实体类 ├── dto // 请求/响应对象(不要直接用entity传给前端) ├── utils // 工具类:JWT工具、密码加密、订单号生成技术整合上有一个非常实用的经验:不要把配置全写在application.yml里,把多环境的配置拆开。我推荐这种结构:
# application.yml 主配置 spring: profiles: active: dev --- # application-dev.yml 开发环境 server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/cinema_db?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8 username: root password: root123 redis: host: localhost port: 6379这样你在本机开发、上线部署时只需要改spring.profiles.active这个配置,不用频繁改动主文件。推荐用spring.profiles.active=prod激活生产配置。
MyBatis-Plus整合时可以配置分页插件、自动填充字段(create_time、update_time自动赋值)。我实际用的pom依赖(SpringBoot 3.x版本)关键部分如下:
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.4.0</version> </dependency>如果你用的是SpringBoot 2.x,对应的依赖坐标则需换成mybatis-plus-boot-starter。这个小细节很容易被忽略,依赖报错排查起来又浪费时间。
3.2 登录鉴权:JWT写起来不难,但要注意这几点
电影购票系统的用户端和管理端共用一套后端接口,所以我用JWT(JSON Web Token)做无状态认证。简单说,用户登录成功后后端签发一个Token返回给前端,前端每次请求在请求头带上Authorization: Bearer <token>,后端通过拦截器校验Token并解析出用户身份。
JWT的核心代码其实很短,我一般自己封装一个工具类,不引入太多框架。主要两个方法:生成Token和解析Token。
public class JwtUtil { // 签名密钥,上线后一定要放到配置文件中,不要写在代码里 private static final String SECRET = "your-secret-key-change-me"; // 过期时间:2小时 private static final long EXPIRE_TIME = 2 * 60 * 60 * 1000; public static String generateToken(String userId, String role) { return JWT.create() .withClaim("userId", Long.parseLong(userId)) .withClaim("role", role) .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRE_TIME)) .sign(Algorithm.HMAC256(SECRET)); } public static Long parseUserId(String token) { DecodedJWT jwt = JWT.require(Algorithm.HMAC256(SECRET)) .build() .verify(token); return jwt.getClaim("userId").asLong(); } }拦截器配置上,要注意把放行路径写清楚:/api/user/register、/api/user/login、/api/movie/list(影片浏览可以不用登录),其他接口都要校验Token。这里有一个我在实训中反复强调的细节:前后端联调时,跨域请求会先发一个OPTIONS预检请求,这个请求不能带业务拦截,也不能要求携带Token,必须在拦截器里对OPTIONS请求直接放行,否则前端会发现所有请求都报401。
很多同学的跨域问题是到联调阶段才爆发的。解决方案很简单,在SpringBoot里配置一个CorsFilter的Bean,或者简单加个@CrossOrigin注解。但用@CrossOrigin有个坑,它只对当前controller生效,而且和拦截器配合容易出问题,推荐用全局配置:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOriginPattern("*"); // 开发阶段允许所有来源 config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }3.3 核心接口:场次和座位状态查询
后端接口设计上,有几个需要点名讲解的地方。第一个是“获取某部电影的场次列表”接口。这个接口前端页面需要根据电影详情页传来的movieId,去查该影片的全部场次,并带上影厅名称、时间、价格等信息。用MyBatis-Plus可以这样做:
public List<SessionVO> getSessionsByMovieId(Long movieId) { LambdaQueryWrapper<Session> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Session::getMovieId, movieId) .ge(Session::getStartTime, LocalDateTime.now()) // 只查未开始的场次 .orderByAsc(Session::getStartTime); List<Session> sessions = sessionMapper.selectList(wrapper); // 逐个填充影厅名称和座位剩余数量 // 也可以通过SQL联表一次性查出 }建议:涉及多表字段拼接时(比如场次表要返回电影标题、影厅名称、剩余座位数),不要用MyBatis-Plus的方法,而是直接在Mapper中写一句自定义SQL联表查询,返回一个VO对象。MyBatis-Plus的Wrapper适合单表简单查询,复杂查询用它硬凑反而更麻烦。
第二个复杂场景是“座位图”接口。前端打开选座页面时,需要拿到某个场次的全部座位状态:可售、已锁定、已售出。我需要把影厅的物理座位完整返回,同时标注每个座位的状态,比如物理上座位存在,但在该场次已被别人买走,那就标记为“不可售”。最终返回结构大概是:
{ "sessionId": 101, "hallName": "3号杜比厅", "rowCount": 8, "colCount": 10, "price": 45.0, "seats": [ { "seatId": 356, "rowNum": 3, "colNum": 5, "status": 0 } ] }这里status的含义最好定得清楚:0=可选,1=已售,2=已锁定(别人正在选)。前端渲染时,把0渲染成浅绿色、1渲染成灰色、2渲染成橙色,用户就能直观看到座位占用情况。
切记不能直接查座位表的state字段来返回状态。seat表的state是物理属性,但同一个座位在不同场次的状态是不同的。座位状态必须动态计算:查ticket表中已售出的座位,查Redis锁定表中该场次被锁的座位,再把物理座位表全量返回,动态标记状态。这是我见过很多初学同学做错的地方:给seat表加了一个status字段,然后所有场次共用一个状态,导致甲场次座位卖光了,乙场次同一个物理座位也显示卖光。
4. 前端Vue页面组织与交互实现
4.1 Vue3项目结构和状态管理的正确姿势
前端我用Vue3 + Vite + Pinia + Element Plus。第一次创建项目时推荐用Vite脚手架:
npm create vite@latest cinema-frontend -- --template vue cd cinema-frontend npm install npm install element-plus pinia axios vue-router npm run dev网上不少人会遇到npm install超时或卡住的问题——在国内使用镜像源或者检查网络是否能正常访问npm官方源。镜像配置方式:
npm config set registry https://registry.npmmirror.com前端目录结构建议如下(这个结构在我的多个Vue项目中验证过,足够清晰):
src ├── api // 按模块拆分的接口定义文件 ├── assets ├── components // 公共组件 ├── router // 路由配置 ├── stores // Pinia状态(用户信息、购物车等价物) ├── views // 页面组件 │ ├── admin // 后台管理页面 │ ├── client // 用户端页面 ├── utils // 请求封装、工具函数状态管理上,用户登录后存的Token、用户信息、以及当前选座等数据建议放入Pinia。比如useUserStore:
import { defineStore } from 'pinia'; import { login, getUserInfo } from '@/api/user'; export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: null }), actions: { async loginAction(loginForm) { const res = await login(loginForm); this.token = res.data.token; localStorage.setItem('token', this.token); this.userInfo = res.data.userInfo; }, logout() { this.token = ''; this.userInfo = null; localStorage.removeItem('token'); } } });4.2 Axios封装:统一错误处理和Token携带
Axios的二次封装应该是前端部分的必修课。我见过太多同学在每个组件里直接axios.get(...),代码重复率高,出错了也不知道在哪统一处理。我在项目中通常这样封装:
// src/utils/request.js import axios from 'axios'; import { ElMessage } from 'element-plus'; import { useUserStore } from '@/stores/user'; import router from '@/router'; const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000 }); // 请求拦截器:自动带token request.interceptors.request.use(config => { const userStore = useUserStore(); if (userStore.token) { config.headers['Authorization'] = 'Bearer ' + userStore.token; } return config; }); // 响应拦截器:统一处理业务错误 request.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { ElMessage.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } return res; }, error => { if (error.response?.status === 401) { // token过期或未登录,跳转登录页 const userStore = useUserStore(); userStore.logout(); router.push('/login'); ElMessage.warning('请先登录'); } else { ElMessage.error('网络请求异常,请稍后重试'); } return Promise.reject(error); } );这样一个文件就解决了三个重复劳动:自动携带Token、统一处理401和网络错误、弹出统一错误提示。后续所有页面调用接口,直接import request from '@/utils/request'后请求就行,代码量明显减少。
经验补充:开发阶段前端和后端端口不同,一定会在本地产生跨域问题。除了后端配置CorsFilter之外,更好的方案是开发环境下在Vite配置代理,让前端请求同源的/api,由Vite转发到后端实际地址。这样生产环境也可以让Nginx做同样的转发,前端代码里不需要硬编码后端地址。Vite配置如下:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } });4.3 选座页面:组件设计和交互细节
选座页面是前端交互最复杂的一块。用户从排片列表点进场次后,选座页需要做几件事:加载座位图、渲染座位格、用户点选座位后回收选中状态并汇总金额、提交订单。
座位的渲染我用最简单的二维数组。后端返回的座位列表中包含rowNum和colNum,前端初始化一个rows * cols的二维数组,再逐个填充座位状态和座位id,然后使用v-for渲染。
<template> <div class="seat-map"> <div v-for="(row, rowIndex) in seatMatrix" :key="rowIndex" class="seat-row"> <span class="row-label">{{ rowIndex + 1 }}排</span> <div v-for="seat in row" :key="seat.seatId" class="seat" :class="{ 'seat-available': seat.status === 0, 'seat-sold': seat.status === 1, 'seat-locked': seat.status === 2, 'seat-selected': selectedSeats.some(s => s.seatId === seat.seatId) }" @click="handleSeatClick(seat)" > </div> </div> </div> </template>这里有一个交互细节大家容易忽略:点击选座时,前端要先对座位状态做一次判断,但真正可靠的校验必须等提交订单时再由后端做第二次校验。用户在页面上看到的座位状态可能是几秒前的数据,可能就在这几秒内座位被别人抢走了。所以点击选座时前端只允许选状态为0的座位,但当用户提交订单时,后端必须再次校验座位实际状态,如果不幸被人抢先买走,后端要返回提示“座位已被他人购买,请重新选座”,前端清空选中状态并重新拉取座位图。这两个层次的校验是前后端分离系统的标准做法,缺一不可。
4.4 后台管理页和数据可视化
后台管理端我拆分成几个模块:影片管理、影厅管理、排片管理、订单管理、数据统计。有了Element Plus的表格和表单组件,这一部分的Crud页面基本是套模板的活,真正有价值的是数据统计页面,用ECharts展示票房趋势、热门影片排行、场次上座率等。
ECharts在Vue里使用,不需要额外引入vue-echarts这种封装库,直接用原生ECharts包加一个自定义组件即可。我的做法是封装一个BaseChart.vue,传入option配置项,组件内负责初始化、动态更新和窗口自适应销毁:
<template> <div ref="chartRef" :style="{ height: height + 'px' }"></div> </template> <script setup> import * as echarts from 'echarts'; import { onMounted, onBeforeUnmount, watch, ref } from 'vue'; const props = defineProps({ option: { type: Object, required: true }, height: { type: Number, default: 300 } }); const chartRef = ref(null); let chartInstance = null; onMounted(() => { chartInstance = echarts.init(chartRef.value); chartInstance.setOption(props.option); window.addEventListener('resize', handleResize); }); onBeforeUnmount(() => { window.removeEventListener('resize', handleResize); chartInstance && chartInstance.dispose(); }); const handleResize = () => chartInstance && chartInstance.resize(); watch(() => props.option, newVal => { chartInstance && chartInstance.setOption(newVal, true); }, { deep: true }); </script>这样使用起来非常干净——父组件里只管造数据、配置option,图表组件本身不用动。数据统计接口可以写在后端一个StatController里,用几条聚合SQL完成:按日统计票房、统计影片Top排行、计算上座率等。
5. 常见问题与排错实录
5.1 依赖版本和编译报错速查
这套项目我前后调试了很多版本,把高频问题和解决方案整理成一个速查表录,可以直接抄作业的那种。
| 问题现象 | 原因 | 解决方案 |
|---|---|---|
| SpringBoot启动报ClassNotFound: javax.servlet | SpringBoot 3.x用了jakarta,但代码里有javax导入 | 全局替换javax为jakarta,或降级到SpringBoot 2.7+JDK8 |
| 启动报Failed to configure a DataSource | 配置文件中数据库地址/用户名/密码错误,或缺少MySQL驱动 | 检查yml配置,确认pom中引入mysql-connector-j |
| MyBatis-Plus分页不生效 | 缺少分页插件配置 | 配置PaginationInnerInterceptor,并确认拦截器添加顺序 |
| 前端打开页面空白,控制台报404 | Vue Router的history模式部署后刷新404 | 开发无所谓,生产建议后端配置转发,或改用hash模式 |
| 后端接收不到前端请求,前端报CORS | 跨域配置缺失或未放行OPTIONS | 使用我上面的CorsConfig全局配置 |
npm run build报内存不足 | Vite打包时Node内存不够 | 在package.json中配置"build": "vite build",或增加Node内存NODE_OPTIONS=--max-old-space-size=4096 |
| 中文乱码 | Tomcat/MySQL字符集不一致 | 数据库连接URL加characterEncoding=utf8,表字符集设置utf8mb4 |
| 座位状态显示错乱,所有场次座位通用 | seat表处理不当 | 座位状态改用动态计算方式,不要静态存status |
5.2 联调Android真机和部署细节
我没有特别提过的几个细腻但实用的注意点,也在这里写出来。
在我实训过程中,有不少同学是在Windows本机上开发,用IDEA跑后端,VSCode跑前端。这种组合本身没问题,但需要注意端口占用。后端默认8080,Vue前端Vite默认5173。如果8080被占用,SpringBoot会启动失败或报端口冲突,解决的方式是改端口或在启动命令中指定:java -jar app.jar --server.port=8081。
数据库方面,建议同学们本地装一个Navicat或DBeaver管理MySQL,建库时统一用utf8mb4字符集,建表语句可以直接导出,方便后面写项目文档。有一个小坑:MySQL 8.x默认的认证插件是caching_sha2_password,有些旧版本的JDBC驱动连接会抛异常,需要更新驱动版本,或者创建用户时指定mysql_native_password。我的建议是直接用较新版本的mysql-connector-j,比如8.0.33以上。
部署上线时,最省心的方案就是用宝塔面板 + Docker。前端构建后的dist目录可以直接交给Nginx托管,后端打成jar包通过Docker部署,Redis也用Docker起一个容器。下面是我常用的一句启动后端容器的命令参考:
docker build -t cinema-backend . docker run -d -p 8080:8080 -e SPRING_PROFILES_ACTIVE=prod --name cinema cinema-backend前端在Nginx里需要配置反向代理,把/api请求转发到后端8080端口,避免前端直接暴露后端地址,同时解决跨域问题。这个配置我在实际项目中已经反复用过很多次,非常稳。
Nginx关键配置示例:
server { listen 80; server_name your-domain.com; root /www/wwwroot/cinema-frontend/dist; index 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; } location / { try_files $uri $uri/ /index.html; } }5.3 一些额外想说的“面试必问”点
基于SpringBoot + Vue的电影院购票系统做完之后,如果你要拿它去面试,不妨提前准备这几个问题的回答:
你这个项目的数据一致性怎么保证的?——可以说通过Redis锁座位和数据库事务双管齐下,锁座位与创建订单之间通过事务保证数据一致,如果创建订单失败则释放座位锁。
订单超时未支付怎么处理?——可以用SpringBoot的@Scheduled定时任务扫描过期订单,把Redis锁释放;更优雅的方案是用消息队列的延迟队列,但初学阶段定时任务够用,且实现简单。
为什么用JWT不用Session?——前后端分离部署,无状态扩展性好,不依赖服务端存储,天然适合多端共用接口。
性能上还有哪些优化空间?——可以加Redis缓存影片列表、热门场次信息,减少数据库压力;座位图数据也可以加缓存,实时性要求不高时可以设置几秒的过期时间。
这个项目的边界在哪里?——真正的线上系统还需要对接真实支付网关、短信验证码、防重复下单、用户风控,这些可以作为系统演进方向提出来。
我个人在实际项目里的体会是:这类“管理系统/业务系统”的项目,技术广度固然重要,但真正拉开差距的永远是对业务细节的把控,比如并发的处理、金额的精度、订单状态的流转。你把这些想清楚了,代码自然有深度,面试也能聊出别人聊不出的东西。
最后再分享一个实操小技巧:前端联调的时候不要每次都登录一次后台获取Token,可以直接在浏览器控制台通过localStorage.setItem('token', 'xxx')手动写入Token,刷新页面后Axios拦截器会自动带上,联调速度和体验明显提升。这个项目后续要继续扩展的话,可以优先考虑接入真实的支付沙箱(支付宝沙箱/微信沙箱),以及用WebSocket做购票后实时通知,作用都是往“可上线”的方向再推进一大步。