
简介基于 Spring Boot、Vue 和 MySQL 前后端分离架构的电影订票及评论网站完整毕业设计资源面向计算机相关专业学生和自学者可同时用于课程设计、毕业设计或全栈实战练习。项目已配齐源码、论文与开题报告覆盖用户注册登录、电影信息浏览、在线选座订票、观影后发表评论等核心模块代码经过完整测试内附数据库文件、运行说明文档按文档搭建环境即可启动遇运行问题可直接私信作者。RAR 压缩包共 758 个文件、约 24.34MB其中以 Java 后端逻辑、Vue 前端组件、JavaScript 脚本、SQL 数据库脚本、Word 论文与开题报告为主另有样式、图片、字体等静态资源目录层次分明方便按模块研读。目前已有 52 人学习适合教学案例或个人练手能帮助学习者掌握前后端分离开发、RESTful API 设计、MySQL 建模等关键技能。1. Spring Boot电影订票及评论网站毕设高频选题做扎实才能答辩不慌Spring Boot电影订票及评论网站在国内高校Java方向的毕设选题里出现频率极高GitHub上相关源码和课程设计也比比皆是可大多数版本只把CRUD做完就交差了无法回答答辩时的关键追问。这篇文章不讲大道理直接按“技术选型 → 数据模型 → 下单与评论实现 → 常见翻车点 → 验收与进阶”这条路拆出一个既能运行、又能讲透的完整方案。读者如果是正在做毕设或春招项目复现的人能从中找到可直接落地的代码结构与参数设置。2. 选型定生死Spring Boot版本、持久层与前端方案的三个关键决策2.1 为什么把Spring Boot版本锁在2.7.x稳定性优先于新特性很多人在选题时随手搜到一篇教程就跟着做等做到一半才发现Spring Boot 3.x要求JDK 17而实验室机器还停留在JDK 8跑一次崩一次。这个坑在毕设场景里特别致命因为答辩现场经常是老师的电脑版本不兼容只能现场翻车。常见做法是把Spring Boot固定在2.7.18这是2.x系列的最后一个维护版本支持JDK 8又能正常引入Spring MVC、MyBatis-Plus、Thymeleaf等组件生态成熟度足够。在pom.xml里这样锁版本parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent使用这个版本组合的好处有三个第一MyBatis-Plus 3.5.x与Spring Boot 2.7.x的兼容性验证过不会出现分页插件启动报错第二Spring Framework 5.3.x的事务、AOP、参数校验机制非常稳定做课程设计不需要额外引入Spring Cloud之类的框架第三网上能搜到的绝大多数教程和代码片段都是基于2.x写的遇到问题能找到大量参考。我建议把spring-boot-starter-web、spring-boot-starter-thymeleaf、mybatis-plus-boot-starter、mysql-connector-j这四组依赖一次性加全省得后面缺包再补时搞乱版本关系。注意mysql-connector-j在Spring Boot 2.7.18里需要用runtime作用域不需要手动指定版本号由parent统一管理。提示如果你的教室环境已经装了JDK 17又想用最新版那选3.x也合理但要接受部分旧教程配置写法失效的现实。对答辩而言版本越低越稳。2.2 持久层选型MyBatis-Plus不是万能药配置不全是坑MyBatis-Plus近年几乎成了毕设标配核心原因是BaseMapper把单表CRUD代码省到极致。但很多人有一种错觉以为用了MyBatis-Plus就不需要写SQL了。实际上订单列表要联表取电影名称和海报、评分要按照电影聚合统计这些场景都必须手写SQL而且手写的部分恰恰是答辩考察的重点。在application.yml里配置MyBatis-Plus有几个关键点mybatis-plus: mapper-locations: classpath:/mapper/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0mapper-locations指定自定义XML文件的位置这是联表查询和复杂更新的主要阵地。log-impl建议只在开发环境开启它能让你在控制台直接看到SQL语句和参数排查问题效率翻倍部署到服务器后要删掉或改成slf4j输出否则日志文件会膨胀得很厉害。map-underscore-to-camel-case让数据库的create_time自动映射到Java属性createTime这个开关强烈建议打开。逻辑删除配置在答辩时是个加分项评论和订单表都需要“软删除”而非物理删除。但要小心逻辑删除配置了全局字段后如果某张表没有deleted字段MyBatis-Plus执行查询时会自动拼上WHERE deleted0导致SQL报错。我见过好几个项目因为这个字段没加而查不到数据。2.3 前端方案Thymeleaf还是前后端分离这个选择题直接决定项目能多快跑起来。如果是毕设和课程设计我推荐Thymeleaf而不是VueSpring Boot前后端分离。原因很现实答辩现场经常没有Node环境前端项目如果依赖npm install就可能现场装包失败而Thymeleaf模板直接打进Spring Boot的jar包一条java -jar命令就能完整启动不依赖任何外部构建工具。常见的做法是把页面放在src/main/resources/templates目录把CSS、JS、图片放在src/main/resources/static目录。Controller层通过ModelAndView或Model注入数据GetMapping(/movie/{id}) public String detail(PathVariable Long id, Model model) { MovieDetailVO vo movieService.getDetail(id); model.addAttribute(movie, vo); model.addAttribute(commentList, reviewService.listPassedByMovieId(id)); return movie/detail; }这段代码的逻辑很简单Model里放两个对象一个是电影详情一个是已过审的评论列表然后返回模板路径movie/detail。Thymeleaf在渲染时会自动从model中取值。关键是这个方案把跨域、Token拦截、接口签名这些变成前后端分离项目才会有的问题全部避开了。模板渲染方案的精力应该放在业务本身而不是纠结access-control-allow-origin。当然如果你已经在简历上写了“熟悉Vue”那用Vue做前端也完全可以只是需要遵守一个额外约定前端项目构建后的dist目录要整个复制到Spring Boot的static目录里这样后端jar包仍能一把启动。3. 数据模型与订票核心从四张表开始把下单流程做对3.1 数据模型设计电影、场次、座位、订单四张表的关系订票业务绕不开四张核心表很多初学者把座位数量和场次塞进同一条记录结果下单时要同时做数量判断和座位位点判断逻辑纠缠在一起非常难维护。正确的做法是拆成独立的表让每张表的职责单一。CREATE TABLE movie ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, cover_url VARCHAR(255) COMMENT 海报地址, duration INT COMMENT 片长单位分钟, release_date DATE, description TEXT, avg_rating DECIMAL(3,1) DEFAULT 0 COMMENT 平均评分冗余字段, review_count INT DEFAULT 0 COMMENT 评论数冗余字段 ); CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL, hall_name VARCHAR(50) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL, remaining_seats INT NOT NULL DEFAULT 50 COMMENT 剩余座位数, version INT DEFAULT 0 COMMENT 乐观锁版本号 ); CREATE TABLE seat_status ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, seat_no VARCHAR(10) NOT NULL COMMENT 如A5、B12, status TINYINT DEFAULT 0 COMMENT 0可售 1锁定 2已售, UNIQUE KEY uk_schedule_seat (schedule_id, seat_no) ); CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, seat_no VARCHAR(10) NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已退票, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME );每张表的设计意图如下。movie表里的avg_rating和review_count是冗余字段用空间换性能详情页不需要每次实时计算平均评分。schedule表是一次具体的放映计划remaining_seats同样是冗余数据为了列表页快速展示。seat_status表才是真实座位的归属与状态来源。t_order表选用t_前缀而非order因为order是MySQL保留字不加前缀会带来SQL语法麻烦。关于索引建议给schedule.movie_id、seat_status.schedule_id、t_order.user_id加上普通索引理由很直接这些字段都是高频查询条件而seat_status表的唯一键uk_schedule_seat已经能同时覆盖场次与座位的精确查找不需要额外加组合索引。3.2 下单流程先用FOR UPDATE锁座位再扣减余票下单是订票系统最核心的操作也是答辩时老师最感兴趣的部分。最容易出错的地方是并发控制两个用户同时抢最后一个座位时如果只是先查再更新必然会出现超卖。正确顺序是先锁座位再扣余票最后生成订单。Service public class OrderService { Autowired private SeatStatusMapper seatStatusMapper; Autowired private ScheduleMapper scheduleMapper; Autowired private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, Long scheduleId, String seatNo) { // 第1步锁定座位行防止并发重复选择 SeatStatus seat seatStatusMapper.selectForUpdate(scheduleId, seatNo); if (seat null || seat.getStatus() ! 0) { throw new BizException(座位不存在或已被锁定); } seatStatusMapper.updateStatus(scheduleId, seatNo, 1); // 第2步扣减场次余票带条件防止扣成负数 int affected scheduleMapper.decreaseRemaining(scheduleId); if (affected 0) { throw new BizException(该场次已满座); } // 第3步查询价格并生成待支付订单 Schedule schedule scheduleMapper.selectById(scheduleId); Order order new Order(); order.setOrderNo(MO System.currentTimeMillis()); order.setUserId(userId); order.setScheduleId(scheduleId); order.setSeatNo(seatNo); order.setAmount(schedule.getPrice()); order.setStatus(0); orderMapper.insert(order); return order; } }第一步的selectForUpdate对应的SQL是SELECT * FROM seat_status WHERE schedule_id ? AND seat_no ? FOR UPDATE它的作用是锁住这一行数据直到当前事务提交或回滚。在同一时刻其他事务执行同样查询时必须等待这就避免了两个用户抢同一座位的竞态。第二步的decreaseRemaining对应的SQL是UPDATE schedule SET remaining_seats remaining_seats - 1 WHERE id ? AND remaining_seats 0受影响行数为0时代表已经满座抛出异常让整体回滚。第三步生成订单时价格取的是schedule表的实时价格不让前端传金额这是防止篡改价格的基本防线。整个方法上的Transactional(rollbackFor Exception.class)很重要rollbackFor的含义是捕获所有异常都触发回滚包括BizException这类业务异常。如果不加这个属性Spring只会对RuntimeException自动回滚BizException如果继承自普通Exception事务不会回滚。注意FOR UPDATE锁只对InnoDB有效MyISAM引擎不支持行锁。建表时务必确认engineInnoDB很多一键安装的数据库默认可能不是这是一处隐藏很深的坑。3.3 订单超时与取消别让不支付的订单卡死座位用户选了座不付钱座位会一直被锁在“锁定”状态必须有超时回收机制。最直接可靠的办法是Spring自带的定时任务扫描超时订单一个Scheduled注解就能实现。Component public class OrderTimeoutTask { Autowired private OrderMapper orderMapper; Autowired private SeatStatusMapper seatStatusMapper; Autowired private ScheduleMapper scheduleMapper; Scheduled(fixedDelay 30000) Transactional(rollbackFor Exception.class) public void cancelExpiredOrders() { ListOrder expiredOrders orderMapper.selectExpiredOrders(15); for (Order order : expiredOrders) { seatStatusMapper.releaseSeat(order.getScheduleId(), order.getSeatNo()); scheduleMapper.increaseRemaining(order.getScheduleId()); orderMapper.updateOrderStatus(order.getId(), 2); } } }fixedDelay 30000表示上一次执行完成后间隔30秒再执行下一次适合这种定期扫描场景。selectExpiredOrders(15)这条SQL的查询条件是status 0 AND create_time NOW() - INTERVAL 15 MINUTE意思是找到超过15分钟仍未支付的待支付订单。这里有一个性能上的权衡定时任务扫全表如果订单量大每30秒一次全表扫描会拖垮数据库。但课程设计这个量级完全没有问题不要为此引入消息队列。演示时如果想现场展示自动取消效果把判断时间从15分钟改成1分钟Scheduled(fixedDelay 10000)扫描间隔改为10秒效果立竿见影。一个细节要注意回滚座位状态和回补余票必须放在同一个事务里否则可能出现座位释放了但余票没加回去的情况。这个事务和下单事务是两回事分别独立运行。4. 评论模块与评分聚合让用户反馈进入业务闭环4.1 评论表设计与防重复评论用唯一键而不是业务判断评论模块看似简单实则有几处关键决策。第一是评分和内容必须绑定。第二是同一用户对同一电影只能评论一次。第三是评论需要审核状态未过审的内容不能出现在公开列表。CREATE TABLE review ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL, user_id BIGINT NOT NULL, rating TINYINT NOT NULL COMMENT 1到5分, content VARCHAR(500) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待审核 1已通过 2已拒绝, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_movie (user_id, movie_id) );这里最值得讲的是uk_user_movie唯一键。如果只靠业务代码“先查一次再插入”两个并发请求可以同时通过检查然后都插入成功唯一键能从数据库层面拦住。当用户重复提交时DuplicateKeyException会被抛出服务层把它转换为“你已经评论过这部电影”的友好提示这比先查再插更加可靠。提交评论的服务方法Transactional(rollbackFor Exception.class) public void submitReview(Long userId, Long movieId, Integer rating, String content) { if (rating null || rating 1 || rating 5) { throw new BizException(评分必须在1到5之间); } if (StringUtils.isBlank(content) || content.length() 500) { throw new BizException(评论内容不能为空且不超过500字); } Review review new Review(); review.setUserId(userId); review.setMovieId(movieId); review.setRating(rating); review.setContent(content); review.setStatus(0); try { reviewMapper.insert(review); } catch (DuplicateKeyException e) { throw new BizException(你已经评论过这部电影); } }参数校验放在数据库操作前面拦截无效数据。rating用TINYINT存限制在1到5的范围比直接用INT省空间也比字符串可比较。提交后状态默认为0即待审核。展示评论列表时联表查用户昵称是基本操作Select(SELECT r.id, r.rating, r.content, r.create_time, u.nickname, u.avatar FROM review r LEFT JOIN user u ON r.user_id u.id WHERE r.movie_id #{movieId} AND r.status 1 ORDER BY r.create_time DESC) ListReviewVO selectPassedByMovieId(Param(movieId) Long movieId);LEFT JOIN取评论人信息WHERE条件中status 1确保未过审的评论不会出现在电影详情页ORDER BY create_time DESC让最新评论排在前面。如果要把“当前登录用户是否已评论”也带出来可以再LEFT JOIN一张包含当前用户ID的临时表但大多数毕设做到上面这个程度已经足够。4.2 评分聚合与审核机制不要在详情页现算平均值电影详情页需要显示平均评分和评论总数两个方案一个是每次请求都执行SELECT AVG(rating) FROM review WHERE movie_id ?一个是把平均值和总数冗余存到movie表。第一种方案在小流量下没问题但每次访问都要聚合一整张表SQL执行时间会逐渐增长。第二种方案是我的推荐做法在审核评论时更新movie表的冗余字段。Transactional(rollbackFor Exception.class) public void approveReview(Long reviewId) { Review review reviewMapper.selectById(reviewId); if (review null || review.getStatus() ! 0) { throw new BizException(评论不存在或已处理); } reviewMapper.updateStatus(reviewId, 1); BigDecimal avgRating reviewMapper.selectAvgRating(review.getMovieId()); Integer reviewCount reviewMapper.selectCountByMovie(review.getMovieId()); movieMapper.updateRatingFields(review.getMovieId(), avgRating, reviewCount); }这个方法做了三件事把评论状态改为已通过重新聚合这部电影的平均评分和评论数再把这两个值回写到movie表。selectAvgRating的SQL对应SELECT ROUND(AVG(rating), 1) FROM review WHERE movie_id ? AND status 1注意过滤条件要带上status 1把拒审的评论排除在统计之外。审核拒绝时也要做同样的事因为拒审评论不应计入评分基数。最常见的一个疏漏是只改状态不回写统计字段导致详情页分数与评论列表的实际数据不一致导师只要点开两三部电影就能发现。如果觉得approveReview和rejectReview两个方法逻辑重复可以抽一个私有方法专门负责重新聚合评分两个审核方法各自调用。当然这里又有事务失效的隐患私有方法是不能被Spring代理的所以聚合方法虽然可以写在同类里但两个审核方法自身的Transactional必须保留。5. 毕设必踩的5个坑事务、时区、分页、上传、字符集5.1 事务静默失效Transactional加在私有方法或自调用上现象下单接口抛异常后座位状态没有回滚库存扣了但订单没生成数据状态完全错乱。原因Spring的事务机制基于AOP代理只有通过代理对象调用public方法时事务才生效。事务方法写成private或者在同一个Service类里用this.createOrder()直接调用都没有走代理Transactional就静默失效了。很多代码模板为了让下载者好复制把createOrder写成私有方法表面看上去能跑其实事务完全没有参与。解决一是事务方法强制public二是同类内部调用必须拆开把事务逻辑放到另一个Service里或者注入ApplicationContext再用代理对象调用。另外Transactional默认只对RuntimeException回滚业务异常如果继承自普通Exception需要在注解上显式写上rollbackFor Exception.class。5.2 时区错乱本地正常部署到服务器后场次时间差8小时现象本地开发一切正常部署到云服务器后场次开始时间显示比数据库里存的值快8小时或慢8小时。原因MySQL连接串没有指定serverTimezoneJDBC驱动做日期转换时采用了JVM时区与数据库时区之间的折中转换结果时间发生了偏移。这是JDBC 8.x之后特别容易遇到的问题换成旧版本驱动反而没有。解决在application.yml的数据库连接URL里加参数。我建议改用配置文件方式spring: datasource: url: jdbc:mysql://localhost:3306/cinema ?useUnicodetrue characterEncodingutf8 serverTimezoneAsia/Shanghai useSSLfalse username: root password: 123456这个组合里serverTimezoneAsia/Shanghai是核心characterEncodingutf8防止中文乱码useSSLfalse避免连接时警告。如果数据库服务端本身的time_zone变量不是08:00还要在MySQL里执行SET GLOBAL time_zone 08:00否则光改连接串仍然会差8小时。5.3 分页插件不生效Page数据全为0或页码不正确现象MyBatis-Plus的分页查询返回的total始终为0或者翻页时数据不变。原因MyBatis-Plus从3.4版本开始分页插件必须用MybatisPlusInterceptor显式注册直接复制早期教程里的PaginationInnerInterceptor配置已经不管用了。解决新建一个配置类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这段代码的作用是注册MyBatis-Plus的拦截器链PaginationInnerInterceptor负责拼接LIMIT分页语句。DbType.MYSQL必须与实际数据库一致换成PostgreSQL后分页语法会从LIMIT变成OFFSET同样失效。另外分页查询的Mapper方法第一个参数必须是Page对象且不能传null。5.4 图片上传后加载失败jar包部署后文件被清空现象本地开发上传电影海报没问题用java -jar部署后海报能传但不能显示重启后图片全部丢失。原因上传时把文件写入了项目运行目录下的临时文件夹jar包模式下System.getProperty(user.dir)指向的是启动目录不是classpath内部重启后临时文件被系统清理上传的图片自然就没了。解决把上传路径抽成配置项指到服务器固定目录file: upload-dir: /home/cinema/upload然后在配置类里注册虚拟路径映射Configuration public class WebConfig implements WebMvcConfigurer { Value(${file.upload-dir}) private String uploadDir; Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadDir /); } }具体逻辑是浏览器访问/upload/xxx.jpg时Spring MVC把这个请求映射到本地磁盘的/home/cinema/upload/xxx.jpg。代码中保存图片路径时不要存“本机绝对路径”而是统一存/upload/xxx.jpg这种URL路径这样换一台服务器部署不需要改数据库里的存量数据。5.5 中文乱码评论和电影名入库变成问号现象页面上正常输入的电影标题、评论内容保存到数据库后变成???或者乱码。原因数据库、表、连接串三者的字符集不统一。最常见的是建库时用了默认的latin1或者连接串里没有加characterEncodingutf8。emoji表情则必须使用utf8mb4而不是utf8因为utf8在MySQL里最多存3字节存不下emoji。解决建库时一次性指定CREATE DATABASE cinema DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;如果库已经建好用这条语句补救ALTER DATABASE cinema CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;注意ALTER DATABASE只改库级别已建表还需要逐个转换旧数据在转换过程中可能丢失所以越早统一越好。数据库表字段级别的字符集也应该宣告为utf8mb4在DDL建表时给VARCHAR字段加上CHARACTER SET utf8mb4是最稳妥的。6. 验收与进阶让课程设计变成能讲的简历项目一个项目写完不能就算交差了我习惯在答辩前做三个验收动作。第一用JMeter开50个并发去压同一个场次的下单接口跑一分钟然后查t_order表里有没有重复的schedule_id seat_no组合没有重复说明座位锁定逻辑基本可靠。第二把一台测试服务器的时区改成UTC再启动项目检查场次时间显示是否正确这一步替我把时区问题拦在答辩前。第三连续快速点击提交评论按钮10次确认唯一键确实能拦住重复评论。这三个动作都过了大多数现场演示场景不会翻车。如果想把课程设计升级成能写进简历、经得起面试追问的项目我会按优先级做三件事。第一把Redis引入下单流程用SETNX分布式锁替换数据库的FOR UPDATE行锁同时用Redis为同一用户下单做幂等控制。第二把订单超时回收从Scheduled换成RabbitMQ延时队列用TTL加死信路由消除定时扫描的延迟与数据库压力。第三把图片和文件存储迁移到对象存储服务本地磁盘只做缓存不做持久化。面试官关于这个项目最常追问的几个点分别是事务传播行为怎么设置的、超卖问题如何解决、Redis锁的过期时间怎么考虑、幂等控制怎么做。只要这部分链路能用真实代码讲清楚远比在简历上多写十几个技术名词更有说服力。我个人的血泪经验是代码只是起点真正有价值的是把设计与权衡沉淀下来。下单为什么要先锁座位而不是先扣余票评论统计为什么要冗余字段而不是实时聚合这些问题回答得越具体项目的含金量越高。希望这份笔记能帮你在订票系统这条路上少踩几个坑把每一步走得踏实。本文还有配套的精品资源点击获取