做Java毕设选题目,SpringBoot前后端分离的电影购票系统是我这几年被问得最多的方向之一。这类系统业务模型足够清晰,既能覆盖用户端、管理端两类角色,又能把SpringBoot、MyBatis Plus、Vue、Redis这些主流技术栈串起来,工作量可控,演示效果又很直观。和传统的图书管理、学生管理系统相比,电影购票系统有真实的业务规则:选座、锁座、订单超时释放、排片管理、会员折扣,这些细节拿出来就是答辩时最好的加分素材。
这篇文章就围绕这个选题展开。我会按实际做项目的顺序,从选题价值、功能设计、数据库建模、核心业务实现、前后端联调、部署调试到答辩扩展,把一套可以完整落地的电影购票系统拆开讲清楚。不管你是准备用它做毕业设计,还是想拿一个项目练手,都可以直接按这个思路复刻。项目结构里会涉及源码和文档的组织建议,后面我会给出一个比较合理的目录规划,方便你边写代码边整理,不至于最后提交时手忙脚乱。
1. 为什么电影购票系统是毕设的"高性价比"选题
1.1 业务复杂度适中但覆盖面足够广
我见过太多毕设项目卡在"太简单"和"太复杂"两个极端。学生管理系统、博客系统这类项目,四张表就完事,框架调用一下,答辩时老师问一句"你这项目的亮点是什么"就很难接上。而电商系统、外卖平台又太重,涉及商品、库存、优惠券、支付对账、物流等多个模块,一个人在规定时间内做完并讲清楚难度很大。
电影购票系统恰好卡在中间。核心业务只有选座、下单、支付、出票,但每个环节都有扩展空间:座位状态要分可售、锁定、已售;订单要有待支付、已支付、已取消、已退款状态;排片要关联电影、影厅、放映时间;甚至还可以做会员积分、优惠券、每日票房统计。这种"业务闭环完整、每张表都有存在价值"的特点,让系统既不多余也不单薄。
从技术层面看,它也足够撑起一份高质量论文和演示。前后端分离可以用SpringBoot做后端接口,Vue做前端页面;接口鉴权可以做JWT登录;高并发选座的场景可以引入Redis分布式锁;订单超时未支付可以用定时任务或延迟队列处理。这些技术点随便挑一个展开,都能写出几百字的设计说明和答辩稿。
1.2 适合用来展示SpringBoot+Vue前后端分离的真实项目能力
很多同学毕设用的还是服务端渲染模式:Thymeleaf模板加jQuery,页面和接口混在一起。不是不能做,但和现在企业主流开发方式差距较大。SpringBoot前后端分离的意义在于,前端和后端各自独立部署、独立开发,数据通过JSON交互,接口可以做统一返回格式,前端可以做路由和状态管理,后端可以单独测试每个接口。这种模式写进简历和论文里,比"使用了SSM框架+JSP"明显更有说服力。
用SpringBoot作为后端基础框架,好处是起步快。内嵌Tomcat,不需要额外配置外部服务器;起步依赖帮你管理好了常用组件版本;配合MyBatis Plus或Spring Data JPA,CRUD的效率非常高。前端用Vue加Element UI或Ant Design Vue,电商风格的组件库可以直接搭出漂亮的影院购票页面。如果你还不太熟Vue,也不要怕,这个项目的页面数量不多,核心就首页、影片详情、选座页、订单确认、后台管理这几块,照着组件库文档抄都能完成。
我更推荐把项目分成前端、后端、数据库脚本、文档四个独立目录,平时开发也按这个结构提交。这样到最后写课程设计说明书或毕业论文时,每个模块的截图和说明都有的放矢,不会出现"代码写完了但文档不知道怎么写"的情况。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统整体架构与功能模块拆解
2.1 前端、后端、数据库三层如何协作
这个项目我用的是经典的三层部署结构:
- 前端:Vue 2或Vue 3 + Vue Router + Pinia/Vuex,负责页面渲染和用户交互,通过axios调用后端接口。
- 后端:SpringBoot 2.7 + MyBatis Plus,负责业务逻辑、权限校验、数据持久化,提供RESTful接口。
- 数据库:MySQL 8.0,存放用户、电影、影厅、场次、订单等数据。
- 中间件:Redis,用于缓存首页热门电影、存储验证码、实现座位锁定和订单超时释放逻辑。
如果你想让项目更有竞争力,可以再加一个RabbitMQ做订单超时消息队列。但需要注意的是,RabbitMQ部署和配置会增加一定门槛,如果答辩时讲不清楚,还不如用Spring的@Scheduled定时任务扫表。我见过不少同学为了炫技引入MQ,结果被老师追问消息丢失怎么办,答不上来,反而扣分。技术栈的选择一定要以自己能讲透为前提。
前后端交互统一走JSON格式,后端封装一个Result对象,包含code、message、data三个字段。前端根据code判断请求是否成功,code=200表示成功,code=401表示未登录或token过期,需要跳转登录页。这样设计的好处是全局统一处理鉴权和错误信息,不需要每个接口自己去判断。
2.2 用户端功能:从首页到出票的完整链路
用户端是演示的重点,我把流程串成一个闭环:
- 用户注册/登录,登录成功返回JWT token,前端存到localStorage,后续请求自动携带到请求头。
- 首页展示正在热映和即将上映的电影,数据来自后端的电影列表接口,支持按状态、名称、类型筛选。
- 点击电影进入详情页,可以看到电影简介、演员、预告片链接、上映日期,同时展示该电影近几天的排片场次。
- 选择场次后进入选座页,前端根据影厅座位布局绘制座位图,灰色为已售,黄色为被锁定,绿色为可选。用户点击座位选中,点击"立即购买"创建订单。
- 订单确认页展示电影、影厅、座位、票价、优惠信息,点击支付模拟完成支付(可以对接支付宝沙箱,也可以做成模拟支付按钮)。
- 支付成功后,我的订单列表出现已支付订单,同时二维码/取票码生成,用户可以在自助取票机或柜台凭取票码取票。
这个流程清晰明了,而且每个步骤都有对应的数据库表变化,演示时老师能很直观看到数据从书架到落库的过程。
2.3 管理端功能:维护系统运行的基础
管理端我建议做成独立模块,登录后通过路由守卫判断用户角色。普通用户和管理员用同一个登录入口,后端根据角色返回不同的菜单列表。
管理端核心功能包括:
- 电影管理:新增、编辑、上下架电影,上传海报,填写片长、导演、主演、类型、上映日期、剧情简介。
- 影厅管理:维护影厅名称、座位行数、列数、座位类型(普通座、情侣座、无障碍座)。
- 场次管理:给指定影厅设置某部电影的放映时间、票价、语言版本(2D/3D/IMAX)。排片时要检查同一影厅同一时段是否已存在场次冲突。
- 订单管理:查看订单列表,按订单号、用户、场次筛选,支持手动取消订单、标记异常订单。
- 数据统计:按日、按月展示票房总额、订单总量、热门电影Top10,可以用ECharts画出折线图和柱状图,这也是答辩时非常出效果的一个页面。
所有管理操作都走同一个SpringBoot后端,每个接口都做权限校验。我会在Controller层的接口上使用自定义注解@RequireRole("ADMIN"),拦截器中解析token里的角色字段,没有权限直接返回403。这样既保证了安全,又能在答辩时讲出"接口级权限控制"这个技术点。
3. 数据库设计的核心要点:从电影排片到座位锁定的表结构
3.1 七张核心表的关系与设计思路
电影购票系统的核心表不需要太多,但每张表之间的关系要理清楚。我最终保留的七张表是:用户表、电影表、影厅表、场次表、座位表、订单表、订单座位关联表。如果你做会员、优惠券、评论,再加对应的表,但核心链路先保证能用。
一个重要设计原则是:不把座位直接存在场次记录里。很多新手会想,一个场次有100个座位,那场次表里放一个字符串存座位状态行不行?这当然可以,但你很难查询哪些座位可用,修改某个座位状态还会涉及字符串处理,并发情况下特别容易出错。正确做法是创建一张独立的座位表,每个场次对应该影厅的每个座位,每个座位有独立的状态字段。
具体表结构如下(核心字段):
user: id, username, password, nickname, phone, avatar, role, status, create_timemovie: id, name, cover_url, director, actor, genre, duration, language, release_date, introduction, status, create_timehall: id, name, row_count, col_count, seat_layout, create_timesession: id, movie_id, hall_id, show_date, show_time, price, status, create_timeseat: id, session_id, hall_row, hall_col, seat_name, seat_type, status, create_timeorders: id, order_no, user_id, session_id, total_price, status, create_time, pay_time, cancel_timeorder_seat: id, order_id, seat_id, create_time
seat表里的session_id很关键,它决定了这张座位属于哪个场次。初始创建场次时,根据该影厅row_count * col_count批量生成座位记录,状态都为AVAILABLE。用户选座时可以非常方便地直接SELECT * FROM seat WHERE session_id = ? AND status = 'AVAILABLE'。
3.2 座位状态的三种取值与订单状态机
座位状态我建议用字符串枚举,简单直接:
AVAILABLE:可选LOCKED:已被锁定,等待支付SOLD:已售出
订单状态则要更复杂一些:
PENDING:待支付PAID:已支付CANCELLED:已取消REFUNDED:已退款
这里有一个关键业务约束:订单创建成功后,对应的座位状态要改成LOCKED,并设置锁定过期时间(比如15分钟)。如果用户在锁定时间内没有完成支付,系统需要把座位重新置为AVAILABLE,同时把订单取消。这个逻辑我建议用Redis实现,比定时扫表更优雅:创建订单时把order_no写入Redis,设置过期时间15分钟;Redis键过期后,通过Spring的事件监听机制触发取消订单操作,将对应座位状态改回可用,把订单改为CANCELLED。
如果不想用Redis和消息队列,也可以在每次查询场次座位时,判断LOCKED状态的update_time是否超过15分钟,如果超过就自动释放。这个方法虽然实时性差一点,但对毕设来说完全够用,而且逻辑更简单,不容易被问倒。
3.3 索引和字段类型设计的实战建议
数据库设计时容易被忽略的是索引。根据实际查询场景,我建了这几个关键索引:
user(username):登录时按用户名查询。session(movie_id, show_date):查询某电影某日的场次。seat(session_id, status):查询某场次可用座位。orders(user_id, status):查询某个用户的不同状态订单。orders(order_no):唯一索引,订单号直接查询。
字段类型方面,金额不要用double,用decimal(10,2),避免浮点精度问题;时间字段统一用datetime;座位行号列号用int,座位名称用varchar,比如"5排8座"。电影海报URL、封面URL这类字段用varchar(255),不要用text,避免查询性能问题。
数据库表设计的时候还要预留扩展字段。比如movie.status可以用来表示1上映中、2已下架、3即将上映,这样首页就能方便地筛选。session.status可以预留1正常、2已开售、3已结束。一份好的表设计,不仅满足当前功能,也要让后续增加功能时不需要大改表结构。
4. 关键业务逻辑实现:选座、下单、支付状态机这样写才不翻车
4.1 批量生成座位数据与前端座位图渲染
创建场次后,需要批量生成该场次的座位数据。我写了一个initSessionSeats方法,根据影厅的行列数循环插入:
java复制public void initSessionSeats(Long sessionId, Hall hall) {
List<Seat> seatList = new ArrayList<>();
for (int row = 1; row <= hall.getRowCount(); row++) {
for (int col = 1; col <= hall.getColCount(); col++) {
Seat seat = new Seat();
seat.setSessionId(sessionId);
seat.setHallRow(row);
seat.setHallCol(col);
seat.setSeatName(row + "排" + col + "座");
seat.setSeatType("NORMAL");
seat.setStatus("AVAILABLE");
seat.setCreateTime(new Date());
seatList.add(seat);
}
}
seatMapper.insertBatch(seatList);
}
前端选座页拿到当前场次的座位列表后,按行和列映射到二维数组,绘制成座位格子。这里要注意座位名格式统一,否则前端排序会乱。我通常让前端按hall_row升序、hall_col升序排列,这样二维数组和影厅布局完全对应。
4.2 创建订单时的并发锁超卖问题
选座最核心的坑是并发。两个用户同时点击同一个座位,如果不加锁,两个请求可能都查询到座位状态为AVAILABLE,然后都创建订单成功,造成超卖。
解决思路有两层:
第一层,在数据库层面加控制。创建订单时,先尝试用UPDATE seat SET status = 'LOCKED' WHERE id = ? AND status = 'AVAILABLE'这样的条件更新语句。如果更新影响行数为1,说明这个座位被当前请求抢到了;如果为0,说明座位已经被别人锁定或售出。这个方案最简单可靠,推荐优先使用。
第二层,用Redis分布式锁锁住整个"选座-下单"操作。锁的key设计为session:seat:lock:{userId},这样可以防止同一个用户重复提交同一个座位,但不同用户选座时,还是需要数据库条件更新兜底。
下单接口的关键代码如下:
java复制@Transactional
public OrderResult createOrder(OrderRequest request) {
List<Seat> seats = request.getSeatIds();
for (Seat seat : seats) {
int updated = seatMapper.lockSeat(seat.getId());
if (updated == 0) {
throw new BizException("座位已被锁定,请重新选择");
}
}
// 生成订单号
String orderNo = generateOrderNo();
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(request.getUserId());
order.setSessionId(request.getSessionId());
order.setTotalPrice(request.getTotalPrice());
order.setStatus("PENDING");
orderMapper.insert(order);
// 保存订单座位关联
for (Seat seat : seats) {
orderSeatMapper.insert(new OrderSeat(order.getId(), seat.getId()));
}
// 保存座位信息到订单快照,返回选座信息给前端
return OrderResult.of(orderNo, seats);
}
这里必须加@Transactional,保证锁定座位和创建订单在同一个事务中,要么全部成功,要么全部回滚。锁座位时使用FOR UPDATE也可以,但条件更新更简单,我实际项目里用的就是条件更新。
4.3 模拟支付与订单状态流转
用户点击支付按钮后,一般有三种做法:接入支付宝沙箱、对接微信支付Native模式、做本地模拟支付。我个人建议:如果是毕设,先用模拟支付,保证流程闭环;如果学有余力,再接入支付宝沙箱,因为沙箱环境不需要真实商户资质,用测试账号就能完成支付回调,演示效果会好很多。
模拟支付接口的逻辑很简单:把订单状态从PENDING改为PAID,同时把座位状态从LOCKED改为SOLD,更新支付时间。为了保证原子性,同样用@Transactional:
java复制@Transactional
public void payOrder(String orderNo) {
Order order = orderMapper.selectOne(
new LambdaQueryWrapper<Order>().eq(Order::getOrderNo, orderNo)
);
if (order == null || !"PENDING".equals(order.getStatus())) {
throw new BizException("订单状态异常");
}
order.setStatus("PAID");
order.setPayTime(new Date());
orderMapper.updateById(order);
// 修改该订单关联座位状态为已售
List<OrderSeat> orderSeats = orderSeatMapper.selectList(
new LambdaQueryWrapper<OrderSeat>().eq(OrderSeat::getOrderId, order.getId())
);
for (OrderSeat os : orderSeats) {
Seat seat = seatMapper.selectById(os.getSeatId());
seat.setStatus("SOLD");
seatMapper.updateById(seat);
}
}
订单状态机可以封装成OrderStatusFlow类,定义允许流转的状态列表,任何非法状态流转都直接抛异常。这个方法虽然简单,但答辩时你一定能用它讲清楚自己考虑了业务边界,而不是只会写CRUD。
4.4 定时清理超时订单的两种方案
超时订单处理是整个项目里很加分的点。我推荐大家至少掌握一种方案。
方案一:Spring定时任务。在启动类加@EnableScheduling,然后写一个@Scheduled(cron = "0 */1 * * * ?")方法,每分钟扫描一次:
java复制@Scheduled(cron = "0 */1 * * * ?")
public void cancelExpiredOrders() {
Date expireTime = new Date(System.currentTimeMillis() - 15 * 60 * 1000);
List<Order> pendingOrders = orderMapper.selectList(
new LambdaQueryWrapper<Order>()
.eq(Order::getStatus, "PENDING")
.lt(Order::getCreateTime, expireTime)
);
for (Order order : pendingOrders) {
// 先取消订单,再释放座位
}
}
方案二:Redis键过期监听。创建订单时设置一个15分钟过期的key,比如order:timeout:123456,在Redis配置类中注册KeyExpirationEventMessageListener,在监听回调中取消订单。需要注意的是,Redis键过期事件默认不是百分百准时触发,而且开启keyspace notifications会消耗一点性能,但演示效果很好,也更贴近实际生产方案。
两种方案对比下来,定时任务重点是简单可靠,适合大多数毕设;Redis方案更"高级"但不稳定性也更多。如果你选择了Redis方案,一定要在论文中写清楚过期事件丢失的补偿策略,否则容易被老师抓漏洞。
5. 前后端分离落地:跨域、接口规范与联调避坑
5.1 统一返回结果与全局异常处理
前后端分离后,接口定义是否规范直接决定联调效率。我在后端写了一个Result类,所有接口都返回这个结构:
java复制@Data
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("success");
result.setData(data);
return result;
}
public static <T> Result<T> error(Integer code, String message) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
return result;
}
}
配合@RestControllerAdvice全局异常处理器,把业务异常、参数校验异常、系统异常统一转换成上面的JSON格式。前端axios封装响应拦截器时,只判断code:
javascript复制service.interceptors.response.use(
response => {
const res = response.data
if (res.code === 200) {
return res
} else if (res.code === 401) {
router.push('/login')
return Promise.reject(new Error(res.message))
} else {
Message.error(res.message)
return Promise.reject(new Error(res.message))
}
},
error => {
Message.error('服务器异常,请稍后重试')
return Promise.reject(error)
}
)
这样统一处理后,后端开发人员只需要关注业务逻辑,前端也只用关心code===200的情况,非常舒服。
5.2 CORS跨域解决的正确姿势
前后端分离开发时,前端地址是http://localhost:8081,后端是http://localhost:8080,端口不同必然触发跨域。我之前见过有人在自己后端Controller上每个方法加@CrossOrigin,也可以运行,但很不优雅,而且生产环境部署时,前后端域名不同,又要改。
更好的方式是在后端写一个全局CORS配置类:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
注意使用allowedOriginPatterns而不是allowedOrigins,因为后者配合allowCredentials(true)时,SpringBoot 2.7以上的版本会报错,这是一个很容易踩的细节。另外,生产环境如果部署在同域下(比如Nginx将/api/转发到后端服务),其实不会触发跨域,但开发阶段这个配置可以保留。
5.3 JWT登录鉴权与前端路由守卫
JWT鉴权流程不复杂:用户登录成功后,后端生成token返回,前端存到localStorage或pinia里。每次请求前端在拦截器中携带Authorization: Bearer token,后端写一个拦截器,在进入Controller前校验token:
java复制public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 放行登录、注册、首页电影列表等无需鉴权接口
String uri = request.getRequestURI();
if (uri.contains("/user/login") || uri.contains("/user/register") || uri.contains("/movie/list")) {
return true;
}
String token = request.getHeader("Authorization");
if (StringUtils.isBlank(token) || !JwtUtil.verify(token)) {
response.setStatus(401);
return false;
}
// 解析userId和角色,放到request attribute中
Claims claims = JwtUtil.parse(token);
request.setAttribute("userId", claims.get("userId"));
request.setAttribute("role", claims.get("role"));
return true;
}
}
前端路由守卫用来控制页面访问权限,比如未登录用户访问"我的订单"时自动跳转登录页:
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
return
}
if (to.meta.role === 'ADMIN') {
const role = localStorage.getItem('role')
if (role !== 'ADMIN') {
next('/')
return
}
}
next()
})
这层校验的核心目的是优化用户体验,真正的安全校验在后端拦截器,前端路由守卫只是辅助。
5.4 接口文档与Mock数据
前后端分离开发时,最好提前约定接口文档。我推荐大家先用Apifox或Postman写好接口,约定好请求方法、路径、参数、返回值,然后前后端可以并行开发。前端在后端接口没写好时,可以先用Mock数据渲染页面,等后端接口就绪后再替换。
我自己的习惯是,先定义好接口路径和参数,例如:
POST /api/user/loginPOST /api/user/registerGET /api/movie/list?status=1GET /api/movie/{id}GET /api/home/bannerGET /api/session/list?movieId=1&date=2025-06-01GET /api/session/{id}/seatsPOST /api/order/createPOST /api/order/payGET /api/order/my
这些接口就是整个前后端协作的契约。联调时如果出问题,多半是参数类型不一致、日期格式不一致、字段名拼写错误。我建议前端所有时间字段统一用字符串yyyy-MM-dd HH:mm:ss,后端用@JsonFormat注解保证序列化格式统一,可以少踩很多坑。
6. 从开发到部署:打包、运行环境与常见问题的应急处理
6.1 项目目录结构与源码组织建议
写毕设项目时,很多人的源码目录很乱,最后交文档时找不到对应模块。我提供一个我自己常用的项目目录,你可以直接参考:
text复制movie-ticket-system/
├── backend/
│ ├── src/main/java/com/example/movieticket/
│ │ ├── config/ # 跨域、拦截器、Redis配置
│ │ ├── controller/ # 接口层
│ │ ├── service/ # 业务逻辑层,接口+实现
│ │ ├── mapper/ # MyBatis Plus Mapper
│ │ ├── entity/ # 数据库实体类
│ │ ├── dto/ # 参数接收对象
│ │ ├── vo/ # 返回视图对象
│ │ ├── common/ # 统一返回结果、异常、枚举
│ │ ├── utils/ # JWT、日期工具类
│ │ └── MovieTicketApplication.java
│ ├── src/main/resources/
│ │ ├── application.yml
│ │ └── mapper/ # XML文件(如需要)
│ └── pom.xml
├── frontend/
│ ├── src/
│ │ ├── api/ # 接口调用封装
│ │ ├── router/ # 路由配置
│ │ ├── stores/ # 状态管理
│ │ ├── views/ # 页面组件
│ │ ├── components/ # 公共组件
│ │ └── main.js
│ └── package.json
├── database/
│ ├── sql/ # 建库建表脚本、初始化数据
│ └── README.md
└── docs/
├── 需求分析.md
├── 数据库设计.md
└── 项目部署说明.md
按这样一个结构组织,不仅代码看着专业,后期写文档时每个模块都能对应上。而且调试时定位问题也快很多,不至于在乱成一团的项目里翻半天。
6.2 本地开发环境的配置细节
本地开发我使用的环境是:JDK 1.8或11、Maven 3.6+、MySQL 8.0、Redis 6.0、Node 16+。SpringBoot版本2.7左右最稳定,不建议一上来就用SpringBoot 3.x,因为3.x要求JDK17,且部分老教程不兼容,毕设还是以稳为主。
application.yml里最需要注意的是数据库和Redis配置:
yaml复制spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/movie_ticket?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
redis:
host: localhost
port: 6379
database: 0
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
map-underscore-to-camel-case: true很关键,不然数据库的create_time映射不到实体的createTime字段。MySQL连接串里的serverTimezone=Asia/Shanghai要加上,不然时间部分可能差8个小时。
6.3 前后端分离部署:Nginx反向代理与后端Jar包
本地开发完成后,部署到服务器也是很多老师喜欢问的内容。后端打包:
bash复制mvn clean package -DskipTests
执行后会生成target/movie-ticket-backend.jar,然后直接运行:
bash复制java -jar movie-ticket-backend.jar --spring.profiles.active=prod
前端构建:
bash复制npm install
npm run build
构建后的dist目录就是静态文件。生产环境我用Nginx将前端静态资源和后端接口做一个统一入口:
nginx复制server {
listen 80;
server_name localhost;
root /usr/share/nginx/html/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
这样用户访问http://localhost时,Nginx返回前端页面;请求/api/开头的接口时,Nginx把请求转发给后端SpringBoot服务。前端打包后,路由模式建议使用history,但要注意Nginx配置try_files,否则刷新页面会404。
6.4 常见启动和运行问题排查
这一部分我整理了几条高频问题,基本都是我实际帮人调试时遇到的:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 启动报端口被占用 | 8080端口被其他进程占用 | netstat -ano 查看PID,任务管理器结束进程,或改server.port |
登录接口报Failed to obtain JDBC Connection |
MySQL未启动,或账号密码错误 | 先检查MySQL服务,再检查application.yml里的url、用户名、密码 |
| 中文乱码 | 数据库连接串未指定utf8 | 在url加characterEncoding=utf8,检查数据库表编码统一为utf8mb4 |
上传文件接口报Current request is not a multipart request |
前端axios未设置Content-Type |
上传文件时让axios自动推断multipart格式,不要手动设置错误头部 |
前端请求接口报CORS错误 |
跨域配置或代理未生效 | 开发时用Vite代理,或后端加全局CORS配置 |
Whitelabel Error Page |
后端接口报错但被默认错误页拦截 | 检查后端控制台日志,用@RestControllerAdvice统一返回JSON错误 |
如果你遇到这类问题,先看后端控制台日志,再把异常信息复制到搜索引擎,基本都能解决。我最怕的是同学发一句"运行不了"但日志一行都不贴,这种问题谁也帮不了。
7. 答辩加分项与二次扩展思路
7.1 用Redis缓存降低数据库压力
作为毕设,如果你的系统只是简单CRUD,老师会觉得没有技术含量。一个比较容易实现的加分项是首页电影列表缓存。首页访问量最大,每次查询都查数据库有点浪费,可以把热门电影列表放到Redis中,5分钟过期:
java复制public List<Movie> getHomeMovieList() {
String key = "movie:home:list";
Object cache = redisTemplate.opsForValue().get(key);
if (cache != null) {
return JSON.parseArray(cache.toString(), Movie.class);
}
List<Movie> movies = movieMapper.selectList(
new LambdaQueryWrapper<Movie>().eq(Movie::getStatus, 1)
);
redisTemplate.opsForValue().set(key, JSON.toJSONString(movies), 5, TimeUnit.MINUTES);
return movies;
}
这样做的意义在于:访问快的接口,可以从Redis读,更重要的是你能在答辩时讲清楚"为什么要用缓存"——减少数据库查询次数,提升并发访问能力,缓存过期时间保证数据最终一致。
7.2 座位预选与用户友好性优化
为了提升用户体验,可以增加"座位预选"功能:用户选座过程中,如果长时间停留在选座页,但不下单,这些座位不应该一直被占用。我实际做的时候,是前端在用户点击座位后,向后端发送一个"预选"请求,后端把座位在Redis中标记为预选状态,比如seat:prelock:{sessionId}:{seatId},过期时间为5分钟。用户点击提交订单时,再判断预选是否过期。
这个功能虽然很小,但涉及Redis过期时间和座位状态联动,属于"细节体现深度"的部分。如果时间紧张,不做也不影响核心流程,但做了之后,你的项目描述中就多一个亮点。
7.3 与真实支付相比,沙箱支付如何接入
支付宝沙箱接入的基本流程是:后端用支付宝SDK创建预支付订单,返回支付链接或二维码给前端,用户扫码后,支付宝沙箱环境模拟回调,后端接收异步通知,验签成功后修改订单状态和座位状态。
具体配置包括在支付宝开放平台申请沙箱应用,获取AppID、应用私钥、支付宝公钥,然后在SpringBoot中配置alipay相关参数。这个接入过程不复杂,但我建议至少留出一周时间调试,因为支付回调的异步通知报文格式、验签逻辑、订单状态更新是很多同学的易错点。如果时间不够,模拟支付完全可行,核心业务逻辑已经演示到位了。
7.4 二次扩展方向:会员、优惠券、评论与数据分析
如果你的项目已经基本完成,想让它更有竞争力,可以按以下优先级扩展:
- 会员系统:用户充值成为会员,购票享受折扣,积分累计。涉及会员等级表、积分流水表,可以在订单支付后增加积分变更逻辑。
- 优惠券系统:发放满减券或立减券,用户下单时选择优惠券抵扣。涉及优惠券模板、用户优惠券、使用记录,计算逻辑不复杂,但能体现完整业务思考。
- 电影评论:用户购票后才能评论,涉及评论表、回复表,还需要对敏感词做过滤。
- 每日票房统计:结合订单表按日期聚合,用ECharts展示趋势图。这个功能数据量不大,SQL写起来却很灵活,能体现你对聚合查询的掌握。
我的建议是,不要一上来就全部做,先把核心闭环跑通,再挑1-2个扩展点深入做。贪多嚼不烂,很多同学前期规划了十个功能,结果到答辩前只完成了三个,反而显得项目不完整。一个稳定运行的核心系统加一个有深度的亮点功能,已经足够拿到很好的成绩。
做这个电影购票系统,我个人的体会是:它不像那些CRUD管理项目一样只用SQL堆页面,也不像高并发秒杀项目一样可望而不可即。它让你在完成项目的同时,真正理解一个线上业务系统的流转过程——从数据表设计到接口实现,从前端交互到服务部署,每一步都需要动脑子。如果你正准备做这个选题,别急着起项目框架,先花两天把表结构和核心流程画清楚,后面写代码会快很多。遇到不懂的地方,多翻官方文档,多调试,哪怕只是把一次报错完整地记录下来,积累下来都是你的经验。这个项目做好了,不仅是一份毕设,更是你简历上可以真正写进"项目经历"里的东西。
