拓冰建站拓冰建站
首页 / 资讯中心 / 正文

影院订票系统实战:SpringBoot+Vue+MySQL防超卖与并发控制

简介基于JAVASpringBootVueMySQL的影院订票系统是一份面向高校毕业设计、课程设计及期末大作业的完整项目资源前后端代码齐全涵盖项目源码、数据库脚本、论文文档及部署软件工具项目经导师指导调试确保可运行。压缩包共771个文件以java后端逻辑、vue前端组件、html/css/javascript界面资源为主辅以sql数据库脚本、xml配置文件及maven构建脚本等整体约19.81MB目录结构清晰便于定位。系统功能完善支持电影选座、在线支付、排期管理、票务管理及用户管理等操作界面美观、流程顺畅具备良好的实际应用价值。项目基于Maven依赖管理使用Navicat操作数据库环境说明齐全下载后即可按说明启动体验。资源目前已有48人学习下载适合计算机相关专业学生用于毕设参考、课程实践或项目二次开发帮助快速掌握前后端分离开发模式及MySQL数据交互的完整流程。1. JAVASpringBootVueMySQL 影院订票系统的技术选型与核心难点影院订票系统用 JAVASpringBootVueMySQL 来落地真正的难点不在增删改查而在座位状态的并发切换。热门场次开票瞬间几十个用户同时抢同一排座位数据库如果没有把行锁和事务边界设计好超卖和“幽灵座位”会一起出现。这套组合的关键配合是SpringBoot 用声明式事务管住 MySQL 的行锁Vue 用响应式状态把座位实时同步给用户MySQL 用唯一约束从根本上切断重复数据。对想拿高分毕设的人来说这套系统有三个能讲透的点MySQL 的表结构与索引规划、SpringBoot 的下单事务和防超卖逻辑、Vue 的选座交互与接口联调。三者互相牵连不能孤立地做。下面的内容按数据模型、后端接口、前端页面、部署验收的顺序展开每个环节都会给出可以直接落地的表结构、代码和命令。2. MySQL 侧影院订票系统的表结构、座位状态与高频查询2.1 八张核心表如何各司其职一个能跑通全流程的影院订票系统核心链路至少覆盖用户、电影、影院、排片、座位、订单几块业务。常规做法是拆成八张表关系是t_cinema影院下挂多个t_hall影厅影厅通过t_seat物理座位记录固定布局t_movie影片与t_hall通过t_schedule排片关联成具体场次用户下单产生t_order订单订单与场次座位再通过t_schedule_seat场次座位状态关联起来。表名职责关键字段t_user用户账号与角色id, username, password, role, statust_movie影片信息id, title, cover, trailer_url, duration, release_datet_cinema影院主体id, name, addresst_hall影厅id, cinema_id, name, row_count, col_countt_seat影厅物理座位id, hall_id, row_num, col_numt_schedule排片场次id, movie_id, hall_id, show_date, start_time, price, statust_schedule_seat场次座位状态id, schedule_id, seat_id, status, order_id, lock_timet_order订单id, order_no, user_id, schedule_id, total_price, status, expire_time为什么要单独建t_schedule_seat而不是在t_seat上直接加状态字段t_seat记录的是影厅的物理座位3 排 5 号这个座位无论放哪部电影、排哪场次都存在。但同一个座位在周一的《流浪地球》场次里和周三的《封神》场次里售卖状态完全无关甚至同一场开场前后状态也不同。座位状态必须绑定到“某一排片 某一座位”的粒度上所以单独拆表排片创建时把这个影厅的所有座位复制成状态行初始为零库存以外的可售状态。2.2 唯一约束与行锁防超卖的第一道防线座位在t_schedule_seat里的状态本质上是有一个状态机的0可售→ 1锁定用户选座提交先锁座再生成订单1锁定→ 2已售订单支付完成1锁定→ 0可售订单超时未支付定时任务释放2已售→ 0可售退票后重新上架t_schedule_seat的建表脚本直接可以在 MySQL 8 上执行CREATE TABLE t_schedule_seat ( id BIGINT NOT NULL AUTO_INCREMENT, schedule_id BIGINT NOT NULL COMMENT 排片ID, seat_id BIGINT NOT NULL COMMENT 物理座位ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可售 1锁定 2已售 3故障, order_id BIGINT DEFAULT NULL COMMENT 锁定或售出的订单ID, lock_time DATETIME DEFAULT NULL COMMENT 锁定时间用于超时释放, PRIMARY KEY (id), UNIQUE KEY uk_schedule_seat (schedule_id, seat_id), KEY idx_schedule_status (schedule_id, status) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4 COMMENT 场次座位状态表;UNIQUE KEY uk_schedule_seat (schedule_id, seat_id)是第一道防线。同一场次的同一个座位只能有一行业务层不需要先 SELECT 再加锁判断唯一索引直接从数据层切断重复记录的可能。有了唯一键剩下的问题是“状态从 0 改成 1”这一瞬间的竞争怎么处理这里必须用带条件的 UPDATEUPDATE t_schedule_seat SET status 1, order_id #{orderId}, lock_time NOW() WHERE id #{seatId} AND status 0;MySQL 拿到这条 UPDATE 后会先对命中的行加排他锁再判断 WHERE 条件。两个用户同时抢同一个座位时后一个事务的 UPDATE 会阻塞在前一个事务提交或回滚之后届时status已经不是 0匹配行数为 0应用层拿到返回结果直接提示“座位已被锁定”。加锁和状态判断在一条语句里完成这是防超卖的标准姿势而不是先去 SELECT status 再在 Java 里 if 判断然后 UPDATE——那两步之间会有时间窗口。2.3 排片查询的聚合 SQL 与复合索引设计订票系统的高频查询集中在三块首页按日期浏览场次、影片详情页查某一场的剩余座位、个人中心查历史订单。以“某部影片未来三天的排片”为例一条聚合查询可以直接返回场次信息和剩余座位数SELECT s.id, m.title, s.start_time, s.price, h.name AS hall_name, SUM(CASE WHEN ss.status 0 THEN 1 ELSE 0 END) AS available_seats FROM t_schedule s JOIN t_movie m ON m.id s.movie_id JOIN t_hall h ON h.id s.hall_id LEFT JOIN t_schedule_seat ss ON ss.schedule_id s.id WHERE s.movie_id 1001 AND s.show_date BETWEEN 2025-06-01 AND 2025-06-03 GROUP BY s.id, m.title, s.start_time, s.price, h.name ORDER BY s.start_time;这里的LEFT JOIN保留了无座位数据的排片理论上是空场SUM只统计 status0 的行得到可售座数。毕业设计数据量小这种聚合没有问题如果将来场次多了可以在t_schedule上冗余一个可售数下单成功和超时释放时同步更新查询就不需要 JOIN 聚合了。对应的复合索引设计索引名表字段服务场景uk_schedule_seatt_schedule_seat(schedule_id, seat_id)同一场次座位唯一idx_schedule_statust_schedule_seat(schedule_id, status)某场次的可售座位列表idx_movie_showdatet_schedule(movie_id, show_date)某部影片几天内的场次idx_show_statust_schedule(show_date, status)按日期浏览全部在售场次idx_order_usert_order(user_id, status)个人中心查订单复合索引的列顺序取决于哪一列先消耗查询条件。在“某部影片几天内的场次”里movie_id 是等值查询show_date 是范围查询所以(movie_id, show_date)正确MySQL 先点查再范围扫描。反过来建成(show_date, movie_id)范围条件在前索引利用率会明显下降。2.4 存储过程在订票系统里的适用边界存储过程在毕设资料里经常被当成加分项但它适合的场景是固定的数据初始化。以“给某个排片批量生成该影厅所有座位”为例用存储过程可以这样写DELIMITER $$ CREATE PROCEDURE sp_generate_schedule_seats( IN p_schedule_id BIGINT, IN p_hall_id BIGINT ) BEGIN DECLARE v_seat_id BIGINT; DECLARE done INT DEFAULT FALSE; DECLARE cur CURSOR FOR SELECT id FROM t_seat WHERE hall_id p_hall_id; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done TRUE; OPEN cur; read_loop: LOOP FETCH cur INTO v_seat_id; IF done THEN LEAVE read_loop; END IF; INSERT INTO t_schedule_seat (schedule_id, seat_id, status) VALUES (p_schedule_id, v_seat_id, 0); END LOOP; CLOSE cur; END$$ DELIMITER ;调用方法是CALL sp_generate_schedule_seats(2025060101, 3);把排片 ID 和影厅 ID 当参数传进去。但把“下单锁座”这类核心业务写进存储过程就不建议了原因有两条一是排片是否存在、影厅 ID 是否合法、是否重复生成这些前置校验放在应用层才能输出可读的中文错误二是存储过程调用的日志在 MySQL 侧出了问题排查比 Java 堆栈困难得多。常见的做法是存储过程脚本放进论文附录当作索引优化研究的素材实际应用代码里用 SpringBoot 服务层循环 批量插入完成同样的初始化。3. SpringBoot 接口JWT 鉴权、选座事务与超时释放3.1 分层结构与依赖定义后端项目按常见的三层结构组织Controller 只做参数校验和返回包装Service 负责业务规则和事务边界Mapper 映射数据库表。额外再加一个 config 包放拦截器和跨域配置common 包放统一返回体和枚举src/main/java/com/cinema ├── controller # 接收请求不做业务逻辑 ├── service # 事务边界、业务规则、锁座流程 ├── mapper # MyBatis-Plus 的 Mapper 接口 ├── entity # 数据库实体映射 ├── config # 拦截器、WebMvc 配置 └── common # 统一响应体、状态枚举、异常类依赖选型用 SpringBoot 2.7.x MyBatis-Plus 3.5.x java-jwt 的组合最稳。SpringBoot 3.x 也能跑但javax.servlet已经改成jakarta.servlet很多网上的旧示例代码会直接编译失败对速成项目不友好。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency配置文件里最容易忽略的是 MySQL 连接的时区和字符集参数spring: datasource: url: jdbc:mysql://localhost:3306/cinema_db?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.DrivercharacterEncodingutf8mb4保证中文字符端到端不乱码serverTimezoneAsia/Shanghai避免本地 MySQL 与 Java 应用的时区不一致导致时间差 8 小时。参数不写对后面订单过期时间、场次开始时间这类比较逻辑全都会被带到坑里。3.2 JWT 拦截器登录后每个受保护接口的身份验证登录注册是每个页面的前置条件密码不能明文存注册时用 BCrypt 加密登录成功后签发 JWT。Token 验证做成一个全局拦截器比在每个 Controller 重复解析头信息干净得多Component public class JwtInterceptor implements HandlerInterceptor { private static final String SECRET cinema-demo-secret-key; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求否则跨域 鉴权叠加时前端会先收到 401 if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); response.getWriter().write(未登录); return false; } try { DecodedJWT jwt JWT.require(Algorithm.HMAC256(SECRET)) .build() .verify(token.substring(7)); request.setAttribute(userId, jwt.getClaim(userId).asLong()); request.setAttribute(role, jwt.getClaim(role).asString()); return true; } catch (JWTVerificationException e) { response.setStatus(401); response.getWriter().write(登录已过期); return false; } } }拦截器里解析出来的 userId 要放进 request attributeController 的方法参数里直接用HttpServletRequest取这样 Service 层不用依赖登录态只有 Controller 与当前请求上下文打交道。注册拦截器时要注意白名单Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new JwtInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/api/auth/login, /api/auth/register, /api/movie/list); } }addPathPatterns(/**)表示所有接口默认都要校验excludePathPatterns放行登录注册和影片列表。如果漏了静态资源路径开发环境下前端页面打开会收到 401排查半天发现是资源请求也被拦截了。3.3 下单事务里锁座和插单的顺序选座下单是整套系统里对事务边界要求最高的接口完整链路是校验场次 → 逐座加锁 → 创建订单 → 座位绑定订单。一次锁多个座位时需要注意锁的顺序避免相互等待造成的死锁Override Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateDTO dto) { Schedule schedule scheduleMapper.selectById(dto.getScheduleId()); if (schedule null || schedule.getStatus() ! ScheduleStatus.SELLING.getCode()) { throw new ServiceException(该场次不存在或未开放售票); } ListLong lockedSeatIds new ArrayList(); for (Long seatId : dto.getSeatIds()) { // select ... for update对目标行加排他锁阻塞其他事务的相同查询 ScheduleSeat ss scheduleSeatMapper.selectForUpdate(dto.getScheduleId(), seatId); if (ss null || ss.getStatus() ! SeatStatus.AVAILABLE.getCode()) { throw new ServiceException(座位已被锁定或售出请刷新后重新选择); } ss.setStatus(SeatStatus.LOCKED.getCode()); scheduleSeatMapper.updateById(ss); lockedSeatIds.add(ss.getId()); } BigDecimal totalPrice schedule.getPrice().multiply(BigDecimal.valueOf(lockedSeatIds.size())); Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setScheduleId(schedule.getId()); order.setTotalPrice(totalPrice); order.setStatus(OrderStatus.PENDING_PAYMENT.getCode()); order.setExpireTime(LocalDateTime.now().plusMinutes(15)); orderMapper.insert(order); scheduleSeatMapper.bindOrder(order.getId(), lockedSeatIds); return order.getId(); }这段代码里有几处取舍值得展开。Transactional(rollbackFor Exception.class)决定updateById、insert、bindOrder是否在同一个数据库连接里一起提交。一旦后面座位绑订单失败前面已经改掉的座位状态要能回滚。如果不写rollbackForSpring 默认只回滚运行时异常ServiceException如果是受检异常数据会停在“座位已锁定但订单不存在”的中间状态。selectForUpdate对应的 SQL 是SELECT ... FOR UPDATE锁会持续到事务结束。同一行在同一时刻只能有一个事务持锁其他事务在等待期间看到的是旧数据等锁释放后才读到 status 已经变成 1 的新状态后面的判断自然不会放行。这就是为什么“先查再改”在事务里是安全的前提是查询带上了FOR UPDATE而不是普通的 SELECT。Order插入成功后拿到自增 id再执行bindOrder把 order_id 写回座位行。后面订单超时释放时可以直接用order_id定位所有座位不需要再查座位内容。订单号在单机部署下用时间戳加随机数就够了private String generateOrderNo() { String time DateTimeFormatter.ofPattern(yyyyMMddHHmmssSSS) .format(LocalDateTime.now()); return CT time ThreadLocalRandom.current().nextInt(100, 999); }这个生成方式不是全局唯一的出现重复的概率极低。担心碰撞的话在t_order.order_no上加唯一索引插入失败自动重试一次成本很低。3.4 定时任务处理超时未支付座位锁定不能永远挂着要有一个兜底任务把超时订单清掉。每笔订单创建时写了expire_time为当前时间加 15 分钟定时任务扫描所有“待支付且过期”的订单Component public class OrderExpireTask { Scheduled(cron 0 */5 * * * ?) Transactional(rollbackFor Exception.class) public void releaseExpiredOrders() { ListLong expiredIds orderMapper.selectExpiredPendingIds(LocalDateTime.now()); for (Long orderId : expiredIds) { int rows orderMapper.cancelIfStillPending(orderId, LocalDateTime.now()); if (rows 1) { scheduleSeatMapper.releaseByOrderId(orderId); } } } }主类上需要加EnableScheduling才能启动定时任务。releaseExpiredOrders中的cancelIfStillPending对应的 SQL 是UPDATE t_order SET status 3, update_time NOW() WHERE id #{orderId} AND status 0 AND expire_time NOW();这里先更新订单状态、后释放座位顺序不能反过来。如果先释放座位再取消订单用户恰好在超时临界点点击支付可能出现座位被释放但订单还在待支付的状态用户支付成功后找不到座位。反过来先UPDATE t_order影响行数为 1 才执行释放等于用 UPDATE 的结果作为判断依据把“用户已经放弃支付”和“用户正在支付”两种情况分开了。受影响行数为 0 时说明订单状态已经被改为已支付座位保持锁定继续走支付成功逻辑。4. Vue 前端路由规划、选座组件与 axios 联调4.1 路由表与登录守卫前端页面划分相对标准Vue 3 Vite 项目的目录通常是 views 装页面components 装复用组件api 统一管请求src ├── router/index.js ├── views │ ├── HomeView.vue # 首页上映中影片 │ ├── MovieDetail.vue # 影片详情 预告片 │ ├── ScheduleView.vue # 选座页 │ ├── OrderList.vue # 订单列表 │ └── LoginView.vue ├── api/index.js # axios 实例与接口函数 └── components/SeatMap.vue # 选座组件vue-router 4 的路由配置const routes [ { path: /, name: home, component: HomeView, meta: { title: 首页 } }, { path: /login, name: login, component: LoginView, meta: { public: true } }, { path: /movie/:id, name: movieDetail, component: MovieDetail }, { path: /schedule/:scheduleId, name: schedule, component: ScheduleView }, { path: /orders, name: orders, component: OrderList, meta: { requiresAuth: true } } ] router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ name: login, query: { redirect: to.fullPath } }) return } next() })登录守卫集中处理“未登录用户直接输入 /orders 地址”的情况登录成功后再通过redirect参数跳回原页面。token 存在 localStorage 里刷新后还能保持登录态不必每次刷新都重新登录。4.2 选座组件的三种状态与点击逻辑选座是前端交互最重的部分。座位从后端拿到的字段是status0 可售、1 锁定、2 已售、3 故障组件内维护一个selectedIds数组记录用户选择了哪些座位数据库状态和本地选择状态需要分开管理template div classseat-map div v-forrow in seatRows :keyrow.rowNum classseat-row span classrow-label{{ row.rowNum }}排/span button v-forseat in row.seats :keyseat.id classseat :classseatClass(seat) :disabledseat.status 2 || seat.status 3 clicktoggleSeat(seat) {{ seat.colNum }} /button /div /div /template script setup import { ref, computed } from vue const props defineProps({ seatList: { type: Array, required: true } }) const selectedIds ref([]) // 后端返回的是扁平列表按行号转成二维数组方便渲染 const seatRows computed(() { const map new Map() props.seatList.forEach(seat { if (!map.has(seat.rowNum)) map.set(seat.rowNum, []) map.get(seat.rowNum).push(seat) }) return Array.from(map.entries()).map(([rowNum, seats]) ({ rowNum, seats: seats.sort((a, b) a.colNum - b.colNum) })) }) function seatClass(seat) { if (selectedIds.value.includes(seat.id)) return seat--selected if (seat.status 1) return seat--locked if (seat.status 2) return seat--sold if (seat.status 3) return seat--broken return seat--available } function toggleSeat(seat) { if (seat.status ! 0) return const idx selectedIds.value.indexOf(seat.id) if (idx -1) { selectedIds.value.splice(idx, 1) } else { if (selectedIds.value.length 6) { alert(单笔订单最多选 6 个座位) return } selectedIds.value.push(seat.id) } } /script组件里的关键逻辑是toggleSeat只有在seat.status 0时才允许点击锁定和已售状态由disabled属性直接置灰。选座完成后提交订单时把selectedIds传给后端createOrder接口支付成功再刷新seatList此时服务端返回的数据才体现真正的数据库状态。不要在组件里直接修改seat.status做即时反馈那是假象。刷新页面或重新进入场次时后端状态会覆盖前端本地改动用户会看到座位重新变成可售体验非常差。4.3 用 axios 拦截器统一处理 token 与错误前后端联调时每个请求都要带 token每个错误响应都要处理重复代码很多。把 axios 实例单独封装拦截器统一解决这两个问题// api/index.js import axios from axios import { notification } from ant-design-vue const http axios.create({ baseURL: /api, timeout: 8000 }) // 请求拦截器自动从 localStorage 取 token 拼进请求头 http.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器401 跳登录其他错误统一弹提示 http.interceptors.response.use( response response.data, error { const status error.response?.status if (status 401) { localStorage.removeItem(token) window.location.href /login } else { notification.error({ message: 请求失败, description: error.response?.data?.message || 网络异常 }) } return Promise.reject(error) } ) export const getSeats scheduleId http.get(/schedule/${scheduleId}/seats) export const createOrder payload http.post(/order/create, payload)后端返回统一结构{ code: 0, data: {...}, message: ok }时前端拦截器里可以直接判断状态码业务组件只需要关心data字段不需要每次都解三层嵌套。createOrder接收的 payload 要包含scheduleId、seatIds、userId三个字段与后端OrderCreateDTO对应。4.4 影片详情页的 m3u8 预告片播放方式影片详情页常常需要播放预告片资源地址可能是 m3u8 格式的 HLS 流。原生 video 标签不支持 m3u8Safari 例外其他浏览器需要借助 hls.jsimport Hls from hls.js function playTrailer(videoEl, trailerUrl) { if (!trailerUrl) return if (Hls.isSupported()) { // 主流浏览器走 MSE 方案hls.js 内部完成分片请求和拼接 const hls new Hls() hls.loadSource(trailerUrl) hls.attachMedia(videoEl) hls.on(Hls.Events.ERROR, (_, data) { if (data.fatal) { hls.destroy() videoEl.src trailerUrl } }) } else if (videoEl.canPlayType(application/vnd.apple.mpegurl)) { // Safari 原生支持 HLS直接赋值即可 videoEl.src trailerUrl } else { // 最后的降级路径后端需要同时保留 mp4 版本 videoEl.src trailerUrl.replace(.m3u8, .mp4) } }这段逻辑的优先级是能跑 hls.js 就优先用Safari 走原生其余情况尝试替换扩展名为 mp4。实际部署时后端如果有转码能力最好同时输出 m3u8 和 mp4 两个版本前端在Hls.isSupported()为 false 时直接用 mp4 兜底画质和兼容性都比硬转 m3u8 更稳定。5. 答辩前检查数据库参数、代理配置与抢座演示5.1 时区与字符集是乱码的直接来源本地环境跑起来中文乱码九成是数据库字符集问题不是代码问题。用下面的 SQL 检查当前环境和会话的配置SELECT global.time_zone, session.time_zone; SHOW VARIABLES LIKE character_set_server; SHOW VARIABLES LIKE collation_server;MySQL 5.7 的默认字符集可能是 latin1需要手动改成 utf8mb4。改完服务端还不够连接串里没有characterEncodingutf8mb4的话Java 驱动写进去还是乱码。MySQL 8 默认是 utf8mb4但 time_zone 默认跟随系统如果系统是 UTCserverTimezoneAsia/Shanghai这个参数必须显式写出否则LocalDateTime和DATETIME的比较会差 8 小时订单超时释放会提早或延后。5.2 开发代理与生产代理的两种写法前端开发时最常见的跨域问题不要在 SpringBoot 后端加CrossOrigin无差别放行而是在 Vite 里配代理转发// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端请求/api/order/create时Vite 开发服务器把它转发到http://localhost:8080/api/order/create浏览器端没有跨域token 也能正常传递。生产部署时换成 nginx 配置把打包后的dist目录指给 rootlocation /api/反向代理到 SpringBoot 端口。两处配置的作用一样解决的问题完全不同答辩时能讲清楚这一层评委一般不会再深挖。5.3 造一条“热门场次抢座”演示数据答辩现场最怕的是打开系统页面影片列表是空的。准备一份初始化脚本一次性生成 5 部电影、3 个影厅、每厅 60 到 80 个座位、未来 5 天的排片再为每个排片批量生成座位状态行。数据的组织方式参考第 2 章的表结构顺序是插入电影 → 插入影院影厅 → 插入排片 → 复制影厅座位到t_schedule_seat。演示时开两个浏览器窗口登录同一账号先操作窗口 A 选中同一排两个座位并提交订单再切到窗口 B 刷新选座页可以看到刚被锁定的座位已经置灰。接着让窗口 A 不支付等 15 分钟超时释放或者手动把expire_time改到过去触发定时任务刷新窗口 B 后座位恢复可售。这条演示链路把 MySQL 行锁、SpringBoot 事务、前端选座交互、定时任务四个模块一次跑通比逐个功能点刷页面更能体现系统的完整性。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门