首页 / 资讯中心 / 文章详情

基于Spring Boot的电影票预订系统:从表结构到并发防超卖实现

基于Spring Boot的电影票预订系统:从表结构到并发防超卖实现 ★ FEATURED ARTICLE
简介基于Spring Boot的电影票预订系统是一套完整的毕业设计与课程作业资料面向Java全栈初学者解决如何从零搭建一个可运行、可演示的在线购票网站。资源包共包含1229个文件压缩后大小约18.36MB文件类型涵盖Java后端代码、Vue前端组件、HTML/CSS/JavaScript页面脚本、MySQL数据库脚本SQL以及XML配置和说明文档同时还附带论文、开题报告与PPT演示稿可满足文档撰写与答辩展示双重需求。已有59人学习适合快速理解Spring Boot与Vue的前后端分离开发模式。系统前台支持用户注册登录、电影列表查看与详情展示、在线选座购票、模拟支付、基于注册喜好的影片推荐、用户评论以及电影论坛后台则提供管理员信息、注册用户、电影信息、推荐内容、评论、论坛、购票订单、支付记录等模块化管理。通过这份资料既能对照学习前后端交互、数据库表设计与权限控制也能直接参考其项目结构完成自己的课程设计或毕业设计整体目录清晰、代码注释可读性强是一份实用性很高的学习范本。1. 电影票预订系统不是增删改查先把核心链路想清楚“电影票预订-JAVA-基于springBoot电影票预订系统设计与实现”这个标题看起来像典型的毕业设计选题但动手做一遍就会发现它比普通的管理系统多出最关键的一环座位这种带状态资源在并发下如何保证不卖重。系统要交付的不只是能跑的前后端代码还有配套的论文、开题报告和PPT。本文按最常见的Spring Boot MyBatis Plus技术路线从表结构、核心接口、文档对齐到并发排查拆成一整套可以直接复现的做法。适合正在做毕设的在校生也适合需要快速搭一个演示系统并补齐设计文档的工程师。我建议先不要急着建工程把“同一场次同一座位只能被一个人下单成功”这件事想清楚后面的实现和答辩都会顺畅很多。2. 从表结构开始拆设计五张表如何支撑一场演出的订票闭环设计电影票预订系统时很多人第一步就是打开IDE开始建工程、写实体类。我习惯反过来先花半天把表结构定下来尤其是座位表。表结构决定业务边界座位这种带状态的资源一旦设计错后续的并发锁、订单状态、页面展示全部跟着返工。这一章我给出一个最小但完整的MySQL表结构并说明为什么这样建。2.1 先把领域模型理顺电影、场次、座位、订单之间的状态关系电影票预订系统里最核心的领域模型不是“电影管理”而是“场次、座位、订单”三条线的状态流转。一个电影下有多个场次一个场次对应一个放映厅放映厅里有一批座位用户选定场次和座位后系统锁定座位并生成订单。如果按后台管理系统的习惯把座位挂在“放映厅”下同一个厅的不同场次就会共用同一批座位状态锁定第一场的A座会把第二场的A座也锁掉。为了解决这个问题常见的做法是按场次复制座位记录每场演出在schedule下生成自己的一份座位集合。这样做的代价是seat表有冗余数据一场电影如果有200个座位就插入200条记录但对中小型系统和毕业设计来说这点存储开销换来了查询和锁行的极大简单。下面是我常用的五张核心表建表脚本。字段刻意精简方便后续写论文时逐个解释。-- 用户表 CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, nickname VARCHAR(64) NOT NULL, phone VARCHAR(20) NOT NULL UNIQUE, password_hash VARCHAR(128) NOT NULL, created_at DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 电影表 CREATE TABLE film ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL, poster_url VARCHAR(255) DEFAULT , duration INT DEFAULT 0 COMMENT 片长/分钟, description TEXT, status TINYINT NOT NULL DEFAULT 1 COMMENT 1:上映 0:下架, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 场次表 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, film_id BIGINT NOT NULL, hall_name VARCHAR(32) NOT NULL, start_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL, created_at DATETIME NOT NULL, KEY idx_film_id (film_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 座位表按场次生成 CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, seat_code VARCHAR(10) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0:空闲 1:锁定 2:已售, price DECIMAL(10,2) NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_schedule_seat (schedule_id, row_no, col_no), KEY idx_schedule_status (schedule_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, seat_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0:待支付 1:已支付 2:已取消, created_at DATETIME NOT NULL, paid_at DATETIME DEFAULT NULL, UNIQUE KEY uk_schedule_seat (schedule_id, seat_id), KEY idx_user_created (user_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个容易忽略的细节。一个是在seat表上加了(schedule_id, row_no, col_no)的唯一索引避免初始化座位时重复插入同一排同一列的记录。另一个是在orders表上加了(schedule_id, seat_id)唯一索引它能让“同一场次同一座位只能产生一条订单记录”即使前面的事务锁出现意外第二个事务插入订单时也会被数据库拒绝这是防超卖的最后一道防线。需要注意这道防线有个前提业务上不允许“取消订单后原座位再次下单”否则唯一索引会挡住后续合法购买。对毕业设计来说通常能接受如果要做退票再购就不能依赖这个唯一索引而要在应用层用状态机控制并在订单表里保留支付成功后的防重。座位表为什么冗余一个price字段因为不同场次的票价可能不同。如果把价格只放在schedule表下单时要join两张表才知道当前座位卖多少钱。把price冗余到seat表下单时查一次座位就能拿到价格和场次信息逻辑更直接。代价是如果后台调整了某场次票价需要同步更新该场次所有座位的price我一般在管理端提供一个“批量调整场次价格”的接口来处理不在单表上手工改。领域模型里的每一个冗余字段都应该有明确理由这也是论文“数据库设计”章节能写出内容的地方。2.2 用 MyBatis Plus 从实体类反推建表 SQL别被“一键生成”忽悠网上常有人搜“mybatisplus根据java实体类生成创建表的sql语句”先说结论MyBatis Plus官方提供的是AutoGenerator代码生成器方向是“从数据库表生成实体类、Mapper、Service”并不是从实体类反向生成建表SQL。那些声称能一键从实体类生成表结构的工具大多对字段类型、默认值、唯一索引支持不完整跑出来的表经常和手写脚本不一致。我个人的做法是把建表SQL当作基准实体类按表结构手动维护两者字段一一对应MyBatis Plus只负责把数据库列名映射到Java属性。这样表结构可控写论文时也方便贴SQL。下面是一个按seat表手写的实体类示例用到了MyBatis Plus的核心注解。Data TableName(seat) public class Seat { TableId(type IdType.AUTO) private Long id; private Long scheduleId; private Integer rowNo; private Integer colNo; private String seatCode; private Integer status; private BigDecimal price; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt; }Data是Lombok注解自动生成getter/setterTableName(seat)把实体类映射到seat表TableId(type IdType.AUTO)声明主键并指定自增策略TableField(fill FieldFill.INSERT_UPDATE)表示插入和更新时由MyBatis Plus自动填充updatedAt。这些注解都不复杂但它们决定了实体类和表之间的映射方式如果漏了任何一个最常见的表现就是插入数据时主键为空或时间字段为null。实体类里面的属性名比如scheduleId会在map-underscore-to-camel-case开启后自动对应数据库的schedule_id这也是MyBatis Plus最常用的配置之一。项目结构我建议按下面这种分层来建它和论文里画的架构图可以直接对应。com.example.cinema ├── controller // HTTP接口层 ├── service // 业务层接口 │ └── impl // 业务实现 ├── mapper // MyBatis Plus Mapper接口 ├── entity // 数据库实体 ├── dto // 请求/响应对象 ├── config // 配置类 └── common // 统一返回、异常、常量为什么不把业务逻辑写在Controller里因为答辩时老师会问“你的事务边界在哪”如果把下单逻辑堆在Controller事务注解既难看又容易失效。Controller只负责接收参数、校验基础格式、调用ServiceService负责事务和业务规则Mapper只做持久化操作。这样分层之后论文的“系统设计”章就可以画一个同样的包图代码结构即文档结构。2.3 让 Spring Boot 和 MyBatis Plus 跑起来的最小配置表结构和实体类都有了接下来需要一份能直接启动的application.yml配置。我把最关键的几项贴出来下面逐项说明参数含义。spring: datasource: url: jdbc:mysql://localhost:3306/cinema?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: autoserverTimezoneAsia/Shanghai常被忽略MySQL驱动8.x不配这个参数时时间经常和本地差8个小时后面避坑章节会细讲。spring.jackson.date-format决定接口返回给前端的日期格式如果不配LocalDateTime默认以ISO格式输出前端很难处理。map-underscore-to-camel-case是最常用的命名映射开关打开后数据库列schedule_id会自动映射到Java属性scheduleId。id-type: auto声明全局主键自增和实体里的TableId配置保持一致。如果你的项目需要逻辑删除还可以在global-config下配置logic-delete-field但演示系统我通常不启用因为逻辑删除会和订单唯一索引产生历史数据冲突不如物理删除简单。3. 用 Spring Boot 实现“选座-锁座-下单”主链路的关键代码表结构定清楚之后核心业务就只剩一条主线用户查询场次座位、提交选座、后端锁座并生成订单。这一章我按接口划分、Service实现、订单状态机三个层次来写每一步都是可以直接复制到项目里改的。3.1 先把接口和数据流定下来查询、选座、下单三步要怎么拆电影票预订系统的前端交互通常是用户先看影片列表再选场次再看座位图最后点“立即预订”。但后端接口不能把整个流程做成一个接口否则一个用户停留在选座页面期间座位状态变化了他都不知道。我一般把流程拆成四个接口影片列表、场次列表、座位图、锁定座位下单。前面三个都是只读接口只有最后一个是写接口这样做的好处是“座位图”可以允许每次进入页面时刷新而下单接口才真正去校验并锁座。下面是不带鉴权的Controller简化版实际答辩项目里可以在Interceptor里做登录态校验。RestController RequestMapping(/api) public class SeatOrderController { private final ScheduleService scheduleService; private final OrderService orderService; public SeatOrderController(ScheduleService scheduleService, OrderService orderService) { this.scheduleService scheduleService; this.orderService orderService; } GetMapping(/schedules/{scheduleId}/seats) public ResultListSeatVO listSeats(PathVariable Long scheduleId) { return Result.ok(scheduleService.listSeats(scheduleId)); } PostMapping(/schedules/{scheduleId}/reserve) public ResultOrderVO reserve(PathVariable Long scheduleId, RequestBody Valid ReserveRequest request) { return Result.ok(orderService.reserve(scheduleId, request)); } }代码里使用了统一的Result 包装类把code、message、data三个字段返回给前端。PathVariable从URL路径取scheduleIdRequestBody把JSON请求体反序列化成ReserveRequest。这里有一点要注意reserve接口同时接收scheduleId和seatId后端必须校验seatId对应座位确实属于这个场次否则用户在选座页面停留很久后前端页面可能已经切换了场次数据提交时就会把上一场的seatId带到下一场。校验逻辑放在Service里Controller不做业务判断。ReserveRequest里至少需要userId和seatId两个字段如果项目没有做登录可以先从前端传userId但答辩时最好补一个简单的拦截器。用NotBlank、NotNull做参数校验需要引入spring-boot-starter-validation依赖这是Spring Boot里非常常见的一个辅助包能把参数错误提前挡在业务代码之外。你可以同时用Validated和Valid但别小看这个步骤它能避免大量空指针问题出现在Service层。3.2 下单接口用数据库悲观锁兜住“同一座位只卖一次”选座下单是整个系统的核心也是答辩时最容易问“并发怎么办”的地方。最简单的错误写法是查座位状态如果空闲就改成已锁定然后插入订单。两个请求同时执行时两个事务都读到status0然后都走完流程于是同一座位卖给了两个人。原因在于普通SELECT走的是快照读不会对行加锁。解决这个问题的标准办法是使用悲观锁让第一个事务锁住座位行第二个事务必须等第一个事务提交后才能读到最新状态。对应SQL就是SELECT ... FOR UPDATE。先在SeatMapper里加一个带锁查询方法Mapper public interface SeatMapper extends BaseMapperSeat { Select(SELECT * FROM seat WHERE id #{id} FOR UPDATE) Seat selectByIdForUpdate(Long id); }然后在下单Service里使用它。注意整个方法必须处于同一个事务中并且要抛出运行时异常才能触发回滚。Service public class OrderService { private final SeatMapper seatMapper; private final OrderMapper orderMapper; Transactional public OrderVO reserve(Long scheduleId, ReserveRequest request) { Seat seat seatMapper.selectByIdForUpdate(request.getSeatId()); if (seat null || !seat.getScheduleId().equals(scheduleId)) { throw new BizException(座位不存在或不属于该场次); } if (seat.getStatus() ! 0) { throw new BizException(座位已被锁定或售出); } seat.setStatus(1); seatMapper.updateById(seat); Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(request.getUserId()); order.setScheduleId(scheduleId); order.setSeatId(seat.getId()); order.setAmount(seat.getPrice()); order.setStatus(0); orderMapper.insert(order); return OrderVO.from(order); } }这段代码的关键点有三个。第一个关键点是selectByIdForUpdate必须在事务内执行事务提交后锁才释放所以Transactional不能加在私有方法上也不能在同一个类里通过this.reserve()调用否则Spring代理不会开启事务锁也就失效了。第二个关键点是座位状态判断和状态更新要在同一个事务里selectByIdForUpdate返回的是最新已提交的数据后到的请求会一直阻塞直到前一个请求提交或回滚然后读到的status已经变成1自然走不进下单逻辑。第三个关键点是订单插入放在座位更新之后如果座位锁定成功但订单插入失败整个事务回滚座位状态也回到0不会出现锁定了一堆座位却查不到订单的脏数据。抛出的BizException必须继承RuntimeException不然Spring默认只对运行时异常回滚自定义的受检异常不会触发事务回滚这一条很容易在测试时踩到。看到的效果是“座位状态已经变成1但订单不存在”比超卖更难排查。我把这个检查写在Service的异常类里public class BizException extends RuntimeException { public BizException(String message) { super(message); } }需要特别说明的是悲观锁会阻塞并发请求但这里的锁粒度是“一个座位一行”不是整个场次一行所以不同座位之间互不干扰同一场次同一时刻可以有多个人买不同座位。这个粒度是合理的。不要把锁加在schedule表上那会让整场电影的所有座位都不能同时下单吞吐量会明显下降答辩时老师可能会问。3.3 订单状态机待支付、已支付、已取消别把状态写死成数字订单状态如果直接在代码里用数字判断比如if(status 1)后续加需求时很容易改漏。更清晰的写法是先定义枚举把状态码和描述集中管理。下面是一个简化版订单状态枚举。public enum OrderStatus { UNPAID(0, 待支付), PAID(1, 已支付), CANCELLED(2, 已取消); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } }订单的状态流转建议严格限制为待支付可以变为已支付或已取消已支付只能退票后人工改为已取消已取消为终态。对于“待支付超时取消”可以用一个简单的定时任务扫描过期订单并释放座位。演示系统可以每5分钟扫一次扫描频率别设太高否则数据库压力大。Scheduled(fixedDelay 300_000) public void cancelExpiredOrders() { ListOrder expiredOrders orderMapper.findUnpaidBefore(LocalDateTime.now().minusMinutes(30)); for (Order order : expiredOrders) { orderMapper.updateStatusById(order.getId(), OrderStatus.CANCELLED.getCode()); seatMapper.releaseSeat(order.getSeatId()); } }Scheduled(fixedDelay 300_000)表示上一次任务执行完毕后等待5分钟再执行下一次。要注意这个注解必须在启动类上加EnableScheduling才会生效。如果项目后面要上生产更合理的方案是用消息队列延迟任务或XXL-Job但毕业设计阶段用Spring自带的调度足够。这个定时任务也是对“座位库存回滚”的完整闭环一旦订单取消座位状态从1回到0后续访客就能继续购买。4. 论文、开题、PPT三件套怎么与工程实现对齐标题里带着“论文开题PPT”说明这套系统的交付物里文档和代码同等重要。很多项目代码写得不错但开题报告写得像通用模板论文第3章全是代码贴图PPT又把代码目录讲了一遍最后答辩分数反而不高。这一章讲常见做法让文档跟着业务链路走而不是跟着代码包走。4.1 开题报告不是复制背景而是写清楚你要解决什么问题开题报告的常规结构是背景与意义、国内外研究现状、研究内容、技术路线、进度安排。问题在于大量开题报告把“研究背景”写成了放之四海而皆准的通用句子放在任何系统里都能成立等于没有写。换成电影票预订系统应该写清楚的是这个场景里真正难解决的问题座位资源是并发共享的如何保证同一场次同一个座位不会被两个用户同时下单订单超时未支付时座位何时释放这两个问题可以支撑起整个系统的研究内容。我一般会把开题中的“研究内容”拆成四块每一块都能对应到工程里的具体模块。这样老师一看就知道你的系统不是简单的CRUD基于Spring Boot的分层架构与REST接口设计基于数据库悲观锁的座位防超卖策略订单状态机与超时自动取消机制基于MyBatis Plus的持久层映射与数据初始化方案。每一块后面再标注对应的包名或接口名比如“座位防超卖策略对应OrderService.reserve()方法”这在开题答辩时非常有用。如果只写“系统功能模块分为用户模块、电影模块、订单模块”就是典型的把“能用”当“研究”深度不够。4.2 论文正文用“业务路径”组织章节而不是按代码包结构论文的系统设计章节和实现章节最容易出现的问题是内容重叠。设计章节贴了一遍表结构实现章节又贴一遍同样的SQL设计章节画了用例图实现章节又画一遍。比较好的分工是设计章节写“方案”实现章节写“关键落地”。比如设计章节讲领域模型和状态流转实现章节只挑一个核心接口讲如何用代码实现防超卖设计章节画订单状态图实现章节贴订单枚举和定时取消代码。下面的对照表是我的常用组织方式。论文章节对照的工程产物需要写清楚的内容需求分析前端页面原型、用例图用户角色、选座下单流程、非功能需求系统设计数据库表、类图、时序图场次与座位关系、订单状态机、接口列表系统实现Controller/Service关键代码防超卖接口、分页查询、异常处理系统测试测试用例、并发测试结果同一座位并发下单的通过/失败数据写需求分析时要注意用例图不要画成“登录、注册、查看电影、下单”这种大而全的清单最好细化到“用户选择场次”“用户锁定座位”“订单超时释放座位”这种带动作和状态的用例。写系统设计时可以在Word里用PlantUML画时序图描述用户、前端页面、Controller、Service、数据库之间的调用顺序这一步比贴代码更能体现“设计能力”。时序图里要明确标出“SELECT ... FOR UPDATE”发生的时刻以及锁在事务提交后释放这是论文中的核心亮点也是答辩老师最喜欢听到的细节。4.3 PPT怎么讲按一条用户操作路径讲而不是按代码目录讲答辩PPT的常见问题是想把所有内容都放上去一页挤了三十行代码结果讲不完老师随便指一个角落问细节就卡壳。PPT的核心作用是辅助讲解不是代码评审。我一般控制在12到15页第一页介绍选题背景和痛点第二页说明技术选型第三页放整体功能结构图第四页放数据库设计重点突出seat表和orders表第五到第七页讲用户从进入页面到下单完成的流程第八到第九页专门讲并发控制贴“SELECT ... FOR UPDATE”和并发测试结果第十页放运行效果或录屏第十一页列不足与展望。如果项目里有亮点比如超时自动取消订单就单独做一页配合定时任务代码片段和运行日志。PPT上需要出现的代码不是全部而是三个片段建表SQL里带唯一索引的那几行、SeatMapper里的FOR UPDATE语句、OrderService里锁座和下单的前后顺序。这三个片段能完整回答“你怎么防止超卖”。其他地方尽量用图比如时序图、状态图、接口表。答辩演示的时候先打开页面走一遍选座下单再切到并发测试的结果页说明同一次操作中只有一个请求成功另一个返回“座位已被锁定”。这个节奏比从头念PPT好很多因为评委已经看到你的系统真的能跑起来而且你也讲得出为什么不会卖重。5. 电影票预订系统常见问题与避坑排查记录这一章把我做过类似系统时遇到的高频问题按“现象、原因、解决”列出来。这些问题不一定同时出现但基本都能在测试或答辩准备阶段遇到早点知道可以少走很多弯路。5.1 座位“超卖”查不出来加了Transactional为什么还是卖重现象用并发工具同时发两个请求抢同一个座位两个订单都创建成功座位状态也都是已锁定。原因有两种。第一种是普通SELECT使用快照读两个并发事务同时读到status0不锁行于是都认为座位空闲。第二种是事务没生效比如在同一个类里用this调用带Transactional的方法Spring的AOP代理没有介入方法直接执行数据库不开启事务。解决方法是把查询改成SELECT ... FROM seat WHERE id? FOR UPDATE执行当前读并锁住该行同时确认调用入口是从外部Bean发起的并且方法没有被private修饰。还有一个兜底手段orders表对(schedule_id, seat_id)建唯一索引即使锁失效第二条订单也会因为唯一键冲突插入失败。5.2 时间字段差了8小时接口返回和MySQL同时翻车现象后端启动参数没配时区的时候前端页面显示的时间比实际时间少8小时数据库里存的值也乱。原因有两层MySQL驱动8.x在连接串里没有指定serverTimezone时会拿数据库服务器的系统时区作为会话时区Java一侧的Jackson序列化LocalDateTime时默认使用JVM时区而本机时区通常已经是Asia/Shanghai一对比就错位。解决的组合是JDBC URL加serverTimezoneAsia/ShanghaiSpring Boot配置里加spring.jackson.time-zone: Asia/Shanghai和spring.jackson.date-format: yyyy-MM-dd HH:mm:ss。这两个配置都用上前后端和数据库就能保持一致。如果实体里的时间字段用了Date而不是LocalDateTime还需要额外检查MyBatis的类型处理器但Spring Boot项目里更推荐直接使用LocalDateTime。5.3 Spring Boot 版本太高复制的旧代码大段编译不过现象从网上复制的Spring Boot教程代码import javax.servlet.*和import javax.annotation.Resource的地方全部报红或者项目启动时提示找不到SpringBootServletInitializer。原因是Spring Boot 3.x基于Jakarta EE 9官方把javax包迁移到了jakarta而网上大量教程还停留在Spring Boot 2.x的写法。解决方法是二选一如果目标是快速完成毕设和文档直接用Spring Boot 2.7.x最省事生态里的老教程和依赖基本都能对上如果想用新的3.x把所有javax改成jakarta同时注意PageHelper、MyBatis Plus等插件版本也要适配否则运行时会遇到一些莫名其妙的报错。这个问题的特点是“编译期暴露早但搜索引擎给出的答案版本混乱”排查时先看自己的Spring Boot主版本再决定代码写法。5.4 MyBatis Plus 自动填充失效createTime一直是null现象实体类里加了TableField(fill FieldFill.INSERT)但插入数据库后created_at字段还是null。原因是MyBatis Plus的自动填充不是注解本身实现的而是需要有一个实现MetaObjectHandler的Spring Bean在insertFill和updateFill方法里给字段赋值。只加注解没有处理器框架不知道该往字段里填什么。解决办法是写一个配置类实现MetaObjectHandler重写insertFill里调用this.strictInsertFill(metaObject, createdAt, LocalDateTime.class, LocalDateTime.now())updateFill里处理updatedAt。同时注意实体字段名要用驼峰写法createdAt数据库列名用created_at依赖map-underscore-to-camel-case的映射关系。如果字段名写错spring的自动填充会因为找不到属性而静默跳过不报错但也不生效。5.5 论文查重和PPT答辩的坑代码贴太多会被“查重”和“追问”双杀现象论文查重率接近40%因为系统实现章几乎把整个Controller和Service都贴进去了答辩时PPT上放了50行代码讲不完老师随手指一个逻辑问为什么这么做一时答不上来。原因是代码片段在查重系统里会被当作文本参与比对而Spring Boot项目的增删改查代码大量雷同贴得越多重复率越高。解决方法是论文里只保留带业务亮点的核心片段比如FOR UPDATE那一行、订单状态机枚举、定时取消订单的循环其余接口用接口表格和类图来表达。PPT上更是不要展示完整类只贴一行关键SQL和一张运行结果截图。并发接口测试的通过/失败统计表比源代码更有说服力因为那才是“这个系统真的不会卖重”的证据。6. 用并发测试脚本验证“同一座位不会卖两次”一个能写进答辩材料的技巧很多人验证防超卖是用JMeter开几百个线程看数据库里有没有重复订单。JMeter能出结果但依赖手工配置不适合每次改完代码都重复跑。我习惯把并发测试直接写成Spring Boot的集成测试放到src/test目录下这样每次跑mvn test都能自动验证核心业务没有被改坏。下面是一个简化版的并发测试用例20个线程同时提交同一个座位的预订请求统计成功的数量。SpringBootTest AutoConfigureMockMvc class SeatOrderConcurrencyTest { Autowired private MockMvc mockMvc; Test void sameSeatCannotBeBookedTwice() throws Exception { int threadCount 20; ExecutorService pool Executors.newFixedThreadPool(threadCount); CountDownLatch start new CountDownLatch(1); AtomicInteger successCount new AtomicInteger(); for (int i 0; i threadCount; i) { pool.submit(() - { start.await(); int status mockMvc.perform(post(/api/schedules/1/reserve) .contentType(MediaType.APPLICATION_JSON) .content({\userId\:1,\seatId\:100})) .andReturn().getResponse().getStatus(); if (status 200) { successCount.incrementAndGet(); } return null; }); } start.countDown(); pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS); assertEquals(1, successCount.get()); } }这个测试的关键点是CountDownLatch的使用主线程先创建好20个任务每个任务都在start.await()处等待任务全部提交后再执行start.countDown()20个线程就会同时发出请求实现“同一起跑线”的并发效果。断言只允许成功1次只要哪天有人把FOR UPDATE改成普通查询测试就会立刻失败。跑测试前要注意seatId100的记录必须先初始化成空闲状态如果上一次测试留下了已锁定数据需要手动清掉否则断言对象就不对了。我把这类测试当成整个系统的“后悔药”每次改动订单或座位逻辑先跑一遍并发用例再继续下一步比到最后答辩前再手工点页面可靠得多。如果时间允许还可以把并发测试的结果统计表截图放进论文“系统测试”章节证明你做的不是只会在单用户场景下工作的演示品。这比任何文字描述都能说明问题。希望在动手做这个电影票预订系统之前这篇笔记能帮你避开我踩过的坑也希望你最终交付的代码和文档能在答辩或评审时经得住追问。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站