SpringBoot+Vue3影院购票系统实战:从架构设计到前后端联调
影院购票系统这类项目在Java Web的学习路线里算是一个“麻雀虽小五脏俱全”的典型而且也是我这些年帮人做毕业设计、内部分享时重复率最高的题目之一。SpringBootVue3MyBatisMySQL这套组合刚好把后端接口、前端交互、数据库设计、权限认证全部串起来做完一遍之后再去理解实际企业项目的开发模式会顺畅很多。这篇文章我不会去给你贴那种整段整段的项目源码而是把整个系统的核心设计思路、表结构怎么落、关键代码怎么写、前后端怎么对接、以及我在实际开发中踩过并且调通了的问题完整拆给你看。你在复现或者二次开发的时候照着这条线走能少走不少弯路。1. 系统全貌与初始设计决策1.1 为什么是SpringBootVue3MyBatis这套组合先说技术选型的问题。很多新手拿到题目后会纠结为什么不用新兴的Sa-Token、MyBatis-Plus、或者后端用Python的FastAPI这里我需要解释一下这套组合在“学习性价比”上的优势因为选型本身就决定了整个项目的落地方向。SpringBoot解决的是Java Web开发中“配置地狱”的问题内嵌Tomcat之后打包即运行不需要单独装外部容器。Vue3作为前端框架配合Vite构建工具开发时的热更新体验比老一代的Vue2Webpack要轻快很多而且组合式APIComposition API在管理像“电影列表影厅座位购物车状态”这种多联动的页面状态时代码组织方式比选项式API清晰得多。MyBatis则是一个半自动的ORM框架SQL由自己掌控对于并发锁座、复杂统计这类场景写原生SQL比全自动ORM更可控。MySQL负责数据持久化这个不用多说中小型的业务系统它完全可以扛得住。这套组合本质上模拟了大多数企业级应用的标配模式前端构建UI交互后端提供RESTful接口数据库负责事务性数据存储。如果项目使用JPA或者全自动型ORM框架对于动态条件查询和报表类SQL反而要绕很多弯路终究不如直接用MyBatis在XML里写SQL来得直接。这也是虽然网上对“手写SQL还是自动生成SQL”争论不休但行业里做业务系统的主流依然是MyBatis系的原因。1.2 系统的两类角色与功能边界划分影院购票系统从业务上天然分成两个端用户端和管理端。我的建议是一开始就把这两条线分开建模块避免后面代码越写越乱。用户端的功能链条很清晰注册登录 - 浏览正在热映的电影 - 查看影片详情 - 选择场次 - 进入选座页面 - 锁定座位并生成订单 - 模拟支付 - 查看电子票和订单记录。管理端则是反向操作电影排片管理上架电影、设置影厅、设置时间段、座位价格调整、订单查询与退款处理、票房数据统计。这两个端的接口天然可以拆成两组前缀/api/user/**和/api/admin/**。拦截器按路径前缀做权限判断比在每一个接口方法上标注解更简单也更容易控制。我见过有的同学把管理员判断写进每个Controller里结果漏掉一个接口就能被普通用户直接调用管理员功能这个在项目答辩或者实际部署里是非常致命的漏洞编码规范从一开始就要定好。2. 数据库设计影院业务的表结构拆解2.1 核心实体关系与建表SQL数据库设计是整个系统最基础的部分。我在设计影院系统时核心的表一共就五张用户表user、电影表movie、放映场次表screening、订单表order、座位表seat。如果把座位状态直接存在订单表里那么一个场次的一排座位可能被多张订单记录覆盖数据冗余和状态竞争会非常严重所以需要尺量拆分清楚。先看最关键的几张表的建表SQL我在开发中实际使用的简化版本如下CREATE TABLE movie ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 电影ID, title varchar(255) NOT NULL COMMENT 电影名称, description text COMMENT 剧情描述, duration int(11) NOT NULL COMMENT 时长分钟, poster varchar(500) DEFAULT NULL COMMENT 海报图片URL, release_date date DEFAULT NULL COMMENT 上映日期, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态0下架 1上映, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT电影信息表; CREATE TABLE screening ( id bigint(20) NOT NULL AUTO_INCREMENT, movie_id bigint(20) NOT NULL COMMENT 电影ID, hall_id int(11) NOT NULL COMMENT 影厅ID, start_time datetime NOT NULL, end_time datetime NOT NULL, base_price decimal(10,2) NOT NULL COMMENT 基础价格, PRIMARY KEY (id), KEY idx_movie_start (movie_id, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT放映场次表; CREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint(20) NOT NULL, screening_id bigint(20) NOT NULL, total_amount decimal(10,2) NOT NULL, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 订单状态0待支付 1已支付 2已取消 3已退款, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, paid_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_created (user_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表; CREATE TABLE order_seat ( id bigint(20) NOT NULL AUTO_INCREMENT, order_id bigint(20) NOT NULL, screening_id bigint(20) NOT NULL, row_no int(11) NOT NULL, col_no int(11) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_screening_seat (screening_id, row_no, col_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单座位表;order_seat表上的唯一键是整个选座并发控制的关键同一场次中同一座位唯一对应一条记录数据库层面直接拦截重复下单这比在应用层代码里先查询再插入安全得多。下表汇总了各表之间的关键关系方便你在设计前后端接口的时候一一对应。数据表关联字段关联对象说明useridorders.user_id用户与订单的一对多movieidscreening.movie_id电影与场次的一对多screeningidorder_seat.screening_id场次与座位明细的多对多ordersidorder_seat.order_id订单与座位明细的一对多hall基础表idscreening.hall_id影厅与会场的一对多2.2 订单状态机与座位锁定策略订单表里我加了一个状态机设计待支付 - 已支付 - 已退款以及独立的已取消状态。很多购票系统在生成订单之后会占用座位用户超时未支付则需要释放座位这个过程用状态机来表达是最清晰的。我的实现方案是通过定时任务扫描创建时间超过15分钟且状态为“待支付”的订单将其改为“已取消”并回收座位。锁座策略上我遇到过一个经典场景用户A和用户B同时看中了同一个座位。如果用“先查座位状态状态为空才插入订单”的思路那么并发情况下两个人查到的都是“未锁定”然后同时插入必然产生脏数据。解决方式有两种一种是应用层加分布式锁如Redis的SETNX另一种是数据库层的唯一索引约束后者更加硬核直接通过数据库来确保幂等。我在项目中采用了“唯一索引插入失败捕获异常”的方案具体逻辑是先创建订单记录再批量插入座位记录如果插入时抛出DuplicateKeyException说明座位被抢则回滚整个订单并告知用户“座位已被选”。需要特别提醒的是创单和座位锁定必须在同一个事务里完成。Spring中使用Transactional注解即可注意放在Service层而不是Controller层否则事务不会生效。我在项目中遇到过类似问题不加Transactional时座位插入失败订单却已经在库了出现了“僵尸订单”这在实际业务里就是资金风险。3. 后端核心模块与实现方案3.1 SpringBoot工程结构与MyBatis分层SpringBoot工程的包结构我建议按“功能技术分层”结合的方式组织Controller层只做参数接收和结果封装Service层写业务逻辑Mapper层负责数据库交互。下面是我习惯的工程结构src/main/java/com/example/cinema/ ├── controller/ │ ├── user/ # 用户端接口 │ ├── admin/ # 管理端接口 │ └── common/ # 公共接口 ├── service/ │ └── impl/ ├── mapper/ ├── entity/ ├── dto/ ├── vo/ ├── config/ ├── interceptor/ ├── common/ │ ├── exception/ │ └── result/ └── utils/这种分层的好处是当你要增加一个接口时路径非常明确当你要排查问题时也能顺着调用链一路找下去。尤其需要注意的是entity和dto别混用接收前端参数的对象和数据库实体对象分离防止信息泄露比如用户表里不要直接暴露密码哈希字段。MyBatis的配置上我有两个经验。第一在application.yml里开启驼峰映射mybatis: configuration: map-underscore-to-camel-case: true mapper-locations: classpath:mapper/*.xml这样数据库的order_no字段会自动映射到Java实体的orderNo属性省去大量ResultMap手写。第二XML文件和Java接口必须在同一个包路径或者通过mapper-locations指定否则启动就会报Invalid bound statement (not found)这是新手最容易犯的问题。3.2 一条典型业务链路从选座到生成订单这里我把“用户选座并生成订单”的完整后端链路写出来因为它是整个业务里最核心、也最容易出问题的接口。前端提交的数据结构大致是这样{ screeningId: 1, seats: [{row: 3, col: 4}, {row: 3, col: 5}] }。后端Service层的处理逻辑如下Override Transactional(rollbackFor Exception.class) public CreateOrderResult createOrder(CreateOrderRequest request, Long userId) { // 1. 检查场次信息 Screening screening screeningMapper.selectById(request.getScreeningId()); if (screening null) { throw new BizException(场次不存在); } // 2. 提前校验座位是否在有效范围内影厅的行列数 // 3. 生成唯一订单号 String orderNo OrderNoGenerator.generate(); // 4. 插入订单状态为待支付 Orders order new Orders(); order.setOrderNo(orderNo); order.setUserId(userId); order.setScreeningId(request.getScreeningId()); order.setStatus(0); // 计算价格基础价 座位附加价 order.setTotalAmount(calculateAmount(screening, request.getSeats())); orderMapper.insert(order); // 5. 批量插入座位记录靠唯一索引防重 try { for (SeatDTO seat : request.getSeats()) { orderSeatMapper.insert(order.getId(), screening.getId(), seat.getRowNo(), seat.getColNo()); } } catch (DuplicateKeyException e) { throw new BizException(座位已被其他用户锁定请重新选择); } return new CreateOrderResult(order.getId(), orderNo, order.getTotalAmount()); }注意要点插入订单之后再插座位但在事务中插座位失败会回滚订单所以执行顺序并不是一个安全隐患。需要正确处理DuplicateKeyException这里抛一个业务异常返回给前端提示用户刷新座位图而不是返回500让前端不知所措。3.3 JWT登录认证与接口安全拦截购票系统的认证方案我选用了JWTJSON Web Token因为它是无状态的适合前后端分离的场景。登录接口校验用户密码后生成一个包含用户ID和角色信息的Token返回给前端前端存到localStorage后续请求在Header里带上Authorization: Bearer token。JWT工具类的核心实现大致如下public class JwtUtil { private static final SecretKey key Keys.secretKeyFor(SignatureAlgorithm.HS256); // 实际项目中应该从配置文件中读取并保持固定的签名密钥 public static String generateToken(Long userId, String role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 1000 * 60 * 60 * 24)) .signWith(key) .compact(); } public static Claims parseToken(String token) { return Jwts.parserBuilder().setSigningKey(key).build() .parseClaimsJws(token).getBody(); } }拦截器层面我写了一个AuthInterceptor在preHandle方法中解析Token并放到ThreadLocal中。SpringBoot中给WebMvcConfigurer配置拦截器时排除掉登录、注册、电影列表、场次查询这些公开接口Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/user/login, /api/user/register, /api/movie/**); }这里有个值得注意的坑Admin接口的权限校验不能只靠一个拦截器因为普通用户和管理员虽然都经过登录但角色不同。我会在拦截器中读取role字段如果访问的是/api/admin/**但role不是ADMIN就直接拒绝。这个校验放在拦截器里AOP也行但拦截器是更直观的方案。4. 前端Vue3核心实现与业务交互4.1 Vue3工程搭建与请求封装前端用Vite创建Vue3项目安装vue-router、pinia、axios和element-plus。工程结构保持清晰src/ ├── api/ # 接口定义 ├── assets/ ├── components/ # 公共组件 ├── router/ # 路由配置 ├── stores/ # Pinia状态管理 ├── views/ │ ├── user/ # 用户端页面 │ └── admin/ # 管理端页面 └── utils/ # 请求封装、工具函数对于请求封装我写了一个统一的request.js核心是为了处理Token注入和错误码统一提示import axios from axios; import { ElMessage } from element-plus; 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 { const res response.data; if (res.code ! 200) { ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res.data; }, error { if (error.response?.status 401) { ElMessage.error(登录已过期请重新登录); localStorage.removeItem(token); router.push(/login); } else { ElMessage.error(error.response?.data?.message || 网络异常); } return Promise.reject(error); } ); export default request;统一错误码处理的好处是前端每个页面不用再写重复的try-catch去处理错误提示全局拦截器一次性搞定。这里我建议后端返回的公共格式为{ code: 200, message: success, data: {} }code非200即为业务异常。4.2 选座页面的核心设计思路选座页面是用户端最复杂的交互模块前后端的联动在这里充分体现。我的实现思路是页面初始化时向后端一次拉取“场次座位图”数据后端返回一个二维数组每个元素代表座位状态0可选、1已售、2已锁定、3自己选中。前端对后端返回的数据要分情况处理// 处理座位图数据 function formatSeatMap(seatMap, selectedSeats) { return seatMap.map((row, rowIndex) { return row.map((seat, colIndex) { if (selectedSeats.some(s s.row rowIndex s.col colIndex)) { return { ...seat, status: selecting }; } return seat; }); }); }这里有一个交互细节需要注意用户点击座位后我先在本地状态中标记为“选中”同时更新底部购票栏的价格汇总。但是只有当用户点击“确认选座购票”时才向后端提交订单请求。这样做可以防止频繁向后端写入“锁定”状态导致数据库压力过大也符合用户的操作心理——不到最后不下单。座位图用CSS Grid布局影厅的影厅配置行数、列数由后端场次信息返回。如果是IMAX影厅行列多一些普通厅少一些前端不用硬编码行列数做到动态渲染即可。4.3 管理端排片与统计报表管理端的功能本质上是表格的增删改查但排片这块有一个业务逻辑要点同一个影厅同一时间段不能排两个场次。前端需要和后端配合做校验我的方案是在后端插入排片记录前检查该影厅的时间段是否冲突SELECT COUNT(*) FROM screening WHERE hall_id #{hallId} AND #{startTime} end_time AND #{endTime} start_time这个SQL的条件就是“范围重叠判定”如果返回的Count大于0就拒绝插入。前端在提交排片表单前也应先在后端返回的电影上映列表里让管理员选择影片然后选择影厅时间输入起始时间时长根据电影自动带出结束时间自动计算。这样既提升了用户体验又减少错误输入。管理端的票房统计我用的是简单的分组SQL按天统计已支付订单的总金额SELECT DATE(paid_at) AS stat_date, COUNT(*) AS order_count, SUM(total_amount) AS revenue FROM orders WHERE status 1 GROUP BY DATE(paid_at) ORDER BY stat_date DESC LIMIT 30这类统计SQL可以直接写在MyBatis XML中但需要注意的是报表查询的结果集和实体类不对应可以定义独立的VO类承接。MyBatis会自动映射stat_date到statDate开启驼峰映射的前提下但如果你要一一对应也可以写一个ResultMap看个人习惯。5. 前后端联调与关键配置5.1 跨域问题与Vite代理配置前后端分离开发时前端跑在5173端口后端跑在8080端口直接访问会产生跨域问题。我的处理方式是开发环境用Vite代理生产环境用Nginx转发而不是在后端代码里写CrossOrigin全部放开。Vite的vite.config.js配置export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })生产环境把前端build出来的静态文件放到Nginx的html目录然后配置Nginx反向代理server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这段配置中比较容易被忽略的是try_filesVue Router如果是history模式刷新子路由页面时Nginx需要把请求重新指回index.html否则会404。如果你用hash模式则没有这个问题但URL会带个#我个人推荐history模式配合Nginx配置更接近真实项目的形态。5.2 跨域联调时常见的两个坑我在实际联调过程中经常碰到两类前端报错这里专门提一下。第一个是“Request failed with status code 404”但网络请求明明能发到后端。这种情况多半是后端接口路径和前端请求路径不一致。比如后端Controller写的是RequestMapping(/api/user)前端请求却是/api/users对不上自然404。排查时直接在浏览器Network面板看实际请求URL再翻后端的Controller映射逐个对比就能发现。第二个是接口通了但拿到的data结构不对。比如后端返回{ code: 200, data: { list: [], total: 0 } }前端在业务代码里写的是res.list却发现是undefined。这通常是在axios拦截器里已经做了解包处理返回了res.data结果页面代码又多取了一层。关于统一响应体的解包层级前端团队必须统一认知否则代码维护成本极高。我的习惯是拦截器解到业务数据层页面拿到的就是最终数据。6. 常见问题汇总之这些坑我帮你提前踩平6.1 SpringBoot启动报错汇总最典型的SpringBoot启动失败有几种情况端口被占用换个端口或停掉占用进程、MyBatis的Mapper接口扫描不到检查启动类MapperScan的路径是否正确、数据库连接超时检查url、用户名、密码、驱动。如果你遇到控制台报Access denied for user rootlocalhost几乎肯定是密码错了。如果你用的是MySQL 8.x在pom.xml中要引入对应的驱动dependency groupIdcom.mysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency同时URL中要加上useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue一堆SSL或者时区相关的报错基本都是这里没配好。6.2 座位锁定的并发问题复盘我之前提到唯一索引防并发锁座这里再补充一个真实的调试经历。第一版的时候我是用“先查后插”的普通逻辑压测工具一开50个并发请求抢同一个座位数据库就出现了多个订单指向同一个座位的情况。我当时的排查过程是先确认接口在代码层面有没有并发控制确认没有加锁然后看数据库表结构发现没有唯一索引。最后加上了唯一索引并捕获DuplicateKeyException重新压测才完全通过。从这个事情里我最大的收获是在没有分布式锁的情况下数据库约束是最后一道防线也是最难被绕过的防线。如果项目里有用到Redis还可以考虑用Redis分布式锁把“选座”这个动作锁住但即使Redis锁失效数据库唯一索引依然可以兜底双保险也是业界常用的策略。6.3 Vue3响应式丢失的经典问题在Vue3开发中一个高频踩坑是从接口拿数据后界面不更新。多数情况下是因为用了ref但在赋值时使用了Object.assign或者直接替换了整个ref.value之外的地方导致响应式丢失。更常见的是把reactive的对象拆解后赋值给普通变量比如const state reactive({ seatMap: [] }); const { seatMap } state; // 这样拿到的seatMap是普通变量不再具备响应性正确方式是始终保持对state.seatMap的引用或者用toRefs解构。这类问题在选座页面几乎必现因为座位图数据本身就是异步加载并重新赋值的一个不小心就会变成“数据变了但界面没反应”。6.4 MyBatis的常见报错Invalid bound statement (not found)这个报错几乎每个用MyBatis的新手都见过。原因一般有两种Mapper接口方法和XML中的id不一致XML文件没有被打包到target目录。后者在Maven项目中尤其常见因为src/main/java下如果放了XMLMaven默认不会打在classpath里。解决方案是在pom.xml中配置resourcesresources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource /resourcesMyBatis的另一个高频问题是一对多映射时使用了错误的关联查询方式导致返回数据量成倍膨胀。查询订单列表并附带该订单的座位明细时如果用嵌套结果映射的方式做好关系映射比单独循环查询要快我第一次写的时候没注意一个订单列表触发了N1条SQL后来改成一次性Left Join接ResultMap才解决问题。7. 项目扩展方向与上线部署建议这套系统的骨架做完之后你可以做的扩展方向非常多。比如加入Redis做热门电影缓存把首页电影列表从MySQL中解放出来用RabbitMQ做订单超时取消的延迟消息替代我方案中的定时扫描任务接入真实的微信或支付宝沙箱支付替代模拟支付流程。每加一种中间件项目在面试中的含金量都会上一个层次。部署上我的建议是使用Docker Compose编排把MySQL、后端服务、Nginx三个容器串起来前端由Nginx容器托管静态文件。这样一个docker-compose.yml文件就能在任意一台服务器上快速复制出整套环境。你本地开发的时候直接跑SpringBoot生产环境走Docker整个过程做完也能理解容器化部署的基本套路。最后再分享一个小技巧前后端分离项目的接口文档别漏了。我用的是knife4j基于Swagger的增强版只需在后端加一个依赖Controller注解写全访问/doc.html就能自动生成一份在线接口文档。这样不仅自己调试方便如果这个项目是给别人做毕设参考或者团队协作对方对照文档就能快速联调省去你反复截图讲接口怎么调的沟通成本。