简介围绕Java与Spring Boot构建电影在线订票选座系统的技术文档面向具备一定Java基础、希望学习或从事电影票务平台设计开发的程序员与创业者重点梳理普通用户、会员与管理员三类角色的功能规划及系统落地思路。文档以IntelliJ IDEA、Maven、Spring Data JPA、Thymeleaf与MySQL为主线串联功能清单、运行截图、项目结构解析、数据库配置、编译运行流程与核心代码示例并补充演示入口、改进方向及安全与异常处理注意事项。包内共1个docx文件约34KB目录紧凑完整便于按功能模块、技术介绍、开发步骤、代码示例和项目总结逐项查阅。已有315人学习适合需要快速厘清订票选座业务边界、对照控制器与实体设计、理解Spring Boot项目组织方式的读者作为方案参考与开发线索。1. 从一份能跑起来的源码说起这套电影订票系统到底给了什么电影在线订票系统这类题目在课程设计和外包原型里出镜率极高但真正解压就能跑的没几个——多数压缩包打开是一堆 Controller 堆砌数据库脚本缺失前端模板路径写死跑起来首页直接白屏。这份基于 Java 与 Spring Boot 的订票系统源码相对完整后端 Spring Boot Spring Data JPA前端 Thymeleaf 服务端渲染数据落 MySQL开发工具指定 IntelliJ IDEA普通用户、会员、管理员三种角色的功能都铺开了影片浏览、场次选座、订单支付、论坛留言、后台管理一路串下来。它适合两类人一类是要交课程设计、需要能演示能答辩的另一类是想拿一套带订单和选座逻辑的 Spring Boot 骨架改造成自己业务原型的。下文按「技术选型为什么这么定 → 环境怎么对齐 → 代码骨架怎么落 → 哪些坑会翻车」的顺序拆。2. 技术栈拆解Spring Boot JPA Thymeleaf 为什么扛得住订票业务订票系统的核心不是页面多漂亮而是「一场电影一百个座位两个人同时点同一个座只能有一个人成功」。这个约束决定了技术选型的下限必须有可靠的事务、必须有数据库层面的唯一性兜底。Spring Boot 在这套源码里的角色是「把事务、连接池、模板引擎、静态资源全部装配好」让开发者只关心业务Spring Data JPA 负责把实体映射成表省掉大量手写 DAOThymeleaf 负责把 Model 里的场次列表渲染成 HTML不需要额外起一个前端工程。有人会问为什么不上 Vue 前后端分离——从交付角度看单 jar 包丢到服务器上java -jar就能访问对答辩演示和小型部署更省事从代码阅读角度看模板和 Controller 一一对应改一个页面不用同时开两个工程。代价是交互体验不如 SPA但对一个订票演示系统来说完全够用。2.1 一次「查场次→选座→下单」的请求链路先把链路走通后面所有配置才有意义。用户在影片详情页点「购票」浏览器发出GET /movie/12/screenings请求进入 DispatcherServlet按路径匹配到ScreeningControllerController 调ScreeningServiceService 通过ScreeningRepository拿到该影片未来三天的场次塞进Model返回逻辑视图名screening/listThymeleaf 引擎找到templates/screening/list.html渲染成 HTML 回给浏览器。下单是另一条链路POST /order/submit携带场次 ID 和座位号Service 层在Transactional里先锁座、再生成订单、最后扣减余票三步要么全成要么全滚。Controller public class ScreeningController { private final ScreeningService screeningService; // 构造器注入不用 Autowired 字段注入单测时能直接 new不依赖容器 public ScreeningController(ScreeningService screeningService) { this.screeningService screeningService; } GetMapping(/movie/{movieId}/screenings) public String list(PathVariable Long movieId, RequestParam(defaultValue 3) int days, Model model) { // 只查未来 days 天内的场次历史场次不进列表避免全表扫描 ListScreeningVO screenings screeningService.listUpcoming(movieId, days); model.addAttribute(screenings, screenings); model.addAttribute(movieId, movieId); return screening/list; // 对应 src/main/resources/templates/screening/list.html } }这段代码有三个值得说的地方。第一注入方式用构造器而非字段注入Spring 官方推荐好处是依赖关系显式、单元测试不用启动容器。第二days给了默认值 3前端不传也不会 400这种防御性默认值在演示环境里能省掉很多「点进去报错」的尴尬。第三返回的是视图名不是ResponseBody的 JSON因为这条链路服务于 Thymeleaf 页面如果同一套 Service 还要给移动端用正确做法是再加一个RestController暴露/api/movie/{id}/screenings复用同一个 Service而不是在页面 Controller 上硬加produces。分层边界要提前定死否则代码会烂得很快。我的习惯是Controller 只做三件事——接参数、做基础校验非空、格式、决定返回视图还是 JSONService 管事务边界和业务规则余票够不够、会员折扣怎么算、订单状态能不能流转Repository 只写查询不写业务判断。常见翻车是把if (seat.getStatus() 1)这种判断写在 Controller 里等要做「同一场次限购 4 张」时发现逻辑散在三个地方改一处漏两处。2.2 五张核心表怎么设计订票业务的表不用多五张就能撑起来用户、影片、场次、场次座位、订单。多出来的论坛、留言、公告属于内容模块跟交易主链路解耦加不加不影响下单。表名关键字段说明sys_userid、username、password、role、points、statusrole 取 USER / MEMBER / ADMINstatus 控制禁用movieid、title、type_id、duration、release_date、poster影片基础信息type_id 关联影片类型screeningid、movie_id、hall_id、start_time、price、stock一个场次一行stock 是剩余可售座位数screening_seatid、screening_id、seat_no、status、order_id每场次每个座位一行status 0 可售 1 锁定 2 已售ordersid、order_no、user_id、screening_id、seat_nos、amount、statusstatus 0 待支付 1 已支付 2 已取消 3 已退款screening_seat是整套系统的命门。做法是场次创建时按影厅座位图批量插入 N 行比如 8 排 × 12 列 96 行而不是下单时才插。原因很直接预先插行才能在这张表上建UNIQUE KEY uk_screening_seat (screening_id, seat_no)让数据库替你做并发互斥。下单时用一条UPDATE ... SET status 1, order_id ? WHERE screening_id ? AND seat_no ? AND status 0受影响行数为 0 就说明被人抢了直接抛异常回滚。索引方面screening表建idx_movie_start (movie_id, start_time)列表页按影片查未来场次走这个索引orders表建idx_user_created (user_id, created_at)个人中心「我的订单」按时间倒序翻页靠它。还有个容易忽略的点金额字段别用double。票价 39.9 元用 double 存两张票算出来 79.80000000000001订单页显示出来就是事故。统一用DECIMAL(10,2)Java 侧用BigDecimal做乘法时指定setScale(2, RoundingMode.HALF_UP)。会员折扣同理折扣率用BigDecimal(0.90)而不是0.9的 double 字面量。2.3 环境准备JDK、Maven、MySQL 版本必须对齐版本不对齐是新手第一道坎而且报错信息往往驴唇不对马嘴。核心判断只有一条看pom.xml里spring-boot-starter-parent的版本。Spring Boot 3.x 要求 JDK 17 起包名从javax.*换成了jakarta.*Spring Boot 2.7.x 及以下用 JDK 8 也能跑包名还是javax.*。如果你拿 JDK 8 去编译一个 3.x 的工程报的错是「找不到 javax.persistence.Entity」看着像缺依赖实际是版本代沟。# 三个版本一次性看清别猜 java -version # 期望 17 或 21若为 1.8 则只能跑 Spring Boot 2.7.x mvn -v # 期望 3.6同时确认 Maven 用的是哪个 JDK mysql --version # 期望 8.0.x5.7 也能用但驱动配置略有差异Maven 这里有个隐形坑mvn -v输出的 Java version 和你终端里java -version可能不是同一个。系统里装了多个 JDK 时Maven 读的是JAVA_HOMEIDE 读的是项目 SDK两边不一致就会出现「命令行编译过了、IDEA 里一片红」。解决办法是在 IDEA 的Project Structure → Project SDK和Settings → Build Tools → Maven → Runner → JRE两处都指向同一个 JDK命令行侧确认JAVA_HOME指向同一目录。MySQL 8 的连接串必须带时区参数否则启动时报The server time zone value xxx is unrecognized这个在下一章配置项里展开。3. 在 IntelliJ IDEA 里把它跑起来配置、建库、启动三步拿到源码包先别急着点 Run。正确顺序是解压 → IDEA 用Open打开含pom.xml的根目录不是File → New → Project→ 等 Maven 依赖下载完 → 改配置 → 建库 → 启动。用 Open 打开的好处是 IDEA 会自动识别为 Maven 工程并生成模块结构用 New Project 打开再手动导经常出现src/main/java没被标成 Sources Root类上全是红波浪线。3.1 application.properties 里必须改的六个参数配置文件在src/main/resources/application.properties。这份源码默认值大概率对不上你的机器逐条过一遍比启动后看报错快得多。# 1. 数据源库名、时区、编码三件套缺一个都可能连不上或中文变问号 spring.datasource.urljdbc:mysql://localhost:3306/movie_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue spring.datasource.usernameroot spring.datasource.password你的密码 spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver # 2. JPA 行为首次跑用 update 自动建表交付前改成 none 并交给 SQL 脚本 spring.jpa.hibernate.ddl-autoupdate spring.jpa.show-sqltrue spring.jpa.properties.hibernate.format_sqltrue # 3. 模板缓存开发期必须关否则改了 HTML 刷新还是老页面 spring.thymeleaf.cachefalse spring.thymeleaf.encodingUTF-8 # 4. 端口8080 常被占用改成 8081 省事 server.port8080 server.servlet.encoding.charsetUTF-8 server.servlet.encoding.forcetrue # 5. 文件上传海报和资源文件走本地磁盘路径按自己系统改 spring.servlet.multipart.max-file-size10MB spring.servlet.multipart.max-request-size20MB app.upload.pathD:/data/movie-ticket/upload/ # 6. 会话超时演示时别让人坐着坐着被踢出去 server.servlet.session.timeout60m逐条解释关键项。连接串里的serverTimezoneAsia/Shanghai解决时区识别失败useSSLfalse是本地开发省掉证书告警allowPublicKeyRetrievaltrue针对 MySQL 8 默认的caching_sha2_password认证插件不加会报Public Key Retrieval is not allowed。ddl-autoupdate在开发期很方便实体加个字段重启就自动加列但它永远不会删列、也不会改列类型所以交付前必须切成none用独立 SQL 脚本管表结构——否则你在开发机上跑得好好的换台机器建出来的表可能少索引。spring.thymeleaf.cachefalse这条血泪经验值得单拎出来开着缓存调试页面改十遍 HTML 刷新都不变很多人会怀疑是不是模板路径写错了其实是缓存。3.2 建库与初始化数据schema.sql 与 data.sql 的加载顺序建库一条命令就够字符集必须显式指定不然默认 latin1 存中文全是问号。CREATE DATABASE IF NOT EXISTS movie_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;接下来是建表和种子数据。Spring Boot 支持在启动时自动执行src/main/resources/schema.sql和data.sql但有两个前提spring.jpa.hibernate.ddl-auto设成none否则 Hibernate 先建表脚本再执行CREATE TABLE就冲突以及spring.sql.init.modealways。加载顺序是 schema 先、data 后这一点由框架保证不用手动控制。-- schema.sql 片段场次座位表唯一索引是防超卖的底牌 CREATE TABLE IF NOT EXISTS screening_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, screening_id BIGINT NOT NULL, seat_no VARCHAR(8) NOT NULL COMMENT 如 A01、B12, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可售 1锁定 2已售, order_id BIGINT DEFAULT NULL, UNIQUE KEY uk_screening_seat (screening_id, seat_no), KEY idx_screening_status (screening_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;-- data.sql 片段至少塞一个管理员和几部影片否则登录页进不去后台 INSERT INTO sys_user (username, password, role, status) VALUES (admin, $2a$10$替换成BCrypt密文, ADMIN, 1); INSERT INTO movie (title, type_id, duration, release_date) VALUES (示例影片一, 1, 118, 2024-06-01), (示例影片二, 2, 96, 2024-07-12);密码这里别图省事存明文。源码里如果用的是 MD5建议直接换成 BCrypt——Spring Security 的BCryptPasswordEncoder引一个依赖就能用encode()生成密文matches()校验加盐逻辑内置比手写「MD5(密码固定盐)」安全得多。种子数据里的密文可以用一段临时测试代码生成后粘进去注意每次encode()结果都不同这是正常的盐随机。另外提醒一句data.sql每次启动都会执行插入语句要么加INSERT IGNORE要么先DELETE否则第二次启动主键冲突直接起不来。3.3 编译、启动与接口自测配置和脚本都就位后走命令行编译一次比在 IDEA 里点 Run 更能暴露问题——IDEA 有时会用缓存的编译产物掩盖依赖缺失。# 跳过测试打包第一次跑先确保能出 jar mvn -DskipTests clean package # 前台启动日志直接打到终端方便看初始化脚本有没有执行 java -jar target/movie-ticket-0.0.1-SNAPSHOT.jar启动日志里重点看三行Tomcat started on port(s): 8080说明 Web 容器起来了Initialized JPA EntityManagerFactory说明数据源和实体映射没问题如果有Executing SQL script from URL [schema.sql]说明初始化脚本被执行了。三行齐了再去浏览器。# 先测不需要登录的接口排除「登录逻辑写错」的干扰 curl -s http://localhost:8080/api/movies/ | head -c 500 # 再测一个带路径参数的 curl -s http://localhost:8080/api/movies/1返回 JSON 说明后端通了再开浏览器访问http://localhost:8080/看首页模板渲染。如果接口通、页面 500问题基本锁定在 Thymeleaf 模板——看日志里TemplateInputException后面的路径十有八九是return movie/list和实际文件templates/movie/list.html对不上或者模板里引用了 Model 中不存在的属性。4. 核心业务实现注册登录、影片查询、选座下单的代码骨架功能列表看着长真正吃功夫的是三段认证、查询、下单。其余论坛、公告、留言本质是 CRUD照着任意一张表抄一遍就行。下面三段代码给的是骨架和边界判断具体字段按你实际实体类对齐。4.1 注册登录Controller 只做参数校验认证逻辑下沉到 Service认证这块最常见的错误是把「查用户、比密码、写 Session」三件事全塞进 Controller导致后面加会员积分、加登录失败次数限制时无处下手。正确切法是 Controller 收参数并校验格式Service 做认证并返回结果对象Session 的写入放在 Controller因为它属于 Web 层职责。RestController RequestMapping(/api/auth) public class AuthController { private final UserService userService; private final PasswordEncoder passwordEncoder; public AuthController(UserService userService, PasswordEncoder passwordEncoder) { this.userService userService; this.passwordEncoder passwordEncoder; } PostMapping(/register) public ResponseEntity? register(RequestBody Valid RegisterRequest req) { // 用户名重复的判断放 ServiceController 不碰 Repository userService.register(req.getUsername(), req.getPassword(), req.getPhone()); return ResponseEntity.ok(Map.of(code, 0, msg, 注册成功)); } PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest req, HttpSession session) { User user userService.authenticate(req.getUsername(), req.getPassword()); if (user null) { // 统一返回「用户名或密码错误」不区分是哪个错避免被枚举账号 return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(Map.of(code, 401, msg, 用户名或密码错误)); } session.setAttribute(LOGIN_USER, user); return ResponseEntity.ok(Map.of(code, 0, role, user.getRole().name())); } }Valid配合 DTO 上的NotBlank、Size(min 6)做格式校验校验失败会抛MethodArgumentNotValidException用RestControllerAdvice统一捕获转成 400 响应比在每个方法里写 if 判断干净。Service 层的authenticate逻辑很短按用户名查用户Optional拿不到就返回 null用passwordEncoder.matches(明文, 密文)比对再判断status是否为启用。这里有个安全细节——即使用户名不存在也要走一次matches的空比对否则响应时间差能被用来探测账号是否存在演示系统无所谓真上线就得注意。会员功能在这套源码里体现为角色和积分两个字段。折扣计算放在订单 Service 里不放在入口处BigDecimal amount price.multiply(BigDecimal.valueOf(seatCount))会员再multiply(new BigDecimal(0.90))最后setScale(2, RoundingMode.HALF_UP)。积分规则建议单独抽一个PointService下单成功后按实付金额累加退款时扣回别把加减积分写在订单状态变更的 if 分支里——后面加「积分兑换」时你会发现找不到唯一的真相来源。4.2 影片与场次查询分页、条件筛选与 DTO 裁剪影片列表页要分页这是必须的。全量findAll()在演示数据只有十条时看不出问题一旦导入几百部影片页面加载直接卡死。Spring Data JPA 的分页接口很省事Pageable从 Controller 传进来PageRequest.of(page, size, Sort.by(releaseDate).descending())构造。GetMapping(/api/movies) public PageMovieListVO list(RequestParam(defaultValue 0) int page, RequestParam(defaultValue 12) int size, RequestParam(required false) Long typeId, RequestParam(required false) String keyword) { // 页码防负数否则 PageRequest 直接抛 IllegalArgumentException int safePage Math.max(page, 0); int safeSize Math.min(Math.max(size, 1), 50); // 上限 50防止有人传 99999 拖垮库 return movieService.pageQuery(typeId, keyword, PageRequest.of(safePage, safeSize)); }两个参数细节。safeSize加了 50 的上限这是对外接口的基本防御——不加的话一个size999999的请求就能让服务端内存飙起来。keyword模糊查询在 Service 里拼%keyword%要注意别在 JPQL 里直接字符串拼接用参数绑定like :kw否则就是注入的入口。DTO 裁剪同样重要Movie实体里有description剧情简介几千字和poster图片二进制或长路径列表页根本不需要直接返回实体会让响应体从几 KB 涨到几百 KB。做法是定义MovieListVO只含id、title、poster、duration、releaseDate用构造器或 MapStruct 转换。场次查询有一个时间边界必须处理干净。listUpcoming(movieId, days)里比较的是start_time与当前时间这里start_time用LocalDateTime类型数据库列用DATETIME两边一致。如果实体用LocalDateTime、数据库列却是TIMESTAMPMySQL 驱动会按连接串时区做转换结果就是查出来的场次时间差 8 小时看起来「今天的场次全都不见了」。这个坑在下一章展开。4.3 选座下单唯一索引 乐观锁挡住超卖这是整套系统唯一真正有并发风险的地方。思路分两层第一层是应用层校验下单前查一次座位状态给用户友好提示第二层是数据库层兜底靠条件更新 唯一索引保证绝不会超卖。只做第一层等于没做因为「查」和「改」之间有时间窗。Service public class OrderService { private final ScreeningSeatRepository seatRepository; private final OrderRepository orderRepository; private final ScreeningRepository screeningRepository; Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, Long screeningId, ListString seatNos) { // 1. 先按 seat_no 排序多线程锁同一批座位时顺序一致降低死锁概率 ListString sortedSeats seatNos.stream().sorted().toList(); // 2. 条件更新只有 status0 的行会被改返回受影响行数 for (String seatNo : sortedSeats) { int updated seatRepository.lockSeat(screeningId, seatNo); if (updated 0) { // 抛运行时异常触发回滚已锁定的座位一并释放 throw new BizException(座位 seatNo 已被抢占请重新选择); } } // 3. 扣减场次余票同样用条件更新防扣成负数 int stockChanged screeningRepository.decreaseStock(screeningId, sortedSeats.size()); if (stockChanged 0) { throw new BizException(余票不足); } // 4. 生成订单金额在 Service 里算不信任前端传值 Order order buildOrder(userId, screeningId, sortedSeats); orderRepository.save(order); seatRepository.bindOrder(screeningId, sortedSeats, order.getId()); return order; } }对应的 Repository 方法用ModifyingQuery写条件更新Modifying Query(update ScreeningSeat s set s.status 1 where s.screeningId :sid and s.seatNo :seatNo and s.status 0) int lockSeat(Param(sid) Long screeningId, Param(seatNo) String seatNo);几个参数和边界必须说清楚。Transactional(rollbackFor Exception.class)显式指定回滚异常类型因为 Spring 默认只对RuntimeException和Error回滚抛受检异常时事务会照常提交——这是「操作失败了但数据脏了」的头号原因。座位号排序是为了避免两个请求分别按[A01, A02]和[A02, A01]顺序加锁导致死锁。lockSeat返回 0 时立刻抛异常让前面已经改掉的行随事务回滚不会留下「锁了一半」的脏状态。余票扣减用update screening set stock stock - :n where id :sid and stock :nwhere里的stock :n保证了不会扣成负数。真实秒杀场景还会加一层乐观锁版本号或 Redis 预扣但订单表这个体量条件更新 唯一索引已经完全够用。唯一索引uk_screening_seat在这里的作用是最后一道闸哪怕有人绕过应用层直接写库同一场次的同一个座位也无法插入两条记录。取消订单时反向操作——status从 2 改回 0、order_id置空、余票加回整个过程同样要在同一个事务里完成。5. 避坑与排查从启动白页到座位超卖的六个翻车点下面六条都是这套技术组合里高频出现的故障按「现象 → 原因 → 解决」写看到相似症状可以直接对号入座。5.1 启动期连接、端口、模板与脚本现象一启动即退出日志里一串Failed to configure a DataSource: url attribute is not specified。原因通常有两个配置写在application.yml里但项目实际加载的是application.properties或者反过来以及配置项缩进错位导致spring.datasource.url没被识别。解决方式是先确认target/classes下到底打包了哪个配置文件再检查缩进——.properties用且行首不能有空格.yml用冒号加空格且层级靠缩进。改完mvn clean重新打包别只点 IDEA 的重新运行旧资源可能还在。现象二Web server failed to start. Port 8080 was already in use。原因是上一个进程没退干净或者本机装了别的服务占了 8080。解决方式是先查占用进程再决定杀掉还是换端口Windows 下netstat -ano | findstr :8080找到 PID 后taskkill /PID xxx /FLinux 下lsof -i:8080。更省事的做法是直接把server.port改成 8081但要同步改前端模板里写死的请求地址否则页面上的 AJAX 全 404。现象三接口curl能返回 JSON浏览器打开首页却是 Whitelabel Error Page 或空白。原因基本锁定在 Thymeleaf 模板。三种典型return movie/list但文件实际叫movieList.html模板里th:eachm : ${movies}而 Model 里塞的键名是movieList模板文件放在了src/main/resources/templates/之外的位置。解决方式是看日志里TemplateInputException或TemplateProcessingException给出的完整模板路径逐字比对文件名和 Model 键名。开发期记得把spring.thymeleaf.cache设为false否则改完模板不生效会误判成路径错误。现象四data.sql执行报Duplicate entry 1 for key PRIMARY第二次启动直接失败。原因是初始化脚本用了裸INSERT第一次启动插入了主键 1第二次启动再插同一条。解决方式三选一改成INSERT IGNORE INTO脚本开头加DELETE FROM 表名或TRUNCATE或者干脆把spring.sql.init.mode设成never手动执行一次脚本。我的习惯是开发期用INSERT IGNORE交付时切成never把脚本单独放进db/目录交给部署的人。5.2 运行期时区、事务、懒加载与并发现象五场次列表查不到「今天」的场次数据库里明明有数据或者订单创建时间总比实际早/晚 8 小时。原因是时区在三处不一致JVM 默认时区、JDBC 连接串的serverTimezone、MySQL 服务端的time_zone。三者只要有一个不是Asia/ShanghaiLocalDateTime的读写就会偏移。排查顺序是先SELECT NOW()看数据库当前时间对不对再看连接串有没有带serverTimezoneAsia/Shanghai最后确认实体字段类型和数据库列类型匹配——LocalDateTime对DATETIME别对TIMESTAMP后者会随时区转换。解决方式是三处统一并在启动类里加一句TimeZone.setDefault(TimeZone.getTimeZone(Asia/Shanghai))兜底别依赖服务器默认值。现象六Service 方法上明明加了Transactional下单失败后座位还是被锁住了。原因有两个常见版本。一是异常被catch住了没往外抛Spring 感知不到失败事务照常提交二是抛的是受检异常Exception子类但非RuntimeException默认不回滚。解决方式是要么在 catch 里处理完再throw new BizException(...)抛运行时异常要么在注解上写Transactional(rollbackFor Exception.class)。还有第三种更隐蔽的情况——同类内部方法互相调用this.methodB()走的是原始对象不是代理对象事务注解直接失效。这种要么把方法拆到另一个 Service要么注入自身代理。现象七两个人同时抢同一个座位居然都下单成功了。原因几乎都是「只做了查询校验没做条件更新」。典型错误代码是先findByScreeningIdAndSeatNo查状态判断status 0然后setStatus(1)再save()——查和改之间那个时间窗足够另一个请求插进来。解决方式是改成UPDATE ... WHERE status 0并检查受影响行数同时确认uk_screening_seat唯一索引真的建上了用SHOW INDEX FROM screening_seat查。验证方法也简单写个 20 线程的CountDownLatch测试同时打同一个座位最后统计成功数必须是 1。现象八影片详情页偶发LazyInitializationException: could not initialize proxy - no Session。原因是实体里配了OneToMany(fetch FetchType.LAZY)的关联比如影片关联场次列表在模板渲染时事务已经结束、Session 已关闭Thymeleaf 遍历集合就炸。解决方式是在 Service 层就把需要的数据查出来转成 DTO/VO模板只碰扁平对象或者用EntityGraph在查询时一次性 join fetch 出来。别图省事改成FetchType.EAGER那会在列表页引发 N1十条影片变成十一条 SQL数据量一大直接拖死。6. 二次开发与验收把演示站改成能交付的原型源码能跑只是起点。真要拿去用先把「演示态」和「交付态」的开关切干净ddl-auto从update改noneshow-sql关掉thymeleaf.cache打开数据库连接串从本地改成环境变量注入${DB_URL}这种写法别把生产密码提交进仓库。这一套切完再走一遍完整流程能提前发现一堆「只在本机能跑」的问题。交付前我会固定跑一张自检表八项全过才算收工检查项通过标准常见不合格表现冷启动空库执行 schema data 后能起依赖开发机已有的表结构登录鉴权未登录访问后台跳登录页直接能进管理员页面并发选座20 线程抢同座只成功 1 个多人同时锁同一座成功金额计算会员折扣后两位小数正确double 误差出现 79.80000001分页边界page-1、size9999 不报 500直接抛 IllegalArgumentException中文编码影片名、评论存进库再取出不乱码页面显示问号或方块取消订单座位释放、余票加回、状态一致座位永久锁定余票不减不回异常提示失败有明确文案不吐堆栈页面直接显示 SQLException二次开发的方向我的建议是先补「场次管理」这块后台能力——很多同类源码只做前台订票管理员加场次还得手动改数据库这是最影响可用性的缺口。加一个场次新增页提交时按影厅座位模板批量生成screening_seat行这一步做完系统才算能自己运转。再往后是支付对接和退款流程但那涉及外部接口演示系统里用「模拟支付」按钮改状态即可别硬接真渠道。有个习惯我保持了挺久每次改完实体类字段不会只重启看页面而是额外执行一次mvn clean package再java -jar跑一遍。之前吃过一次亏——IDEA 增量编译把旧的 class 留在target里页面上新加的字段一直显示空排查了快两个小时才发现是编译产物没清干净。从那以后我每次改完实体或配置都强制走一遍「clean → package → jar 启动 → 打开无痕窗口验证」不再信 IDE 的热部署。希望帮到你。本文还有配套的精品资源点击获取