ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue电影售票系统:技术选型、核心实现与部署避坑指南

SpringBoot+Vue电影售票系统:技术选型、核心实现与部署避坑指南 简介这是一套基于SpringBoot与Vue.js构建的电影售票及影院管理系统完整工程源码适合具备Java与前端基础的开发者用于毕业设计、课程项目或业务系统复刻。后端以SpringBoot覆盖用户登录、电影管理、排片、选座、订单及管理员权限等模块前端用Vue.js实现页面交互与接口通信。资源包为标准zip压缩包共314个文件以java源码、vue组件、xml配置、js逻辑、sql数据库脚本及jpg/png图片素材为主整体约16.14MB目录结构清晰。目前已有71人学习下载。通过该资源读者可获得可导入IDE运行调试的完整项目、数据库初始化脚本及前后端分离架构的具体实现参考对理解SpringBoot接口设计、Vue组件化开发和订单流程控制都有很强的实操价值。1. 这套系统解决什么问题影院售票系统看起来只是“选个座、付个钱”但真做起来比绝大多数管理后台都棘手热门场次选座时得扛住并发订单超时 15 分钟不付款就得自动释放座位影厅的座位布局要能配置成不同排布前端还要在手机上做到流畅选座。基于 SpringBoot Vue 的电影售票及影院管理系统恰好覆盖了这条完整链路——后端处理业务规则和订单状态前端负责用户选座体验和后台管理界面。无论你是准备做毕设的技术选型还是接到中小影院的真实定制需求这个方向都值得投入因为它的核心难点不在 CRUD而在“状态变更的准确性”。2. 选型理由与工程结构为什么要这么搭2.1 为什么是 SpringBoot而不是 SSM 或 DjangoSpringBoot 能成为这类系统的默认选择不是因为它代码生成能力强而是因为它把“服务端要用的东西”都封装成了起步依赖。售票系统需要定时任务来关闭超时订单需要拦截器做登录态校验需要 Redis 做分布式锁——这些事情在 SpringBoot 里都是“加依赖 写少量配置”而在传统 SSM 里你得先把 Spring 和 MyBatis 的一堆 XML 配置理顺。另一个实际因素是部署SpringBoot 打出来的 fat jar 直接java -jar就能跑对没有专职运维的中小影院来说这就是“省一个部署环境”。Vue 这边的理由更偏向体验。用户选座是要频繁操作页面的点击座位、缩放影厅图、查看已售和锁定状态这些交互用 Vue 的响应式数据绑定比 jQuery 操作 DOM 轻松一个数量级。而且 Vue 的路由和组件化让“首页、选座页、订单页、后台管理”整条链路分得很清楚团队协作时前端页面和后端接口可以并行开发不用等对方。2.2 工程结构前后端分离还是单体打包我在实操中遇到的第一个纠结是“分离开发”还是“合并部署”。分离开发指的是前端和后端分成两个目录、两个端口跑通过代理联调合并部署是指前端经过npm run build后把产物放到 SpringBoot 的src/main/resources/static下最终只跑一个 Java 进程。我的建议是开发期分离、生产期合并。开发期分开是为了用 Vite 的热更新改一行代码浏览器立即生效生产期合并保存了一个 Tomcat 端口少一个需要维护的 Node 进程。但合并部署有一个坑当用户直接访问/login路由时刷新页面会安全地返回 SpringBoot 的 404因为所有页面路由都挂在index.html上而后端只认index.html这一个入口。这个问题的解法放在第 6 章生产部署已是必经之路。整个工程的目录组织我一般这样摆movie-ticket/ ├── backend/ # SpringBoot 工程 │ ├── src/main/java/... │ ├── src/main/resources/ │ └── pom.xml └── frontend/ # Vue 工程 ├── src/... ├── package.json └── vite.config.js这个结构把两个技术栈物理隔离后端不接触前端的node_modules前端不关心后端的 Maven 依赖。IDEA 里导入backendWebStorm 或 VSCode 打开frontend互不干扰。2.3 数据库设计五张核心表的状态流转售票系统的数据模型核心是“场次-座位-订单”三者的关系少了任何一张表都会出大问题。我用的字段设计是这样的表名关键字段作用filmid, title, duration, poster_url, release_date电影基本信息hallid, name, row_count, col_count影厅的座位排布维度sessionid, film_id, hall_id, start_time, price某影厅在某时间播放某部电影ordersid, session_id, user_id, seat_ids, status, create_time一张订单可买 1~5 个座位用逗号分隔存 seat_idsseat_lockid, session_id, seat_id, order_id, expire_time座位锁记录某个座位在某个场次被哪个订单锁定订单状态的流转是最容易出错的地方PENDING待支付→PAID已支付→CANCELLED用户取消或超时取消。seat_lock表的存在是为了让“选座但未支付”的时间段内其他人选不了同一个座位。如果一个观众选了座但迟迟不付钱订单超过 15 分钟后要自动流转成CANCELLED同时删除对应的seat_lock记录。这个动作靠 SpringBoot 的Scheduled定时任务跑每 30 秒扫描一次过期订单。3. 后端从初始化到可调通核心接口的落地写法3.1 初始化项目Dependencies 与 application.yml创建 SpringBoot 项目时我建议直接去 Spring Initializr 选依赖避免 IDEA 内置初始化器版本滞后。最核心的三个起步依赖是Spring Web、MyBatis Plus或原生MyBatis、MySQL Driver另外视需求加上Lombok和Spring Data Redis。依赖选好以后最先改的是application.yml这里面有两个配置经验端口不要用默认的 8080因为前端 Vite 在5173两个端口挨在一起好记也好排查另外 MySQL 的时区参数必须显式声明server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/movie_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghaijackson.date-format和time-zone这两个参数值得提前写上不然前端拿到的时间比实际慢 8 小时。后端返回给前端的时间字段我一般不用Date类型而是直接用LocalDateTime配上上面的全局序列化配置就能保证格式统一。这里不推荐DateTimeFormatter写在每个字段上代码会非常啰嗦。3.2 登录鉴权JWT 拦截器售票系统肯定要分用户端和管理员端用户在微信或 App 里买票管理员在后台管排片。我用的是 JWT 做登录态登录成功后后端签发 token前端在后续每个请求的 Header 里带Authorization: Bearer token。这个方案的好处是后端不用存 Session天然适合前后端分离架构。生成和校验 token 的工具类核心代码如下public class JwtUtil { private static final String SECRET_KEY your-secret-key-please-change; private static final long EXPIRE_TIME 24 * 60 * 60 * 1000; // 24小时 public static String generateToken(Long userId, String role) { return Jwts.builder() .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); } public static Claims parseToken(String token) { return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody(); } }SECRET_KEY在真实项目里必须放在配置文件中用Value注入不要写死在代码里——这是第一条安全教训。claim里放了userId和role两个字段后端在拦截器里就能快速判断当前请求是用户还是管理员不用每次都查库。角色控制我直接在拦截器里检查role值普通用户请求/admin/**直接返回 403。写完工具类再用HandlerInterceptor注册一个全局的登录校验public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equals(request.getMethod())) { return true; // 放行预检请求 } String token request.getHeader(Authorization); if (token null || token.isEmpty()) { response.setStatus(401); return false; } try { Claims claims JwtUtil.parseToken(token.replace(Bearer , )); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { response.setStatus(401); return false; } } }拦下来的逻辑很直接token 不合法就返回 401合法就把userId和role放进去OPTIONS请求必须放行这个细节十有八九是因为前端跨域导致的登录取不到数据。前端拦截器在收到 401 的时候要跳转回登录页这属于前后端配合的约定后面第 4 章会写到。3.3 核心接口场次查询与座位锁定查询接口没什么好讲的纯粹是 MyBatis Plus 的单表查询加一个JOIN。真正的难点在“选座”接口上——用户提交选座后后端要检查这些座位是否已被锁定或售出然后创建订单并写入seat_lock。先看座位查询接口如何返回“哪些座位可售”我用的是直接返回一张二维数组的思路GetMapping(/session/{sessionId}/seats) public Result getSeats(PathVariable Long sessionId) { Session session sessionMapper.selectById(sessionId); // 构建 row x col 的二维数组 ListSeatLock locks seatLockMapper.selectList( new LambdaQueryWrapperSeatLock() .eq(SeatLock::getSessionId, sessionId) .gt(SeatLock::getExpireTime, new Date())); SetString lockedSeats locks.stream() .map(lock - lock.getSeatRow() - lock.getSeatCol()) .collect(Collectors.toSet()); // 每个座位一行数据行号、列号、状态0空闲 1锁定 2已售 ListSeatVO seats new ArrayList(); for (int r 1; r session.getRowCount(); r) { for (int c 1; c session.getColCount(); c) { String key r - c; int status lockedSeats.contains(key) ? 1 : 0; seats.add(new SeatVO(r, c, status)); } } return Result.ok(seats); }这个接口的逻辑是先把当前场次所有seat_lock中未过期的查出来放进一个Set然后遍历影厅行数 × 列数逐座标记状态。时间复杂度是 O(rows × cols)一般影院也就 8 排 × 10 列性能完全够。用Set做查重比逐个list.contains()快得多数据量小的时候差别不明显但 코드 看起来也更干净。3.4 下单接口锁座与校验的并发写法“用户点击选座 → 提交订单”这一步是整套系统最脆弱的环节。我先判断这些座位是否已被锁是检查seat_lock表第二步插入订单和锁记录必须放在同一个事务里。Transactional PostMapping(/orders) public Result createOrder(RequestBody OrderCreateDTO dto, HttpServletRequest request) { Long userId (Long) request.getAttribute(userId); ListLong seatIds dto.getSeatIds(); // 检查座位是否被锁 Integer count seatLockMapper.selectCount( new LambdaQueryWrapperSeatLock() .eq(SeatLock::getSessionId, dto.getSessionId()) .in(SeatLock::getSeatId, seatIds) .gt(SeatLock::getExpireTime, new Date())); if (count 0) { return Result.error(您选的座位中有人正在购买请重选); } Orders order new Orders(); order.setUserId(userId); order.setSessionId(dto.getSessionId()); order.setSeatIds(seatIds.stream().map(String::valueOf).collect(Collectors.joining(,))); order.setStatus(PENDING); order.setCreateTime(new Date()); orderMapper.insert(order); // 写入座位锁15分钟后过期 for (Long seatId : seatIds) { SeatLock lock new SeatLock(); lock.setSessionId(dto.getSessionId()); lock.setSeatId(seatId); lock.setOrderId(order.getId()); lock.setExpireTime(new Date(System.currentTimeMillis() 15 * 60 * 1000)); seatLockMapper.insert(lock); } return Result.ok(order.getId()); }这段代码要注意Transactional注解传在方法上不能在同一个类里直接调用私有方法因为 Spring 的事务代理要穿透类边界生效。下单时检查锁和插入锁是两步如果在检查之后、插入之前恰好另一个请求插入了同一个座位就会出线超卖。要彻底防住得给seat_lock加(session_id, seat_id)的联合唯一索引插入时捕获DuplicateKeyException捕到说明座位刚被别人锁了返回“请重选”。这是数据库层面兜底比单纯靠count检查更可靠。4. Vue 端把售票流程做出来路由、选座与支付回调4.1 Vue 工程初始化与目录设计前端用 Vite 创建工程命令是npm create vuelatest选上Router和Pinia两个插件。创建完之后我在src/router/index.js里定义四组页面首页电影列表、选座页场次和座位、订单页订单列表和支付、后台管理管理员的排片和订单管理。Vue Router 里有两个易踩的配置一个是createWebHistory的路由模式一个是路由守卫。路由模式如果用createWebHashHistory打包后部署到 Nginx 不会出现刷新 404但 URL 会带一个#看起来不专业用createWebHistory需要后端配合处理 404这个我们在第 6 章部署部分讲。路由守卫的写法我一般长这样const router createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) } else { next() } })meta.requiresAuth在后台管理页面配置为true普通游客进入后台会被引导到登录页。redirect参数存了用户原目标地址登录成功后跳回去这个细节真实需求里经常出现用户从别的页面被登出后重新登录想回到刚才的页面。4.2 axios 封装与跨域代理联调axios 请求封装是所有前后端分离项目的标配。我在src/utils/request.js里做了一套带拦截器的封装拦响应里的 401 自动清除 token 并跳登录页import axios from axios import router from /router const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response response.data, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) } ) export default requestbaseURL设置成/api而不是http://localhost:8080是为了在开发环境通过 Vite 的代理解决跨域。vite.config.js里这样配export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } })代理配置的原理是浏览器访问 Vite 的5173端口的/api/xxxVite 帮我们把请求转发到8080的 SpringBoot。rewrite的作用是去掉前缀/api——前提是后端接口路径没有/api这个统一前缀。如果后端所有接口都以/api开头那rewrite的这行就去掉。最容易翻车的地方在于前后端对“路径前缀”的理解不一致联调时第一个 404 基本都是出在这里。4.3 选座组件的状态管理选座页是前端最体现工程量的组件。我用二维数组seatMap[r][c]来复用后端返回的座位数据整个组件的核心状态只有三个selectedSeats当前选中、lockedSeats后端返回的锁定座位、soldSeats已售出。选座规则是“点击切换选中状态最多选 5 张”。这个逻辑直接写在组件的toggleSeat方法里不需要用状态管理器因为数据只在当前组件内部流转。但已完成支付后的跳转我需要把订单传给订单页展示这时用 Pinia 存一下比用路由参数传对象更干净export const useOrderStore defineStore(order, { state: () ({ createdOrder: null }), actions: { setCreatedOrder(order) { this.createdOrder order } } })内存中变量存页面跳转数据有个致命问题刷新页面后 store 被清空订单页就失去了数据。所以我一般在创建订单成功后就跳到订单详情路由而不是依赖 store。订单详情接口从后端根据订单 ID 查询这才是真正的数据源。5. 避坑指南跨域、时间、并发、打包四类高频事故5.1 SpringBoot 版本太高导致 application.yml 配置项失效这些年 Boot 在疯狂迭代一个具体场景是某同学从2.7升到3.x后原来spring.mvc或server.compression开头的配置直接不生效。原因也很简单——升级后配置路径改了spring.mvc下的view配置挪了地方有些被移到了spring.web有些被移到了专门的xxxProperties里。解决方式不是去猜是先把spring-boot-starter-parent的版本降回 2.7.x或用 Idea 的spring-boot-configuration-metadata提示来看配置是否合法。更稳妥的做法是从application.yml里挑出关键配置比如server.port、spring.datasource.url写在一个配置文件里启动后在 Actuator 的/actuator/configprops里验证是否加载。有的项目确实需要新版本那就必须去翻官方文档或源码看配置前缀。这个坑的教训是配置不生效的时候永远先考虑版本升级导致的命名迁移不要把时间花在检查拼写上。5.2 时间字段在后端返回给前端时少了 8 小时MySQL 的DATETIME默认不带时区SpringBoot 的 JDBC 驱动读取时如果没指定serverTimezoneAsia/ShanghaiJDBC 默认用服务器的时区去解释。如果前后端不在同一个时区——比如服务器在海外 IDC但本地浏览器是中国时区——就会多出 8 小时。最直接的处理方式是在application.yml里serverTimezone配成Asia/Shanghai同时把spring.jackson.time-zone也设置成Asia/Shanghai。两个地方如果只配一个经常会遇到“数据库存对了JSON 返回错了”的怪问题。另外 PostgreSQL 没有这个问题但 MySQL 必须显式声明时区。5.3 座位锁定失效的高并发超卖这是电影售票系统最容易踩重的坑。检查seat_lock表记录数和插入锁记录看起来在同一个事务里但高并发下两个请求同时经过“检查”这一步然后都去插入自己的锁第二个插入时就会发现唯一索引冲突。如果唯一索引没有建两人就都下单成功了超卖。解决的办法是三层防线。第一层是最外层“检查锁数是否存在”普通量级下能过滤掉大部分冲突第二层是数据库加(session_id, seat_id)联合唯一索引这是硬兜底第三层是在插入失败时捕获DuplicateKeyException返回友好提示“手慢了座位被别人选了”。这三层缺一不可一个项目哪些会踩务必在测试时用JMeter并发压一下下单接口别等上线后被真实用户压出问题。5.4 Vue 页面刷新后出现 404 白屏开发时用createWebHistory挺舒服但是把npm run build出来的文件放进 SpringBoot 的static目录后访问/order/123直接返回 404。原因很简单用户刷新时请求的是GET /order/123后端只有index.html没有叫order的控制器。市场上两种解决思路一是把路由模式改回createWebHashHistoryURL 变成/#/order/123后端永远返回index.html就不会 404缺点是丑且有人会纠结 SEO二是保持createWebHistory在后端做一个 unrecognized 转发把所有非/api开头的路径转发到index.html。如果你把它们放在一起用 SpringBoot常见的做法是写一个WebMvcConfigurer来做转发代码如下Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{path:[^\\.]*}).setViewName(forward:/index.html); } }匹配规则是“路径里不含点”就转发到index.html比如/order/123成功转发而/api/orders由后端接口处理不经过这个 ViewController。这个方案在本地联调和生产部署都稳定前提是后端接口路径不能出现“无点但跟 index.html 冲突”的情况。5.5 前端依赖装上容易删了也容易的幽灵依赖npm install在 Vue 3 Vite 下偶见failed to load tsconfig或依赖版本空指针的情况。排查路径一般是先删掉node_modules和package-lock.json再执行npm cache clean --force最后重新npm install。如果还是报错看 node 版本Vite 6 以上要求 Node 18低版本 Node 安装依赖后运行时会崩得莫名其妙。这个属于环境基础题基本上每个季度都会遇到一次多花几分钟把依赖装干净别急着调试代码。6. 打包部署与上线验证从开发机到生产环境的最后一里路6.1 前端产物合并进 SpringBoot 的 static 目录这一步几乎是这个项目的必经之路虽然开发期前后端分离但最终拿给影院方的是一个可直接运行的 jar。流程如下# 1. 前端构建 cd frontend npm run build # 2. 把 dist 产物复制到后端静态目录 mkdir -p ../backend/src/main/resources/static cp -r dist/* ../backend/src/main/resources/static/ # 3. 后端打包 cd ../backend mvn clean package -DskipTestsmvn package打出来的 jar 包含了前端页面和后端接口单文件部署到服务器用java -jar movie-ticket.jar就能启动。需要注意复制前先清空static目录否则旧文件名会残留另外dist/index.html里的 JS/CSS 路径是绝对路径/assets/xxx.js别改它SpringBoot 的静态资源默认从static根路径匹配。6.2 生产环境的 Nginx 反向代理与刷新 404虽然 jar 能直接跑但如果影院已有 Nginx 做域名代理我习惯让 Nginx 处理前端请求、/api转发到 Java 进程。此时前端页面放在 Nginx 的html目录后端只提供接口Nginx 配置里最关键的一段是location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }第一段try_files专门解决 Vue 路由刷新 404Nginx 会先把 URL 映射到磁盘文件映射不到就回落到index.html第二段把/api开头的请求转发给后端的8080端口。这种部署方式的好处是 Nginx 能顺带做 HTTPS 证书终止和静态资源缓存SSL 配置不用写在 SpringBoot 里。6.3 验证清单与交易闭环上线前我一般会手工跑一遍完整闭环注册 → 选座 → 锁定座位 → 等待 15 分钟超时 → 确认座位释放。第二个验证是并发选同一批座位用两个浏览器登录两个账号同时点进同一场次的同一排看是否只有一个能下单成功。第三个验证是模拟支付回调——如果没有对接真实微信/支付宝后端可以写一个 mock 接口直接改订单状态真实对接时回调接口必须做幂等处理因为支付平台会重复推送通知。这个项目我做了不止一次每一次在新环境重新部署时最先遇到的就是跨域、时区和路由这三个问题多踩了几次之后把上面这些坑固化成了清单整套上线验证基本一小时内能完成。平时新起项目时只要沿用这套结构把后端业务逻辑往里填大多数骨架代码都是能直接复用的。希望这套方案能帮你在售票系统的实现上少走几段弯路。本文还有配套的精品资源点击获取
返回列表