简介:这是一套面向Java初学者与课程设计者的电影票购票管理系统实战项目,基于JDK1.8与Java Swing构建桌面端GUI,配合MySQL5.7完成电影、场次、座位等数据的存储与查询,适合用来练习Swing组件布局、事件监听、JDBC数据访问以及MVC分层设计。资源包共236个文件,包含52个java源码、128个class编译文件、28张jpg与19张png运行截图、1个mp4视频运行教程、1个sql数据库脚本及运行环境说明文档,压缩包约232.48MB,覆盖从源码到部署的完整链路。已有1855人学习下载。读者可借助视频教程完成JDK与MySQL环境配置、项目导入与运行,通过数据库文件快速还原预设数据,并结合截图理解选座购票的交互流程,是掌握Java桌面应用开发与数据库整合的实用素材。
1. 电影票购票管理系统到底在解决什么问题
打开任何一个影院售票页面,你看到的只是「选座-下单-支付」三步,但背后要处理的是场次排期、座位锁定、订单超时释放、退改签规则、票价计算这一整套链路。Java 电影票购票管理系统就是把这套链路用 Java 技术栈完整实现一遍的课程设计级项目,通常配套视频讲解和源码,适合正在找 Java 课程设计案例源码、想拿一个完整项目练手的人。
它解决的核心问题有三个:一是把面向对象编程 Java 的思路落到真实业务实体上(影片、影厅、场次、座位、订单);二是把数据库事务、并发锁、定时任务这些 Java 基础面试题里反复出现的知识点串起来;三是给出一份能跑起来的源码,让你不用从零搭架子。如果你正在刷 Java 面试八股文却缺少项目支撑,或者课程设计需要一个有业务深度的题目,这个方向值得投入。下面按「先跑通、再拆解、后避坑」的顺序讲清楚怎么做。
2. 技术选型与最小可运行环境搭建
2.1 为什么是 Spring Boot + MyBatis + MySQL 这套组合
课程设计类项目最怕的是环境复杂到跑不起来。我一般会选 Spring Boot 2.x 做骨架,原因很直接:内嵌 Tomcat,一个 main 方法就能启动,不需要单独装 Web 容器;starter 依赖把常用库版本都锁好了,省掉大量 Maven 冲突排查时间。持久层用 MyBatis 而不是 JPA,是因为购票场景里「查某场次剩余座位」「按条件筛选排片」这类 SQL 需要精细控制,MyBatis 的 XML 映射写起来更直观,也方便你在面试时讲清楚每条 SQL 的执行逻辑。
数据库选 MySQL 8,字符集用 utf8mb4,因为影片名、影院名可能带生僻字或特殊符号。前端部分,课程设计通常用 Thymeleaf 或直接静态 HTML + Ajax,不引入 Vue/React 那套构建工具链,降低上手门槛。缓存层可选 Redis,用来做座位锁和验证码,但如果只是本地跑通,先用数据库行锁也能撑住。
这套组合的版本对应关系建议锁死,避免「在我电脑上能跑」的玄学问题:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| JDK | 8 或 11 | 8 兼容性最好,11 是 LTS |
| Spring Boot | 2.7.x | 2.x 最后稳定分支,资料多 |
| MyBatis Starter | 2.3.x | 与 Boot 2.7 匹配 |
| MySQL | 8.0.x | 注意驱动类名带 cj |
| Maven | 3.8+ | 依赖解析更稳 |
2.2 从零把项目跑起来的最小命令
拿到源码后不要急着改代码,先按下面步骤把环境跑通。假设你已经装好 JDK 和 Maven,MySQL 也在本地运行。
# 1. 建库,字符集必须是 utf8mb4 mysql -uroot -p -e "CREATE DATABASE movie_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导入源码里的建表脚本(通常在 sql/ 目录下) mysql -uroot -p movie_ticket < sql/schema.sql # 3. 导入初始数据(影片、影厅、场次) mysql -uroot -p movie_ticket < sql/data.sql # 4. 修改 application.yml 里的数据库连接 # url: jdbc:mysql://localhost:3306/movie_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai # username / password 改成你自己的 # 5. 编译并启动 mvn clean package -DskipTests java -jar target/movie-ticket-0.0.1-SNAPSHOT.jar启动后访问http://localhost:8080,能看到排片列表就说明主链路通了。这里有几个参数必须核对:serverTimezone不写会导致时间字段差 8 小时,场次时间全乱;characterEncoding=utf8不写,中文影片名会变问号。这两个坑我在不同项目里反复见过,属于血泪经验级别。
2.3 核心表结构怎么设计才不返工
购票系统的表设计决定了后面写业务顺不顺。最少需要这几张表:film(影片)、cinema(影院)、hall(影厅)、schedule(场次)、seat(座位)、order(订单)、order_seat(订单座位关联)。关键点在于座位和场次的关系——座位是影厅的物理属性,但「某场次某座位是否已售」是动态状态,所以不要直接把座位状态写在seat表里,而是用order_seat关联表记录已售座位,查询时用NOT IN或LEFT JOIN排除。
-- 场次表关键字段 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, film_id BIGINT NOT NULL, hall_id BIGINT NOT NULL, start_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 1 COMMENT '1可售 0停售', INDEX idx_film_start (film_id, start_time) ) ENGINE=InnoDB; -- 订单座位关联,唯一索引防止同一座位被重复下单 CREATE TABLE order_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, seat_row INT NOT NULL, seat_col INT NOT NULL, UNIQUE KEY uk_schedule_seat (schedule_id, seat_row, seat_col) ) ENGINE=InnoDB;uk_schedule_seat这个唯一索引是整个系统防超卖的最后一道防线。即使应用层锁没做好,数据库层面也会拦住重复插入。索引idx_film_start是为了加速「查某影片未来场次」这个高频查询。设计阶段多花十分钟想清楚索引,比上线后加索引要轻松得多。
3. 购票主链路:选座、下单、支付、出票
3.1 选座接口怎么防止两个人抢同一个座位
选座是并发最集中的环节。常见做法是「查询可用座位 → 用户点击 → 提交订单时校验」。但校验和插入之间有窗口期,两个人可能同时通过校验。解决方式有两种:悲观锁和乐观锁。课程设计里我推荐用数据库唯一索引 + 捕获异常的方式,简单可靠。
@Service public class OrderService { @Autowired private OrderSeatMapper orderSeatMapper; @Transactional(rollbackFor = Exception.class) public Result createOrder(Long scheduleId, List<SeatDTO> seats, Long userId) { // 1. 先创建订单主记录,状态为待支付 Order order = new Order(); order.setUserId(userId); order.setScheduleId(scheduleId); order.setStatus(OrderStatus.UNPAID.getCode()); order.setCreateTime(new Date()); orderMapper.insert(order); // 2. 逐条插入座位,靠唯一索引防重 try { for (SeatDTO seat : seats) { OrderSeat os = new OrderSeat(); os.setOrderId(order.getId()); os.setScheduleId(scheduleId); os.setSeatRow(seat.getRow()); os.setSeatCol(seat.getCol()); orderSeatMapper.insert(os); } } catch (DuplicateKeyException e) { // 座位已被占,事务回滚 throw new BizException("座位已被选走,请重新选择"); } return Result.ok(order.getId()); } }这段代码的逻辑是:先插订单主表拿到订单号,再插座位关联表。@Transactional保证任何一条座位插入失败时整个订单回滚,不会留下脏数据。DuplicateKeyException是 Spring 对唯一索引冲突的封装,捕获它比先查后插更可靠,因为查和插之间没有间隙。参数上要注意rollbackFor = Exception.class,默认只回滚运行时异常,加上这个才能覆盖所有情况。
3.2 订单超时未支付怎么自动释放座位
用户下单后不付款,座位不能一直占着。标准做法是用定时任务扫描超时订单。Spring Boot 里用@Scheduled就能实现,不需要引入 Quartz 那么重。
@Component public class OrderTimeoutTask { @Autowired private OrderMapper orderMapper; // 每 30 秒执行一次,扫描 15 分钟前创建的未支付订单 @Scheduled(fixedDelay = 30000) @Transactional(rollbackFor = Exception.class) public void releaseTimeoutOrders() { Date deadline = new Date(System.currentTimeMillis() - 15 * 60 * 1000); List<Order> timeoutOrders = orderMapper.selectTimeoutUnpaid(deadline); for (Order order : timeoutOrders) { // 更新订单状态为已取消 orderMapper.updateStatus(order.getId(), OrderStatus.CANCELLED.getCode()); // 删除座位关联,释放座位 orderSeatMapper.deleteByOrderId(order.getId()); } } }fixedDelay表示上一次执行结束后再等 30 秒,避免任务重叠。15 分钟的超时阈值写在代码里是硬编码,实际项目应该放到配置文件。这里有个细节:更新订单状态和删除座位必须在同一个事务里,否则可能出现订单取消了但座位还占着的情况。另外,如果订单量很大,selectTimeoutUnpaid要加LIMIT,分批处理,不然一次拉几万条会把内存打满。
3.3 支付回调怎么保证幂等
支付环节最容易翻车的是重复回调。第三方支付平台可能因为网络问题多次通知同一笔订单,如果你的接口不幂等,就会重复出票或重复扣库存。做法是在更新订单状态时加条件判断。
public Result handlePayCallback(String orderNo, String tradeNo) { // 只更新状态为「待支付」的订单,已支付的直接返回成功 int affected = orderMapper.updateStatusByOrderNo( orderNo, OrderStatus.PAID.getCode(), OrderStatus.UNPAID.getCode() // 前置状态条件 ); if (affected == 0) { // 说明订单已经是已支付或已取消,直接返回成功,不重复处理 return Result.ok("重复回调,已忽略"); } // 记录支付流水 payLogMapper.insert(orderNo, tradeNo, new Date()); return Result.ok(); }核心在 SQL 的WHERE status = #{preStatus},把状态判断和更新合成一条原子操作。affected == 0就说明订单不处于待支付状态,直接返回成功让支付平台停止重试。这个模式在 Java 怎么保证数据一致性的面试题里经常被问到,实际写一遍比背答案印象深得多。
4. 排片管理与后台功能的实现要点
4.1 场次冲突检测怎么做
后台添加场次时,同一个影厅同一时间段不能有两场电影。检测逻辑是:新场次的开始时间要晚于已有场次的结束时间,或者结束时间早于已有场次的开始时间。电影时长从film表取,场次结束时间 = 开始时间 + 时长 + 清洁时间。
public boolean hasConflict(Long hallId, Date startTime, Integer durationMinutes) { Date endTime = new Date(startTime.getTime() + (durationMinutes + 15) * 60 * 1000L); // 查询该影厅下所有与新场次时间重叠的场次 int count = scheduleMapper.countConflict(hallId, startTime, endTime); return count > 0; }对应的 SQL 用start_time < #{endTime} AND end_time > #{startTime}判断重叠,这是区间重叠的标准写法。加 15 分钟清洁时间是行业惯例,不加的话两场之间没有间隔,实际运营会出问题。这个检测要在插入前做,并且最好在数据库层加唯一约束兜底,防止并发添加时绕过应用层检查。
4.2 票价计算与优惠策略
票价不是简单的一个场次一个价。常见规则包括:不同影厅类型(普通厅、IMAX、VIP)基础价不同;早晚场有折扣;会员有折扣;节假日上浮。设计上建议把价格计算抽成独立服务,用策略模式组织。
public BigDecimal calculatePrice(Schedule schedule, User user, Date showTime) { BigDecimal price = schedule.getBasePrice(); // 1. 影厅类型加价 price = price.add(hallTypeSurcharge(schedule.getHallType())); // 2. 时段折扣:12 点前 8 折 if (isMorningShow(showTime)) { price = price.multiply(new BigDecimal("0.8")); } // 3. 会员折扣:9 折 if (user != null && user.isMember()) { price = price.multiply(new BigDecimal("0.9")); } // 4. 保留两位小数,向上取整到分 return price.setScale(2, RoundingMode.HALF_UP); }顺序很重要:先加价再打折,和先打折再加价结果不同。业务上通常按「基础价 + 加价项 → 折扣项」的顺序。RoundingMode.HALF_UP是四舍五入,避免出现 0.005 这种无法支付的小数。如果后续要加优惠券,在最后一步减固定金额即可,但要注意不能减成负数,加一个max(price, 0)保护。
4.3 影片海报上传与静态资源映射
后台需要上传影片海报。Spring Boot 默认的静态资源目录是classpath:/static/,但上传的文件不应该打包进 jar,而是放在外部目录,通过配置映射访问。
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${upload.path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { // 把 /poster/** 映射到本地上传目录 registry.addResourceHandler("/poster/**") .addResourceLocations("file:" + uploadPath); } }upload.path在application.yml里配置,比如/data/movie/poster/。注意file:前缀不能少,否则 Spring 会当成 classpath 路径去找。上传接口里要校验文件类型和大小,只允许 jpg/png,限制 2MB 以内,防止有人传个大文件把磁盘塞满。文件名用 UUID 重命名,避免中文名和特殊字符导致的路径问题。
5. 避坑与排查:那些让项目跑不起来的细节
5.1 启动报错「Public Key Retrieval is not allowed」
现象:MySQL 8 连接时抛java.sql.SQLNonTransientConnectionException: Public Key Retrieval is not allowed。
原因:MySQL 8 默认使用caching_sha2_password认证插件,JDBC 驱动在非 SSL 连接下需要显式允许获取公钥。
解决:在 JDBC URL 后面加allowPublicKeyRetrieval=true&useSSL=false。生产环境应该配 SSL,本地开发这样写没问题。
5.2 中文乱码从数据库一路乱到页面
现象:影片名在数据库里是问号,或者页面显示乱码。
原因:三个环节的字符集没统一——数据库建库时不是 utf8mb4、JDBC URL 没指定编码、HTTP 响应没设 Content-Type。
解决:建库语句加DEFAULT CHARACTER SET utf8mb4;JDBC URL 加characterEncoding=utf8;Controller 返回 JSON 时 Spring Boot 默认就是 UTF-8,但如果是自己写response.getWriter(),要手动setCharacterEncoding("UTF-8")。三处都对齐才不会出问题。
5.3 定时任务在集群环境下重复执行
现象:部署两个实例后,同一笔超时订单被释放两次,日志里出现重复的取消记录。
原因:@Scheduled在每个 JVM 实例里都会执行,没有分布式协调。
解决:课程设计单机跑没问题,但如果要模拟集群,需要加分布式锁。简单做法是用 Redis 的SETNX加过期时间,抢到锁的实例才执行任务。或者用数据库的SELECT ... FOR UPDATE锁住一批订单,处理完再释放。这个坑在单机测试时完全发现不了,一上多实例就暴露。
5.4 座位图渲染错位
现象:选座页面的座位行列和实际影厅对不上,有的座位重叠。
原因:前端用绝对定位画座位时,行列间距计算用了固定像素,但不同影厅的座位数不同,没有动态计算。
解决:后端返回影厅的行数、列数、过道位置,前端根据这些参数动态计算每个座位的left和top。过道位置单独留空,不要用座位填充。如果影厅有弧形排列,用 CSStransform做偏移,不要硬编码坐标。
5.5 订单号重复导致主键冲突
现象:高并发下偶尔出现Duplicate entry错误,订单号撞了。
原因:用时间戳或随机数生成订单号,并发时可能重复。
解决:用雪花算法(Snowflake)生成分布式唯一 ID,或者用数据库自增主键 + 日期前缀。课程设计里最简单的是直接用数据库自增 ID,订单号展示时拼一个日期前缀即可。不要自己用System.currentTimeMillis()加随机数,那个碰撞概率比你想的高。
6. 从能跑到好用:几个提升项目质量的技巧
把主链路跑通只是第一步。如果你想让这个项目在课程设计答辩或面试里更有说服力,下面几个点值得花时间打磨。
第一,给选座接口加限流。用 Guava 的RateLimiter或 Redis 计数器,限制同一用户每秒最多请求 5 次。不是为了防黑客,而是防止有人写脚本刷座位。代码就几行,但讲出来能体现你有安全意识。
第二,把座位状态查询做成缓存。每次打开选座页都查数据库,场次热门时数据库压力很大。用 Redis 缓存「场次-已售座位」列表,下单成功后更新缓存,设置过期时间为场次结束时间。缓存和数据库的一致性用「先更新数据库,再删除缓存」的策略,虽然理论上还有极小概率不一致,但对课程设计足够。
第三,加一个简单的对账功能。每天凌晨跑一次,对比订单表和支付流水表,找出「已支付但没有流水」或「有流水但订单未支付」的记录。这个功能代码量不大,但能让你在面试时讲清楚「怎么保证数据一致性」的落地做法,比背八股文强得多。
第四,日志要打对地方。下单、支付、取消这三个操作必须打 INFO 日志,带上订单号和用户 ID。异常打 ERROR 日志,带上堆栈。不要用System.out.println,用 SLF4J。日志格式统一,方便出问题时用grep捞。
private static final Logger log = LoggerFactory.getLogger(OrderService.class); public Result createOrder(...) { log.info("创建订单开始, userId={}, scheduleId={}, seats={}", userId, scheduleId, seats.size()); // ... 业务逻辑 log.info("创建订单成功, orderId={}", order.getId()); }最后说一个我自己的习惯:每次改完代码,先跑一遍「选座 → 下单 → 支付 → 查订单」的完整流程,再跑一遍「下单 → 不支付 → 等超时 → 查座位是否释放」。这两个流程覆盖了 80% 的 bug。不要只测单个接口,购票系统的坑几乎都出在状态流转上。希望帮到你。
本文还有配套的精品资源,点击获取