ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot + Vue电影院购票系统:从架构到选座实战

Spring Boot + Vue电影院购票系统:从架构到选座实战 这段时间我在持续迭代一套电影院购票管理系统技术栈就是经典中的经典Spring Boot做后端Vue做前端。做它的起因很实际——本地一家小型影院还在用Excel排片和人工记票顾客能不能买到票要看运气售票员还要在开演前反复核对余票。于是我带着一个很朴素的诉求启动了这个项目让用户在手机上能看电影排期、选座、下单、支付让管理员在后台能维护电影和场次所有数据实时统一。现在这套系统已经跑得比较顺源码、说明文档、调试方法都整理成了一份可交付的成果。我会把整体架构、核心模块、前端交互、调试踩坑和文档组织方式全部摊开讲一遍。适合三类人一是想拿JavaVue做大型课程设计的学生二是想快速给门店或小影院搭建线上售票入口的开发者三是准备应聘全栈开发但还差点完整作品的朋友。文章不按什么“从入门到精通”的流水账来写我直接从我和这个系统磨合的过程说起。1. 购票管理系统到底在解决什么问题先理解业务再动手很多人一听到电影院购票系统第一反应就是“这不是电商吗电影列表、场次列表、订单三个表搞定”。真上手做就会发现购票业务和普通商品交易有一个本质区别商品有库存座位也有库存但座位的库存还带着坐标和相邻关系。你要做的不是简单地扣一个数字而是要管住“哪个座位被谁占了”“并发请求同时抢同一个座位怎么办”“开演前多久停止退票”这些细节。1.1 购票业务里最容易被忽略的三件事第一件是座位锁定与订单有效期的配合。用户选好座位后不能直接就把座位状态改成已售万一他没付款呢所以常见做法是“锁定”状态锁定后还要给一个倒计时比如10分钟内未支付自动释放。这个倒计时如果只在前端做用户一刷新就失效正确做法是后端记录锁定时间定时任务或者懒释放兜底。第二件是超卖和重复下单。多个用户同时点击同一个座位后端要是只用普通的select再update会有并发问题。要么给座位表加锁要么用数据库的唯一索引要么引入Redis锁选一种适合自己项目规模的方案。我在这套系统里用的是数据库行锁加状态机校验简单、好解释也足够应对一个普通影院的峰值流量。第三件是场次、影片、影院三者之间的关联。一个影院有多个厅一个厅在不同时间段放映不同电影这其实就是经典的排片问题。排片表要存影厅ID、影片ID、开始时间、结束时间、票价、余票状态。票价还分普通厅、情侣厅、VIP厅这些信息必须落在场次里而不是简单的“电影表有价格字段”。1.2 为什么我坚持用 Spring Boot Vue 这套组合后端用Spring Boot核心原因是它把大量配置都“约定好了”提供内嵌Tomcat打个jar包就能跑不需要单独部署Web容器。这对一个要频繁交付、换环境调试的项目来说太重要了。前端用Vue是因为它的组件化开发非常适合“选座”这种高强度交互页面而且Vue生态里的Element Plus组件库可以快速搭出后台管理界面表格、表单、弹窗都是现成的。更重要的是这套技术栈在中文社区里的资料极多。遇到任何报错把关键报错信息复制到搜索引擎基本都能找到同款问题。对于需要自学、需要找人答疑的开发者来说这种“试错成本低”的优势非常明显。你不太会卡在一个冷门技术上两周出不来。1.3 一套完整的交付物该包含哪些东西我交付这个项目时给它定义成“源码文档调试基础修改答疑”五个部分而不是只丢一个压缩包。源码就不多说了一定是能直接编译运行的文档要包括环境安装说明、数据库初始化脚本、接口说明和启动手册调试是帮对方把本地环境跑通并教会他自己处理常见报错基础修改是支持改改影院名称、票价、座位图、订单超时时间这些常规配置答疑则是提供一段时间的持续支持。如果你是自己学习我建议也按这个标准来要求自己。一个项目值不值钱不在于代码多炫而在于别人能不能快速看明白、跑起来、改得动。2. 技术架构与数据库表设计先把地基打稳这类管理系统的架构已经高度成熟不需要创新关键是规范。后端我采用经典的四层结构Controller层接收请求、Service层处理业务逻辑、Mapper层操作数据库再加上一个实体类层和DTO层。前端采用Vue单页应用通过Axios调用后端接口。2.1 后端项目结构和依赖清单一个标准的Spring Boot项目包结构是这样的controller用户端接口、管理员端接口分开建包service核心业务逻辑事务都在这一层控制mapperMyBatis的接口层配合XML或注解SQLentity与数据库表对应的实体类dto前端传参和接口返回的数据对象config跨域配置、拦截器配置、WebMvc配置common统一返回结果类、异常处理类、工具类依赖方面我选的是Spring Boot 2.7.14、MyBatis-Plus 3.5.3、MySQL 8.0、Hutool工具库、JWT做登录令牌、Lombok简化实体类。我没有引入过于复杂的Spring Cloud或者分布式组件因为一个电影院购票系统是典型的单体应用引入微服务反而是负担。如果你想练手也可以把Redis加进来做缓存和分布式锁但初版尽量别给自己加戏。选MyBatis-Plus而不是Spring Data JPA是看重它的BaseMapper能直接提供单表CRUD分页查询也有现成插件开发效率高。但真正复杂的SQL我还是写在XML里比如多表关联查询场次和影片信息这样后期调优的时候能一眼看清Sql执行计划。2.2 核心数据表设计从用户到订单的完整链路一个可用的电影院购票系统数据表至少要覆盖这些用户表、电影表、影厅表、场次表、座位表、订单表、订单明细表。表关系并不复杂但每张表都有一些关键字段不能省。表名关键字段设计说明t_userid, username, password, phone, role角色字段区分普通用户和管理员t_movieid, title, cover_url, duration, release_date, status上映状态为上架/下架t_hallid, name, seat_layoutseat_layout储存默认座位图JSONt_scheduleid, movie_id, hall_id, start_time, end_time, price, status排片核心表价格放到场次级t_seatid, schedule_id, row_no, col_no, status, lock_time, order_id座位状态可用/锁定/已售t_orderid, order_no, user_id, total_amount, status, create_time订单状态待支付/已支付/已取消/已退款座位表这里需要特别解释一下。我之前也考虑过用Redis里的位图来表示座位状态性能最高但对于一个教学型、交付型项目把座位落到MySQL表里更容易理解也方便随时检查数据。每条场次记录会生成对应影厅的一批座位记录比如一个厅10排、每排12座那就是120条记录。场次上座率、座位状态统计都直接查这张表逻辑一目了然。2.3 统一返回结果和全局异常处理前后端分离的项目最忌讳每个接口返回的数据格式不一样。我的做法是定义一个ResultT类里面包含code、message、data三个字段。成功返回200业务异常返回自定义错误码未登录返回401。前端Axios统一拦截看到code ! 200就直接弹出错误提示不需要每个页面重复处理。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; } }全局异常处理用RestControllerAdvice把参数校验异常、业务异常、数据库异常分别捕获转换成统一的Result返回。这样做的好处是前端永远只需要处理同一种结构排查问题时翻阅后端日志也能快速定位是哪一层抛出的异常。3. 后端核心模块实现登录鉴权、排片、选座与订单这一章是整个系统真正值钱的部分。增删改查谁都会写但登录鉴权、选座锁座、订单状态流转这些需求才是决定系统能不能真正上线运行的关键。3.1 基于JWT的登录鉴权方案现在的管理系统基本不会再使用Session方案了接口要支持微信小程序、App、网页多端调用无状态是最省心的。我采用的是JWT方案用户登录成功后后端生成一个包含用户ID和角色信息的token前端存储在localStorage里之后每次请求在Authorization头带上这个token。后端用一个拦截器来统一校验token白名单放行登录接口、电影列表接口等不需要登录就能访问的接口。受保护的接口在拦截器里解析token解析通过就把用户信息放入ThreadLocalController里直接获取当前登录用户这样接口就不用每次都传用户ID了。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StringUtils.isBlank(token)) { throw new BusinessException(401, 未登录); } Long userId JwtUtil.parseToken(token); UserContext.set(userId); return true; } }密码存储一定要用BCrypt加密不要明文存数据库这也是我在文档里反复强调的安全底线。哪怕是个课程设计也应该从第一行代码就养成这种习惯。3.2 排片管理场次生成与影厅冲突校验管理员创建场次的时候必须校验同一影厅在同一时间段是否已经安排了其他场次。这个校验放在Service层插入前先查询SELECT COUNT(*) FROM t_schedule WHERE hall_id #{hallId} AND status ! 2 AND #{startTime} end_time AND #{endTime} start_time这样就把时间交叉的场次拦住了。同时系统要根据场次自动生成座位记录我在新增场次的方法上加了Transactional保证场次和座位记录要么同时成功要么同时回滚。生成座位的逻辑就是从影厅表读取默认座位布局JSON循环生成t_seat记录初始状态全部为可用。这里有一个很实际的经验用户端展示场次列表时不能把已结束的场次也展示出来。查询的时候要用当前时间过滤而且最好把end_time也算出来避免出现“电影放了半小时还能买票”的滑稽情况。3.3 选座锁定与并发防超卖这是整个系统技术含量最高的地方。我采用的方案是用户在选座页面点选座位后前端先把座位临时标灰同时调用后端lockSeat接口后端用数据库行锁保证同一时间只有一个请求能操作同一个座位。Transactional public ListSeatVO lockSeats(Long scheduleId, ListString seatCodes, Long userId) { Schedule schedule scheduleMapper.selectByIdForUpdate(scheduleId); for (String code : seatCodes) { Seat seat seatMapper.selectByScheduleAndCodeForUpdate(scheduleId, code); if (seat.getStatus() ! 0) { throw new BusinessException(座位已被锁定或售出); } seat.setStatus(1); seat.setLockTime(new Date()); seat.setUserId(userId); seatMapper.updateById(seat); } return getSeatList(scheduleId); }selectByIdForUpdate和selectByScheduleAndCodeForUpdate这两句SQL都会在查询时加上行级排他锁事务提交后锁才释放。也就是说同一时间多个用户抢同一个座位只有一个事务能成功执行更新其他事务会等待锁一旦获得锁后发现状态已经不是可用就会抛出业务异常。同时我会在Redis里给该用户设置一个10分钟的临时键当用户提交订单后清除。如果前端拿到“座位已被锁定”这个异常就提示用户重新选择其他座位。这套逻辑不需要引入消息队列和复杂分布式锁已经能保证一个几千人同时在线的影院的正常购票流程。3.4 订单生成、支付模拟与超时释放锁座成功之后前端跳转订单确认页后端根据锁定的座位和场次信息计算出订单金额生成一条待支付订单。订单号我用年月日时分秒加随机数生成避免使用数据库自增ID作为对外订单号那样太容易被人遍历。支付环节做了两层设计第一层是模拟支付接口用户在前端点“立即支付”后端直接调用模拟支付方法把订单状态更新为已支付座位状态更新为已售第二层保留了真实支付渠道的接入位置以后要接微信支付或支付宝只需要在Service层增加一个接口实现类。这种设计对交付型项目特别友好因为对方不一定有商户号但也要能完整跑通购票流程。超时释放的逻辑用一个定时任务每两分钟扫描一次锁定时间超过10分钟且未支付订单将对应座位还原为可用状态订单状态置为已取消Scheduled(fixedDelay 120000) public void releaseExpiredLocks() { ListOrder orders orderMapper.findExpiredPendingOrders(); for (Order order : orders) { orderMapper.updateStatus(order.getId(), ORDER_CANCELED); seatMapper.releaseSeatsByOrderId(order.getId()); } }还要注意一个边界问题买家已经下单了但支付回调还没确认此时座位应该是什么状态我的处理是座位锁定状态下不允许同时被另一个订单关联只有支付成功才改成已售这样即使支付结果延迟也不会出现一票多卖。4. Vue前端的关键交互从首页到选座的完整链路后端做得再完善前端体验不行用户照样不会用。这一章我挑重点讲尤其是选座组件的实现和前后端联调最容易出问题的地方。4.1 前端工程结构与管理端布局Vue项目我使用的是Vue 3加Vite配合Vue Router和Pinia。目录结构上views下面按照home、movie、booking、order、admin分模块components里放了SeatPicker、MovieCard、ScheduleList等通用组件。管理端单独使用一套路由并且通过路由守卫判断用户角色普通用户访问后台会被重定向到首页。很多人会在前端用localStorage存登录状态但我建议把登录状态交给Pinia统一管理同时配合路由守卫在跳转前判断是否存在token和用户信息。刷新页面后Pinia状态会丢失所以要写一个初始化逻辑从localStorage恢复用户信息。这个细节不写会导致刷新后菜单状态错乱、接口401等问题。4.2 开发环境代理解决跨域问题前后端分离开发最容易遇到的第一个大坑就是跨域。后端虽然做了CORS配置但我建议前端在开发环境用Vite的proxy代理来转发请求因为这样生产环境和开发环境的请求路径完全一致。// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })配置好代理后前端所有请求写成/api/movie/listVite开发服务器会把它转发到http://localhost:8080/movie/list前端页面不会有任何跨域报错。等到打包部署时再把后端接口路径和前端静态资源放在同一个域名下更不会出现跨域问题。4.3 选座组件的核心逻辑选座是前端所有页面里交互最复杂的。我在SeatPicker组件里用一个二维数组表示座位图每个座位有四种状态可选、已售、已锁定、自己选中的。渲染时直接遍历二维数组生成带坐标的div或button。const seatMap ref([]) const selected ref(new Set()) function toggleSeat(row, col) { const key ${row}-${col} const seat seatMap.value[row][col] if (seat.status available) { if (selected.value.has(key)) { selected.value.delete(key) } else { selected.value.add(key) } } }点击座位后要立即调用后端锁座接口而不是等到最后一步才锁。这个体验和主流购票平台是一致的你选中一个座位的瞬间它就被别人锁定了。锁座成功后更新本地座位状态如果锁定失败就把座位状态回退为可选并弹出提示。前端状态和接口必须同步管理不能出现前端显示可选、后端已经卖出这种不一致。我还会限制单选和连坐的逻辑普通厅允许单个座位购买但两张以上时尽量引导选择相邻座位VIP厅支持一次整排锁定。用一个checkAdjacent方法在提交前校验减少用户下单后因为座位不相邻而产生的投诉。4.4 订单确认与支付页面用户点“提交订单”后前端把场次ID、座位编号列表、用户ID传给后端。后端生成订单后返回订单号和待支付金额前端跳转到订单确认页展示订单详情和倒计时。倒计时一到前端自动刷新订单状态提示用户订单已取消。支付按钮点击后如果是模拟支付模式直接调后端接口标记支付成功然后跳转到支付成功页。这个流程虽然短但要注意按钮防重复提交用户手快连点两次会产生两笔相同订单或一次请求被发送两次。我的做法是点击后立刻把按钮置为loading同时加一个不可重复提交的标识直到接口返回成功或失败才恢复。5. 调试排错实战这些坑我替各位踩过了交付源码只是第一步真正拉开差距的是调试能力。一个项目在别人电脑上能跑在你的电脑上报错80%都出在环境配置、依赖版本、路径这三类问题上。我按照自己实际踩过的坑把最典型的几个排错链路完整写出来。5.1 端口占用后端8080起不来这是最简单的坑。启动Spring Boot时报Port 8080 was already in use第一反应不应该是去改配置而是先查谁占用了端口。Windows用netstat -ano | findstr 8080Linux用lsof -i:8080找到PID后结束进程或者确实需要保留那个进程时再改端口。改端口建议在application.yml里显式配置server.port而不是依赖默认值。改完之后还要注意前端代理的target端口要对应修改很多人改了后端端口忘了改Vite里的代理配置导致前端仍然访问旧端口结果一堆404。5.2 Maven依赖下载缓慢或版本冲突国内Maven仓库默认是中央仓库下载速度很慢。我的方案是在settings.xml里配置阿里云镜像。如果你的代码是从别处拿来的还要检查pom.xml里各依赖版本是否与JDK版本兼容。Spring Boot 2.x一般配JDK 8或11Spring Boot 3.x必须配JDK 17以上。如果下载的依赖版本太高比如Spring Boot 3.2配了旧版MyBatis-Plus启动阶段就可能报ClassNotFoundException。排查依赖冲突我习惯在运行项目前执行mvn dependency:tree看有没有多个版本的相同jar包。遇到无法解决的冲突直接去掉子依赖里的传递依赖显式引入需要的版本。一个问题如果不解决后面每启动一次报一次非常消耗时间。5.3 IDEA调试后端的正确姿势我很少用System.out.println调试接口。后端调试基本是断点加观察变量。在Service层接口实现方法上打断点用IDEA的调试模式启动项目前端触发请求后可以逐行看参数传递、SQL执行、状态变更。step over是逐行执行step into是进入方法内部evaluate expression可以临时执行表达式这些基础操作要熟练。如果接口出问题我更建议先看日志。打印SQL的配置要开起来logging: level: com.example.mapper: debug这样MyBatis执行的每条SQL和参数都会打印出来很多时候问题一眼就能看出来——可能是参数没传上、SQL写错了、查询结果为空。5.4 Vue前端调试和打包问题前端调试优先用浏览器的开发者工具。Network面板看接口请求状态码、请求参数、响应内容Console面板看前端报错。Vue项目打印信息默认带组件的文件路径和行号点击即可跳转到源码。还有一个高频报错是[Vue warn]: Property xxx was accessed during render but is not defined on instance这是模板里引用了未定义的变量。直接在模板里写console.log排查是不管用的建议用{{ JSON.stringify(obj) }}临时输出到页面或者用Vue Devtools检查组件状态。打包阶段最常见的坑是静态资源路径。Vite默认构建出的index.html引用/assets绝对路径如果部署到服务器子目录下需要切换为相对路径。在vite.config.js里设置base: ./。很多本地开发没问题、部署后白屏十有八九就是这个问题。5.5 把Vue打包结果放进Spring Boot运行有时候为了简化部署我会把Vue构建出的dist目录复制到Spring Boot的src/main/resources/static下这样前端和后端就是一个工程。操作前要把前后端接口地址调整好前端请求必须使用相对路径/api/**不能再写http://localhost:8080这种硬编码。启动Spring Boot后访问http://localhost:8080就能看到首页。但如果你的前端路由使用了history模式部署到Spring Boot后刷新页面会报404。这个问题的根源是后端没有把前端路由请求转发到index.html。我提供两个解决办法一是后端写一个转发控制器或加一个HistoryFallback配置二是前端改用hash模式。对于单机小项目hash模式更省事URL里会多一个#但不会出现刷新404的尴尬。6. 文档、基础修改与答疑交付之后才是价值开始源码能跑通只能说完成了一半。一个项目从“能跑”到“能被别人复现和使用”文档和答疑占了剩余的一半。我在整理交付物的时候尽量把自己放在一个完全新手的立场上想象对方拿到压缩包后打开README会发生什么。6.1 一份合格文档必须写清楚的六块内容第一环境要求。JDK版本、Maven版本、Node版本、MySQL版本都要写清楚最好用表格列出。第二数据库初始化。SQL脚本要放在根目录的db文件夹里README里要写“先执行create_database.sql再执行init_data.sql”。第三启动步骤。后端怎么启动、前端怎么启动每一步都给出命令行和预期结果。第四默认账号。管理员账号、密码、用户测试账号都要写出来。第五接口说明。核心接口至少列个表格写明路径、请求方式、主要参数、返回结果。第六常见问题。把自己调试时踩过的坑按“问题—原因—解决方法”的格式列进去。写文档最基本的检验标准是找一个没接触过这个项目的人只让他看README看他能不能10分钟内把系统跑起来。如果可以文档就是合格的。6.2 “基础修改”通常改哪些我给你列个清单基础修改本质上就是“不改代码逻辑只改配置或少量代码就能适配新场景”。比如项目里的影院名称、联系电话、首页轮播图这些如果有配置表就直接改表没有配置表就把常量抽出来统一维护。票价修改一般在场次表直接改price字段不需要动代码。座位图修改有两种方式直接在数据库里改座位表的row_no和col_no或者改写影厅表的默认座位布局JSON重新生成场次时应用新布局。订单超时时间是一个很常见的修改需求我把时长抽成了application.yml里的一个配置项order: expire-minutes: 10这样后台人员不用改代码只需改配置文件的参数再重启即可。做项目时凡是业务人员可能调整的数值都应该尽量配置化这是从业余开发走向专业交付的一个重要标志。6.3 答疑的本质帮对方建立排查思维答疑不只是回答问题更重要的是教对方怎么自己发现问题。我收到过的最典型问题是“我按你说的做了怎么跑不起来”。这种时候我不会上来就试而是先让他提供三个信息报错截图、启动日志、操作步骤。80%的问题在提供这三个信息的过程中对方自己就找到原因了。剩下20%我能通过日志明确告诉他是环境问题还是代码问题。在答疑过程中我会用“你告诉我你看到了什么”而不是“你应该这样”来引导让他学会自己观察现象、定位模块、分析原因。这样几次之后对方基本就具备了独立排错的能力后面再出现问题他自己就能处理而不是继续依赖别人。这也是为什么成熟源码比较少见但持续答疑会使项目圈层更牢固的原因我不主张无限期问题回答但建议是建立一套针对常见问题的FAQ库把每一次新的报错和解决过程补充进去。6.4 后续还能怎么扩展如果你准备把这个项目当作毕设或上线前的基础扩展方向其实很多接入微信小程序复用同一套后端接口只需要新增一个前端应用引入Redis缓存热门电影列表和场次信息把数据库压力降下来接入真实微信支付或支付宝把模拟支付Service替换成真实实现增加会员积分和优惠券模块让系统更接近商业体形态把不同影院的独立系统改造成多租户架构。这些扩展不会改变核心的“Spring BootVue”骨架都是在外围加模块。所以把基础架构和核心业务逻辑做踏实了后面无论加什么功能都不会伤筋动骨。我个人在实际操作中最深的体会是这类系统项目的重心永远不在“会写几个接口”而在于“把数据状态理清楚”。选座锁定、订单状态、座位状态这三者的流转哪怕看代码十遍也不如自己断点跟踪一遍订单流程来得扎实。如果你刚拿到这套源码我强烈建议你先不跑前端直接从后端的OrderServiceImpl里面下单方法开始打断点一步步看座位状态怎么变、订单状态怎么变、异常怎么回滚这个过程走完你才算真正拥有了这个项目。
返回列表