简介:围绕Java与Spring Boot构建电影在线订票选座系统的技术文档,面向具备一定Java基础、希望学习或从事影视票务系统开发的人员,梳理普通用户、会员与管理员三类角色的功能边界与业务逻辑,并交代IntelliJ IDEA下的项目搭建、Maven依赖管理、Spring Data JPA持久化与Thymeleaf模板渲染等技术选型,可服务于在线票务平台的需求分析与运维升级参考。压缩包内为1个docx文档,约34KB,涵盖功能清单、系统截图说明、Spring Boot项目结构解析、开发与编译运行步骤、核心代码示例,以及未来改进方向和安全、数据完整性等注意事项,便于按章节检索。目前已有315人学习。读者可据此理解数据模型构建与订单、影片、论坛、留言等模块的流程设计,参考控制器与服务层代码写法,快速搭建可运行原型。
1. 从一份能跑的电影订票源码说起:三角色权限与选座链路怎么落地
拿到一份标注「Java + Spring Boot 电影在线订票系统」的压缩包,很多人的第一反应是导入 IntelliJ IDEA 直接点运行,然后在Communications link failure这一行报错上卡半天。这套资源的骨架是 Maven + Spring Boot + Spring Data JPA + Thymeleaf + MySQL,把普通用户、会员、管理员三类角色的功能一次性铺开:影片浏览、论坛发帖、留言评论、资源中心、选座下单、积分折扣、后台的影视信息与用户管理都在里面。它适合有 Java 基础、想拿一个业务闭环完整的订票系统做课程设计或二次开发底座的人。票务系统真正难的不是增删改查,而是选座那一秒的并发判定和订单状态流转,这份代码给了一个能跑的起点,剩下的边界得自己补。
2. 拆开 Maven 骨架:实体关系与配置项的选型理由
2.1 目录结构与 application.properties 的四个必改项
项目是标准 Maven 分层:src/main/java下按controller / service / repository / entity / config分包,src/main/resources下放application.properties、templates(Thymeleaf 模板)和static(css、js、影片海报)。这种分法不新鲜,但对订票业务来说够用——它把「页面渲染」和「接口返回」两条路线同时留了出来:后台管理页走 Thymeleaf 服务端渲染,前后端分离的调用走@RestController返回 JSON,一份代码两种消费方式。
配置文件是整个项目第一道关,原文只提到「在 application.properties 中配置数据库连接和其他参数」,实际要改的远不止一行 url。下面是一份能直接抄的配置:
# 服务端口,被占用就改这里 server.port=8080 # 数据源:库名、时区、编码三件事必须写全 spring.datasource.url=jdbc:mysql://localhost:3306/movie_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true spring.datasource.username=root spring.datasource.password=改成你自己的密码 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # JPA:首次跑用 update 自动建表,交付前改成 validate spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.jpa.open-in-view=false # Thymeleaf:开发期关缓存,改模板不用重启 spring.thymeleaf.cache=false spring.thymeleaf.encoding=UTF-8参数含义上,serverTimezone=Asia/Shanghai不是可选项,MySQL 8 的驱动在时区未声明时会直接抛连接异常;allowPublicKeyRetrieval=true是驱动默认禁用公钥检索导致的认证失败兜底;ddl-auto=update让 Hibernate 根据实体自动建表,开发期省事,但交付或上线前一定切成validate,否则一次实体改名就可能把列悄悄加出来。open-in-view=false是我习惯加的一条,它逼着你在 Service 层把关联数据取完,避免 Thymeleaf 渲染时因为懒加载抛LazyInitializationException——这个错在带关联的电影详情页里出现频率极高。
2.2 实体建模:User、Movie、Screening、Order 的主外键怎么定
原代码给了User和Movie两个实体,这是最小集,真跑订票还缺三张关键表:场次(Screening)、座位库存(SeatStock)、订单(TicketOrder)。选型上有两条路:把座位做成Movie里的一个 JSON 字符串,或者拆成独立表。前者的好处是写起来快,坏处是并发扣减时你只能整行加锁,一个场次的所有座位共用一把锁,吞吐量掉得很难看。我一般选后者,因为订票系统的核心矛盾就是「同一场次同一座位只能卖一次」,这个约束交给数据库的唯一索引最省心。
@Entity @Table(name = "ticket_order", indexes = @Index(name = "uk_screening_seat", columnList = "screeningId,seatId", unique = true)) public class TicketOrder { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long userId; private Long screeningId; private Long seatId; @Enumerated(EnumType.STRING) private OrderStatus status; // CREATED / PAID / CANCELLED / TIMEOUT private BigDecimal payable; // 折后实付金额 private Integer points; // 本单产生的积分 private LocalDateTime createdAt; @Version private Integer version; // 乐观锁版本号 // getter / setter 省略 } @Entity @Table(name = "seat_stock", indexes = @Index(name = "uk_stock_screening_seat", columnList = "screeningId,seatId", unique = true)) public class SeatStock { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long screeningId; private Long seatId; private Integer status; // 0 可售 1 已锁定 2 已售出 private String orderNo; // 占用它的订单号 // getter / setter 省略 }TicketOrder和SeatStock各挂一个唯一索引,作用不同:订单表的(screeningId, seatId)唯一约束是最终防线,即使并发逻辑写漏了,数据库也会把第二笔同座订单挡下去;库存表的唯一索引保证一个场次一个座位只有一条记录,更新时不会出现「更新了 0 行却以为成功」的静默失败。@Version是为乐观锁留的,虽然本项目用条件更新更直接,但保留它方便你在后台改座位状态时不覆盖别人的修改。
实体关系上,User与TicketOrder是一对多,Movie与Screening是一对多,Screening与SeatStock是一对多。别把Screening直接塞进Movie做@OneToMany双向关联,订票页只需要场次列表,双向关联会让 Jackson 序列化时无限递归,也会让 JPA 生成一堆多余的 join。单向、按需查,是这类业务系统里更省事的写法。
2.3 从空库到能访问首页:建库、编译、启动的三条命令
环境依赖三件套:JDK、Maven、MySQL。JDK 版本要和pom.xml里spring-boot-starter-parent的版本对齐,Boot 2.x 配 JDK 8/11,Boot 3.x 必须 JDK 17 起,这条对不上会在启动时抛UnsupportedClassVersionError,很多人误以为是代码问题。
# 1. 建库,字符集一次定死,后面不用再改 mysql -uroot -p -e "CREATE DATABASE movie_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 编译并跳过测试,第一次跑先把依赖拉全 mvn clean package -DskipTests # 3. 启动:开发期用插件跑,能热加载模板 mvn spring-boot:run # 或者直接跑打好的包 java -jar target/movie-ticket-0.0.1-SNAPSHOT.jar-DskipTests不是偷懒,是首次构建时测试类往往依赖一个还没初始化的库,先跳过能让项目尽快起来,等库表建好再回来跑测试。mvn spring-boot:run和java -jar的差别在于前者会读src/main/resources下的实时文件,改 Thymeleaf 模板刷新页面就生效;后者读的是 jar 内的副本,改完要重新打包。开发阶段我用前者,交付演示用后者,别混。
启动成功的标志不是「没有报错」,而是日志里出现Tomcat started on port(s): 8080且Started XxxApplication in N seconds。如果进程起来又立刻退出,且末尾几行没有明显异常,八成是数据源初始化失败被 Spring 静默吞了,第 5 章会细说。
3. 订票主链路:选座、下单、支付状态机怎么写
3.1 场次与座位:把「座位图」拆成三张表
座位图在页面上是一张 8 行 12 列的网格,但落到库里不要存成一张图。拆法是:Screening记录一场放映(影片、影厅、开场时间、票价),Seat记录影厅的物理座位(行号、列号),SeatStock记录「某场次的某座位当前能不能卖」。三张表各管一件事,查询时按场次 id 一次捞出该场次的全部库存,前端按行列渲染,灰的红的由status决定。
这个拆法的收益在扩展上:加一个影厅只是多插几行Seat,加一场放映是按模板批量生成SeatStock,改票价只动Screening一行。代价是初次初始化要多写一个生成方法:
@Service public class ScreeningService { @Autowired private SeatRepository seatRepo; @Autowired private SeatStockRepository stockRepo; // 新建场次时,把该影厅所有座位复制成该场次的库存记录 @Transactional public void initStock(Long screeningId, Long hallId) { List<Seat> seats = seatRepo.findByHallIdOrderByRowNoAscColNoAsc(hallId); List<SeatStock> stocks = seats.stream().map(s -> { SeatStock st = new SeatStock(); st.setScreeningId(screeningId); st.setSeatId(s.getId()); st.setStatus(0); // 0 = 可售 return st; }).collect(Collectors.toList()); stockRepo.saveAll(stocks); // 批量插入,避免 N 次单条 insert } }saveAll在数据量小的时候和循环save没区别,但座位数上到几百条时,Hibernate 的批量插入需要配合spring.jpa.properties.hibernate.jdbc.batch_size=50才真正生效,否则每条都是一次独立往返。如果你的影厅只有几十个座位,这条可以不加;如果要做多厅影院,建议一开始就配上。
3.2 防超卖:唯一索引兜底 + 条件更新
这是整个项目最容易被跳过、也最容易在答辩或上线时翻车的地方。原始代码的createOrder只有一句save,两个人同时点同一个座位,两条订单都能落库。正确的写法是把「判断可售」和「占用座位」合成一条原子 SQL,用更新行数判断成败:
public interface SeatStockRepository extends JpaRepository<SeatStock, Long> { // 只有当前仍是可售(0)才更新成功,返回受影响行数 @Modifying @Query("update SeatStock s set s.status = 1, s.orderNo = :orderNo " + "where s.screeningId = :screeningId and s.seatId = :seatId and s.status = 0") int lockSeat(@Param("screeningId") Long screeningId, @Param("seatId") Long seatId, @Param("orderNo") String orderNo); }调用侧必须包在事务里,并且要对每个座位判断返回值:
@Service public class OrderService { @Autowired private SeatStockRepository stockRepo; @Autowired private TicketOrderRepository orderRepo; @Transactional(rollbackFor = Exception.class) public String createOrder(Long userId, Long screeningId, List<Long> seatIds, BigDecimal total) { String orderNo = "T" + System.currentTimeMillis(); for (Long seatId : seatIds) { int rows = stockRepo.lockSeat(screeningId, seatId, orderNo); if (rows == 0) { // 抛异常触发回滚,前面已锁的座位一起释放 throw new SeatOccupiedException("座位 " + seatId + " 已被占用"); } } TicketOrder order = new TicketOrder(); order.setUserId(userId); order.setScreeningId(screeningId); order.setStatus(OrderStatus.CREATED); order.setPayable(total); order.setCreatedAt(LocalDateTime.now()); orderRepo.save(order); return orderNo; } }逻辑上分三层保险:第一层是where status = 0的条件更新,数据库行锁保证同一时刻只有一个事务能把它从 0 改成 1;第二层是@Transactional让「多个座位只成功一半」的情况整体回滚,不会出现锁了两个座位却只下一单的脏状态;第三层是订单表的唯一索引,万一有人绕过 Service 直接写库,数据库仍然拒绝重复。
参数上要注意@Modifying必须搭配事务,单独调用会抛TransactionRequiredException;rollbackFor = Exception.class是因为默认只回滚运行时异常,自定义的业务异常如果是受检异常,不回滚就白写了。这两点在第 5 章还会以「事务失效」的形式再出现一次。
3.3 订单状态机与 15 分钟超时释放
订单只有四个状态:CREATED(已锁座未支付)、PAID(已支付)、CANCELLED(用户主动取消)、TIMEOUT(超时未付)。状态流转必须单向,PAID不能回到CREATED,TIMEOUT不能回到PAID。没有状态机的项目里,最常见的 bug 是用户点了支付又点取消,两个请求并发,最后座位被释放但钱也扣了。
超时释放用 Spring 自带的定时任务就够,不必上消息队列:
@Component public class OrderTimeoutTask { @Autowired private TicketOrderRepository orderRepo; @Autowired private SeatStockRepository stockRepo; // 每 60 秒扫一次,生产环境建议加上分页游标避免全表扫 @Scheduled(fixedDelay = 60_000) @Transactional(rollbackFor = Exception.class) public void releaseTimeout() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(15); List<TicketOrder> expired = orderRepo.findByStatusAndCreatedAtBefore(OrderStatus.CREATED, deadline); for (TicketOrder o : expired) { o.setStatus(OrderStatus.TIMEOUT); stockRepo.releaseByOrderNo(o.getOrderNo()); // status 置回 0,清空 orderNo } } }fixedDelay和fixedRate的区别这里值得说一句:fixedDelay是上一次执行完再计时,任务变慢不会堆积;fixedRate是固定频率,上一轮没跑完下一轮就来了,在扫表任务里容易叠出一堆重复释放。超时时间 15 分钟是行业习惯值,改小能提高座位周转率,改大对用户友好,这个数值放在配置里而不是硬编码,后期调起来才不用重新打包。
4. 三角色权限与 Thymeleaf 渲染:登录态怎么串起来
4.1 登录态传递:拦截器 + Session 的最小实现
原文的角色枚举USER / MEMBER / ADMIN是对的,但权限判断不能只写在页面上的th:if里——页面藏起来不等于接口调不到。最小的可行方案是拦截器统一校验,Session 里存登录用户,拦截器按路径前缀判角色:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { Object user = req.getSession().getAttribute("loginUser"); if (user == null) { resp.sendRedirect("/login"); // 未登录统一送回登录页 return false; } // 后台路径只放行 ADMIN if (req.getRequestURI().startsWith("/admin") && !isAdmin(user)) { resp.sendError(403); return false; } return true; } private boolean isAdmin(Object user) { return user instanceof User && ((User) user).getRole() == Role.ADMIN; } }注册到容器时,放行名单是最容易漏的地方:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/register", "/css/**", "/js/**", "/images/**", "/api/movie/list", "/error"); } }/error必须放行,否则任何一次 500 都会因为跳转错误页被拦截器再拦一次,浏览器看到的是无限重定向而不是真实异常。/css/**、/js/**不放行的后果是登录页没样式,很多人第一次跑起来看到裸 HTML 就以为是模板没渲染,其实是拦截器把静态资源也拦了。这种坑属于「不写一次永远记不住」的类型。
4.2 会员折扣与积分:把规则从 Controller 挪到 Service
会员折扣、积分兑换是这套系统区别于普通 CRUD 的地方,但规则散落在 Controller 里就废了——下单、退款、后台补单三处都要重算,迟早不一致。我一般把计价收敛成一个方法,输入原始总价和用户,输出应付金额和积分:
@Service public class PriceService { private static final BigDecimal MEMBER_RATE = new BigDecimal("0.90"); public PriceResult calc(BigDecimal total, User user) { BigDecimal payable = total; if (user.getRole() == Role.MEMBER) { payable = total.multiply(MEMBER_RATE).setScale(2, RoundingMode.HALF_UP); } // 每消费 10 元得 1 分,向下取整,避免出现小数积分 int points = payable.divide(BigDecimal.TEN, 0, RoundingMode.DOWN).intValue(); return new PriceResult(payable, points); } }金额一律用BigDecimal而不是double,这个不是洁癖:0.1 + 0.2在 double 下等于0.30000000000000004,订单金额差一分钱对账时就够你查半天。setScale(2, RoundingMode.HALF_UP)明确舍入方式,别用默认的HALF_EVEN,财务口径通常不接受银行家舍入。积分用RoundingMode.DOWN是为了永远不超发,宁可少给一分,也不能让用户刷出负数订单。
会员权益里还有「优先预约」和「收藏夹容量」这类非金额规则,实现上不需要复杂设计,在对应的查询上加role条件排序即可,比如优先预约就是把会员的预约请求按role排序后先处理。别为了这两条把整个订单表加一堆状态字段,先用最简单的方式跑通,等真有并发压力再谈队列。
4.3 Thymeleaf 片段复用与后台列表页分页
前台和后台共用顶栏、侧边栏,用th:fragment抽出来,改一处全站生效:
<!-- templates/common/layout.html --> <nav th:fragment="topbar" class="topbar"> <a th:href="@{/}">首页</a> <a th:href="@{/movie/list}">影片</a> <a th:href="@{/forum/list}">论坛</a> <span th:if="${session.loginUser != null}" th:text="${session.loginUser.username} + '(' + ${session.loginUser.role} + ')'"></span> <a th:unless="${session.loginUser != null}" th:href="@{/login}">登录</a> </nav><!-- 任意页面引入 --> <div th:replace="~{common/layout :: topbar}"></div>th:replace是整块替换,th:insert是插入到标签内部,两个混用会出现「导航栏套导航栏」的诡异结构。后台列表页统一走分页,Controller 侧不要一次findAll()把几千条影片塞进模板:
@GetMapping("/admin/movie/list") public String list(@RequestParam(defaultValue = "0") int page, Model model) { Page<Movie> result = movieRepo.findAll( PageRequest.of(page, 10, Sort.by("id").descending())); model.addAttribute("page", result); model.addAttribute("movies", result.getContent()); return "admin/movie_list"; }模板里用th:each遍历movies,翻页按钮用page.hasPrevious()/page.hasNext()判断,不要自己算总页数。Sort.by("id").descending()让最新录入的影片排前面,这是运营侧的实际需求,默认按 id 升序会让管理员每次都要翻到最后一页。
5. 避坑排查:五个让项目起不来的真实翻车点
5.1 启动即退出,日志只有一行 Communications link failure
现象:mvn spring-boot:run跑起来几秒后进程结束,异常栈里最关键的一行是Communications link failure,后面跟着一长串 Socket 信息。
原因:九成不是 MySQL 没启动,而是连接串缺参数。MySQL 8 的驱动要求显式声明时区,未声明时抛The server time zone value 'xxx' is unrecognized;另外驱动默认禁止公钥检索,用caching_sha2_password认证的账号会直接认证失败。这两种错在日志里都被包装成连接失败,看起来像网络问题。
解决:把 url 补成useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true,再确认driver-class-name是com.mysql.cj.jdbc.Driver(Boot 3 里老包名com.mysql.jdbc.Driver已被移除)。改完先用mysql -uroot -p手工连一次,排除账号密码本身的问题,再启动应用。
5.2 页面报 Whitelabel Error,看不到具体异常
现象:浏览器打开首页返回 500,页面上只有一句Whitelabel Error Page,控制台日志里也找不到模板名。
原因:Thymeleaf 的模板解析规则是「返回值 + 前缀 + 后缀」,Controller 返回"movie/list"时找的是templates/movie/list.html。文件名大小写、目录层级、后缀任何一个对不上都会 500,而默认错误页把原始异常吞掉了。
解决:临时在application.properties加server.error.include-message=always和server.error.include-stacktrace=always,把真实异常打出来;同时核对spring.thymeleaf.prefix=classpath:/templates/和suffix=.html没被覆盖。如果模板里用了th:field却没有对应的表单对象,也会以 500 的形式出现,这类错误只能靠完整栈定位。
5.3 选座提示成功,但座位库存没减
现象:下单接口返回成功,订单表里有记录,seat_stock的status还是 0,换个账号还能选同一个座位。
原因:@Transactional失效。最常见的是同类内部调用——OrderService里 A 方法直接this.createOrder(...),绕过了 Spring 的代理对象,注解不生效;其次是异常被try-catch吞掉,事务管理器收不到回滚信号;第三种是方法不是public,CGLIB 代理拿不到。
解决:把事务方法抽到独立的 Service 里通过注入调用;捕获异常后必须throw出去或者手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();确认启动类上有@EnableTransactionManagement(Boot 自动配置一般已带)。验证方式是打开logging.level.org.springframework.transaction=DEBUG,看日志里有没有真的开启事务。
5.4 后台管理页能打开,但样式全丢、按钮没反应
现象:登录后台,页面结构在,CSS 和 JS 全部 404,浏览器控制台一片红。
原因:拦截器的addPathPatterns("/**")把静态资源也拦了,未登录或非管理员访问/css/admin.css时被重定向到登录页,浏览器拿到的是一段 HTML 当 CSS 解析,自然全丢。
解决:excludePathPatterns里补上/css/**、/js/**、/images/**、/webjars/**,并且确认静态资源放在src/main/resources/static下而不是templates下。另一个隐蔽点是 Boot 3 里WebMvcConfigurer的默认静态资源映射没变,但如果项目自定义了addResourceHandlers,路径前缀写错同样会 404。
5.5 影片名和评论里的中文变成问号,时间总是差 8 小时
现象:数据库里查出来的中文是???,订单创建时间比实际早或晚 8 小时。
原因:建库时没指定字符集,MySQL 默认可能是latin1;时间问题是 JVM 时区和 JDBC 时区不一致,serverTimezone写了但 JVM 的user.timezone是 UTC。
解决:建库语句带上DEFAULT CHARACTER SET utf8mb4,连接串带characterEncoding=utf8;时间统一用LocalDateTime并在连接串声明serverTimezone=Asia/Shanghai,启动参数加-Duser.timezone=Asia/Shanghai。已经存了乱码的数据没法救,只能清库重导,所以字符集这一步在第一次建库时就要定死,没有后悔药。
6. 验收与二次开发:用并发脚本证明选座不超卖
功能点全绿不等于系统能用。订票系统交付前,我固定会做一件事:对同一个座位发 20 个并发下单请求,看成功数是不是 1。这个测试不需要压测平台,一段 Python 就够:
import requests from concurrent.futures import ThreadPoolExecutor URL = "http://localhost:8080/api/order/create" HEADERS = {"Cookie": "JSESSIONID=替换成登录后的会话值"} # 两个不同账号各跑一轮更准 PAYLOAD = {"screeningId": 1, "seatIds": "3"} def fire(i): r = requests.post(URL, data=PAYLOAD, headers=HEADERS, timeout=5) return r.status_code, r.text[:60] with ThreadPoolExecutor(max_workers=20) as pool: result = list(pool.map(fire, range(20))) ok = [r for r in result if r[0] == 200] print("成功数:", len(ok), "| 失败样例:", result[0])max_workers=20模拟 20 个用户同时点确认,seatIds故意只传一个座位,把冲突压到最大。期望输出是「成功数: 1」,其余 19 条应该是业务异常返回的 4xx 而不是 500——如果返回 500,说明你的SeatOccupiedException没被全局异常处理器接住,前端拿到的是堆栈而不是提示,这本身就是个体验坑。测完记得查一次seat_stock表,确认被锁的那个座位status=1且order_no和成功订单对得上,再等 15 分钟看定时任务有没有把它释放回 0。
二次开发方向上,这份代码最容易加的三处,按投入产出排:
| 改造点 | 改动位置 | 注意 |
|---|---|---|
| 支付对接 | OrderService支付回调 | 回调必须幂等,同一笔订单重复通知只处理一次 |
| 影片推荐 | 首页查询 + 用户观影记录 | 先用规则(同类型高分)跑通,别一上来就上模型 |
| 移动端适配 | Thymeleaf 模板 + 响应式 CSS | 后台管理页不用适配,省一半工作量 |
我踩过最深的一次坑是给订单表加了「备注」字段后直接ddl-auto=update重启,结果 Hibernate 把原有的一列类型从varchar(255)改成了varchar(500),历史数据虽然没丢,但索引长度超限导致线上报警。从那以后我每次改实体前都强制走一遍「先导出建表语句做 diff,再手动执行 ALTER」的流程,ddl-auto只在本地开发库上用,测试库和线上库永远是validate。这份订票系统源码适合当底座,但底座能不能承重,取决于你有没有在它上面补上并发和状态这两块砖。希望帮到你。
本文还有配套的精品资源,点击获取