Java Spring Boot电影在线订票系统:选座锁座与防超卖实战
简介这份面向具备Java基础、希望学习或从事在线票务系统开发人员的项目文档围绕基于Java与Spring Boot的电影在线订票选座系统展开梳理普通用户、会员与管理员三类角色的功能边界并说明注册登录、影片浏览、论坛交流、订单管理、会员折扣与积分、后台用户及影视信息管理等模块的设计思路。文档结合IntelliJ IDEA、Maven、Spring Data JPA、Thymeleaf与MySQL等技术栈讲解项目结构、配置方式、编译运行流程及核心控制器代码示例可帮助读者理解从数据模型到业务流程的实现路径也为企业或创业者搭建线上票务服务平台提供参考。资源包共1个docx文件大小约34KB内容以系统功能清单、实现截图说明、技术解析、开发步骤、核心代码片段与项目总结为主适合按模块查阅和对照学习。目前已有315人学习便于快速把握系统整体架构与关键实现细节。1. 从一张“选座图”开始的电影在线订票系统课程设计或者小团队外包里冒出“电影在线订票系统”这几个字通常意味着你要在两周内交出一套能登录、能选片、能下单、后台还能改数据的完整东西。这套基于 Java 与 Spring Boot 的电影在线订票系统把三种角色的业务都摊开了普通用户注册登录、浏览影片、论坛发帖、选座下单会员多一层折扣和积分管理员负责影片、场次、留言、订单和统计数据。技术栈很直白——Spring Boot 起服务Spring Data JPA 做数据访问Thymeleaf 走服务端渲染MySQL 落库IntelliJ IDEA 加 Maven 管工程。它适合两类人一类是刚学完 Java Web 想找完整业务链路练手的开发者另一类是拿到源码要改成自己票务平台雏形的团队。真正难的不是增删改查而是座位怎么锁、订单怎么和场次对上。2. 工程骨架与数据模型从 Maven 目录到六张核心表拿到压缩包先别急着点运行先看清楚它凭什么能跑起来。这套系统的工程骨架是标准的 Spring Boot Maven 结构src/main/java放后端代码src/main/resources放配置和 Thymeleaf 模板pom.xml决定你本地能不能编译通过。数据模型这一块是整份源码里最值得抄的部分原文只给了User和Movie两个实体但真正撑起“订票”这件事的是场次、座位、订单这几张表怎么和用户挂上关系。2.1 目录结构与 pom.xml 依赖清单先把目录树贴出来对照着看后面读代码就不会迷路。movie-ticket-system/ ├── pom.xml ├── src/main/java/com/example/ticket/ │ ├── MovieTicketSystemApplication.java # 启动类 │ ├── controller/ # UserController / MovieController / OrderController │ ├── service/ # 业务层事务边界在这一层 │ ├── repository/ # JpaRepository 接口 │ ├── entity/ # User / Movie / Screening / Seat / Order │ ├── config/ # 拦截器、跨域、WebMvc 配置 │ └── common/ # 统一返回体、异常处理、枚举 ├── src/main/resources/ │ ├── application.properties # 数据库、JPA、Thymeleaf 配置 │ ├── templates/ # Thymeleaf 页面index / movie / seat / admin │ └── static/ # css、js、海报图片、座位图脚本 └── sql/init.sql # 建库建表 初始化数据pom.xml里的依赖基本决定了这套代码的写法。原文提到 Maven 管依赖但没列坐标按这个项目场景最常见的组合核心几项长这样dependencies !-- Web 层REST 接口 内嵌 Tomcat -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 数据访问JPA Hibernate 实现 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency !-- 服务端模板渲染后台和前台页面都靠它 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency !-- 参数校验NotBlank / Email 这些注解要用 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- MySQL 驱动坐标别用老的 mysql:mysql-connector-java -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里有个容易被忽略的点spring-boot-starter-validation是可选的但一旦你打算用Valid校验注册参数没有它启动就直接报找不到校验器。lombok标了optional意味着它只在编译期起作用打包进 jar 时不会带进去线上出问题不会赖到它头上。再说controller / service / repository / entity这四层的分工。很多人写这种系统会把业务逻辑全塞进 Controller看起来能跑但一旦下单要开事务、要判库存就会发现方法上挂Transactional没生效——因为事务切面在 Service 层代理Controller 里调自己的方法不走代理。我一般会把“扣座位、生成订单、回写积分”这三步强行收在一个 Service 方法里这是后面第 3 章要展开的重点。2.2 实体建模用户、影片、场次、座位、订单怎么连原文给的User只有 id、username、password、role 四个字段够登录但不够支撑会员体系Movie只有 id、title、description、releaseDate够展示但不够排片。真实订票场景里一部影片对应多个场次一个场次对应一个影厅的座位图一张订单对应若干张票。补全后的关系是表名关键字段和谁关联作用userid, username, password, role, points—三种角色共用一张表role 区分movieid, title, description, release_date, duration, poster, status—影片基础信息screeningid, movie_id, hall_name, start_time, price, versionmovie_id → movie一个场次version 做乐观锁seatid, screening_id, row_no, col_no, statusscreening_id → screening座位状态 0 可售 1 已售 2 锁定ordersid, order_no, user_id, screening_id, total_amount, statususer_id、screening_id订单主表order_itemid, order_id, seat_id, priceorder_id、seat_id一单多票实体代码按 JPA 注解写关键是关联关系和枚举映射别写错Entity Table(name user) Data public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(unique true, nullable false, length 50) private String username; /** 存 BCrypt 加密后的串长度给 100 够用别用 32 */ Column(nullable false, length 100) private String password; /** 枚举按字符串存库里看到 USER/MEMBER/ADMIN 而不是 0/1/2排查时救命 */ Enumerated(EnumType.STRING) Column(nullable false) private Role role Role.USER; /** 会员积分普通用户为 0 */ private Integer points 0; } Entity Table(name screening) Data public class Screening { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; ManyToOne(fetch FetchType.LAZY) JoinColumn(name movie_id, nullable false) private Movie movie; private String hallName; private LocalDateTime startTime; /** 票价用 BigDecimal别用 double钱算错一次就够喝一壶 */ Column(precision 10, scale 2) private BigDecimal price; /** 乐观锁版本号扣座位时靠它防超卖 */ Version private Integer version; }Enumerated(EnumType.STRING)这个选择值得多说一句。用ORDINAL是默认值存的是 0/1/2一旦哪天枚举顺序调整历史数据全部对不上角色属于典型的“当时省事、后来返工”。项目里只要涉及角色、订单状态这类枚举我一律强制STRING。ManyToOne(fetch FetchType.LAZY)也是刻意写的。默认是EAGER查场次会把影片一起捞出来列表页一页二十条就是二十次关联查询看起来正常实际在 JPA 的 N1 里流血。LAZY 之后记得在需要的接口里显式join fetch或者用 DTO 投影否则序列化时会撞上LazyInitializationException这个坑第 5 章会细说。2.3 application.properties 与建库脚本配置文件是新手翻车率最高的地方原文只说了“在 application.properties 中配置数据库连接”实际要配的东西比想象中多# 数据源库名、时区、字符集三件套 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 # JPA第一次跑建议 update交作业前改成 none spring.jpa.hibernate.ddl-autoupdate spring.jpa.show-sqltrue spring.jpa.open-in-viewfalse spring.jpa.properties.hibernate.format_sqltrue # Thymeleaf开发期关缓存改模板不用重启 spring.thymeleaf.cachefalse spring.thymeleaf.prefixclasspath:/templates/ spring.thymeleaf.suffix.html server.port8080这里open-in-viewfalse是我特意加的。默认值是 true它让 Hibernate 的 Session 一直开到视图渲染结束表面上是“解决了懒加载报错”实际上把数据库连接持有时间拉长到整个请求并发一上来连接池就空。关掉它懒加载该报错就报错反而逼你把查询写对。建库脚本建议单独放sql/init.sql别指望ddl-autoupdate帮你建索引和初始数据CREATE DATABASE IF NOT EXISTS movie_ticket DEFAULT CHARSET utf8mb4; USE movie_ticket; CREATE TABLE screening ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL, hall_name VARCHAR(50) NOT NULL, start_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL, version INT DEFAULT 0, KEY idx_movie_time (movie_id, start_time) -- 按影片查场次必用 ) ENGINEInnoDB; CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, screening_id BIGINT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, status TINYINT DEFAULT 0, UNIQUE KEY uk_screening_seat (screening_id, row_no, col_no) -- 防重复座位 ) ENGINEInnoDB; INSERT INTO movie (title, description, release_date, status) VALUES (示例影片A, 用于验证链路的占位数据, 2024-01-01, 1);uk_screening_seat这个唯一索引很关键。程序里判断座位是否已售是靠查询-更新两次请求中间有窗口期加上唯一索引之后就多了一层数据库兜底重复插入直接抛异常比在 Java 里加synchronized靠谱得多。索引idx_movie_time则是为首页“按影片查最近场次”准备的数据量上千条之后没有它会明显卡顿。3. 核心链路拆解注册登录、影片查询、下单锁座三段代码原文的核心代码展示部分只给了 Controller 层的两个类UserService.login()里面是一行// Authentication logic注释。这份源码真正有价值的是把这条注释补全之后的样子因为订票系统的成败就在这三段链路上注册登录决定数据安不安全影片查询决定页面快不快下单锁座决定业务对不对。3.1 注册登录Controller 只做参数校验业务全在 Service先看 Controller。原文的写法是直接userService.register(user)然后返回“注册成功”问题在于没有校验、没有异常分支。改造后的版本RestController RequestMapping(/api/user) RequiredArgsConstructor public class UserController { private final UserService userService; /** 注册DTO 接参不直接暴露实体避免前端传 role 把自己改成管理员 */ PostMapping(/register) public ResponseEntity? register(Valid RequestBody RegisterRequest req) { userService.register(req.getUsername(), req.getPassword()); return ResponseEntity.ok(Result.ok(注册成功)); } PostMapping(/login) public ResponseEntity? login(Valid RequestBody LoginRequest req, HttpSession session) { User user userService.login(req.getUsername(), req.getPassword()); // 登录态放 sessionThymeleaf 页面直接用 ${session.user} 取值 session.setAttribute(user, user); return ResponseEntity.ok(Result.ok(user.getRole())); } }RegisterRequest里挂NotBlank、Size(min 6, max 20)配合 Controller 上的Valid参数不合法会直接抛MethodArgumentNotValidException交给统一异常处理器转成 400不用在每个方法里写 if 判断。session.setAttribute这一步是为了 Thymeleaf 模板能直接取到当前用户前后端一体渲染的项目用 session 比 JWT 省事不用在每段 JS 里塞 token。真正的业务在 ServiceService RequiredArgsConstructor public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; // BCrypt 实例见下方配置 Transactional public void register(String username, String rawPassword) { // 先查重唯一索引是最后一道防线但先查能给用户友好提示 if (userRepository.findByUsername(username).isPresent()) { throw new BizException(用户名已存在); } User user new User(); user.setUsername(username); // 明文入库是这类源码最常见的硬伤必须换成 BCrypt user.setPassword(passwordEncoder.encode(rawPassword)); user.setRole(Role.USER); userRepository.save(user); } public User login(String username, String rawPassword) { User user userRepository.findByUsername(username) .orElseThrow(() - new BizException(用户名或密码错误)); // matches 内部做盐值比对不要用 equals 比密文 if (!passwordEncoder.matches(rawPassword, user.getPassword())) { throw new BizException(用户名或密码错误); } return user; } }PasswordEncoder用BCryptPasswordEncoder的 Bean 配置强度参数默认 10够用调到 12 以上单次登录要几百毫秒测试环境会让人怀疑代码有性能问题。matches()每次结果不同是因为密文里带了随机盐别写成encode(raw).equals(dbPassword)那样永远不相等。还有一处细节登录失败时用户名不存在和密码错误返回同一个提示。这不是洁癖是防止有人拿用户名列表去撞库探测哪些账号真实存在。原文的写法是return success ? ok : unauthorized逻辑对但少了这层考虑。3.2 影片与场次查询分页 条件筛选的正确打开方式原文的MovieController只有getMovieById和getAllMoviesfindAll()一把梭。影片表几百条还能忍加上场次、海报、简介字段之后首页一次性捞全表会明显拖慢首屏。改成 Spring Data JPA 的分页查询RestController RequestMapping(/api/movies) RequiredArgsConstructor public class MovieController { private final MovieService movieService; /** 首页列表支持关键字 状态筛选分页返回 */ GetMapping public ResponseEntityPageMovie list( RequestParam(defaultValue ) String keyword, RequestParam(defaultValue 0) int page, RequestParam(defaultValue 12) int size) { // page 从 0 开始和前端习惯的 1 差一这里统一在 Controller 减一 return ResponseEntity.ok(movieService.search(keyword, page, size)); } GetMapping(/{id}/screenings) public ResponseEntityListScreeningVO screenings(PathVariable Long id) { return ResponseEntity.ok(movieService.findUpcomingScreenings(id)); } }Service 层用方法名派生查询就够了没必要上SpecificationService RequiredArgsConstructor public class MovieService { private final MovieRepository movieRepository; private final ScreeningRepository screeningRepository; public PageMovie search(String keyword, int page, int size) { Pageable pageable PageRequest.of(page, size, Sort.by(releaseDate).descending()); if (keyword null || keyword.isBlank()) { return movieRepository.findByStatus(1, pageable); } // 模糊匹配标题数据量大时建议换成全文索引或 ES return movieRepository.findByTitleContainingAndStatus(keyword, 1, pageable); } public ListScreeningVO findUpcomingScreenings(Long movieId) { // 只查今天以后的场次按开始时间升序join fetch 避免 N1 return screeningRepository.findByMovieIdAndStartTimeAfterOrderByStartTime( movieId, LocalDateTime.now()) .stream().map(ScreeningVO::from).toList(); } }PageRequest.of(page, size, Sort)三个参数分别是页码、每页条数、排序规则。Sort.by(releaseDate).descending()对应实体字段名不是数据库列名写错了启动不报错、运行时报PropertyReferenceException这个错很多人第一次见会懵。findByTitleContainingAndStatus这个方法名是 Spring Data 的命名约定Containing对应LIKE %keyword%And连接两个条件方法名写错一个字就启动失败好处是编译期就能发现。ScreeningVO.from()是手动组装 DTO把screening.getMovie().getTitle()拍平成一层。这一步比在实体上挂JsonIgnore或JsonManagedReference干净得多也顺带解决了懒加载序列化的问题——VO 在 Service 的事务内组装完成出了事务已经是纯净的 Java 对象。3.3 下单与锁座一个事务里把票卖出去这是整份源码最该重写的地方原文压根没给下单代码。订票系统出事故无非两种同一张票卖给两个人或者订单生成了座位没锁。常见做法是把“查座位可用 → 更新座位状态 → 生成订单 → 扣积分”收进一个 Service 方法用Transactional保证原子性再用条件更新防并发Service RequiredArgsConstructor public class OrderService { private final SeatRepository seatRepository; private final ScreeningRepository screeningRepository; private final OrderRepository orderRepository; /** * param userId 当前登录用户 * param screeningId 场次 * param seatIds 选中的座位一次最多 4 个 */ Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, Long screeningId, ListLong seatIds) { if (seatIds null || seatIds.isEmpty() || seatIds.size() 4) { throw new BizException(一次最多选 4 个座位); } // 1. 条件更新只有 status0 的行才会被改成 1返回影响行数 int updated seatRepository.lockSeats(screeningId, seatIds, 0, 1); if (updated ! seatIds.size()) { // 影响行数对不上说明有人抢先了事务回滚座位状态自动还原 throw new BizException(手慢了部分座位已被选走请重新选座); } // 2. 算钱场次单价 × 座位数会员再打折 Screening screening screeningRepository.findById(screeningId) .orElseThrow(() - new BizException(场次不存在)); BigDecimal total screening.getPrice() .multiply(BigDecimal.valueOf(seatIds.size())); // 3. 落订单主表 明细 Order order new Order(); order.setOrderNo(generateOrderNo()); // 时间戳 用户尾号 随机数 order.setUserId(userId); order.setScreeningId(screeningId); order.setTotalAmount(total); order.setStatus(OrderStatus.UNPAID); orderRepository.save(order); return order; } }Repository 里那句自定义更新是防止超卖的核心public interface SeatRepository extends JpaRepositorySeat, Long { /** 必须是 Modifying Query且 WHERE 里带上 status 条件 */ Modifying Query(update Seat s set s.status :to where s.screeningId :sid and s.id in :ids and s.status :from) int lockSeats(Param(sid) Long screeningId, Param(ids) ListLong ids, Param(from) int from, Param(to) int to); }逐段解释。Modifying告诉 Spring Data 这是更新语句不加会当成查询执行直接抛IllegalStateException。Query里的status :from是乐观思想的体现不是先查出来判断再更新而是把判断条件写进update语句本身数据库行锁保证同一时刻只有一个事务能改成功第二个事务拿到的影响行数是 0。方法返回int就是为了接这个行数。Transactional(rollbackFor Exception.class)里的rollbackFor不能省Spring 默认只对RuntimeException回滚你如果抛的是受检异常前面更新成功的座位状态就留在库里了等于把票锁死又没生成订单属于最恶心的脏数据。generateOrderNo()常见的踩坑是用UUID或者单纯时间戳。UUID 太长占索引空间纯时间戳在并发时可能撞号。我一般用yyyyMMddHHmmss 用户 id 后四位 三位随机数既有序又不冲突加个唯一索引兜底。事务提交后还得考虑“用户选了座却一直不付款”的情况。源码里没有这套但业务上必须有座位状态 2 表示锁定下单时置 2支付成功置 1超时未付由定时任务扫回 0。这个定时任务用Scheduled(fixedDelay 60000)挂在启动类上就行扫描orders里status UNPAID且create_time now - 15min的记录回滚座位、关订单。4. 角色与权限普通用户、会员、管理员三套视图怎么切原文把功能列成三张清单看着清楚落到代码里其实是一件事同一个 URL 路径不同角色看到的内容不一样。普通用户点“收藏”提示要看广告会员直接收藏成功管理员看到的是影片的后台上架按钮。这套系统用 Thymeleaf 做服务端渲染权限判断天然适合放在后端别指望前端 JS 藏按钮。4.1 角色枚举与拦截器比 Spring Security 更轻的做法这个体量的项目硬上 Spring Security 会带来一堆配置成本登录页、失败跳转、CSRF token 都要改。更贴合的方案是HandlerInterceptor加自定义注解public enum Role { USER, MEMBER, ADMIN } Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { Role[] value(); // 允许访问的角色满足其一即可 } Component public class AuthInterceptor implements HandlerInterceptor { private final UserService userService; public AuthInterceptor(UserService userService) { this.userService userService; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod method)) return true; RequireRole require method.getMethodAnnotation(RequireRole.class); if (require null) return true; // 没标注的接口放行 User user (User) request.getSession().getAttribute(user); if (user null) { // 未登录接口返回 401页面跳登录 response.sendRedirect(/login); return false; } if (Arrays.stream(require.value()).noneMatch(r - r user.getRole())) { throw new BizException(无权访问该功能); } return true; } }逻辑很直白只有方法上挂了RequireRole才校验没挂的接口首页、影片列表谁都能访问。method.getMethodAnnotation拿的是方法级注解如果需要类级注解就用AnnotatedElementUtils.findMergedAnnotation它能把方法和类上的注解合并查找。注册拦截器别忘了Configuration RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/**) .excludePathPatterns(/login, /api/user/**, /static/**, /css/**); } }excludePathPatterns里必须放登录接口和静态资源否则会出现“登录页自己被拦截永远跳登录页”的死循环。这是我第一次写拦截器时卡了半小时的经典场面。另外注意addPathPatterns(/**)和/api/**的区别前者会连页面请求也拦用sendRedirect处理更自然后者只拦接口未登录返回 JSON 401前端再自己跳。项目里两者可以混用但别搞混。4.2 会员折扣与积分把规则收在一个类里会员功能原文列了折扣、积分、优先预约、收藏夹扩容、观影记录分析五条。真正需要写代码的是前两条后三条本质是查询权限和数据容量限制。折扣和积分的坑在于规则会变今天满 100 减 10明天改九折如果这些数字散落在各处if里改一次全项目搜索。Service public class MemberService { /** 折扣率九折写 0.90放常量便于日后改配置 */ private static final BigDecimal MEMBER_DISCOUNT new BigDecimal(0.90); /** 每消费 1 元累积 1 分用 BigDecimal 避免浮点误差 */ private static final BigDecimal POINTS_PER_YUAN BigDecimal.ONE; public BigDecimal applyDiscount(BigDecimal amount, Role role) { if (role ! Role.MEMBER) return amount; // setScale(2, RoundingMode.HALF_UP) 给钱做四舍五入别让 33.333 出现在页面上 return amount.multiply(MEMBER_DISCOUNT).setScale(2, RoundingMode.HALF_UP); } Transactional public void addPoints(Long userId, BigDecimal paidAmount) { User user userRepository.findById(userId) .orElseThrow(() - new BizException(用户不存在)); if (user.getRole() ! Role.MEMBER) return; int gain paidAmount.multiply(POINTS_PER_YUAN).intValue(); // 用 update 语句累加别在 Java 里 get 再 set并发会丢积分 userRepository.addPoints(userId, gain); } }applyDiscount接收Role而不是布尔值这样以后加 VIP 等级、学生票都不用改签名。setScale(2, RoundingMode.HALF_UP)是必须的BigDecimal直接相乘可能得到33.3333存进DECIMAL(10,2)会被数据库四舍五入但页面上显示的是未处理的原始值用户看到的金额和实际支付对不上会很尴尬。积分累加用Query写update User u set u.points u.points :gain where u.id :id而不是查出来setPoints(user.getPoints() gain)。后者在两个请求同时进来时会丢分而且因为 Hibernate 的脏检查丢分过程静默无日志属于典型的血泪经验。4.3 管理后台CRUD 之外的统计怎么做管理员的九项功能里用户管理、影片类型管理、影视信息管理、留言板管理、论坛监督、资源管理这六项都是标准 CRUDSpring Data JPA 的save / deleteById / findAll(Pageable)直接撑住。真正要单独写的是数据分析这块原文只说“通过图表分析数据”没给实现。最常见的实现是后端出聚合 SQL前端用 Chart 库画图public interface OrderRepository extends JpaRepositoryOrder, Long { /** 近 7 天每日成交额date 截断到天 */ Query(value SELECT DATE(create_time) AS d, SUM(total_amount) AS amount FROM orders WHERE status 1 AND create_time :start GROUP BY DATE(create_time) ORDER BY d, nativeQuery true) ListObject[] sumAmountByDay(Param(start) LocalDateTime start); /** 各影片销量 Top 10联表查场次 */ Query(value SELECT m.title, COUNT(*) AS cnt FROM orders o JOIN screening s ON o.screening_id s.id JOIN movie m ON s.movie_id m.id WHERE o.status 1 GROUP BY m.title ORDER BY cnt DESC LIMIT 10, nativeQuery true) ListObject[] topMovies(); }用nativeQuery true是因为 JPQL 对DATE()这类函数支持不好写原生 SQL 更直接。返回ListObject[]意味着要手动转型Object[0]是日期、Object[1]是金额容易下标错位。项目里可以接受正式一点的做法是定义投影接口public interface DailyAmount { String getD(); // 别名要和 SQL 里的 AS 对上 BigDecimal getAmount(); }把返回类型改成ListDailyAmountSpring Data 会自动做接口投影不需要手动(BigDecimal) row[1]。这个方法名映射在写报表接口时特别省事强烈建议替换掉Object[]的写法。后台模板用 Thymeleaf 的th:each渲染列表th:href{/admin/movie/edit(id${m.id})}拼接路径注意{...}里的参数写法写成{/admin/movie/edit(id${m.id})}才是对的写成字符串拼接在中文字符和特殊符号上会翻车。分页栏用th:eachi : ${#numbers.sequence(0, page.totalPages - 1)}生成页码page.totalPages为 0 时会渲染出空串记得加判空否则后台没数据时页面会报表达式异常。5. 避坑与排查本地跑这套系统最容易翻车的五个地方这份源码的 README 大概只会写“配置数据库后运行启动类”但真跑起来会遇到一堆它没提的问题。下面五条是我在不同机器上复现过的按“现象 → 原因 → 解决”记下来省得你一条条搜。5.1 环境与启动类问题踩坑一启动直接报Failed to configure a DataSource: url attribute is not specified。现象是启动类刚跑两秒就退出日志里翻来覆去说找不到数据源。原因有兩種application.properties放错了位置或者文件放在src/main/java下而不是src/main/resources也有人把配置写进了application.yml但同时留着一个空的application.propertiesSpring Boot 优先读 properties空文件把 yml 覆盖了。解决方法是确认配置文件在src/main/resources根目录且同一层级不要同时存在两份主配置。另外如果用 IDEA 的多模块工程检查运行配置里的 Working directory 是不是子模块目录否则启动类能找到、配置读不到。踩坑二数据库连上了但中文字段全是问号或时间差 8 小时。现象是影片标题存进库里变成???或者上映时间明明填的 20:00页面上显示 12:00。原因是 JDBC URL 少参数。characterEncodingutf8决定字符集serverTimezoneAsia/Shanghai决定时区两个都得写。只写useUnicodetrue是不够的那是十几年前的老写法。还有一个隐蔽点JDK 17 之后mysql-connector-j8.x 对serverTimezone的默认值改成了系统时区如果你的机器是 UTCDocker 里的 MySQL 也是 UTC本地看着对、容器里就偏所以宁可显式写死。踩坑三Thymeleaf 模板改了不生效或者访问页面报 404。现象是后端接口都返回 200浏览器打开/index却是 Whitelabel Error Page。原因通常是模板路径不对。spring.thymeleaf.prefix默认是classpath:/templates/你的文件如果放在templates/pages/index.htmlController 里return index就找不到。另一个原因是开发期没关缓存改了 html 刷新没反应必须重启。解决办法spring.thymeleaf.cachefalse打开路径按 Controller 返回值拼return pages/index就对应templates/pages/index.html。生产环境记得把 cache 改回 true不然每次请求都解析模板QPS 上不去。5.2 数据与业务逻辑问题踩坑四压测时同一张票卖了两次或者座位显示已售但订单不存在。现象是两个用户几乎同时下单订单表里出现两条针对同一座位的记录。原因是先findById判断status 0再save改状态两步之间没有锁。单机测试永远复现不了一上并发就炸。解决办法就是第 3 章那段条件更新把判断写进update ... where status 0用影响行数决定成败再给seat表加(screening_id, row_no, col_no)唯一索引兜底。还有个配套动作是把Transactional放在 Service 的 public 方法上别放在 private 方法或者同类内部调用的方法上那样代理不生效回滚也就无从谈起。踩坑五列表接口偶尔抛LazyInitializationException: could not initialize proxy。现象是影片列表页正常点进详情页报 500堆栈里一堆HibernateProxy。原因是实体上用了FetchType.LAZY序列化时 Session 已经关了代理对象初始化不了。三种解决路径在 Repository 里用EntityGraph或join fetch一次性把关联查出来在 Service 的事务内转成 DTO或者干脆把关联字段改成EAGER——最后一种是饮鸩止渴不推荐。我一般选第二条在ScreeningVO.from()里把需要的字段拍平接口只返回 VO实体永远不出 Service 层。这样也顺带解决了另一个坑直接把实体返回给前端User的 password 字段也会一起序列化出去等于把密文公开了。6. 二次开发把课程级系统改成能接真流量的版本这套代码跑通之后多数人的下一步是往商用方向改。我给几个优先级从高到低的改造点按投入产出排序附带风险提示。改造点涉及文件工作量风险支付流程抽象成接口新增 PaymentService 回调 Controller中回调幂等没做好会重复发货座位图换成前端渲染templates/seat.html static/js中老浏览器兼容热点影片加缓存MovieService Redis 配置小缓存与库不一致订单超时回收定时任务新增 ScheduledTask小集群部署会重复执行影片搜索换全文索引MovieRepository DDL大索引重建期间查询变慢支付这块我建议先把接口抽出来别在OrderService里直接调某个 SDKpublic interface PaymentGateway { /** 发起支付返回跳转地址或二维码内容 */ PayResult createPayment(Order order, String channel); /** 校验回调签名防止伪造通知 */ boolean verifySign(MapString, String callbackParams); }OrderService只依赖PaymentGateway具体实现按渠道各写一个类用Qualifier注入。这样以后加渠道、换服务商业务代码一行不动。verifySign这块必须做回调接口是暴露在公网的验签漏了等于让人随便改订单状态。座位图建议前端渲染。后端只提供GET /api/screenings/{id}/seats返回座位数组前端用 Canvas 或绝对定位的 div 画出来点击选中后再提交 id 列表。这么做的好处是座位状态可以定时轮询刷新用户体验接近真实购票站。后端渲染整张图每次都要重新查库、重新拼 HTML改一次样式要动模板维护成本高。缓存这块movie表的读远大于写首页和详情页都可以加 Redis。常见做法是在MovieService.findById上加Cacheable(value movie, key #id)后台改影片时CacheEvict清掉。注意一件事Cacheable对同类内部调用无效跟事务一个道理得从别的 Bean 调进来才走代理。订单超时回收用Scheduled很方便但集群部署时每个实例都会跑一遍同一批订单被回收两次。稳妥的做法是加分布式锁或者用数据库的update ... where status UNPAID and create_time ?抢占式处理抢不到的实例影响行数为 0自然跳过。这些改造里最容易被跳过、又最该先做的是统一异常处理。写个RestControllerAdvice把BizException、参数校验失败、数据库唯一键冲突分别映射成不同的状态码和提示文案前端不用为每个接口单独写错误分支。地位不高但它决定了这套系统能不能交给别人接手。我第一次把这种课程级项目往线上推的时候图省事没加订单号唯一索引测试环境跑了三天没事上线第二天因为一次时钟回拨撞了两个订单号两笔支付对到了一张票上退款对了整整一下午。从那以后我每次建订单表order_no上的唯一索引和Transactional(rollbackFor Exception.class)都是先写上去再说功能可以后补数据脏了没有后悔药。希望这套源码在你手里能少走几步我走过的弯路希望帮到你。本文还有配套的精品资源点击获取