
前后端分离的影院购票系统听起来像个标准的课程设计但真把它从零搭起来、跑通、再部署上线每一步都有不少可以深挖的细节。我之前帮别人带过这类项目也自己动手复刻过一版发现很多人卡住的地方不是某个技术难点而是不知道整个系统该有哪些模块、表怎么拆、接口和页面怎么对接。这篇文章我就以SpringBoot Vue MyBatis MySQL这套组合为例把影院购票系统的完整设计思路、核心代码逻辑和部署过程都讲清楚尤其是我实际踩过的那些坑。无论是准备毕设答辩、面试讲项目还是单纯想学一下前后端分离的完整链路这篇都值得你花十分钟看完。1. 为什么是前后端分离这套影院系统的模块边界与业务流程1.1 从购票场景出发的功能梳理影院购票系统的核心业务并不复杂用户看到正在上映的电影选择一个场次进入选座页面挑座位生成订单后完成支付系统出票。管理员则负责维护电影信息、安排影厅场次、查看销售数据。把这个流程再拆细一点用户端大概有这些页面注册登录、电影列表、电影详情、选座下单、订单列表、个人中心。管理端有电影管理、影厅管理、排片管理、订单管理、数据统计。两边加起来差不多十来个页面对于一个前后端分离项目来说规模刚好合适——既不是那种一个页面糊弄过去的小demo又不像电商系统那样复杂到一个人做不完。任何一个购票系统最核心、也最容易出问题的都是选座和订单这两块。选座涉及并发同一时间可能有多个用户在看同一个场次的座位图两个人同时点同一个座位系统只能让一个人成功。订单则涉及状态流转待支付、已支付、已取消、已退票不同状态下座位和处理方式都不一样。这些才是这个项目的灵魂后面我会重点讲。1.2 技术选型理由SpringBoot Vue MyBatis MySQL为什么是黄金组合先说后端。SpringBoot在这个场景下几乎是唯一的选择内置Tomcat不用额外配置服务器自动配置机制把大部分繁琐的Spring配置都省掉了生态成熟JWT、定时任务、文件上传这些常用功能都有现成的starter可以引入。对一个要快速出成果的项目来说效率是最重要的。MyBatis和JPA之间的选择我倾向于MyBatis。影院系统里有大量需要手写SQL的查询比如排片冲突检测、座位状态更新、销售统计报表这些用MyBatis写XML可以直接控制SQL执行逻辑清晰也方便优化索引。JPA虽然省代码但遇到复杂查询时要么靠方法名拼要么写JPQL调试起来反而不顺手。前端Vue Element UI或者Vue 3 Element Plus算是最稳妥的搭配。Vue的组件化开发很适合这种管理后台加用户端的项目Element UI的表格、表单、弹窗、日期选择器开箱即用。为什么不选React不是说React不好而是Vue的中文资料多、上手快在这个量级的项目里Vue的写法对新手更友好。MySQL相比其他数据库最大的优势就是轻量、免费、部署简单配合Navicat之类的工具管理起来非常方便。1.3 前后端交互的整体链路前后端分离项目的整体交互链路可以用一句话概括前端负责展示和用户操作后端通过REST接口提供数据能力。一次完整的选座下单请求大致是这样走的用户在Vue页面上点击座位前端把座位ID和场次ID通过axios发送POST请求到后端ControllerController接收参数后调用Service层Service层先检查座位状态再调用Mapper执行具体的SQLMySQL返回结果后后端再逐层返回给前端。其中任何一步失败前端都能通过统一的错误提示看到原因。这里有个容易忽略的点前后端分离之后跨域问题就出来了。开发环境前端的Vite/Webpack服务跑在8080端口后端SpringBoot跑在8081端口端口不同就是跨域。解决方案无非两种开发环境用代理转发生产环境用Nginx反向代理。我后面部署章节会专门讲清楚这两套方案的区别。2. 数据库表设计从购票流程倒推出来的核心表结构2.1 表清单与关系说明数据库设计这件事最怕上来就闷头建表。我是从购票流程倒推的用户要看电影得有电影表电影要在影厅里放得有影厅表和排片表用户要选座得有座位表用户要买票得有订单表。这样一推核心表就出来了表名用途关键字段user用户和账号信息username, password, rolemovie电影信息title, poster_url, duration, release_datehall影厅信息name, row_count, col_countschedule场次/排片信息movie_id, hall_id, start_time, priceseat座位状态按场次schedule_id, row_no, col_no, statusorders订单信息order_no, user_id, schedule_id, total_price, status这六个表之间怎么关联排片表同时关联电影表和影厅表座位表关联排片表不直接关联影厅表订单表关联用户表和排片表再通过order_id与座位表关联。其中最容易搞错的就是座位表的设计。一个影厅有固定数量的座位但不同场次的座位状态完全独立——上午10点场次的座位可以被买走下午2点场次同样的座位可能还是空的。所以座位表必须以schedule_id为维度去建每个场次一份座位数据而不是在影厅表里存一份座位。2.2 关键字段设计的理由很多人在建表时容易踩几个坑我逐一说明。第一个是价格字段。电影票价涉及金额如果用float或double经过多次运算会出现精度问题比如19.9变成19.899999。正确做法是使用DECIMAL(10, 2)10位总长度、2位小数既够用又安全。我用订单表total_price举例这个字段在统计票房时直接SUM即可不会有精度损失。第二个是订单号。不用数据库自增ID当订单号原因很简单自增ID会被外界猜到。用户下单第100单别人就能推测出第99单的存在这对订单系统来说是不安全的。我习惯用时间戳加随机数生成唯一订单号类似202506071530123456789这种格式同时加上唯一索引防止并发下重复。第三个是状态字段。无论是订单状态还是座位状态我都用整型枚举不用字符串。比如订单状态0待支付、1已支付、2已取消、3已退票座位状态0可选、1已售、2锁定中。用整型的好处是后端代码里可以直接写常量或枚举类对比数据库查询也快。这里要注意前端展示时得做一次数字到文字的映射这个放后端或者前端都行我习惯放后端返回一个stateText字段。另外关于外键我强烈建议不要建物理外键。表与表之间的关系靠逻辑外键维护也就是查询时用关联字段JOIN。物理外键在数据量大、并发高的时候会影响写入性能而且删除数据时容易触发外键约束报错。逻辑外键配合代码层面的事务控制既能保证数据一致性又更灵活。2.3 核心建表SQL示例贴几个核心表的建表SQL其余的表结构照着这个思路补就行。注意字符集统一用utf8mb4不然存emoji或者生僻字会报错。CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, nickname varchar(50) DEFAULT NULL, phone varchar(20) DEFAULT NULL, role tinyint NOT NULL DEFAULT 1 COMMENT 0管理员 1普通用户, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;CREATE TABLE seat ( id bigint NOT NULL AUTO_INCREMENT, schedule_id bigint NOT NULL, row_no int NOT NULL, col_no int NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0可选 1已售 2锁定中, order_id bigint DEFAULT NULL COMMENT 锁定/售出时关联的订单, PRIMARY KEY (id), KEY idx_schedule_status (schedule_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;seat表里我建了一个联合索引idx_schedule_status这是因为选座页面打开时会按schedule_id查整个座位图人多的场次还会按schedule_idstatus筛选可选座位。这个索引的收益非常明显不加的话数据一多查询就慢。3. 后端实现要点登录鉴权、选座锁座与订单状态流转3.1 登录鉴权JWT的用法与拦截器配置影院系统的登录鉴权我用的方案是JWT。用户登录成功后后端生成一个包含用户ID和角色的token返回给前端前端把token存在localStorage里之后每次请求都在请求头带上Authorization后端写一个拦截器对需要登录的接口统一校验token。JWT的核心代码很简单核心就是生成和解析两段逻辑public class JwtUtils { private static final String SECRET 请换成你自己的随机密钥; private static final long EXPIRE 1000L * 60 * 60 * 24; public static String generateToken(Long userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }拦截器里做的事情是从请求头取token解析失败直接返回401状态码前端收到401就自动跳登录页。这里有个细节容易忽略——前端发请求时可能会先发一个OPTIONS预检请求这个预检请求是不带token的拦截器得直接放行否则跨域请求永远到不了业务接口。角色权限这块我是在拦截器校验通过后把从token里解析出的用户ID和角色存入ThreadLocal后续Service层随时取用。管理端接口则单独写一个AdminInterceptor校验角色必须为0。这样普通用户就算知道管理接口的URL没有管理员token也调不通。3.2 选座与锁座并发场景下怎么避免多人抢同一个座位选座锁座是整个系统最核心的部分也是我当初被问得最多的问题。最原始的写法是用户点一个座位后端先SELECT查一下状态如果状态是0就UPDATE成2然后创建订单。这个写法在单机、低并发下没问题但一旦两个人同时操作就可能出现都查到了状态0、都执行UPDATE的情况导致一个座位被锁定两次。正确的做法是直接用一条带条件的UPDATE语句实现乐观锁的效果update idlockSeat UPDATE seat SET status 2, order_id #{orderId} WHERE id #{seatId} AND status 0 /update这条SQL的精髓在于WHERE条件里带了status 0。数据库层面行锁会保证同一时刻只有一个事务能更新成功如果影响行数为0说明座位已被别人锁定或买走后端直接返回该座位已被选择即可。不需要额外的分布式锁也不需要复杂的Redis操作这个场景用数据库自身的原子更新就够了这也是我认为最高性价比的方案。座位锁定成功之后紧接着创建一条待支付订单把订单ID回写到座位表的order_id字段。然后问题来了用户锁了座位但不支付怎么办总不能让座位一直锁着。我加了一个定时任务每5分钟扫描一次待支付订单超过30分钟未支付的把关联座位恢复为可选状态订单改为已取消。Scheduled(cron 0 */5 * * * ?) public void releaseExpiredOrders() { // 查询创建时间超过30分钟且状态为0的订单 // 逐个恢复座位状态把订单改为已取消 }这个机制的巧妙之处在于它不需要用户主动触发也不需要前端配合纯粹由后端兜底。当然用户主动取消订单时也要同步释放座位这两条路径都要保证座位和订单状态的一致性所以我把座位释放的逻辑抽成了一个独立方法不管定时任务还是主动取消都调它。3.3 订单状态机待支付、已支付、已取消、已退票订单状态我用了四个数字0待支付、1已支付、2已取消、3已退票。这里要说一个不少新手会犯的错误——用删除记录代替取消订单。删除订单数据会导致一个问题排片统计、票房报表会少掉记录而且如果未来对接真实支付退款记录必须保留原订单。所以业务类系统的状态变更一律用UPDATE不用DELETE。状态流转的规则是待支付可以取消释放座位也可以支付成功确认出票已支付可以退票释放座位并退款已取消是终态不能再变。由于我们接的是模拟支付支付接口的逻辑就是前端调用支付接口后后端把他的待支付订单改成已支付。但我建议后端在代码里预留一个支付回调接口的注释位置将来接真实支付时直接填回调处理逻辑就行——真实支付都是由支付平台回调通知结果不是前端说支付了就完了。3.4 管理端核心接口排片冲突检测与销售统计管理端里比较有含金量的接口是排片。新增一个场次时同一个影厅的时间段不能重叠这是一个很典型的时间区间重叠判断。算法不复杂新场次的开始时间要在已有场次的结束时间之后或者新场次的结束时间要在已有场次的开始时间之前。写成SQL就是SELECT COUNT(*) FROM schedule WHERE hall_id #{hallId} AND status 1 AND #{startTime} end_time AND #{endTime} start_time只要查出来大于0说明时间冲突。拍摄检查这个逻辑用一条SQL解决比在Java里遍历判断高效得多。销售统计接口我按电影维度统计票房和订单数SELECT m.title, SUM(o.total_price) AS revenue, COUNT(o.id) AS order_count FROM orders o JOIN schedule s ON o.schedule_id s.id JOIN movie m ON s.movie_id m.id WHERE o.status 1 GROUP BY m.id ORDER BY revenue DESC这类SQL是MyBatis的强项放在mapper XML里维护清楚明了。统计页面的数据可以让前端用ECharts画柱状图或者饼图展示出来效果比较好。4. Vue前端与接口对接路由守卫、Axios封装与跨域处理4.1 项目初始化与目录结构前端我用Vue脚手架初始化项目目录结构会按功能拆分。这里给出我实际使用的目录组织方式src/ ├── api/ # 接口请求模块按功能拆分文件 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由配置 ├── store/ # 全局状态用户信息、角色 ├── views/ # 页面组件按用户端/管理端分文件夹 └── utils/ # 工具类如request.jsaxios封装api目录里每个模块一个文件比如user.js、movie.js、order.js。页面不直接调axios而是import api文件里的方法。这样做的最大好处是后端接口地址如果变了只需要改一个文件不需要在几十个页面里找字符串。这也是前后端分离项目应该有的基础规范。4.2 Axios封装统一处理token和401axios封装是前端工程化的基础操作。我在utils/request.js里创建一个axios实例统一设置baseURL和超时时间然后在请求拦截器里加token响应拦截器里统一处理错误。import axios from axios const service axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器统一加token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) // 响应拦截器统一处理401和业务错误 service.interceptors.response.use( response { const res response.data if (res.code ! 200) { // 统一提示比如该座位已被选择 return Promise.reject(new Error(res.msg)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } )这里有个细节后端的统一返回结构是{code, msg, data}业务成功时code为200。前端拦截器里把data直接返回页面代码就不用层层取data了直接拿到业务数据清爽很多。错误提示我用的是Element UI的Message组件在统一拦截器里弹一次页面代码里不再重复处理。4.3 路由守卫登录校验与管理员权限控制路由守卫解决两个问题没登录的用户不能访问需要登录的页面普通用户不能进管理后台。我在路由meta里配置requiresAuth和role两个字段然后在全局前置守卫里判断。router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role store.state.user.role if (to.meta.requiresAuth !token) { next(/login) } else if (to.meta.role ! undefined to.meta.role ! role) { next(/403) } else { next() } })管理端路由统一挂在layout组件下面meta.role设为0普通用户一旦访问就会被踢到403页面。这个方案比后端加权限校验更适合前端做页面展示层控制但要注意它只能防君子——真正负责权的是后端拦截器前端路由守卫只是优化体验不能作为安全手段。4.4 核心页面电影列表与选座页的实现逻辑选座页是整个前端最有技术含量的一页。后端返回的座位数据是一个二维数组每个元素包含座位ID、行号、列号、状态。前端根据status渲染三种样式status为0是可选的灰色座位点击后切换成选中的橙色status为1是已售出的红色座位不可点击status为2是锁定的黄色座位同样不可点击。// 伪代码座位点击逻辑 selectSeat(seat) { if (seat.status 1 || seat.status 2) { this.$message.warning(该座位不可选) return } // 切换选中状态并把座位加入selectedSeats数组 seat.selected !seat.selected }提交订单时把选中座位的ID数组和场次ID一起POST到后端。后端一次性锁定所有座位如果其中某个已被抢走返回错误并提示用户刷新座位图。这里不要一个个调接口锁定座位一个订单一个事务搞定否则会出现锁了一部分座位、另一部分失败导致数据不一致的情况。页面里还有一个倒计时提示用户30分钟内完成支付倒计时一到座位的锁定会由后端定时任务释放前端只需要做展示提醒。电影列表页相对简单主要是分页查询加上海报展示。海报图片我存在本地服务器的upload目录返回给前端的是一个相对路径比如/upload/poster1.jpg前端拼上当前服务的baseURL就能显示。这个方式上线时需要额外配置静态资源映射后面部署部分会讲到。4.5 前后端联调时的跨域处理开发环境代理与生产环境Nginx开发环境的跨域我在Vue项目的vue.config.js里配置代理把/api开头的请求转发到后端的8080端口devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端代码里所有请求都写/api/xxx前端开发服务器收到后主动转发给后端浏览器视角没有跨域问题。生产环境则用Nginx做反向代理把/api也转发到后端服务。这两种方案的本质都是让前后端同源只是一个是开发环境的Node代理一个是生产环境的Nginx。我不推荐在后端代码里直接用CrossOrigin注解开跨域因为生产环境走Nginx代理之后后端和前端已经是同源了再开跨域反而画蛇添足。5. 完整部署实录从本地跑通到服务器上线的全流程5.1 本地环境准备与后端配置部署的第一步是确认环境版本。我用的版本组合供你参考组件版本说明JDK1.8或11SpringBoot 2.x均可Maven3.6编译打包Node.js16前端构建MySQL5.7或8.08.0需要时区配置Nginx1.20生产环境静态托管与反向代理后端的关键配置集中在application.yml。数据库连接串务必带上时区参数否则MySQL 8.0会报时区错误spring: datasource: url: jdbc:mysql://localhost:3306/cinema_db?useSSLfalsecharacterEncodingutf-8serverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB max-request-size: 20MBmultipart的配置是用来放开上传大小限制的SpringBoot默认只允许1MB电影海报动辄几MB不配置的话上传必报错这个坑我帮别人调过好多次。5.2 后端打包与服务器启动后端打包只要一条命令在项目根目录执行mvn clean package -DskipTests打包完成后会在target目录生成一个可执行的jar包。把这个jar包上传到服务器然后用nohup命令后台启动nohup java -jar cinema-system-0.0.1-SNAPSHOT.jar --server.port8080 app.log 21 这里解释一下几个参数nohup让程序在后台运行即使断开SSH连接也不会停 app.log 21把标准输出和错误输出都写入日志文件方便排查问题--server.port是启动参数可以覆盖配置文件里的端口。端口我为什么用8080不上80因为80端口要留给Nginx用Nginx接收外部请求后再转发给8080的后端服务。5.3 前端打包与Nginx配置前端构建在本机执行npm install npm run build构建完成后生成dist目录把dist里的所有文件上传到服务器的/home/www/cinema/dist目录。然后配置Nginxserver { listen 80; server_name 你的域名或服务器IP; root /home/www/cinema/dist; index 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; } location /upload/ { alias /home/www/cinema/upload/; } location / { try_files $uri $uri/ /index.html; } }这个配置有三个关键点。第一location /api/把前端请求的接口转发到后端Java服务这是前后端分离生产环境的核心桥梁。第二location /upload/映射图片上传目录否则前端页面上的海报全都会404。第三location /里的try_files是一个极易踩的坑——Vue路由是history模式用户直接访问/detail/3这种URL时后端没有这个文件如果不加try_filesNginx会返回404加了之后会把请求回退到index.html由Vue路由接手处理。5.4 上线的常见坑与排查经验部署上线这几个坑是我和周围人反复踩过的重灾区集中列一下。第一是MySQL 8.0时区问题。这个问题在本地开发时可能不出现因为本地MySQL默认配置一般没问题但服务器装的8.0版本对时区比较严格。解决的唯一标准做法就是JDBC连接串加serverTimezoneAsia/Shanghai。报错信息通常长这样The server time zone value Öйú±ê׼ʱ¼ä is unrecognized看到乱码基本就是时区问题。第二是图片上传后无法回显。如果你用的是本地存储方案要确保两点后端配置了静态资源映射比如把/upload/**映射到服务器上的upload目录Nginx的location /upload/配置正确。如果上传后能访问URL但页面上不显示第一检查服务器upload目录下有没有文件第二检查URL是不是经过Nginx转发用curl在服务器本地测一下接口再在浏览器访问一下完整地址很容易定位。第三是数据库连接池耗尽。如果上线后日志里频繁出现Connection is not available说明并发请求把连接池打满了。合理的做法是把连接池参数调大比如初始10个、最大50个同时检查代码里有没有忘记关闭的数据库连接。MyBatis本身会自动管理连接释放但如果你手动获取过Connection就一定要在finally里关掉。第四是防火墙和安全组。很多服务器云平台默认只开放22和80端口你部署好后端8080端口从外面访问不了但前端通过Nginx转发8080请求没问题因为Nginx转发是在服务器内部进行的不经过外网防火墙。如果你的Nginx还没配好想直接从浏览器访问IP:8080来测试就得先去安全组放行8080端口。这个顺序搞反了会让人一度以为后端没启动成功。第五是前端路由刷新404。这个问题在第5.3节已经讲了记得在Nginx的server块里配上try_files $uri $uri/ /index.html。不配的话用户停留在某个子页面时一刷新就白屏很多人排查半天最后发现问题出在这。还有一个是在部署环境里别忘了执行初始化SQL脚本包括建库、建表、插入管理员账号和测试电影数据。我的做法是把所有SQL集中在一个init.sql文件里部署完成后用mysql命令一次性导入避免手工一条条执行遗漏也方便后面在其他环境重新部署。整体做下来这个项目从零到上线一个人大概花一到两周就能完成。我的经验是先搭数据库再做后端接口前端页面可以等接口文档确定后再动工最后统一联调。选座锁座和订单状态流转这两个点尽量自己动手推一遍完整流程下单、支付、取消、超时释放每一步都验证到位。面试时能把这两个设计讲清楚这个项目就已经不只是一个课设的含金量了。