1. 项目概述与技术定位
作为一个折腾过好几个前后端分离项目的开发者,我第一次看到“SpringBoot+Vue+MyBatis+MySQL”这个组合时,第一反应就是:这几乎是当前中小型Web系统最主流的标配之一,尤其是学生项目、毕业设计、个人练手项目,用这个组合做的比例相当高。电影院购票系统这个选题也很典型——它不像电商系统那么庞杂,但又有完整的业务闭环:用户注册登录、电影展示、场次排片、选座、下单、订单管理、后台管理,一套流程走下来,几乎覆盖了一个信息系统该有的所有核心环节。
用这个项目来学习或者作为毕设参考,最大的价值在于它不偏门、不冷门,技术栈成熟稳定,遇到问题时网上一搜一大把解决方案,而且业务逻辑足够清晰,不至于像某些脱离实际场景的“管理系统”一样做着做着就不知道下一步该干什么了。简单说,这个项目能让你真正把“前端调接口、后端写逻辑、数据库存数据”这条链路跑通。
我有一个判断标准:如果一个系统做完,你能解释清楚每条数据从浏览器到数据库再返回浏览器的完整路径,那这个项目的学习目标就达成了。电影院购票系统正好能满足这个标准。
2. 整体架构设计与技术选型思路
2.1 为什么要用前后端分离
现在做Web项目,最基本的选择就是:传统单体模板渲染(比如JSP、Thymeleaf)和前后端分离两种路线。电影院购票系统选SpringBoot+Vue前后端分离,不是盲目跟风,而是基于实际业务场景的考虑。
前端分离后,页面展示逻辑和数据处理逻辑被彻底拆开。前端只负责渲染数据和交互,后端只负责提供接口和数据处理,两边通过JSON数据对接。这样做的好处主要体现在三个方面:
第一,并行开发效率高。前端和后端可以同时开工,只要先把接口文档定好,前端按Mock数据先开发页面,后端按接口定义开发业务逻辑,最后联调时再对接真实数据。整体开发周期能明显缩短。
第二,部署灵活。前端打包成静态文件,可以扔到Nginx里直接跑,后端打包成jar包独立运行。用户访问的是前端页面,前端再向后端发请求取数据。两者互不影响,出问题时也容易排查。
第三,职责边界清晰。前端不会直接操作数据库,所有数据变更都走后端接口校验,安全性和规范性更好控制。
2.2 SpringBoot + MyBatis + MySQL 的组合逻辑
服务端选SpringBoot几乎没有悬念。它内嵌了Tomcat,不需要额外部署外部容器,打一个jar包就能跑起来;自动配置机制省掉了大量重复的XML配置文件;配合Spring MVC、Spring Security这些生态组件,做一个中小型系统非常顺手。
数据访问层用MyBatis,主要有两个原因:一是SQL可控性强,尤其适合电影院这种查询条件复杂的业务(按电影类型、上映时间、地区、票价区间组合筛选),自己写SQL能精确控制查询逻辑和性能;二是动态SQL处理起来比JPA的自动生成更直观。这一点在订单和座位相关的多表查询中尤其明显,尤其是“查某场次已售座位”这类场景,一条联合查询SQL就能解决。
数据库选MySQL,理由更简单:开源免费、普及率最高、文档资料最多,遇到任何问题几乎都能找到现成的解决方案。对学习项目来说,这就是最大的优势。
2.3 Vue + Element UI 的前端技术栈
前端我用的是Vue 2 + Element UI,虽然Vue 3和Element Plus已经很成熟了,但是对于参考和学习的目的来说,Vue 2的生态依然能满足需求,而且教程数量更为可观。如果你有精力,用Vue 3 + TypeScript重构一遍也未尝不可,毕竟核心的组件化思路和API交互方式是一致的。
Element UI这类组件库的价值在于,它能帮你快速搭建出风格统一的后台管理界面:表格、表单、弹窗、分页、消息提示都是现成的,不用从头写样式。前端用Vue Router管页面跳转,用Axios统一发HTTP请求——这两个搭配是Vue项目最基础的组合。
2.4 项目目录结构规划
项目按照前后端分离的规范,分成两个独立子项目来管理:
后端结构:
cinema-server/ ├── src/main/java/com/example/cinema/ │ ├── controller/ // 接口层,接收前端请求 │ ├── service/ // 业务逻辑层,处理核心业务 │ ├── mapper/ // MyBatis数据访问层接口 │ ├── entity/ // 实体类,对应数据库表 │ ├── dto/ // 数据传输对象,接口参数封装 │ ├── config/ // 配置类,如跨域、拦截器 │ ├── common/ // 公共工具类、统一返回结果 │ └── CinemaApplication.java └── src/main/resources/ ├── mapper/ // MyBatis XML映射文件 └── application.yml // 项目配置文件前端结构:
cinema-web/ ├── src/ │ ├── views/ // 页面组件,如首页、购票页、订单页 │ ├── router/ // 路由配置 │ ├── api/ // 接口请求函数封装 │ ├── store/ // 全局状态管理(Vuex) │ ├── components/ // 公共组件 │ └── utils/ // 工具函数,如请求封装 ├── public/ └── package.json这样的目录划分,核心原则是“按职责分层”。Controller只做参数接收和响应封装,不带业务逻辑;Service专注业务处理;Mapper只负责数据操作。这个约束在写代码时要严格遵守,否则项目到了后期会变成一坨大杂烩,改一个功能牵一发而动全身。
3. 数据库设计——这个环节决定项目上限
很多新手做系统,上来就写代码,写到后面发现数据表设计不合理,又回头改表、改代码,来回折腾。电影院购票系统我建议先花一两个小时把数据库表设计好,这比写几天代码都重要。
3.1 核心表结构规划
电影院购票系统的核心业务围绕“电影—场次—座位—订单”这条链路展开,合理的表结构如下:
用户表(user)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| username | varchar | 用户名,唯一 |
| password | varchar | 密码,加密存储 |
| phone | varchar | 手机号 |
| varchar | 邮箱 | |
| role | tinyint | 角色:0用户,1管理员 |
| create_time | datetime | 注册时间 |
电影表(movie)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| title | varchar | 电影名 |
| poster | varchar | 海报图片地址 |
| director | varchar | 导演 |
| actors | varchar | 主演 |
| genre | varchar | 类型(动作/科幻/爱情等) |
| duration | int | 片长(分钟) |
| release_date | date | 上映日期 |
| description | text | 剧情简介 |
| status | tinyint | 状态:0下架,1上映中 |
影厅表(hall)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| name | varchar | 影厅名称(如1号厅) |
| row_count | int | 排数 |
| col_count | int | 列数 |
场次表(session)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| movie_id | bigint | 关联电影ID |
| hall_id | bigint | 关联影厅ID |
| start_time | datetime | 开场时间 |
| end_time | datetime | 散场时间 |
| price | decimal | 票价 |
| status | tinyint | 状态:0关闭,1售票中 |
座位表(seat)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| session_id | bigint | 关联场次ID |
| row_num | int | 排号 |
| col_num | int | 列号 |
| status | tinyint | 状态:0可选,1已锁定,2已售出 |
订单表(order)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar | 订单编号(唯一) |
| user_id | bigint | 关联用户ID |
| session_id | bigint | 关联场次ID |
| total_price | decimal | 总金额 |
| status | tinyint | 状态:0待支付,1已支付,2已取消,3已退票 |
| create_time | datetime | 下单时间 |
| pay_time | datetime | 支付时间 |
订单座位表(order_seat)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_id | bigint | 关联订单ID |
| seat_id | bigint | 关联座位ID |
3.2 设计时最容易踩的坑
第一,座位表的设计不要搞错维度。座位是物理存在的位置,它属于“影厅”,不属于“场次”;但是一个座位在某场次是否可买,是“运行状态”,属于“场次”。所以seat表里一定要有session_id,也就是说每个场次都会生成自己的一套座位记录,每个座位的状态独立变化。这是确保选座互不冲突的关键设计。
第二,订单和座位是多对多关系。一个用户可以一次购买多个座位,一个订单包含多个座位,所以必须通过order_seat关系表来连接,不能在order表里直接加一个seat_ids字段。否则后续查订单明细、退票换座都会非常麻烦。
第三,金额字段用decimal而不是float或double。浮点数计算金额会产生精度误差,这在涉及钱的项目里是大忌。我见过有人用double存价格,结果用户买三张票出现小数点差异,虽然只是几分钱,但体验很差。decimal(10,2)足够用。
第四,所有表的主键都用bigint自增,不要用UUID字符串。自增主键在MyBatis里可以通过useGeneratedKeys直接拿到新增记录的ID,而UUID是字符串,排序和索引性能都更差,联表查询时也更大。
3.3 为什么需要冗余设计
场次表里我存了movie_id和hall_id,理论上通过联表就能查出电影名和影厅名。但实际开发中,查询订单列表时往往需要同时展示电影名称、海报、影厅名、场次时间这些信息,如果每次都做三表联查,SQL会变得非常复杂,效率也不理想。
一个简单的做法是:在需要展示的查询里通过JOIN关联电影表和影厅表拿名称,这是正规做法。但如果你嫌SQL麻烦,也可以在设计阶段就做小规模冗余,比如订单表里冗余一个movie_title字段。不过在参考项目中我建议你优先用JOIN方案,因为这样数据一致性更有保证,冗余方案容易出现数据不同步的问题。
3.4 建表SQL示例
CREATE TABLE `movie` ( `id` bigint NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '电影名', `poster` varchar(255) DEFAULT NULL COMMENT '海报地址', `director` varchar(50) DEFAULT NULL COMMENT '导演', `actors` varchar(255) DEFAULT NULL COMMENT '主演', `genre` varchar(50) DEFAULT NULL COMMENT '类型', `duration` int DEFAULT NULL COMMENT '片长(分钟)', `release_date` date DEFAULT NULL COMMENT '上映日期', `description` text COMMENT '简介', `status` tinyint DEFAULT '1' COMMENT '0下架 1上映', PRIMARY KEY (`id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影表'; CREATE TABLE `session` ( `id` bigint NOT NULL AUTO_INCREMENT, `movie_id` bigint NOT NULL COMMENT '电影ID', `hall_id` bigint NOT NULL COMMENT '影厅ID', `start_time` datetime NOT NULL COMMENT '开场时间', `end_time` datetime DEFAULT NULL COMMENT '散场时间', `price` decimal(10,2) NOT NULL COMMENT '票价', `status` tinyint DEFAULT '1' COMMENT '0关闭 1售票', PRIMARY KEY (`id`), KEY `idx_movie` (`movie_id`), KEY `idx_start_time` (`start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场次表';注意建表时统一使用utf8mb4字符集,因为utf8mb4才支持emoji和一些特殊字符,而且存储中文没有任何问题。MySQL 5.7及以上版本默认就是utf8mb4,但新建库表时最好显式声明,避免某些环境下默认字符集不一致导致中文乱码。
4. 核心功能模块与实现思路
电影院购票系统从功能上可以拆分成两大类:用户端和管理端。用户端包括注册登录、浏览电影、查看场次、选择座位、下单支付、查看订单;管理端包括电影管理、场次排片、影厅管理、订单管理。我挑几个最核心、也最能体现系统价值的模块详细说一下。
4.1 用户注册登录与JWT鉴权
登录认证这块,我建议用JWT,而不是传统的Session方案。核心原因有两条:一是前后端分离部署后,后端是无状态的,Session在跨域和集群环境下处理起来很麻烦;二是JWT把用户信息加密放在Token里,后端拿到Token就能解析出用户身份,不需要在服务端保存登录状态,实现简单、扩展性好。
JWT的工作流程是这样的:用户登录成功后,后端生成一个包含用户ID、用户名、角色、过期时间的Token返回给前端;前端把Token存在本地存储里,每次请求在请求头加上Authorization: Bearer <token>;后端用一个拦截器统一校验Token,校验通过就把用户信息放入请求上下文,校验失败直接返回401状态码。
完善的后端配置如下:
首先是pom.xml引入依赖:
<!-- JWT --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>然后是JWT工具类的核心方法:
public class JwtUtil { private static final String SECRET = "your-secret-key-please-change-it"; private static final long EXPIRE = 7 * 24 * 60 * 60 * 1000L; // 7天 // 生成Token public static String generateToken(Long userId, String username, Integer role) { return Jwts.builder() .setSubject(username) .claim("userId", userId) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + EXPIRE)) .signWith(Keys.hmacShaKeyFor(SECRET.getBytes()), SignatureAlgorithm.HS256) .compact(); } // 解析Token public static Claims parseToken(String token) { return Jwts.parserBuilder() .setSigningKey(Keys.hmacShaKeyFor(SECRET.getBytes())) .build() .parseClaimsJws(token) .getBody(); } }实用提示:JWT的SECRET在生产环境必须用环境变量或配置中心管理,不要硬编码在代码里。上面的示例SECRET只是为了方便演示。
前端的导航守卫配合起来:
// 路由守卫:未登录跳转登录页 router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })这里有一个容易忽略的点:前端的路由守卫只能控制“页面能不能访问”,真正安全的拦截必须靠后端接口的Token校验。前端跳过了守卫,最多是看到页面但拿不到数据,后端如果没做校验,那数据就裸奔了。所以后端拦截器是必须的,前端守卫只是提升用户体验。
4.2 电影和场次查询的接口设计
电影列表和场次查询是用户访问最频繁的接口,设计上要特别注意查询效率和接口返回格式。
电影列表接口支持分页和按类型筛选,对应的MyBatis动态SQL如下:
<select id="selectMoviePage" resultType="com.example.cinema.entity.Movie"> SELECT * FROM movie <where> <if test="genre != null and genre != ''"> AND genre LIKE CONCAT('%', #{genre}, '%') </if> AND status = 1 </where> ORDER BY release_date DESC LIMIT #{offset}, #{pageSize} </select>LIMIT #{offset}, #{pageSize}是经典的分页写法,offset从0开始。如果数据量大,可以改成LIMIT #{pageSize} OFFSET #{offset},效果一样。MyBatis的<where>标签会自动去掉多余的AND,比手动拼字符串省心很多。
场次查询接口相对复杂,前端需要根据电影ID或时间范围查场次,同时要展示余座状态。这里我建议在SQL里直接把余座数查出来,而不是前端再分别查场次和查座位,减少一次请求:
SELECT s.id, s.movie_id, s.hall_id, s.start_time, s.end_time, s.price, h.name AS hall_name, (SELECT COUNT(*) FROM seat st WHERE st.session_id = s.id AND st.status = 0) AS available_seats FROM session s LEFT JOIN hall h ON s.hall_id = h.id WHERE s.movie_id = #{movieId} AND s.start_time >= NOW() AND s.status = 1 ORDER BY s.start_time这个查询把“场次基本信息+影厅名称+可用座位数”一次返回,前端拿到数据就能直接渲染电影排期列表。用子查询统计可选座位数量,场次数据量少的情况下性能没有压力。
4.3 选座和座位状态变更——最核心的业务逻辑
电影院系统的“点睛之笔”就是选座购票。这个过程绝不能只是简单的“前端点座位、后端生成订单”就够了,它需要处理好“超卖”“并发”“事务一致”这三个问题。
我采用的方案是这样的:
用户点击座位后,前端先把座位标记为“选中”状态(这只是临时的UI状态),确认购买时后端接收一个座位ID列表。后端事务内执行以下步骤:
第一步,把选中的座位从“可选”临时改为“锁定”状态:
UPDATE seat SET status = 1 WHERE id = #{seatId} AND status = 0这里通过“id + status = 0”作为更新条件,利用数据库的行锁保证不会有两个事务同时抢同一个座位。如果UPDATE影响行数为0,说明这个座位已经被别人买走或锁定了,就直接返回“座位已被选择”的错误提示。
第二步,创建订单,状态为“待支付”,同时把订单和座位关联起来,批量插入order_seat表。
第三步,启动定时任务,超过比如15分钟未支付的订单自动取消,并将对应座位状态恢复为0(可选)。
这套方案的关键点在于:先锁座位,再创建订单。如果先创建订单再锁座位,可能出现订单创建成功但座位被别人抢走的情况,数据对不上。如果锁座位和创建订单不在一个事务里,可能出现座位被锁但没有订单的脏数据。
具体实现时,我把整个流程放在一个带@Transactional注解的方法里:
@Transactional(rollbackFor = Exception.class) public Order createOrder(Long userId, Long sessionId, List<Long> seatIds) { // 1. 验证场次还在售票状态 // 2. 循环锁座位,每个座位都执行 UPDATE ... WHERE status = 0 // 3. 如果任何座位锁定失败,抛出异常,事务回滚 // 4. 计算总价 // 5. 插入订单表 // 6. 批量插入订单座位关联表 // 7. 返回订单信息(含订单编号) }事务回滚后,之前锁定的座位状态自然恢复,不会产生脏数据。
4.4 订单超时未支付的自动取消
用户锁座后一直不支付,座位不能永远锁下去。我的做法是两种方案结合:
方案一是定时任务扫描。写一个Spring定时任务,每30秒扫描一次订单表,把创建时间超过15分钟且状态为“待支付”的订单置为“已取消”,同时把关联的座位恢复为可选状态。实现简单粗暴,适合这个项目的量级:
@Scheduled(fixedDelay = 30000) @Transactional public void cancelTimeoutOrders() { List<Order> orders = orderMapper.selectTimeoutOrders(15); for (Order order : orders) { orderMapper.updateStatus(order.getId(), 2); // 2=已取消 orderSeatMapper.releaseSeatsByOrderId(order.getId()); } }方案二是消息队列延迟消息,比如RabbitMQ的延迟队列。但引入消息队列对这个项目来说就有点过度设计了。定时任务虽然有一定延迟,但15分钟的超时时间对电影院系统来说完全够用。
一个细节是:定时任务也是要加事务的,取消订单和释放座位必须原子完成。如果取消订单成功但释放座位失败,就会出现订单已取消但座位仍被占用的脏数据。
4.5 后台管理端的CRUD设计
后台管理端的电影、影厅、场次管理,本质上就是标准的CRUD。这块没有太多高深技术,但要注意接口的规范性。
我的建议是所有的后台接口都预设好统一的返回格式,比如:
{ "code": 200, "message": "success", "data": { ... } }前端封装好Axios后,统一处理code和message,业务逻辑里不需要再重复判断HTTP状态码和响应状态。这样前后端联调时的效率能高不少。
数据校验必须在后端做一遍,不能依赖前端表单验证。比如新增电影时,电影名、导演、片长都是必填,片长必须是正整数。用Spring的@Validated注解配合实体类的校验注解就能实现,不用自己手写大量的if判断。
5. 前端核心页面与实现细节
5.1 项目初始化和Axios统一封装
Vue项目创建后第一件事是安装依赖和封装Axios。直接在每个页面里写this.$http.get(...)虽然也能跑,但代码重复率太高,而且无法统一处理Token注入和错误提示。我的做法是在utils/request.js里做统一封装:
import axios from 'axios' import { Message } from 'element-ui' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截:自动带Token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = 'Bearer ' + token } return config }, error => Promise.reject(error)) // 响应拦截:统一处理业务错误码 request.interceptors.response.use(response => { const res = response.data if (res.code !== 200) { Message.error(res.message || '请求失败') if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message || '请求失败')) } return res }, error => Promise.reject(error)) export default request一个实际开发中的常见问题:baseURL: '/api'需要配合Vue的代理配置,在开发环境把/api开头的请求转发到后端服务端口。配置文件vue.config.js里这样写:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }这样配置后,前端开发环境里请求/api/movie/list会自动转发到http://localhost:8080/api/movie/list,避免了跨域问题。这里有个小细节,后端接口的RequestMapping路径最好也带上/api前缀,这样代理的转发逻辑更清晰。
5.2 电影首页的渲染
首页是用户的第一个接触点,UI上要展示正在热映的电影海报、简介、评分等信息。数据来源是后端提供的电影列表接口。
用Vue渲染电影卡片列表没有太多技术难点,但要注意海报图片的路径处理。海报url通常有两种存法:一种存完整的网络地址,一种只存相对路径由后端拼接。推荐后一种,这样可以方便换服务器不换数据。
还有个实际经验:电影列表的接口返回数据比较多时,前端渲染可能会短暂白屏。优化方法是在数据加载完成前显示加载动画(Element UI的v-loading指令),或者用骨架屏。虽然只是很小的体验优化,但用户感知非常明显。
5.3 选座页面的前端交互
选座页是前端最复杂的页面。设计上的要点是:用二维数组渲染座位格子,前端UI上的“选中”“已售”“可选”三种状态用不同的CSS类表示。
核心逻辑是这样的:
// 座位状态 const SEAT_AVAILABLE = 0 // 可选 const SEAT_LOCKED = 1 // 已锁定(别人正在选) const SEAT_SOLD = 2 // 已售出 // 数据结构:由后端返回该场次的所有座位 seatList: [ { id: 1, rowNum: 1, colNum: 1, status: 0 }, { id: 2, rowNum: 1, colNum: 2, status: 2 }, ... ]前端渲染时遍历二维表格,按rowNum和colNum定位每个座位的格子。用户点击“可选”状态的座位时,把它的临时状态变为“选中”(本地定义的状态3),点击已售或锁定状态的座位时直接忽略。
选完座位后在“确认购买”按钮上显示已选座位数量和总价。购买按钮点击后调用创建订单接口,成功后跳转到支付页面。
这个页面的体验优化点在于:用户选座时,接口不需要每次都向后端发请求确认座位是否可买,因为座位状态实时变化,用户点之前是可选,点完之后别人可能已经买了。最好的做法是下单时后端再做最终校验,前端只管把用户的选择提交上去,后端返回成功或失败。这样可以减少请求次数,同时保证一致性,后端锁定失败的时候给个“手慢了,座位被选走”的提示就好。
5.4 前端路由与权限控制
前端路由分成两组:用户端页面和管理端页面。用户端包括首页、电影详情、选座、订单列表、个人中心;管理端包括电影管理、场次管理、影厅管理、订单管理。管理端页面需要管理员权限才能访问。
实现权限控制我用的方案是:在路由的meta字段里声明需要的角色,在导航守卫里检查用户角色:
const router = new VueRouter({ routes: [ { path: '/', name: 'Home', component: Home }, { path: '/movie/:id', name: 'MovieDetail', component: MovieDetail }, { path: '/select-seat/:sessionId', name: 'SelectSeat', component: SelectSeat, meta: { requiresAuth: true } }, { path: '/admin', component: AdminLayout, meta: { requiresAuth: true, requiresAdmin: true }, children: [ { path: 'movies', name: 'AdminMovies', component: AdminMovies }, { path: 'sessions', name: 'AdminSessions', component: AdminSessions }, { path: 'orders', name: 'AdminOrders', component: AdminOrders } ] } ] }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('role') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else if (to.meta.requiresAdmin && role !== '1') { next('/') } else { next() } })要注意的是,前端角色判断用的是localStorage里的role,这个值是用户登录时后端返回的,前端保存下来的。因为用户可以手动修改localStorage绕过前端权限控制,所以后端接口必须再做一层权限校验——管理端接口在拦截器中检查JWT里的role是否为管理员,不是就返回403。前后端的权限控制都要做,但真正安全的防线在后端。
5.5 订单列表和时间显示
订单列表页展示用户的购票记录,包括订单号、电影名、影厅、场次时间、座位号、金额和状态。这里有个细节:座位号通常展示为“5排8座”这样的格式,而不是原始的行列数字,前端把rowNum和colNum拼接成可读的字符串即可。后端在查询订单详情时,可以通过关联查询查出订单对应的场次、电影名、影厅名、座位列表,一次性返回给前端。
时间显示也需要注意。后端返回的datetime格式是2025-01-15T14:30:00这种ISO格式,如果不做处理直接显示,用户看到的就是T字母加UTC时间,很影响体验。处理方式有两种:一是后端在JSON序列化时配置好日期格式,统一返回yyyy-MM-dd HH:mm:ss;二是前端用dayjs或moment.js格式化。我建议两种都做,后端保证格式统一,前端再做业务上的转换显示(比如把“今天”“明天”这样的相对时间也显示出来)。
6. 开发与部署的实战经验
6.1 开发环境的准备清单
在我实际搭过这个项目后,整理了一份开发环境清单,按这份清单准备好工具,能避免很多环境层面的坑。
| 工具 | 版本建议 | 备注 |
|---|---|---|
| JDK | 8及以上 | 8是Spring Boot 2.x的常用版本,11或17也可以 |
| Maven | 3.6+ | 依赖管理必备 |
| Node.js | 14+ | Vue 2项目要求不严格 |
| MySQL | 5.7或8.0 | 建议8.0 |
| IDE | IDEA + VSCode | 后端IDEA,前端VSCode |
| Git | 任意 | 版本管理 |
这里特别提一句:后端和前端是两个独立项目,不要混在一个项目里。很多新手第一次接触前后端分离,会问“前端代码放在后端的resources目录里行不行”,虽然最终部署时可以整合到jar包里,但开发阶段强烈建议分开跑,保持职责清晰,不然合并代码时冲突会让人崩溃。
6.2 接口联调时最容易出的问题
前后端联调是整个开发过程中最磨人的阶段,但大部分问题其实都是固定的几类。
第一类:跨域问题。现象是浏览器控制台报Access-Control-Allow-Origin错误。解决办法是用开发环境代理(前面提到的vue.config.js配置),同时在后端也配置跨域过滤器:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }但如果你已经在前端配了代理,后端这个配置就不是必须的了。代理方式更安全,因为它是服务端转发,请求头不会暴露前端的Origin。
第二类:字段名不一致。后端的驼峰命名(比如userId)和前端的下划线命名(比如user_id)对应不上,前端取不到值。解决措施是MyBatis配置开启驼峰映射:
mybatis: configuration: map-underscore-to-camel-case: true这条配置的作用是把数据库的snake_case字段自动映射为Java/CamelCase属性,强烈建议开启。
6.3 项目打包与部署
前后端分离项目的部署相对简单。后端打包:
mvn clean package -DskipTests java -jar cinema-server-1.0.0.jar前端打包:
npm run build前端构建完成后会生成dist目录,包含静态HTML、CSS、JS文件。可以用Nginx部署,配置如下:
server { listen 80; server_name your-domain.com; # 前端静态文件 root /var/www/cinema-web/dist; index index.html; # 解决Vue Router的history模式刷新404问题 location / { try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这段Nginx配置重点在try_files $uri $uri/ /index.html这一行,它解决的是Vue Router的history模式问题——用户在非首页路由刷新时,Nginx会尝试找对应的文件,找不到就返回index.html,由前端路由接管处理。没有这一行,刷新页面就会404。
6.4 代码规范和个人心得
写这个项目时,我体会最深的一点是:前后端分离的项目,接口文档一定要先定。哪怕只用简单的Markdown表格,也要把“接口路径、请求参数、返回格式”写清楚,不然前后端各自按各自的理解开发,联调时就是无休止的扯皮。
另外,后端代码不要堆在Controller里。我见过一些人图省事,把业务逻辑全写在Controller方法里,一个方法几百行,看着很爽,以后改的时候欲哭无泪。Service层和Controller层分开,哪怕只有几十行逻辑也要保持分层习惯。这样做的好处是:以后代码出了问题,你能快速定位是参数问题(Controller层)还是业务问题(Service层)。
7. 调优思路与扩展功能建议
7.1 查询性能的优化
电影院系统的数据量一般不会特别大,常规的索引设计就够用了。但如果要考虑并发和性能,有几个点值得关注:
第一个点是热点数据的缓存。场次余座和电影列表的接口被访问最频繁,可以用Redis做缓存,设置合理的过期时间(比如电影列表缓存5分钟,场次座位状态缓存10秒)。引入Redis后需要注意缓存与数据库的一致性,但在这个项目量级上,不需要太复杂的缓存策略,直接“查询先走缓存,写操作删缓存或更新缓存”就够了。
第二个点是避免N+1查询问题。MyBatis的<collection>标签可以实现一次查询查完主表和关联表,但使用不当会导致查询次数爆炸。比如查询订单列表时,如果不加优化地在循环里查询每个订单的座位信息,订单一多性能立刻下降。更合理的方式是用JOIN一次查出来,或者在SQL里用GROUP_CONCAT把座位号拼成一串返回。
7.2 业务上的扩展方向
如果这个项目做完了还有余力,可以考虑以下几个扩展方向:
电影评价系统。增加一个评分和评论表,用户购票完成后可以对电影打分并写短评,首页展示评分。这个功能逻辑不复杂,但对用户体验的提升很明显。
优惠券功能。设计一张优惠券表,用户可以领取或系统发放,下单时抵扣金额。牵扯到优惠券的核销状态管理,会让订单模块更有挑战性。
在线支付对接。对接支付宝或微信支付的沙箱环境,完成真实的支付链路。这个扩展能让项目从“演示”变成“可用”,虽然沙箱环境不涉及真实资金交易,但能让你理解支付流程中的签名、回调、验签这些核心概念。
分布式会话或SSO。如果还想深入,可以把登录认证从JWT换成OAuth2或Spring Security的更复杂方案。但以这个项目本身来说,JWT已经够用,过度设计反而增加了学习负担。
7.3 对这个项目整体的心得体会
做一个完整的电影院购票系统,收获最大的不是某个具体的技术点,而是把“一个业务闭环从想法变成可运行系统”的全过程。
这中间一定会遇到各种问题:数据库设计不合理导致后面加字段改来改去、前后端字段名对不上联调半天、并发抢座出现超卖、部署到Linux环境后MySQL字符集不对导致乱码……每踩一个坑,你对系统的理解都会加深一层。
我个人非常建议在开发这个项目时建立“技术复盘笔记”的习惯。每解决一个难题,就用几句话记录问题现象、排查过程和最终方案。项目做完之后,你会发现归结下来的坑其实就那么十几类,写进简历或面试时说这些真实遇到的问题,比背十遍八股文都更有说服力。
最后再分享一个小细节:做选座并发测试时,我一直用“两个浏览器无痕窗口同时购买同一个座位”来验证自己的锁座位逻辑。第一次测试时就发现了问题——两个窗口同时提交时,后端确实只有一个事务能成功更新座位状态。这个测试方法简单有效,比写复杂的并发测试脚本更直观。做类似系统时,你也可以先手动模拟并发场景,再决定要不要引入更复杂的压力测试方案。