简介:这是一套面向Java初学者与课程设计者的电影票购票管理系统实战资料,基于Java Swing桌面界面、JDK1.8与MySQL5.7数据库构建,模拟影院选座、购票、场次与影片管理等完整业务流程,适合用来练习GUI编程、JDBC数据访问与MVC分层设计。压缩包共236个文件,约232.48MB,其中52个java源码文件承载业务逻辑与界面代码,128个class为编译产物,28张jpg与19张png提供运行截图与素材,另含1个mp4运行教程视频、1个sql数据库脚本、1个docx说明文档及jar、classpath等工程配置,便于直接导入IDE运行。目前已有1855人学习下载。读者可据此掌握Swing组件与事件监听、Lambda与Stream等JDK1.8特性、MySQL表结构设计与增删改查实现,并借助视频与截图快速完成环境搭建和功能验证,是提升Java项目开发能力的实用素材。
1. 从一份 Java 电影票购票管理系统源码说起:它到底能解决什么问题
很多人第一次接触 Java 电影票购票管理系统,是在课程设计或者毕业设计阶段。需求听起来不复杂:用户选影片、选场次、选座位、下单支付、后台排片。但真正动手写的时候,问题会集中爆发——座位怎么保证不被两个人同时抢到、场次和影厅怎么关联、订单超时未支付怎么自动释放座位、后台排片和前台展示怎么共用一套数据。这套系统本质上是一个带并发约束的电商变种,只是商品变成了「某个场次的某个座位」。
它适合三类人:正在做 Java 课程设计、需要一套能跑通全流程参考实现的学生;想练手 Spring Boot + MyBatis 全栈 CRUD 和事务控制的初中级开发者;以及需要给团队做技术选型演示、想快速搭一个可演示原型的工程师。源码和视频的价值不在于照抄,而在于看清「座位锁定」和「订单状态机」这两处是怎么落地的,这两处才是这类系统真正的技术含量所在。
2. 技术选型与数据库设计:为什么这套组合能撑起购票场景
2.1 分层架构与依赖选型
一套能讲清楚的电影票购票管理系统,后端常见做法是 Spring Boot + MyBatis-Plus + MySQL + Redis,前端用 Thymeleaf 或者前后端分离的 Vue。选 Spring Boot 是因为它把 Tomcat、数据源、事务管理器都自动装配好了,省掉大量 XML 配置;选 MyBatis-Plus 是因为它能在单表 CRUD 上省掉 80% 的样板代码,同时保留手写 SQL 的能力——购票场景里座位查询、订单统计这类复杂查询,最终还是要落到手写 SQL 上。
Redis 在这类系统里不是可选项。座位锁定如果用数据库行锁硬扛,并发一上来就是大量锁等待,体验直接崩。常见做法是把「场次 + 座位」作为 key 写入 Redis,设置一个和支付超时时间一致的过期时间,下单时先抢 Redis 锁,抢到再落库。这样数据库只承担最终一致性,压力小很多。
依赖清单大致如下,版本按你本地 JDK 选,JDK 8 和 JDK 17 都能跑:
<!-- pom.xml 关键依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>逻辑说明:web starter 提供内嵌容器和 MVC,mybatis-plus starter 负责 ORM 和分页插件,redis starter 提供 StringRedisTemplate。参数上,MyBatis-Plus 版本不要低于 3.5,低版本的分页插件配置方式不同,容易踩坑。
2.2 核心表结构与字段含义
数据库设计是这类系统的地基,表建错了后面全是补丁。核心表有六张:用户表、影片表、影厅表、场次表、座位表、订单表。座位表是重点,它记录每个影厅的物理座位,场次和座位的关联通过「场次座位状态表」实现,而不是直接改座位表——因为同一个座位在不同场次的状态是独立的。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| user | id, username, password, phone | 密码存 BCrypt 哈希,不存明文 |
| movie | id, title, duration, poster, status | status 控制上架下架 |
| hall | id, name, row_count, col_count | 影厅行列数决定座位生成 |
| schedule | id, movie_id, hall_id, start_time, price | 场次关联影片和影厅 |
| seat | id, hall_id, row_num, col_num | 物理座位,一个影厅一套 |
| schedule_seat | id, schedule_id, seat_id, status, order_id | status: 0可售 1锁定 2已售 |
| order | id, user_id, schedule_id, total_amount, status, create_time | status: 0待支付 1已支付 2已取消 |
场次座位状态表是并发控制的核心。下单时不是去改 seat 表,而是更新 schedule_seat 的 status,并且用UPDATE ... WHERE status = 0这种带条件的更新来保证原子性。如果影响行数为 0,说明座位已经被别人抢走,直接返回失败。
-- 下单时锁定座位的原子更新 UPDATE schedule_seat SET status = 1, order_id = #{orderId} WHERE schedule_id = #{scheduleId} AND seat_id = #{seatId} AND status = 0;逻辑说明:这条 SQL 的 WHERE 条件里带了status = 0,数据库在执行时会加行锁,两个并发请求只有一个能更新成功,另一个影响行数为 0。参数说明:scheduleId 和 seatId 来自前端选座,orderId 是预生成的订单号。这种「乐观锁 + 条件更新」的方式比 SELECT 再 UPDATE 可靠得多,后者在并发下必然超卖。
3. 购票主流程实现:从选座到下单的完整链路
3.1 选座接口与座位状态渲染
前端进入选座页时,需要拿到某个场次下所有座位的状态。接口设计成GET /api/schedule/{scheduleId}/seats,返回座位矩阵,每个座位带 status 字段。后端查询 schedule_seat 表,按 row_num 和 col_num 排序,组装成二维数组返回。
@GetMapping("/api/schedule/{scheduleId}/seats") public Result<List<List<SeatVO>>> seats(@PathVariable Long scheduleId) { // 查询该场次所有座位状态 List<ScheduleSeat> list = scheduleSeatMapper.selectList( new LambdaQueryWrapper<ScheduleSeat>() .eq(ScheduleSeat::getScheduleId, scheduleId) .orderByAsc(ScheduleSeat::getRowNum) .orderByAsc(ScheduleSeat::getColNum) ); // 按行分组,组装成二维结构给前端渲染 Map<Integer, List<SeatVO>> rowMap = new LinkedHashMap<>(); for (ScheduleSeat ss : list) { rowMap.computeIfAbsent(ss.getRowNum(), k -> new ArrayList<>()) .add(new SeatVO(ss.getSeatId(), ss.getColNum(), ss.getStatus())); } return Result.ok(new ArrayList<>(rowMap.values())); }逻辑说明:用 LinkedHashMap 保证行顺序,每行内部按列号升序,前端拿到后直接双重循环渲染成座位图。参数说明:SeatVO 里 status 用 0/1/2 表示可售/锁定/已售,前端根据状态给不同颜色。这里不要返回用户信息,避免泄露谁买了哪个座位。
3.2 下单与支付超时释放
下单是整个系统最容易出问题的地方。流程是:校验场次是否可售 → 校验座位状态 → 生成订单 → 锁定座位 → 返回支付信息。锁定座位用 2.2 里的条件更新,订单状态初始为待支付。支付超时释放座位,常见做法是用 Redis 的过期 key 配合定时任务,或者用延迟队列。
@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Long userId, Long scheduleId, List<Long> seatIds) { // 1. 生成订单号 String orderNo = IdWorker.getIdStr(); // 2. 逐个锁定座位,任何一个失败就回滚 for (Long seatId : seatIds) { int affected = scheduleSeatMapper.lockSeat(scheduleId, seatId, orderNo); if (affected == 0) { throw new BizException("座位已被抢购,请重新选择"); } } // 3. 写入订单 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setScheduleId(scheduleId); order.setStatus(0); orderMapper.insert(order); // 4. 写入 Redis,30 分钟未支付自动释放 redisTemplate.opsForValue().set("order:timeout:" + orderNo, "1", 30, TimeUnit.MINUTES); return new OrderVO(orderNo, order.getTotalAmount()); }逻辑说明:@Transactional保证座位锁定和订单写入在同一个事务里,任何一步失败整体回滚。参数说明:rollbackFor = Exception.class必须显式指定,否则默认只回滚运行时异常,业务异常不会触发回滚,这是血泪经验。Redis 的过期时间要和业务约定的支付超时时间一致,定时任务扫描过期 key 后把订单置为已取消并释放座位。
3.3 订单状态机与后台排片
订单状态只有四个:待支付、已支付、已取消、已退款。状态流转必须单向,不允许从已取消回到待支付。后台排片是另一个高频操作,管理员选影片、选影厅、设时间、设价格,系统自动根据影厅行列数生成该场次的座位记录。
public void createSchedule(ScheduleDTO dto) { // 1. 校验时间冲突:同一影厅同一时间段不能有两场 Long conflict = scheduleMapper.selectCount( new LambdaQueryWrapper<Schedule>() .eq(Schedule::getHallId, dto.getHallId()) .lt(Schedule::getStartTime, dto.getEndTime()) .gt(Schedule::getEndTime, dto.getStartTime()) ); if (conflict > 0) { throw new BizException("该影厅时间段已被占用"); } // 2. 插入场次 Schedule schedule = new Schedule(); BeanUtils.copyProperties(dto, schedule); scheduleMapper.insert(schedule); // 3. 根据影厅行列数批量生成座位状态记录 Hall hall = hallMapper.selectById(dto.getHallId()); List<ScheduleSeat> seats = new ArrayList<>(); for (int r = 1; r <= hall.getRowCount(); r++) { for (int c = 1; c <= hall.getColCount(); c++) { ScheduleSeat ss = new ScheduleSeat(); ss.setScheduleId(schedule.getId()); ss.setRowNum(r); ss.setColNum(c); ss.setStatus(0); seats.add(ss); } } // 批量插入,避免逐条 insert scheduleSeatMapper.insertBatch(seats); }逻辑说明:时间冲突校验用区间重叠判断,start < 对方end AND end > 对方start就是重叠。参数说明:批量插入用 MyBatis-Plus 的insertBatch或者自己写 foreach 的 insert,逐条插入在 200 个座位时就会明显变慢。座位状态记录生成后,前台选座页才能查到数据。
4. 避坑与排查:这类系统最容易翻车的五个地方
4.1 座位超卖:现象是同一座位被两个订单锁定
现象:压测或者多人同时下单时,同一个座位出现两条锁定记录,或者订单表里两个订单指向同一个座位。原因:用了先 SELECT 查状态再 UPDATE 的写法,两个请求都查到 status=0,然后都执行更新。解决:改成 2.2 里的条件更新UPDATE ... WHERE status = 0,用影响行数判断是否抢到。这是最经典也最容易犯的错,没有之一。
4.2 事务不回滚:现象是座位锁了但订单没生成
现象:下单接口抛了业务异常,但座位状态已经变成锁定,订单表里却没有记录。原因:@Transactional默认只回滚 RuntimeException,自定义的 BizException 如果继承 Exception 而不是 RuntimeException,事务不会回滚。解决:注解上显式写rollbackFor = Exception.class,或者让业务异常继承 RuntimeException。排查时看日志里有没有「Transaction rolled back」字样。
4.3 支付超时座位不释放:现象是订单取消了座位还是锁定
现象:用户下单后没支付,订单被定时任务置为已取消,但座位状态还是 1。原因:释放座位的逻辑只改了订单状态,忘了同步更新 schedule_seat。解决:取消订单和释放座位必须在同一个事务里,先更新订单状态,再UPDATE schedule_seat SET status = 0, order_id = NULL WHERE order_id = ?。排查时直接查 schedule_seat 表里 status=1 但对应订单已取消的记录。
4.4 时间冲突漏判:现象是同一影厅排了两场重叠的影片
现象:后台排片时,同一影厅出现了时间重叠的两场,前台选座页数据错乱。原因:冲突校验的 SQL 条件写反了,或者只校验了开始时间没校验结束时间。解决:用区间重叠公式newStart < oldEnd AND newEnd > oldStart,两个条件缺一不可。排查时把影厅所有场次按开始时间排序,肉眼检查有没有交叉。
4.5 密码明文存储:现象是数据库里能直接看到用户密码
现象:user 表 password 字段存的是明文,或者用了 MD5 没加盐。原因:图省事直接存了原始密码。解决:用 BCrypt,Spring Security 里有现成的BCryptPasswordEncoder,每次加密结果不同但校验能通过。排查时看数据库字段长度,BCrypt 哈希是 60 位,MD5 是 32 位,一眼能分辨。
5. 进阶技巧:用 MyBatis-Plus 代码生成器把建表效率提上去
前面几章的表结构如果手动写实体类和 Mapper,六张表至少要写半天。MyBatis-Plus 的代码生成器能根据数据库表直接生成 entity、mapper、service、controller 四层代码,省掉大量重复劳动。这个技巧在课程设计里尤其值钱,因为时间都花在业务逻辑上比花在写 getter/setter 上划算得多。
public class CodeGenerator { public static void main(String[] args) { // 数据源配置 DataSourceConfig dsc = new DataSourceConfig.Builder( "jdbc:mysql://localhost:3306/movie_ticket?useUnicode=true&characterEncoding=utf8", "root", "your_password" ).build(); // 全局配置:作者、输出目录、是否覆盖 GlobalConfig gc = new GlobalConfig.Builder() .author("your_name") .outputDir(System.getProperty("user.dir") + "/src/main/java") .fileOverride(false) // 不覆盖已有文件,防止手写代码被冲掉 .build(); // 包配置 PackageConfig pc = new PackageConfig.Builder() .parent("com.example.ticket") .moduleName("") .build(); // 策略配置:指定要生成的表 StrategyConfig sc = new StrategyConfig.Builder() .addInclude("user", "movie", "hall", "schedule", "seat", "schedule_seat", "order") .entityBuilder() .enableLombok() // 实体类用 Lombok 简化 .enableTableFieldAnnotation() // 字段加 @TableField 注解 .build(); new AutoGenerator(dsc).global(gc).packageInfo(pc).strategy(sc).execute(); } }逻辑说明:这段代码跑一次,七张表的实体类、Mapper 接口、Service 接口和实现、Controller 全部生成到指定目录。参数说明:fileOverride(false)很关键,第二次跑的时候不会覆盖你手写过的业务方法,否则改过的代码全没了,这是后悔药级别的配置。enableLombok()要求项目里已经引入 Lombok 依赖,否则生成的实体类编译不过。
生成之后要做两件事:一是把 Controller 里的通用 CRUD 接口按业务需要裁剪,不要把所有表的增删改查都暴露出去;二是给实体类补上业务字段的校验注解,比如@NotNull、@Min,这些生成器不会自动加。
验证生成结果是否可用,最直接的办法是启动项目,用 Postman 或者浏览器访问生成的列表接口,看能不能查出数据。如果报 404,检查包扫描路径;如果报字段映射错误,检查@TableField注解和数据库列名是否一致。我一般会先跑通一张表的查询,再批量生成其余表,这样出问题能快速定位是生成器配置问题还是项目本身配置问题。
最后说个习惯:每次用生成器之前,先把数据库表结构定稿,字段名、类型、注释都确认好。生成器是照着表结构反向生成代码的,表结构改一次就要重新生成一次,如果表结构反复变,生成器反而变成负担。我自己的做法是先用 SQL 脚本把建表语句写死,评审通过后再跑生成器,这样一次成型,后面只改业务逻辑不动表结构。希望帮到你。
本文还有配套的精品资源,点击获取