ARTICLE DETAIL

资讯详情

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

基于Node.js和Vue的电影院选座系统开发实战

基于Node.js和Vue的电影院选座系统开发实战 最早让我萌生自己做一个电影座位预订系统是去某家影院小程序购票时被反复折磨选座页加载慢不说三个座位的图刷新后有一半是灰的好不容易选好座位又提示已被锁定。索性自己动手用 Node.js 做服务端Vue 做前端交互完整重写了一套电影院选票选座系统。这套系统我前后打磨了两三个版本把选座、锁座、订单、支付状态都串了起来。今天这篇就把整个开发过程从头捋一遍重点讲清楚为什么这样设计、实际编码时哪些地方容易踩坑希望对正在做同类项目或毕设选座系统的人有帮助。1. 选票选座系统要解决的不只是“点个座位”很多第一次做这类项目的朋友会把注意力全放在“怎么画一个能点的座位图”上。实际上电影院在线选票选座真正难的地方是“座位的实时状态”和“订单生命周期”的联动问题画一张图只是最基础的一步。1.1 线下买票的真实痛点观众买票时最在意三件事场次时间合不合适、座位舒不舒服、支付后能不能立刻拿到票。线下柜台买票可以看着屏幕选座但线上系统一旦出现多个用户同时看同一场次座位状态就需要极快地同步。最早我用的那张座位图每次点击座位都要重新拉一次全量接口结果两个人同时打开一个影厅A 点了 5 排 6 座B 那边 10 秒后才看到这个座位变成灰色这时候 B 如果也点了同一个座位订单冲突就产生了。电影院还有一个容易被忽略的点同一个影厅在不同场次里的座位可用性可能不一样。比如 19:00 场次第 3 排可能有一部分座位被包场预留21:30 场次却全部开放。如果座位状态只存在“影厅”这个层级一定会出问题。所以整套系统需要把“影厅物理座位”和“某一场次下的座位状态”拆开这是一开始就要想清楚的。1.2 功能边界不是越多越好我第一版的功能清单拉得很长会员等级、退票、改签、优惠券、影院会员日折扣……结果做了两周连基础流程都没跑通。后来我把功能边界砍成最小闭环用户端电影列表、场次列表、选座、创建订单、模拟支付、查看订单管理端电影管理、影厅座位布局管理、场次排片、订单查看支付我并没有接真实支付渠道用的是模拟支付因为真实支付涉及支付平台审核、对账、退款回调对于学习项目和演示项目来说太重了。如果你确实要上线可以在模拟支付的位置换成微信/支付宝官方SDK整个流程的结构是一样的。1.3 用户角色与核心流程用户角色其实就两种普通观众和管理员。普通观众的核心路径是选择电影 → 选择场次 → 进入座位图选座 → 锁定座位 → 创建订单 → 模拟支付 → 完成。管理员的核心路径是维护电影和影厅 → 配置座位图 → 给影厅分配场次 → 查看订单和上座率。从状态流转上来讲座位最开始是“可售”用户选中后变成“锁定”支付成功变成“已售”。如果锁定后超时未支付要自动回到“可售”。这个状态机的设计是整个系统的心脏我在后面单独用一章详细讲。2. 为什么我用 Node.js Vue 做这套系统选型这件事没有绝对正确答案但既然题目是 Node.js Vue我就说说这个组合在“选票选座系统”这个场景下到底合不合理。2.1 后端用 Node.jsIO密集场景的天然优势座位状态高频变化、订单数据频繁写入这类业务属于典型的 IO 密集型操作而不是 CPU 密集型。Node.js 的异步事件模型在这种场景下非常合适一个进程就能扛住大量等待型的请求不需要像传统多线程模型那样为每个连接分配线程。我使用的是 Express 作为服务端框架虽然比较基础但胜在简单直观中间件生态也成熟。登录用 JWT参数校验用 Joi数据库用 MySQL再加一个 Redis 做锁座缓存这一套组合对单机部署的中小影院足够用了。对比 Spring Boot 那套Node.js 最大的优势是前后端语言统一Vue 里的对象结构和后端 JavaScript 对象可以直接复用省去了大量 JSON 序列化的心智负担。2.2 Vue 在前端选座交互上的优势Vue 的核心是“数据驱动视图”这一点和座位图特别贴合。座位图本质上是一个二维数组行号、列号、座位类型、状态。Vue 可以用嵌套 v-for 直接渲染出座舱图把每个座位的状态绑定到 class 上点击事件只管修改数组里的对象DOM 的增删和样式更新由框架自动完成不需要手动操作一堆 className。另外Vue 的组件化很适合把“选座区”“底部结算栏”“已选座位弹窗”拆成独立组件各自维护内部状态再通过唯一的 store 同步。这样即使后面你要支持 IMAX 厅、情侣厅、VIP 厅只需要改数据结构和样式文件不需要重写交互逻辑。2.3 技术栈版本与周边库如果你现在才新建项目建议直接用 Vue 3 Vite Pinia而不是 Vue 2 Vue CLI。Vue 2 已经进入维护尾声新项目没必要再往里跳。我自己的开发环境用的是 Vue 3.4、Vite 5、Pinia 2、Express 4、MySQL 8、Redis 7。模块选型说明前端框架Vue 3Composition API 更适合逻辑复用构建工具Vite启动快热更新快状态管理Pinia比 Vuex 更简洁TS 友好HTTP 请求axios统一封装请求和鉴权后端框架Express 4稳定、资料多、中间件丰富数据库MySQL 8或PostgreSQL关系型数据适合订单和座位缓存/锁Redis座位锁、超时释放、接口缓存运行环境Node.js 18 LTS生产环境推荐 LTS 版本这套组合最低配置也能跑一台 2C4G 的云服务器MySQL 和 Redis 用 Docker 启动Node.js 直接用 PM2 守护前端打包成静态文件交给 Nginx足够支撑一个中型影院的日常访问量。3. 开发环境搭建Node.js 和 npm 为什么这么多坑做这个项目的朋友大概率是跟着教程一步一步装环境但环境配置这个环节真的是“看起来简单做起来全是坑”。尤其是 Windows 系统光 npm 的 PowerShell 脚本问题就能劝退一半新手。3.1 Node.js 安装与环境变量配置Node.js 我推荐直接去官网下载 LTS 版本不要下载 CURRENT 版本LTS 稳定性好很多。安装包安装过程中记得勾选“Add to PATH”这样安装完会自动配置好环境变量否则命令行里怎么敲 node -v 都提示找不到命令。安装完之后打开命令行验证node -v npm -v如果提示不是内部或外部命令说明 PATH 没配上。你在系统环境变量里手动加上 Node.js 的安装目录比如C:\Program Files\nodejs\保存后重新打开命令行就好了。另外建议额外设置NODE_HOME环境变量指向安装根目录很多老教程和工具会读取这个变量。还有一个很小的细节不要把 Node.js 装到带空格的路径里比如D:\Program Files (x86)\nodejs。虽然官方支持但后面某些原生模块编译时会因为路径空格踩到奇怪的坑干脆从一开始就装到D:\nodejs这种路径。3.2 那个让人头疼的 npm.ps1 禁止运行脚本问题有段时间我一执行npm install就看到这样一段报错无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本这个报错和 npm 本身没关系是 PowerShell 的执行策略拦截了.ps1脚本。npm 命令在 PowerShell 里实际执行的是 npm.ps1系统默认执行策略可能是不允许运行脚本所以要修改 PowerShell 的执行策略。以管理员身份打开 PowerShell运行Set-ExecutionPolicy RemoteSigned -Scope CurrentUser然后输入Y确认。RemoteSigned的意思是本地脚本可以运行从网上下载的脚本需要签名比你直接改成Unrestricted安全得多。改完之后重新打开终端npm 命令就能正常用了。如果你不想动 PowerShell 策略也可以所有命令都去 CMD 里执行CMD 不会处理.ps1脚本所以不会触发这个报错。但这只是绕开问题不是解决问题我建议还是顺手把执行策略改了后续你会碰到非常多依赖.ps1脚本的命令。3.3 Vue 项目脚手架创建我个人的习惯是优先用 Vite 创建 Vue 项目npm create vitelatest cinemaseat -- --template vue cd cinemaseat npm install npm run devVite 创建的项目结构很干净src/views放页面src/components放组件src/store放 Pinia 状态。比 Vue CLI 少了很多模板代码热更新也快很多。如果你看的老教程用的是 Vue CLI命令通常是npm install -g vue/cli然后vue create project这个流程也没问题只是 Vue CLI 官方维护已经进入维护模式新项目不推荐但老项目继续用完全没事。3.4 依赖安装常见的报错处理我在开发过程中遇到过几次非常典型的 npm 报错安装依赖卡在idealTree很久不动网络问题可以把 npm 源的公共镜像地址临时改一下国内常见做法是用npm config set registry https://registry.npmmirror.com之后npm install会快很多。ERESOLVE依赖冲突多见于老项目升级依赖时可以在命令后面加--legacy-peer-deps绕过 peerDependencies 的严格检查。某个原生模块编不过去比如node-sass这类问题可以尝试先删除node_modules和package-lock.json重新执行npm install。实在不行就升级 Node.js 版本。还有一个容易被忽视的工具Vue 官方浏览器插件 devtools。如果你用 Vue 3去浏览器扩展商店搜“Vue.js devtools”安装这样调试组件状态和 Pinia 的 store 会方便很多。我在排查选座状态同步问题时全靠它看每个组件的状态变化。4. 数据模型和接口设计把座位状态和订单状态分开这是整个系统最值得讲的部分。如果你只是把座位表设计成seat表里面直接加一个status字段短时间跑通没问题但一旦换场次、座位被锁定超时释放、多用户并发选座数据就会开始错乱。4.1 数据库表结构设计我的最终结构分了五张核心表分别是电影表、影厅表、场次表、影厅座位模板表、场次座位实例表再加订单表。为什么要拆这么细因为“影厅里有个座位”和“这个座位在某场次被卖出去了”是两码事。核心建表语句简略版CREATE TABLE halls ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, row_count INT NOT NULL, col_count INT NOT NULL ); CREATE TABLE hall_seats ( id INT PRIMARY KEY AUTO_INCREMENT, hall_id INT NOT NULL, seat_row INT NOT NULL, seat_col INT NOT NULL, seat_type TINYINT DEFAULT 0, UNIQUE KEY uk_hall_row_col (hall_id, seat_row, seat_col) ); CREATE TABLE screenings ( id INT PRIMARY KEY AUTO_INCREMENT, movie_id INT NOT NULL, hall_id INT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, price DECIMAL(10,2) NOT NULL ); CREATE TABLE screening_seats ( id INT PRIMARY KEY AUTO_INCREMENT, screening_id INT NOT NULL, seat_row INT NOT NULL, seat_col INT NOT NULL, status TINYINT NOT NULL DEFAULT 0, lock_id VARCHAR(64) DEFAULT NULL, lock_time DATETIME DEFAULT NULL, UNIQUE KEY uk_screening_row_col (screening_id, seat_row, seat_col) ); CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, screening_id INT NOT NULL, user_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, expire_time DATETIME NOT NULL, created_at DATETIME NOT NULL );方案的核心在于hall_seats描述影厅固定布局screening_seats才是某场次下的座位实时状态。座位排片的时候管理员只需要给场次初始化一份与影厅座位布局相同的screening_seats数据即可后续针对单个场次做预留、锁定、售出操作彼此之间互不影响。4.2 座位状态机可售、锁定、已售、不可用我定义的状态值是这样的status含义前端表现0available 可售绿色可点击1locked 已锁定灰色或红色不可点击2sold 已售出深灰色/爱心不可点击3disabled 不可用无按钮通常是边角/维护座位锁定状态还要额外记录锁定 ID 和锁定时间。锁定 ID 用于用户创建订单时证明“这个座位是我锁的”锁定时间用于超时释放判断。订单状态我单独用一个字段取值通常是待支付、已支付、已取消、已退款。座位状态和订单状态要分开维护不能因为订单取消就把座位状态直接改成“可售”而要通过一个显式的释放动作去更新这样后续要查审计日志时才说得清楚。4.3 接口清单与请求时序我设计和实际使用的接口按模块划分大致是这样电影模块GET /api/movies、GET /api/movies/:id场次模块GET /api/movies/:id/screenings座位模块GET /api/screenings/:id/seats锁定模块POST /api/screenings/:id/seats/lock、DELETE /api/locks/:lockId订单模块POST /api/orders、GET /api/orders/:id、POST /api/orders/:id/pay核心时序这样走用户进入选座页前端先请求座位全量数据用户点击座位后前端只是本地高亮并不立即调用接口点击“提交订单”时后端一次性锁定所有选择的座位锁定成功返回lockId和过期时间随后前端带着lockId和座位ID数组创建订单最后模拟支付成功后后端把座位状态从锁定置为已售。这个设计有一个好处避免用户选一个座就调一次接口减少请求数量。但也要求“锁定座位”和“创建订单”之间要容错如果用户创建订单失败需要让前端显式调用释放锁的接口或者等待锁超时自动释放。4.4 并发控制从数据库乐观锁到 Redis 分布式锁并发选座是这类系统最容易被问到的技术难点。两个人同时抢同一个座位后端必须保证只有一个能成功。最核心的防线是数据库条件更新。假设用户要锁定 5 排 6 座后端执行UPDATE screening_seats SET status 1, lock_id ?, lock_time NOW() WHERE screening_id ? AND seat_row 5 AND seat_col 6 AND status 0这条 SQL 的WHERE status 0就是乐观锁的体现如果执行后受影响行数为 1说明抢座成功如果为 0说明座位已经被别人锁定或卖出。在单机部署的场景下这种写法已经能解决 99% 的并发问题因为它依赖数据库的行级锁天然原子。如果后面系统扩展到多台 Node.js 实例数据库条件更新依然够用因为是同一个数据库在做判断。真正需要 Redis 分布式锁的场景是你要对“剩余可售数量”做缓存预热或者对同一用户的重复请求做幂等限制。比如用户连续点了两次“提交订单”第一次已经创建了订单第二次如果不做处理就会生成两个订单。这种场景我通过给每个请求生成一个clientToken在 Redis 里以clientToken为 key 做set nx重复请求直接拒绝。5. 前端选座组件的实现从二维数组到丝滑交互前端选座组件是整个项目里最直观的部分也是用户感知最强的地方。把座位图做到清晰、流畅、不出错比做所谓炫酷动效更重要。5.1 座位图渲染用 Vue 二维渲染我从后端拿到的座位数据是一个数组里面每一项包含row、col、status、seatType。前端状态管理里维护一个seatMap二维数组在组件中直接嵌套遍历div v-forrow in seatMap :keyrow[0].row classseat-row div v-forseat in row :keyseat.row - seat.col classseat :class[seat-- seat.status, { is-selected: isSelected(seat) }] clickhandleSeatClick(seat) /div /div每个座位格子的线条我用 CSS 控制比如seat--0表示可售seat--1表示锁定seat--2表示售出.is-selected是用户本地选中的高亮。这样状态展示完全由数据驱动后端状态一变前端 UI 自动同步不需要手动处理 DOM。座位的可视化布局我用 CSS Grid 实现每个座位宽 28px、高 28px行间距和列间距保持一致。影厅规模大也没有问题一个 12 排 16 座的小厅只有不到两百个座位渲染成本很低。5.2 选座交互点击、取消、数量限制点击事件的核心逻辑是切换选中状态但前提是座位当前状态为可售。如果后端返回状态为锁定或已售点击直接禁止。我维护一个selectedSeats数组存放选中的座位function handleSeatClick(seat) { if (seat.status ! 0) return; const found selectedSeats.find( (item) item.row seat.row item.col seat.col ); if (found) { selectedSeats selectedSeats.filter( (item) !(item.row seat.row item.col seat.col) ); } else { if (selectedSeats.length maxSelectCount) return; selectedSeats.push(seat); } }一次最多选 5 个这是我做的限制。电影院购票场景通常不会让人一口气选二十个限制也能避免一次锁定过多座位影响其他用户。底部结算栏会实时显示已选座位和总价这些都用 Vue 计算属性完成相当丝滑。5.3 大影厅的响应式处理不同影厅的行列数差异很大有的厅是 10 排 8 座有的是 18 排 20 座直接写死宽度肯定不行。我做的方案是设置一个基准尺寸然后用外层容器的可用宽度除以座位列数动态计算每个座位的尺寸const seatSize computed(() { const containerWidth containerRef.value?.clientWidth ?? 600; const gap 6; const colCount seatMap.value[0]?.length ?? 10; return Math.min(32, Math.floor((containerWidth - gap * colCount) / colCount)); });这样在手机上和电脑上都能正常展示不会出现小屏上座位挤成一团的情况。不过要说明的是如果座位列数太多比如超过 22 列压缩后的座位会太小这时候我会额外允许外层容器横向滚动而不是一直压缩。移动端还有另一个交互细节点击座位区域要防止误触我在触摸事件上加了 100ms 的延迟判断滑动过程中不触发选座只有明显的点击手势才生效。这点在做移动端 H5 时很关键。5.4 跨组件状态管理选座列表和订单页的联动选座页和订单确认页看似是两个页面但都依赖同一个状态当前选中了哪些座位。这种共享状态必须放到 Pinia 里而不是放在某个组件内部。我建了一个bookingStore保存当前用户选中的场次、座位列表、锁座结果以及订单号。选座页修改selectedSeats确认页读取同一个selectedSeats渲染订单信息。这样即使用户在确认页刷新页面也能通过已经拿到的lockId和场次ID重新拉取座位图恢复已选状态。如果用户在选座页停留太久锁已经过期点击“确认下单”时后端会返回锁失效错误。我的处理是提醒用户重新选择并在收到错误后清空 store 里的selectedSeats和后端锁信息同时重新拉取座位图让用户看到哪些座位已经被别人抢走了。6. 实测阶段最容易翻车的地方功能写完只是第一步实际跑起来之后会遇到各种你没想到的问题。我把自己踩过的坑按严重程度列一下每一个都值得你提前注意。6.1 座位超时释放与重复购买问题座位锁定后不能无限期占着我刚开始设置的锁定时间是一分钟用户从选座到点击下单常常超过一分钟导致订单创建时座位已经释放非常尴尬。后来我把锁定时间调整为五分钟并在前端实时倒计时五分钟后自动将座位状态恢复为可售清空锁座信息。但数据库里的状态也必须在超时后更新。我的做法是启一个简单的定时任务每 10 秒扫描一次screening_seats中status 1且lock_time超过 5 分钟的数据将它们重置为可售。这个扫描任务放在 Node.js 进程里用node-cron实现单机部署完全够用。Redis 里的锁可以通过设置 key 的EX时间来让它自动过期但数据库里的状态不能依赖 Redis 过期时间自动改正所以定时任务兜底很有必要。这里有一个容易被忽略的点用户支付成功后后端要把座位状态从锁定改成已售但如果此时锁已经超时释放别的用户已经锁定了同一座位那就不能直接更新而要先判断lock_id是否等于当前用户的lockId。等于才能改不等于则拒绝支付并提示用户座位已失效。6.2 接口性能瓶颈每次选座都全量拉取座位图我最初在选座页每次轮询都调用GET /api/screenings/:id/seats每 2 秒拉一次全量座位数据。一个 300 个座位的影厅JSON 体积约 8KB看起来不算大但并发用户一多数据库查询压力和带宽消耗就上来了。优化办法是给座位数据加一个version字段比如每次座位状态变化时整个场次的version加一。前端轮询时传上次的version后端只需要判断版本有没有变没有变就返回304 Not Modified变了才返回全量数据。这种方案实现简单且能滤掉大部分无效轮询。如果想做得更实时可以改用 WebSocket把座位变化事件推送给所有正在查看该场次的用户。但需要处理断线重连、事件幂等和按场次订阅复杂度明显上升。我的建议是小项目先用短轮询不丢数据逻辑也简单等真正有大并发需求再升级 WebSocket。6.3 m3u8 预告片播放的兼容性问题电影详情页通常要放预告片部分素材源返回的是.m3u8格式的流媒体地址。原生video标签只有在 Safari 里能直接播放 HLS 流Chrome、Edge 和绝大多数 Android 浏览器都不支持。我的解决方案是用hls.js这个库在video元素上判断如果浏览器原生支持 m3u8就直接把video.src设为流地址如果不支持就用Hls.js实例加载并绑定到 video 元素。代码逻辑大概是if (Hls.isSupported() video.canPlayType(application/vnd.apple.mpegurl)) { const hls new Hls(); hls.loadSource(m3u8Url); hls.attachMedia(video); } else if (video.canPlayType(application/vnd.apple.mpegurl)) { video.src m3u8Url; }这个坑之所以单列是因为它不像选座那样是逻辑问题而是兼容性生态问题。你不做预告片功能时可以跳过但只要涉及在线视频大概率会碰到这个点。6.4 前后端联调时的跨域和代理问题前后端分离项目最经典的问题就是跨域。我在开发时前端地址是localhost:5173后端是localhost:3000前端直接请求后端接口必然被 CORS 拦截。简单粗暴的方式是后端加cors中间件允许所有来源但生产环境我不能把跨域开得太散所以更推荐用 Vite 代理解决开发环境跨域。在vite.config.js里配置server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } }这样前端请求/api/movies时Vite 会把请求转发到http://localhost:3000/api/movies浏览器侧看不到跨域问题。生产环境我使用 Nginx 反向代理将/api路径转发到 Node.js 服务前端静态文件也交给 Nginx一举解决跨域和静态资源分发两个问题。7. 上线前的最后优化清单眼看功能都能用了但离“敢上线”还差几步。我自己经历过一次演示现场打不开座位图最后才发现是接口没做缓存数据库连接池太小。所以我把最后的优化项列在这里你可以照着一项一项检查。7.1 座位图初始化接口的缓存与并发保护GET /api/screenings/:id/seats在电影开场前会被大量用户访问每次都查数据库虽然慢不到哪里去但在活动场次、热门影片上映时还是会打满数据库。我做的优化是把场次基础信息和座位布局在 Redis 里缓存 30 秒但座位状态不能缓存过久因为锁定和购买操作要保证实时。更好的做法是给缓存加版本每次座位状态发生变化就更新版本号前端看到版本号更新后立即刷新。这样座位状态变化的时效性可以做到秒级而不会因为缓存导致用户看到过期座位。7.2 参数校验与幂等性设计别小看参数校验选座接口如果被别人直接遍历调用可以把整个影院的座位都锁一遍。我在创建订单和锁定座位的接口里做了三件事一是限制最多选座数量二是校验每个座位的行列号是否在合法范围三是校验订单创建请求的clientToken唯一性保证同一个用户同一个 token 只能创建一次订单。还有订单号生成不要用自增 ID 直接用建议用随机生成的orderNo比如时间戳加随机字符串或 UUID这样既不会暴露用户量也能防止订单号被猜测。7.3 Node.js 进程管理和部署建议开发时直接node app.js跑没问题但部署到服务器进程崩溃后没人重启就很麻烦。推荐用 PM2 守护 Node.js 进程npm install -g pm2 pm2 start app.js --name cinema-server pm2 save pm2 startup这样服务器重启后 PM2 会自动拉起服务不需要手动启动。前端构建产物放在dist/Nginx 直接指向该目录。数据库方面至少做每日备份如果用的是 MySQL可以写个 crontab 每天执行mysqldump备份。部署时的环境变量管理我也踩过一次坑。数据库密码、JWT 密钥、Redis 地址这类敏感信息不要硬编码在代码里建议通过.env文件注入然后用dotenv加载。这样开发环境、测试环境、生产环境切换时只需要改配置文件不用改代码。最后分享一点我做这套系统的心得电影选座这类系统最大的难点不是 UI而是座位状态的强一致。写 Demo 时可以用 setTimeout 倒计时释放但真要多人同时在线一定要把锁、订单和支付三个闭环考虑清楚。我目前把项目跑在本地服务器用 Nginx 做了反向代理体验已经接近正式上线下一步准备加入会员价和退票流程。如果你也在做类似项目建议先从简化版跑通再逐步加限制条件避免一上来就被并发问题劝退。
返回列表