资讯详情

资讯详情

基于SpringBoot+Vue的电影院购票系统开发全记录

1. 项目概述与技术定位作为一个折腾过好几个前后端分离项目的开发者我第一次看到“SpringBootVueMyBatisMySQL”这个组合时第一反应就是这几乎是当前中小型Web系统最主流的标配之一尤其是学生项目、毕业设计、个人练手项目用这个组合做的比例相当高。电影院购票系统这个选题也很典型——它不像电商系统那么庞杂但又有完整的业务闭环用户注册登录、电影展示、场次排片、选座、下单、订单管理、后台管理一套流程走下来几乎覆盖了一个信息系统该有的所有核心环节。用这个项目来学习或者作为毕设参考最大的价值在于它不偏门、不冷门技术栈成熟稳定遇到问题时网上一搜一大把解决方案而且业务逻辑足够清晰不至于像某些脱离实际场景的“管理系统”一样做着做着就不知道下一步该干什么了。简单说这个项目能让你真正把“前端调接口、后端写逻辑、数据库存数据”这条链路跑通。我有一个判断标准如果一个系统做完你能解释清楚每条数据从浏览器到数据库再返回浏览器的完整路径那这个项目的学习目标就达成了。电影院购票系统正好能满足这个标准。2. 整体架构设计与技术选型思路2.1 为什么要用前后端分离现在做Web项目最基本的选择就是传统单体模板渲染比如JSP、Thymeleaf和前后端分离两种路线。电影院购票系统选SpringBootVue前后端分离不是盲目跟风而是基于实际业务场景的考虑。前端分离后页面展示逻辑和数据处理逻辑被彻底拆开。前端只负责渲染数据和交互后端只负责提供接口和数据处理两边通过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字段名类型说明idbigint主键自增usernamevarchar用户名唯一passwordvarchar密码加密存储phonevarchar手机号emailvarchar邮箱roletinyint角色0用户1管理员create_timedatetime注册时间电影表movie字段名类型说明idbigint主键titlevarchar电影名postervarchar海报图片地址directorvarchar导演actorsvarchar主演genrevarchar类型动作/科幻/爱情等durationint片长分钟release_datedate上映日期descriptiontext剧情简介statustinyint状态0下架1上映中影厅表hall字段名类型说明idbigint主键namevarchar影厅名称如1号厅row_countint排数col_countint列数场次表session字段名类型说明idbigint主键movie_idbigint关联电影IDhall_idbigint关联影厅IDstart_timedatetime开场时间end_timedatetime散场时间pricedecimal票价statustinyint状态0关闭1售票中座位表seat字段名类型说明idbigint主键session_idbigint关联场次IDrow_numint排号col_numint列号statustinyint状态0可选1已锁定2已售出订单表order字段名类型说明idbigint主键order_novarchar订单编号唯一user_idbigint关联用户IDsession_idbigint关联场次IDtotal_pricedecimal总金额statustinyint状态0待支付1已支付2已取消3已退票create_timedatetime下单时间pay_timedatetime支付时间订单座位表order_seat字段名类型说明idbigint主键order_idbigint关联订单IDseat_idbigint关联座位ID3.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/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 idselectMoviePage resultTypecom.example.cinema.entity.Movie SELECT * FROM movie where if testgenre ! null and genre ! AND genre LIKE CONCAT(%, #{genre}, %) /if AND status 1 /where ORDER BY release_date DESC LIMIT #{offset}, #{pageSize} /selectLIMIT #{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, ListLong seatIds) { // 1. 验证场次还在售票状态 // 2. 循环锁座位每个座位都执行 UPDATE ... WHERE status 0 // 3. 如果任何座位锁定失败抛出异常事务回滚 // 4. 计算总价 // 5. 插入订单表 // 6. 批量插入订单座位关联表 // 7. 返回订单信息含订单编号 }事务回滚后之前锁定的座位状态自然恢复不会产生脏数据。4.4 订单超时未支付的自动取消用户锁座后一直不支付座位不能永远锁下去。我的做法是两种方案结合方案一是定时任务扫描。写一个Spring定时任务每30秒扫描一次订单表把创建时间超过15分钟且状态为“待支付”的订单置为“已取消”同时把关联的座位恢复为可选状态。实现简单粗暴适合这个项目的量级Scheduled(fixedDelay 30000) Transactional public void cancelTimeoutOrders() { ListOrder 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 开发环境的准备清单在我实际搭过这个项目后整理了一份开发环境清单按这份清单准备好工具能避免很多环境层面的坑。工具版本建议备注JDK8及以上8是Spring Boot 2.x的常用版本11或17也可以Maven3.6依赖管理必备Node.js14Vue 2项目要求不严格MySQL5.7或8.0建议8.0IDEIDEA VSCode后端IDEA前端VSCodeGit任意版本管理这里特别提一句后端和前端是两个独立项目不要混在一个项目里。很多新手第一次接触前后端分离会问“前端代码放在后端的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后需要注意缓存与数据库的一致性但在这个项目量级上不需要太复杂的缓存策略直接“查询先走缓存写操作删缓存或更新缓存”就够了。第二个点是避免N1查询问题。MyBatis的collection标签可以实现一次查询查完主表和关联表但使用不当会导致查询次数爆炸。比如查询订单列表时如果不加优化地在循环里查询每个订单的座位信息订单一多性能立刻下降。更合理的方式是用JOIN一次查出来或者在SQL里用GROUP_CONCAT把座位号拼成一串返回。7.2 业务上的扩展方向如果这个项目做完了还有余力可以考虑以下几个扩展方向电影评价系统。增加一个评分和评论表用户购票完成后可以对电影打分并写短评首页展示评分。这个功能逻辑不复杂但对用户体验的提升很明显。优惠券功能。设计一张优惠券表用户可以领取或系统发放下单时抵扣金额。牵扯到优惠券的核销状态管理会让订单模块更有挑战性。在线支付对接。对接支付宝或微信支付的沙箱环境完成真实的支付链路。这个扩展能让项目从“演示”变成“可用”虽然沙箱环境不涉及真实资金交易但能让你理解支付流程中的签名、回调、验签这些核心概念。分布式会话或SSO。如果还想深入可以把登录认证从JWT换成OAuth2或Spring Security的更复杂方案。但以这个项目本身来说JWT已经够用过度设计反而增加了学习负担。7.3 对这个项目整体的心得体会做一个完整的电影院购票系统收获最大的不是某个具体的技术点而是把“一个业务闭环从想法变成可运行系统”的全过程。这中间一定会遇到各种问题数据库设计不合理导致后面加字段改来改去、前后端字段名对不上联调半天、并发抢座出现超卖、部署到Linux环境后MySQL字符集不对导致乱码……每踩一个坑你对系统的理解都会加深一层。我个人非常建议在开发这个项目时建立“技术复盘笔记”的习惯。每解决一个难题就用几句话记录问题现象、排查过程和最终方案。项目做完之后你会发现归结下来的坑其实就那么十几类写进简历或面试时说这些真实遇到的问题比背十遍八股文都更有说服力。最后再分享一个小细节做选座并发测试时我一直用“两个浏览器无痕窗口同时购买同一个座位”来验证自己的锁座位逻辑。第一次测试时就发现了问题——两个窗口同时提交时后端确实只有一个事务能成功更新座位状态。这个测试方法简单有效比写复杂的并发测试脚本更直观。做类似系统时你也可以先手动模拟并发场景再决定要不要引入更复杂的压力测试方案。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →