首页 / 资讯中心 / 文章详情

电影院售票系统课设:从ER图到高并发防超卖的完整实现

电影院售票系统课设:从ER图到高并发防超卖的完整实现 ★ FEATURED ARTICLE
简介本资源是一份面向软件工程专业本科生的课程设计实践文档聚焦电影院售票系统的全流程开发与实现适用于课程设计报告撰写、系统分析与设计能力训练及毕业设计参考。文档完整覆盖可行性研究、需求分析、系统设计含数据流图、ER图、数据库设计、模块化功能说明售票、退票、会员、维护等及界面实现方案内容结构严谨符合软件工程规范。压缩包为单个1.82MB的Word文档.doc涵盖目录、各章节详细设计说明及附录便于直接用于报告提交或教学复盘。目前已有663人学习下载读者可直接获取完整的课程设计框架、标准化文档模板、关键模块流程逻辑图示及数据库设计思路尤其适合缺乏项目经验的学生快速掌握从需求到实现的全周期工程化表达方法。1. 软件工程课程设计电影院售票系统不是Demo是能跑通的最小闭环业务系统你有没有试过——花三天搭完Spring Boot后发现连“选一场电影、点一个座位、生成一张票”都卡在数据库外键约束报错上或者写完登录模块一测并发退票就出现座位重复释放这不是玄学是软件工程课设里最真实的断层需求文档写得像教科书代码跑起来像黑匣子。这份《软件工程课程设计电影院售票系统》不是PPT式空谈而是一套从ER图落地到可执行SQL、从UML用例图映射到Java类方法、从管理员后台跳转逻辑覆盖到用户端选座防重机制的完整闭环。它解决的不是“能不能显示电影列表”而是“当3个用户同时抢最后一张《流浪地球3》首映厅7排5座时谁该成功、谁该失败、失败提示是否带具体原因”。适合两类人新手想照着把课设交上去不被导师问住熟手想拆解一个真实业务系统里“事务边界怎么划、库存扣减怎么防超卖、退票时间窗怎么校验”的血泪经验。它不承诺高并发百万QPS但保证你在本地MySQLTomcat上用学生机配置跑通全部核心路径——售票、退票、查余票、会员绑定、场次动态上下架五件事全链路可验证。2. 需求到数据库从ER图到可执行SQL脚本的硬核落地2.1 为什么这张ER图必须砍掉“影院”实体原文图3-2-3中把“影院”作为独立实体与“影片”“顾客”并列这是典型的需求翻译失真。实际业务中“影院”不是数据操作对象——管理员不会对“XX影城”本身做增删改而是管理该影城下的影厅、排期、座位。真正需要建表的是cinema_hall影厅表含影厅ID、名称、总座位数、所属影院ID若多影院则需单影院可省screen_schedule排期表含排期ID、影片ID、影厅ID、开始时间、结束时间、票价、剩余座位数seat座位表含座位ID、影厅ID、行号、列号、状态0空闲/1已售/2锁定提示原文ER图中“员工”“权限”等字段在课设级别纯属冗余。学生项目聚焦“票务流”管理员工号密码用admin_user单表足矣强行加RBAC只会让事务复杂度指数级上升。2.2 关键字段设计为什么screen_schedule.remaining_seats不能靠程序计算初学者常犯错误不存remaining_seats每次查票时用SELECT COUNT(*) FROM ticket WHERE schedule_id ? AND status sold反推。这会导致两个致命问题高并发下超卖A、B用户同时查到剩余1张都发起购买最终卖出2张性能雪崩每查一次余票都要全表扫描ticket表10万条记录时响应超2秒正确做法是冗余存储原子更新-- 创建排期表关键字段加注释 CREATE TABLE screen_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL COMMENT 关联film表, hall_id BIGINT NOT NULL COMMENT 关联cinema_hall表, start_time DATETIME NOT NULL COMMENT 精确到分钟如2024-06-01 19:30:00, end_time DATETIME NOT NULL, price DECIMAL(6,2) NOT NULL DEFAULT 45.00 COMMENT 单位元, remaining_seats INT NOT NULL DEFAULT 0 COMMENT 实时剩余座位数必须维护, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常上映, 0已下架 ); -- 初始化一条排期假设影厅有100座 INSERT INTO screen_schedule (movie_id, hall_id, start_time, end_time, price, remaining_seats) VALUES (1, 1, 2024-06-01 19:30:00, 2024-06-01 21:45:00, 45.00, 100);参数说明remaining_seats初始值必须与影厅总座位数一致status字段替代原文“上档/下档”模糊操作避免用DELETE物理删除排期导致历史订单关联断裂。2.3 会员与购票强耦合为什么member表必须含discount_rate且默认1.0原文3.7节提到“会员优惠”但未定义折扣规则。课设中若只做“注册即会员”则失去业务价值。真实落地需支持普通用户无折扣discount_rate 1.0金卡会员95折discount_rate 0.95订单价 screen_schedule.price × member.discount_rate-- 会员表精简版去掉原文冗余字段 CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL UNIQUE COMMENT 关联user表, card_level VARCHAR(20) NOT NULL DEFAULT NORMAL COMMENT NORMAL/GOLD/PLATINUM, discount_rate DECIMAL(3,2) NOT NULL DEFAULT 1.00 COMMENT 折扣率1.00无折扣, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_id (user_id) ); -- 用户购票时关联会员关键JOIN逻辑 SELECT s.id AS schedule_id, s.price, m.discount_rate, ROUND(s.price * m.discount_rate, 2) AS final_price FROM screen_schedule s LEFT JOIN member m ON m.user_id ? -- ?为当前登录用户ID WHERE s.id ?; -- ?为用户选择的排期ID逻辑说明LEFT JOIN确保普通用户无会员记录也能购票discount_rate默认1.00避免NULL参与计算导致结果为NULLROUND(...,2)强制保留两位小数防止浮点精度问题。2.4 售票核心表ticket为什么必须用schedule_id seat_row seat_col作联合唯一索引原文未明确票的唯一性约束这是并发安全的命门。若仅用自增ID无法阻止同一场次同一座位被卖两次。必须强制物理层面唯一CREATE TABLE ticket ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL COMMENT 关联screen_schedule, user_id BIGINT NOT NULL COMMENT 购票用户, seat_row TINYINT NOT NULL COMMENT 座位行号如7, seat_col TINYINT NOT NULL COMMENT 座位列号如5, status ENUM(sold,locked,refunded) NOT NULL DEFAULT sold COMMENT 状态机, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, -- 关键联合唯一索引杜绝同一场次同一座位重复售卖 UNIQUE KEY uk_schedule_seat (schedule_id, seat_row, seat_col), -- 加速查询按场次查所有已售票 INDEX idx_schedule_status (schedule_id, status) );参数说明uk_schedule_seat是防超卖的最后防线status用ENUM而非INT避免非法状态如status999idx_schedule_status支撑“查某场次已售座位列表”功能前端渲染座位图时必需。3. 核心模块实现从流程图到可运行Java代码的逐行拆解3.1 售票模块为什么sellTicket()方法必须用Transactional(isolation Isolation.REPEATABLE_READ)原文图3-6-3中“判断是否存在→验证时间→出票”是理想流程但没提数据库隔离级别。MySQL默认READ COMMITTED在高并发下会读到旧的remaining_seats值。必须升级到REPEATABLE_READ并配合SELECT ... FOR UPDATEService public class TicketService { Transactional(isolation Isolation.REPEATABLE_READ) public ResultVOString sellTicket(Long scheduleId, Integer seatRow, Integer seatCol, Long userId) { // 1. 锁定排期记录防止并发修改remaining_seats ScreenSchedule schedule scheduleMapper.selectByIdForUpdate(scheduleId); if (schedule null || schedule.getStatus() ! 1) { return ResultVO.fail(场次不存在或已下架); } if (schedule.getRemainingSeats() 0) { return ResultVO.fail(该场次已无余票); } // 2. 检查座位是否已被售双重校验DB唯一索引 业务逻辑 int existCount ticketMapper.countByScheduleAndSeat(scheduleId, seatRow, seatCol); if (existCount 0) { return ResultVO.fail(该座位已被购买请重新选择); } // 3. 扣减余票原子操作 int updateRows scheduleMapper.decreaseRemainingSeats(scheduleId, 1); if (updateRows ! 1) { return ResultVO.fail(余票扣减失败请重试); } // 4. 生成票唯一索引会拦截重复插入 Ticket ticket new Ticket(); ticket.setScheduleId(scheduleId); ticket.setUserId(userId); ticket.setSeatRow(seatRow); ticket.setSeatCol(seatCol); ticket.setStatus(sold); ticketMapper.insert(ticket); return ResultVO.success(购票成功验证码 generateVerifyCode()); } }逻辑说明selectByIdForUpdate在screen_schedule表上加行锁阻塞其他事务修改同一scheduleIddecreaseRemainingSeats是UPDATE语句利用MySQL的行级锁保证原子性countByScheduleAndSeat是SELECT但因uk_schedule_seat存在即使并发插入也会被DB层拒绝形成双重保险。3.2 退票模块为什么必须校验now() - ticket.created_time 10 minutes原文3.6.2要求“影片开始前10分钟未换纸质票即退票”但未定义时间基准。真实场景中退票时间窗应以购票时间为起点而非场次开始时间。否则用户提前3天购票第2天退票会被拒违背业务常识。// 退票逻辑简化版 Transactional public ResultVOString refundTicket(Long ticketId, Long userId) { Ticket ticket ticketMapper.selectById(ticketId); if (ticket null || !ticket.getUserId().equals(userId)) { return ResultVO.fail(票据不存在或非本人购买); } if (!sold.equals(ticket.getStatus())) { return ResultVO.fail(票据状态异常不可退票); } // 关键退票时间窗 购票后10分钟内非场次开始前10分钟 LocalDateTime now LocalDateTime.now(); LocalDateTime buyTime ticket.getCreatedTime(); Duration duration Duration.between(buyTime, now); if (duration.toMinutes() 10) { return ResultVO.fail(超过购票后10分钟不可退票); } // 释放座位先更新ticket状态再增加排期余票 ticket.setStatus(refunded); ticketMapper.updateById(ticket); scheduleMapper.increaseRemainingSeats(ticket.getScheduleId(), 1); return ResultVO.success(退票成功); }参数说明Duration.between()比System.currentTimeMillis()更精准避免时区问题increaseRemainingSeats必须与decreaseRemainingSeats使用相同锁机制如都用FOR UPDATE否则可能因锁粒度不一致导致死锁。3.3 查询余票模块为什么前端座位图必须用SELECT seat_row, seat_col, status FROM ticket WHERE schedule_id ?原文图3-4-2(2)只说“选择场次和座位号”未说明如何渲染座位图。真实实现中前端需要知道每个座位的实时状态空闲/已售/锁定。不能靠remaining_seats反推必须查ticket表// 获取某场次所有已售座位供前端渲染灰色座位 GetMapping(/seats/{scheduleId}) public ResultVOListSeatStatusVO getSoldSeats(PathVariable Long scheduleId) { ListSeatStatusVO soldSeats ticketMapper.selectSoldSeats(scheduleId); return ResultVO.success(soldSeats); } // SeatStatusVO.java public class SeatStatusVO { private Integer seatRow; private Integer seatCol; private String status; // sold or refunded }-- MyBatis Mapper XML select idselectSoldSeats resultTypeSeatStatusVO SELECT seat_row AS seatRow, seat_col AS seatCol, status FROM ticket WHERE schedule_id #{scheduleId} AND status IN (sold, refunded) /select逻辑说明返回status字段让前端区分“已售”和“已退”避免将已退票座位误标为占用IN (sold,refunded)确保不漏掉已退票但未释放座位的脏数据需配合定时任务清理。3.4 管理员模块为什么updateMovieStatus()必须用UPDATE ... SET status ? WHERE id ? AND status ! ?原文3.4.2(6)提到“添加、修改、删除、备份”但未防误操作。例如管理员手抖点两次“下架”第二次执行会把已下架影片又下架一遍虽无害但暴露逻辑缺陷。应加入状态校验// 影片上下架安全版 Transactional public ResultVOString updateMovieStatus(Long movieId, Integer newStatus) { // 只有当前状态≠newStatus时才更新避免重复操作 int rows movieMapper.updateStatusIfChanged(movieId, newStatus, newStatus 1 ? 0 : 1); if (rows 0) { return ResultVO.fail(newStatus 1 ? 影片已是上映状态 : 影片已是下架状态); } return ResultVO.success(操作成功); }!-- MyBatis XML -- update idupdateStatusIfChanged UPDATE film SET status #{newStatus}, updated_time NOW() WHERE id #{movieId} AND status ! #{newStatus} -- 关键只更新状态不同的记录 /update参数说明status ! #{newStatus}确保幂等性updated_time NOW()便于审计若需记录操作日志应在updateStatusIfChanged后追加logMapper.insert(...)而非在UPDATE语句中拼接。4. 避坑指南课设中最容易翻车的5个边界问题4.1 现象用户选座时看到座位图全是空闲提交后提示“该座位已被购买”原因前端未对选座请求加防重锁多个用户同时提交同一座位ticket表唯一索引拦截后返回错误但用户体验差。解决在选座页面加载时用SELECT ... FOR UPDATE锁定该场次所有座位记录或至少锁定用户选中的座位并在事务中完成校验与插入。若锁等待超时前端提示“座位状态刷新中请稍候”。4.2 现象退票后remaining_seats没增加导致后续购票失败原因退票事务中先更新ticket.status再调用increaseRemainingSeats()但后者因网络波动失败事务回滚时只回滚了ticket更新remaining_seats未恢复。解决将ticket.status更新与remaining_seats增加放在同一SQL中用存储过程或MyBatisupdate标签的set动态SQL保证原子性UPDATE screen_schedule s JOIN ticket t ON s.id t.schedule_id SET s.remaining_seats s.remaining_seats 1 WHERE t.id #{ticketId} AND t.status sold;4.3 现象管理员修改影片信息后已售出的票关联影片名变成空原因ticket表未冗余存储影片名称而是通过JOIN film获取。当管理员UPDATEfilm.name时历史订单显示新名称违反“订单快照”原则。解决在ticket表中增加film_name VARCHAR(100)字段购票时INSERT时写入当前影片名与film.id共同存在。查询订单时优先读film_name保证历史数据不变。4.4 现象MySQL报错Deadlock found when trying to get lock原因售票事务锁screen_schedule退票事务也锁screen_schedule但加锁顺序不一致如售票先锁schedule再锁ticket退票先锁ticket再锁schedule。解决统一加锁顺序——所有事务必须按screen_schedule → ticket → member顺序加锁。在MyBatis中用SELECT ... FOR UPDATE显式声明避免隐式锁。4.5 现象用户注册时邮箱重复但报错信息显示“用户名已存在”原因user表对username和email都建了唯一索引但MyBatis未捕获具体哪个字段冲突统一返回模糊提示。解决在INSERT后捕获MySQL错误码针对1062 Duplicate entry解析key nametry { userMapper.insert(user); } catch (DuplicateKeyException e) { if (e.getMessage().contains(uk_username)) { return 用户名已存在; } else if (e.getMessage().contains(uk_email)) { return 邮箱已被注册; } }5. 进阶技巧用3个SQL语句搞定课设答辩高频问题5.1 如何证明系统支持“同一用户不能重复购买同一场次”用这条SQL查出所有违规记录若有则系统有缺陷SELECT user_id, schedule_id, COUNT(*) as buy_count FROM ticket WHERE status sold GROUP BY user_id, schedule_id HAVING COUNT(*) 1;执行结果应为空。若非空说明ticket表缺少UNIQUE KEY uk_user_schedule (user_id, schedule_id)约束——这是比座位唯一性更上层的业务规则。5.2 如何快速验证“退票后座位是否释放”对比退票前后screen_schedule.remaining_seats与ticket表统计数-- 退票前 SELECT s.id, s.remaining_seats, COUNT(t.id) as sold_count FROM screen_schedule s LEFT JOIN ticket t ON s.id t.schedule_id AND t.status sold WHERE s.id 123 GROUP BY s.id, s.remaining_seats; -- 退票后执行refund后立即查 -- 若s.remaining_seats比sold_count大1则释放成功技巧在测试时用SELECT ... FOR UPDATE锁住排期人工模拟并发比写JUnit更直观。5.3 如何向导师演示“会员折扣实时生效”构造一个包含会员与非会员的对比查询-- 查看某场次所有购票用户及其最终价格 SELECT u.username, m.card_level, m.discount_rate, s.price as original_price, ROUND(s.price * IFNULL(m.discount_rate, 1.0), 2) as final_price FROM ticket t JOIN user u ON t.user_id u.id JOIN screen_schedule s ON t.schedule_id s.id LEFT JOIN member m ON u.id m.user_id WHERE t.schedule_id 123 AND t.status sold ORDER BY final_price;输出效果第一行显示普通用户付45.00元第二行显示金卡会员付42.75元45×0.95第三行显示铂金会员付40.50元45×0.9——折扣率变化立刻反映在订单上无需重启服务。从那以后我每次写课设都会在application-dev.yml里强制开启spring.jpa.show-sqltrue把控制台打印的每条SQL拷进MySQL客户端手动执行一遍。不是为了炫技而是确保每一行代码落库时都和我脑中画的ER图严丝合缝。那些看似多余的FOR UPDATE、UNIQUE KEY、IFNULL都是我在调试窗口里看着红色报错一行行补上的后悔药。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站