做全栈项目最怕什么不是框架不会用而是做到一半发现无从下手。前后端分离影院购票系统这个题目我打磨过好几版这次把SpringBootVueMyBatisMySQL这一套完整源码从数据库设计讲到服务器部署所有步骤都是我自己实操过的拿到就能跑。前后端分离这个词很多人挂在嘴边但真正理解“分离”带来的开发和部署变化的人并不多。这个项目正好把登录、影片展示、选座、下单、支付模拟、后台管理串成一条完整的业务线适合在校学生拿来交课程设计也适合刚转Java后端的人拿来完成第一份有价值的实战项目。1. 项目整体设计为什么把技术栈定成这一套1.1 前后端分离到底在分离什么很多初学者分不清传统Web开发和前后端分离的区别。说白了传统项目里页面是后端生成的Java代码里写JSP、Thymeleaf模板前端页面上的数据通过模板引擎渲染好再发给浏览器。这种模式最简单但有一个硬伤后端和前端逻辑全耦合在一起改个页面样式要动Java代码想给手机App接口几乎要重写整个项目。影院购票系统用前后端分离之后后端只做一件事提供JSON接口。前端Vue拿这些JSON数据自己渲染页面用户操作由前端发起HTTP请求后端处理完后返回结果。这样做什么都方便同一个后端接口既可以给浏览器用以后接小程序、APP也只是换个前端壳子。项目结构上前端是一个独立的Vue工程后端是一个独立的SpringBoot工程数据库是MySQL接口层用MyBatis操作。开发时前后端可以并行推进谁也不用等谁。1.2 技术选型与版本依赖这套技术栈我建议固定版本不要一上来就追最新版。版本之间兼容性问题极其折磨人网上搜索踩坑经验时也要小心有些老教程用的是旧版本照抄反而出问题。我用的组合如下组件版本选择理由SpringBoot2.7.14处于开源维护期Maven依赖成熟比3.x更稳MyBatis3.5.x mybatis-spring-boot-starter 2.3.x手写SQL更能练基本功面试也常被问到MySQL8.0.34主流数据库Navicat操作方便生产环境也常见Vue2.7 Element UI 2.15上手门槛低组件全部署资料多Node16.x与Vue2开发环境兼容性最好这里说句实话项目阶段选择Vue2而不是Vue3不是Vue3不行而是Vue2的入门资料和遇到问题时能搜到的解决方案更多。如果你的简历里想体现Vue3经验把前端迁到Vue3其实不难核心业务逻辑基本不变。后端框架同理SpringBoot 2.7够了别在课程设计阶段和自己较劲。2. 数据库设计七张表撑起一条业务线2.1 表结构设计与关联关系电影院购票系统核心表就七张用户表、影片表、影厅表、场次表、座位表、订单表、订单座位明细表。订单单独拆明细表的原因很简单一个订单买好几张票如果只存一个逗号拼接的座位字段后面退款或者查询某个座位卖没卖时处理起来会非常痛苦。用户表记录账号密码、手机号、昵称密码用BCrypt加密存储千万别用明文。影片表存片名、导演、演员、类型、时长、上映日期、海报URL、简介。影厅表和场次表关系紧密影厅表记录这个影厅的座位排数和列数场次表记录某部电影在某影厅的放映时间和票价。座位表定义影厅的物理座位订单表和订单座位明细表记录用户买了哪场电影、坐在哪个位置。这几张表的关联关系不复杂场次表关联影片表订单表关联用户表和场次表订单座位明细表关联座位表。核心业务都在这条主链路上后台管理也是围绕这几张表做增删改查。2.2 座位锁定的状态机设计核心排座位是购票系统里最有技术含量的部分。一开始会觉得简单不就是存一个座位状态嘛但真的写代码时就会发现并发问题两个用户同时选同一个座位怎么办用户下单后不支付座位锁到什么时候我在这里采用了一个独立的场次座位表。物理座位表只定义影厅有哪些座位场次座位表则记录具体某一场电影每个座位当前的状态。状态有三种0表示可售1表示锁定中2表示已售出。用户选座时把状态从0改成1支付成功后改成2超时未支付再改回0。CREATE TABLE movie_session_seat ( id bigint NOT NULL AUTO_INCREMENT, session_id bigint NOT NULL COMMENT 场次ID, row_no int NOT NULL COMMENT 排号, col_no int NOT NULL COMMENT 列号, status tinyint NOT NULL DEFAULT 0 COMMENT 0-可售 1-锁定 2-已售, PRIMARY KEY (id), KEY idx_session_status (session_id, status) ) ENGINEInnoDB COMMENT场次座位表;同时给(session_id, status)建一个联合索引。这个表的设计是整个项目对并发处理思考的体现面试时能讲清楚这一点比写十张表都有用。2.3 索引与初始化数据索引设计上订单表是查询频率最高的表用户查自己的订单、运营查某场次的订单都走它。我给订单表建了user_id、session_id、status三个普通索引再给订单号建一个唯一索引。别小看这几条索引项目后期数据量上来之后没有索引的SQL会把数据库拖垮。初始数据主要是影厅和座位的生成。影厅建好后座位表不需要手动一条条录入可以用SQL批量生成。INSERT INTO movie_seat (hall_id, row_no, col_no) SELECT 1, r.row_no, c.col_no FROM (SELECT 1 AS row_no UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5) r, (SELECT 1 AS col_no UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5) c;同理新开场次时用INSERT INTO SELECT把该影厅的所有物理座位复制到场次座位表初始状态全部为0。这种做法比在代码里循环插入高效得多。3. 后端实现从分层到订单业务闭环3.1 后端目录结构与统一返回体后端项目我建议严格按照Controller、Service、Mapper三层划分实体类和Mapper接口用MyBatis逆向生成可以省不少时间但Service层的业务逻辑必须手写。目录结构如下cinema-server/ ├── src/main/java/com/cinema/ │ ├── controller/ # 接口层只做参数接收和返回 │ ├── service/ # 业务层事务边界在这层 │ ├── mapper/ # MyBatis接口 │ ├── entity/ # 数据库实体 │ ├── dto/ # 请求参数对象 │ ├── vo/ # 返回给前端的视图对象 │ ├── common/ # 统一返回体、异常处理 │ ├── config/ # 拦截器、跨域等配置 │ └── CinemaApplication.java ├── src/main/resources/ │ ├── mapper/ # Mapper XML文件 │ ├── application.yml │ └── sql/init.sql前后端分离项目中接口层最容易出现的问题是返回结构不统一。有的接口返回{code:200, data:...}有的直接返回一个List前端联调时必须一个个看极其痛苦。我习惯所有接口统一返回一个Result对象。public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT fail(String message) { ResultT r new Result(); r.code 500; r.message message; return r; } }再配一个全局异常处理器把参数校验异常、业务异常、系统异常全部转成统一的Result格式返回。这样前端看到code为200才处理数据其它情况统一弹错误提示联调效率直接翻倍。3.2 登录鉴权JWT拦截器怎么做购票系统的很多接口都需要登录后才能访问比如下订单、查订单、管理后台。传统做法是服务端存Session但前后端分离之后浏览器和接口之间跨域很常见Session的跨域问题处理起来麻烦我用的方案是JWT令牌。public class JwtUtil { private static final String SECRET cinema-secret-key; public static String generateToken(Long userId, String username) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setExpiration(new Date(System.currentTimeMillis() 1000 * 60 * 60 * 24)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } }前端登录成功后把JWT存到localStorage每次请求在拦截器里加到Authorization请求头。后端写一个拦截器除了登录接口、影片查询等公开接口外其他接口都校验JWT是否有效解析出来的用户信息放到ThreadLocal里后续业务直接取。这样做的好处是后端不存Session天然支持多端登录以后部署的时候多开几个后端节点做负载均衡也没问题。这个点面试经常被追问值得好好研究。3.3 选座下单乐观锁防止超卖用户选座下单是整个业务链路的核心处理不好会出现两个严重问题一个是重复下单另一个是超卖。所谓超卖就是两个用户同时锁定了同一个座位最后各自都下单成功一个座位卖了两张票。解决超卖不能用“先查再改”的方式那是典型的竞态条件。必须把检查状态和更新状态做成一条原子SQL。MyBatis的Mapper XML里写一个带条件的UPDATE只有状态为0时才能改成1受影响行数返回0就说明座位已经被别人抢了。update idlockSeat UPDATE movie_session_seat SET status 1 WHERE id #{seatId} AND status 0 /updateService层用事务包裹整个下单逻辑Transactional(rollbackFor Exception.class) public Long placeOrder(PlaceOrderRequest req) { ListLong seatIds req.getSeatIds(); // 逐条锁定座位只要有一条失败整体回滚 for (Long seatId : seatIds) { int rows seatMapper.lockSeat(seatId); if (rows 0) { throw new BusinessException(座位已被锁定请重新选择); } } MovieOrder order new MovieOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(req.getUserId()); order.setSessionId(req.getSessionId()); order.setSeatIds(StringUtils.join(seatIds, ,)); order.setStatus(0); // 待支付 order.setTotalPrice(req.getTotalPrice()); orderMapper.insert(order); for (Long seatId : seatIds) { OrderSeat os new OrderSeat(); os.setOrderId(order.getId()); os.setSeatId(seatId); orderSeatMapper.insert(os); } return order.getId(); }这个方案的关键在于MySQL默认使用InnoDB引擎UPDATE语句本身会对命中行加锁。同一时刻只有一个事务能把状态从0改成1另一个事务执行时会阻塞前一个事务提交后发现状态已经不是0受影响行数为0直接抛异常回滚。用最简单的手段解决了并发问题。如果想进一步减少短时阻塞可以用乐观锁版本号但在本项目场景下这种原子UPDATE已经非常稳定。3.4 模拟支付回调与订单状态流转真实项目对接微信支付或支付宝时支付结果不是前端告诉后端的而是支付平台异步回调通知后端的。没有真实支付网关时我写了一个模拟支付接口来走完整的业务流程。这个设计非常关键因为它让项目具备完整闭环。public void payCallback(String orderNo) { MovieOrder order orderMapper.selectByOrderNo(orderNo); if (order null || order.getStatus() ! 0) { return; // 订单不存在或已处理 } order.setStatus(1); // 已支付 order.setPayTime(new Date()); orderMapper.updateById(order); // 把锁定中的座位改为已售 for (String seatId : order.getSeatIds().split(,)) { seatMapper.finishSeat(Long.valueOf(seatId)); } }订单状态有四种0待支付、1已支付、2已取消、3已退票。用户在下单后15分钟内不支付负责释放座位的定时任务会将对应座位状态改回0把订单置为已取消。这个超时释放的逻辑虽然不是直接拿来卖钱但很贴近真实业务也是项目里能拿出来讲的一个亮点。4. 前端实现Vue从零搭出用户购票台4.1 Vue项目初始化与路由设计前端工程是基于Vue CLI搭建的。安装依赖时经常遇到的问题我后面会专门讲这里先把项目结构说清楚src/views目录放页面src/router目录放路由src/api目录放接口请求src/utils目录放工具函数。路由设计上用户端页面有首页、影片详情页、选座页、订单列表页管理端页面有影片管理、场次管理、订单管理。const routes [ { path: /, component: Home }, { path: /film/:id, component: FilmDetail }, { path: /booking/:sessionId, component: Booking }, { path: /login, component: Login }, { path: /register, component: Register }, { path: /orders, component: Orders, meta: { requiresAuth: true } }, { path: /admin, component: AdminLayout, meta: { requiresAuth: true, requiresAdmin: true } } ];路由守卫是在前端判断登录状态的一道防线但前端判断只是提升用户体验真正要保证安全必须靠后端拦截器。前端路由守卫代码如下没有登录就访问受保护页面时自动跳到登录页。4.2 座位图组件与联动逻辑前端最值得关注的组件是座位图。影厅座位本质上是一个二维数组每行座位刮号、每列座位编号后端返回的是这场的场次座位列表前端根据影厅的排数列数渲染出一个动态的网格。我习惯用 computed 属性把后端返回的扁平座位列表转化成二维数组这样模板里可以直接用双层v-for渲染。template div classseat-map div classseat-row v-for(row, rowIndex) in seatMatrix :keyrowIndex span classrow-label{{ rowIndex 1 }}排/span div classseat-item v-forseat in row :keyseat.id :classseatClass(seat) clicktoggleSeat(seat) {{ seat.colNo }} /div /div /div /template这里的计算属性会根据hall.rowCount和hall.columnCount生成一个空二维数组然后遍历后端返回的场次座位数据填充。座位状态的展示逻辑很直观已售座位禁点锁定中的座位也禁点可售座位点击后加入已选列表再次点击取消。屏幕中间还可以加一个“银幕”占位元素让选座界面看起来更真实。选中座位后底部显示已选座位的排位信息、数量和总价。前端不能只展示数据还要在点击下单按钮前先做一次校验没选座位就点击下单的话直接提示用户。4.3 Axios封装、Token注入与跨域处理Axios封装是每个Vue项目都要做的事情。我习惯在src/utils/request.js里创建一个axios实例配置基础URL为/api然后设置请求拦截器和响应拦截器。请求拦截器统一从localStorage取token并放到请求头响应拦截器统一处理后端返回的Result对象code不为200就直接抛一个包含错误信息的异常。service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; }); service.interceptors.response.use(res { const result res.data; if (result.code 401) { localStorage.removeItem(token); window.location.href /login; return Promise.reject(new Error(登录已过期)); } if (result.code ! 200) { return Promise.reject(new Error(result.message)); } return result.data; });这里的401处理其实和后端拦截器是对应的。后端JWT解析失败时返回code为401前端收到后直接把用户踢回登录页。这套联调规范一旦建立后面写页面就很快了不用每个页面都catch一遍错误。5. 完整部署从本地联调走到服务器上线5.1 本地联调阶段proxy代理与CORS前端开发服务器默认跑在8080以外的端口后端跑在8080两个端口不一样就存在跨域。本地联调最省事的方式不是在浏览器里开跨域插件而是利用Vue CLI的proxy功能。在vue.config.js里配置代理开发时前端请求/api开头的接口全部转发到http://localhost:8080。module.exports { devServer: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } };生产环境下跨域问题用Nginx反向代理解决后端接口和前端静态资源跑在同一个域名下自然没有跨域问题。所以后端虽然也加了一个CORS配置但实际上生产环境根本不会用它有这个配置只是为了本地调试时多一种选择。5.2 后端打包与jar部署后端部署我用的是SpringBoot内置Tomcat直接打包成可执行jar。打包前检查application.yml里的数据库连接、文件路径等配置是否改成生产环境的不要带着localhost配置部署上线。mvn clean package -DskipTests打包完成后在target目录下会生成一个jar文件上传到服务器执行java -jar cinema-server.jar --spring.profiles.activeprod如果服务器没有JDK环境先安装对应版本的JDK。用systemd或nohup把进程挂在后台运行别直接关掉终端就退出进程。这里有一个要注意的细节数据库URL里配置的serverTimezoneAsia/Shanghai必须加上否则MySQL 8连接会因为时区问题报错。5.3 前端构建与Nginx部署前端部署分两步构建出静态文件然后用Nginx托管。先在前端工程目录里执行npm run build构建成功后dist目录里就是所有静态资源。把它拷贝到服务器Nginx的静态目录下比如/usr/share/nginx/html/cinema。然后配置Nginx前端路由如果用history模式必须配置try_files否则用户刷新页面就会404。后端接口通过反向代理转发。server { listen 80; server_name cinema.example.com; root /usr/share/nginx/html/cinema; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这段配置我用在一个实际项目上非常稳。前端的dist目录发布时尽量不用scp手动传可以写成一条发布脚本把构建、压缩、上传、解压串起来以后每次部署就是跑一条命令的事。5.4 MySQL数据库初始化数据库初始化直接在MySQL里执行项目自带的init.sql脚本。用命令行执行最直接mysql -uroot -p cinema init.sql也可以用Navicat连接后手动导入。导入完成后检查三件事表是否都建好、初始影厅座位数据是否生成、测试账号密码是否存的是BCrypt加密后的密文。如果白屏或者报错先看后端的启动日志再看Nginx的错误日志基本上一两分钟内就能定位问题。6. 排查避坑与面试加分点6.1 我踩过的五个典型坑第一个坑是MySQL 8的加密方式问题。MySQL 8默认的认证插件是caching_sha2_password老版本的连接工具可能连不上。解决方法是在创建用户时指定mysql_native_password。第二个坑是Element UI组件的表单校验失效。这个一般是因为自定义组件的v-model事件名称没对上导致Form组件无法感知到内部控件值的变化。排查时先检查组件有没有正确触发input事件。第三个坑是前端刷新404。如果路由配置成history模式但Nginx没有配try_files规则刷新页面时Nginx找不到对应的静态文件就会报404。解决办法就是上面Nginx配置里的那句try_files $uri $uri/ /index.html。第四个坑是MyBatis的map-underscore-to-camel-case没开启。数据库字段user_name默认不会自动映射成Java里的userName必须在application.yml里开启驼峰映射。mybatis: configuration: map-underscore-to-camel-case: true第五个坑是Maven依赖版本冲突。SpringBoot 2.7.14配上最新版的mybatis-spring-boot-starter有时会出问题建议用2.3.x版本兼容性和稳定性都验证过了不要盲目用3.x。6.2 项目谈到什么程度算过关面试时项目被追问是家常便饭最常见的问题是前后端分离如何解决跨域、JWT无状态登录原理、座位并发怎么防止超卖、数据库为什么加索引。这些问题的答案其实在这个项目的实现里都能找到关键是你自己有没有真正想明白。我给几条建议首先别只说做法把原理讲清楚比如跨域本质是浏览器同源策略限制其次一定要主动提到用乐观锁UPDATE的方式避免超卖这比说一句“加锁”有说服力得多最后就是项目里的扩展点能讲出来比如把超时释放改成Redis过期事件、把本地锁改成Redisson分布式锁、文件存储换成MinIO、海报走CDN。这些点不用全部实现但能在对话中带出来很容易体现出个人的思考深度。真正做完一个项目最明显的感受是以前看别人讲SpringBoot和Vue总觉得自己会了上手做才发现细节太多。版本兼容、跨域配置、Tomcat端口、Nginx路由、MySQL时区每一个都是能卡住你的地方。但恰恰是这些坑踩完之后你对整个前后端分离项目的理解才算真正落地。把代码跑起来永远只是第一步能把每个环节为什么会出错、每个配置为什么这么写讲清楚这个项目才算真正属于你。建议你拿到源码后先从数据库脚本初始化开始把整套流程走通一次再回头读代码收获会大得多。