ARTICLE DETAIL

资讯详情

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

Node.js+Vue剧院售票系统开发实战:选座并发与预约冲突处理

Node.js+Vue剧院售票系统开发实战:选座并发与预约冲突处理 1. 项目背景与系统定位做这个项目时接到的需求很明确一家大剧院需要一套自己的演出售票与场地预约系统。标题里的 node.js 和 vue 就是它的技术底色——后端用 Node.js 提供 API 服务前端用 Vue 搭界面。整个系统的核心场景是三件事演出信息和场次的发布管理、观众在线选座购票、剧场空闲场地的档期预约。以前剧院票房是怎么干的靠一张 Excel 表记场次有人在窗口卖票就用笔记本手动划掉座位号经常出现两个人买了同一个座位、某天晚上场地被重复租给两个剧团的尴尬事。这套系统要解决的就是这些问题座位状态实时同步、订单超时自动释放、场地预约冲突检测。所有信息线上更新用户端和剧院管理端看到的是同一套数据。这个项目适合谁参考一类是刚学了 Vue 和 Node.js 想找个完整案例练手的学生另一类是做外包开发时接到类似票务、预约类需求的全栈开发者。项目麻雀虽小五脏俱全涉及前后端分离架构、权限认证、并发锁座、订单超时任务、文件上传、二维码生成这些常见模块把这些东西串起来跑通一遍远比零散地看教程有用。2. 技术架构与基础环境搭建2.1 为什么选 Node.js Vue 的前后端分离方案技术选型时其实纠结过一阵子。剧院这边没有专职的 Java 运维团队服务器也只是一台普通的云主机所以后端没有选 Spring Boot 那套重型框架而是用了 Node.js 的 Express。原因很直接Express 轻量、启动快、内存占用小一台 2C4G 的机器跑起来毫无压力而且前后端都是 JavaScript数据格式天然契合不需要像 Java 那样做大量的 Bean 映射和类型转换。Vue 作为前端框架的理由更简单——生态成熟上手曲线平缓。项目里的管理端界面大量使用表格、表单、弹窗、菜单这类经典后台元素Element UI 组件库几乎全包了开发效率非常可观。再加上 Vue Router 做路由、Vuex 做状态管理选座时跨页面传递订单数据也方便。这里放一个选型对比表供你参考考量点Node.js ExpressJava/Spring BootGo/Gin开发效率高前后端同一语言一般配置和模板代码较多较高但上手成本略高部署运维成本低node 单进程即可跑高需要容器和应用服务器中需编译部署适合体量中小型系统、高 I/O 场景大型复杂业务系统高并发微服务周边生态npm 包丰富二维码、Excel、图表开箱即用生态成熟但偏企业级生态较新选 Node.js 还有一个隐蔽的好处如果团队里前端同学临时要改后端接口他不用切换语言环境直接就能看明白逻辑。这次项目就是我一个人前后端通吃这种一套语言从头写到尾的体验确实省了很多上下文切换的时间。2.2 环境准备Node.js 安装与 Vue 脚手架初始化开工第一步是环境准备。这里把踩过的坑先说清楚Node.js 版本千万别下最新的奇数版本老老实实装 LTS 长期支持版比如 18.20.4 或 20.x。我最初装了 21 版本结果某个 npm 包编译原生模块时一直报错查了半天才发现是版本兼容性问题换回 LTS 之后一次通过。安装完成之后命令行里先验证一下node -v npm -v国内环境建议顺手把 npm 源换成镜像不然后面装依赖的时候会怀疑人生npm config set registry https://registry.npmmirror.com后端项目我直接手动搭的目录结构没有用 express-generator因为脚手架生成的代码有些冗余。初始化方式很简单mkdir theatre-server cd theatre-server npm init -y npm install express mysql2 sequelize jsonwebtoken bcryptjs cors前端用 Vue CLI 创建这里注意版本匹配Vue CLI 4.x 对应 Vue 2Vue CLI 5.x 对应 Vue 3。项目下单系统用 Vue 2 Element UI 更省心因为 Element UI 官方稳定支持 Vue 2而 Vue 3 对应的是 Element Plus组件 API 有差异网上很多现成例子是基于 Vue 2 写的直接抄作业更顺畅。vue create theatre-web # 选择 Router Vuex 配置 cd theatre-web npm install element-ui axios qrcode前后端分离的开发模式下前端跑在 8080 端口后端跑在 3000 端口所以需要在前端 vue.config.js 里配置代理避免联调时被跨域问题卡住module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } };这样前端代码里请求/api/shows时开发服务器会自动转发到http://localhost:3000/api/shows前后端联调期间不用处理 CORS 配置省了一大堆麻烦。3. 数据库建模演出、场次、座位与预约3.1 核心表结构与字段设计数据库设计是整个系统的地基。我一开始参考了某票务平台的公开文档思路最后整理了六张核心表用户表、演出表、场次表、座位表、订单表、场地预约表。用户表最简单但有个细节容易被忽略不要只存明文密码要存password_hash。我用 bcryptjs 做哈希加盐后端代码里永远不出现用户原始密码。演出表shows的字段设计如下字段类型说明idINT PK主键titleVARCHAR(100)演出名称categoryVARCHAR(20)类型话剧/音乐会/舞剧等cover_urlVARCHAR(255)海报图片地址descriptionTEXT演出简介statusTINYINT0下架 1上架场次表schedules才是购票的核心。一个演出可能演三天每天下午晚上各一场所以演出和场次是一对多的关系。场次表记录演出 id、具体演出时间、对应的场地 id、以及该场次的座位总数。这里有个重要字段是status用于标记场次是否开放售票比如某些场次留给包场或内部活动就不开放线上购买。场地表venues存的就是剧场里的各个演出区域大剧场、实验剧场、排练厅。每个场地有自己的座位布局参数——多少排、每排多少个座位。这里我采用的是一种折中方案不把座位图做成复杂的自定义画布而是用统一的矩形区域管理排数rows和每排座位数cols记录在场地表里。场地预约表venue_bookings记录外部团体或演出团体租用场地的申请属于哪个场地、开始时间和结束时间、用途说明、联系人、审批状态。这是区别于普通购票系统的重要模块也是剧院实际经营中很常见的需求。3.2 座位状态的流转与并发控制座位状态是售票系统最容易翻车的地方。售票时最怕什么两个用户同时锁同一个座位或者锁座之后用户迟迟不支付导致座位一直被占着卖不出去。我设计了一张schedule_seats表专门记录某个场次下每个座位的实时状态。字段包含schedule_id、seat_row、seat_col、status、locked_at。status 用 0 表示空闲、1 表示锁定、2 表示已售出。状态流转的逻辑是用户选中座位前端创建订单后端把对应座位从 0 改为 1同时记录locked_at时间。用户支付成功后座位从 1 改为 2订单状态变为已支付。如果用户超过 15 分钟未支付后端定时任务把locked_at超时的座位状态从 1 重置回 0同时把订单标记为已取消。这里的并发控制是重点。单纯用先查一下状态再更新的做法在高并发下会出问题两个请求同时查到座位是空闲的然后同时改成锁定就超卖了。正确的做法是用数据库的行级锁在事务内锁定这行记录再做判断。await sequelize.transaction(async (t) { const seat await ScheduleSeat.findByPk(seatId, { transaction: t, lock: t.LOCK.UPDATE }); if (seat.status ! 0) { throw new Error(该座位已被选择); } seat.status 1; seat.locked_at new Date(); await seat.save({ transaction: t }); });加上lock: t.LOCK.UPDATE之后事务提交前其他事务无法修改这一行第二个请求会一直等待第一个请求提交再执行超卖问题从根本上被消灭了。4. 后端 API 与核心业务逻辑实现4.1 接口设计与登录认证后端按模块拆路由所有接口统一走/api前缀。核心路由包括POST /api/auth/register 用户注册 POST /api/auth/login 登录返回 JWT Token GET /api/shows 演出列表分页 分类筛选 GET /api/shows/:id 演出详情包含场次列表 GET /api/schedules/:id/seats 获取某场次的座位图 POST /api/orders 创建订单锁座 POST /api/orders/:id/pay 模拟支付 GET /api/orders 我的订单列表 POST /api/venue-bookings 提交场地预约申请 GET /api/venue-bookings 预约记录列表认证方案用的是 JWT登录成功后后端返回一个 token前端存到 localStorage 里之后每个请求都在 axios 拦截器里加上Authorization: Bearer token头。后端写一个简单的中间件做守卫解析 token 拿到用户 id 后挂在req.user上。function authMiddleware(req, res, next) { const token req.headers.authorization?.split( )[1]; if (!token) return res.status(401).json({ message: 未登录 }); try { const decoded jwt.verify(token, process.env.JWT_SECRET); req.user decoded; next(); } catch (e) { return res.status(401).json({ message: 登录已过期 }); } }这里提一个实际项目里必须要做但新手常忽略的点不要把 JWT_SECRET 硬编码在代码里放在.env环境变量文件中并且加入.gitignore。否则代码一旦提交到公开仓库任何人都可以用你的密钥伪造登录 token。4.2 锁座、创建订单与超时释放锁座和创建订单要放在同一个事务里保证两条记录同时成功或同时失败。流程是前端提交座位 id 列表后端先校验这些座位都属于同一个场次然后逐一锁座接着创建一条待支付订单订单里记录所选座位 JSON 串。订单表必须算对总金额。这里有个业务细节要特别处理同一个演出可能分不同票价区比如一楼池座 380 元、二楼楼座 280 元。所以座位表和票价之间不能直接一对一我在场次表里额外加了一个price_mapJSON 字段格式大概是{1_1: 380, 1_2: 380, 2_1: 280}用排号和列号的组合作为 key。订单创建时后端通过这个价格映射逐一计算总价不信任前端传来的金额因为前端金额可以被随意篡改。超时释放用定时任务实现。我在项目里用了一个轻量方案node-cron每小时跑一次扫描所有状态为待支付且created_at超过 15 分钟的订单将其状态改为已取消同时把关联的座位释放为 0。这里要提醒一下扫描的时候要分批处理不要一次性把所有过期订单都查出来 update数据量大了之后会拖垮数据库。用LIMIT 100反复执行直到没有可处理行为止。4.3 场地预约的冲突检测场地预约是另一个业务核心核心难点在于时间段冲突判断。需求是这样的一个排练厅某天下午 14:00 到 17:00 已经被预约了新申请就绝不能在这个时间段内被批准。冲突判断的 SQL 条件其实很经典SELECT COUNT(*) FROM venue_bookings WHERE venue_id ? AND status approved AND start_time :newEndTime AND end_time :newStartTime;这个条件的逻辑很好理解两条时间区间相交的充要条件是旧开始时间 新结束时间且旧结束时间 新开始时间。把这个判断包在事务里并且给venue_bookings表加上(venue_id, start_time, end_time)联合索引避免并发预约时出现重复预订。4.4 门票二维码生成支付成功后系统要给用户生成一张电子票以二维码的形式展示。我用的是qrcodenpm 包把订单号、场次 id、座位号拼成一个 JSON 字符串作为二维码内容生成 DataURL 后存到订单表里。检票时工作人员扫描二维码解析出订单号在后端查一次订单状态就能判断是否是有效票。二维码内容不建议只放一个订单号因为如果用户买了多个座位订单号对应多张票扫一次没法确定是哪个座位。我选择的是把每张票单独生成一个二维码内容里包含订单号加座位标识数据长度也不大扫描识别速度不受影响。5. 前端 Vue 页面与选座交互5.1 页面清单与路由规划前端部分按角色分成两端用户端和管理端。用户端页面包括首页演出列表、演出详情页、选座页、订单确认页、个人中心、场地预约页。管理端页面包括演出管理、场次管理、场地管理、订单管理、预约审批。路由规划时用到 Vue Router 的嵌套路由和动态路由特性const routes [ { path: /, component: Home }, { path: /shows/:id, component: ShowDetail }, { path: /order, component: OrderLayout, children: [ { path: select-seat/:scheduleId, component: SelectSeat }, { path: confirm, component: OrderConfirm }, { path: result, component: OrderResult } ] }, { path: /venue-booking, component: VenueBooking }, { path: /admin, component: AdminLayout, meta: { requiresAuth: true, requiresAdmin: true }, children: [ { path: shows, component: ShowManage }, { path: schedules, component: ScheduleManage }, { path: bookings, component: BookingManage } ] } ];路由守卫里要做的检查包括订单确认页没有订单数据就重定向回选座页管理端路由没有管理员权限就跳到登录页。这些逻辑如果散落在各个组件里会重复写很多遍在router.beforeEach里统一处理是最干净的方案。5.2 选座组件实现思路选座页面是整个前端最需要花心思的模块。我最初想用 Canvas 绘制座位图后来发现 SVG 更适合这个场景——每个座位就是一个独立的元素响应点击事件可以借助 Vue 的事件绑定机制不需要手动计算点击坐标是否落在某个座位区域内。具体的做法是从后端接口拿到座位数据后前端根据rows和cols动态渲染一个 SVG 网格。每个座位是一个rect元素通过座位状态决定样式空闲用浅灰色选中用主题色已售用一个对角的斜线纹理表示不可点击。座位下方显示排号和列号。svg v-forrow in rows :keyrow rect v-forcol in cols :keycol :fillseatColor(scheduleSeats[row _ col]) clicktoggleSeat(row, col) / /svg这里的seatColor方法返回不同状态下的颜色值toggleSeat方法负责维护一个已选座位数组并且实时计算总价。选座时还会限制每位用户单笔订单最多买 6 张票这也是剧院售票常见的规范。选座组件里有一个交互细节值得注意当用户点击已购座位时应该给出一个轻提示而不是完全没有反应。用户其实并不知道灰色带斜线的座位是不可点击的很多人会反复点同一个已售座位如果没有提示体验就很糟糕。我在这里用 Element UI 的Message组件弹了一条该座位已被购买的提示虽然是个很小的细节但完成度完全不一样。5.3 订单流程与状态管理订单流程涉及多个页面之间的数据传递选座页选好的座位和金额要在确认页展示提交成功后还要在结果页显示订单信息。这些数据如果通过路由参数传递刷新页面就丢了所以必须用 Vuex 管理。Vuex 里我维护了一个orderModulestate 包含scheduleId、selectedSeats、totalAmount、orderId。选座页每次勾选座位都 commit 一次updateSelectedSeats同时触发 getter 重新计算总金额。提交订单成功之后清空 selectedSeats在结果页通过 orderId 从详情接口拉取完整的票务信息。这里分享一个我踩过的坑订单提交后如果用户手滑点击浏览器刷新订单状态其实已经是待支付了但前端 Vuex 里的数据全丢了用户会以为没下单又去重新选座提交一遍导致座位被锁了两份。解决方式是在订单结果页增加一个回查逻辑页面加载时读取路由里的 orderId向后端拉一次订单详情如果发现订单存在且为待支付状态就直接展示继续支付的按钮而不是让用户重新选座。5.4 管理端页面与 Element UI 组合管理端的表格页面大量使用 Element UI 的el-table和el-dialog组合。比如演出管理页面左侧是一个演出列表的表格右侧点击新增演出弹出一个表单对话框提交后通过this.$refs.table.refresh()重新加载数据。场次管理稍微复杂一些因为一场演出有多个场次而且场次关联场地和票价。这里我用el-select选择场地用动态表格让管理员设置每一档座位的价格。比如一楼 1-8 排是 A 档9-15 排是 B 档每个档位填一个价格保存时后端根据这些档位自动生成price_map。场地预约审批页就是一个带状态筛选的表格待审批的预约申请在表格里有通过和驳回两个操作按钮。点击通过时后端会再次执行前面写的时间冲突检测如果冲突就返回错误提示前端用 Message 展示该时间段已被占用。这里的校验不能在页面上做因为页面数据可能过期必须以服务端检测结果为准。6. 联调部署与常见问题排查6.1 前后端联调中的典型坑前后端联调阶段iOS 开发者有句老话叫证书是万恶之源放在全栈联调里最常见的坑就是时间格式。后端从 MySQL 拿到的DATETIME字段经过 Sequelize 序列化后默认输出格式是2024-06-15T14:00:00.000Z带T和Z前端直接显示的话会非常奇怪。处理方式是在 Sequelize 的模型定义里加一下时间格式化{ type: Sequelize.DATE, get() { const raw this.getDataValue(start_time); return raw ? dayjs(raw).format(YYYY-MM-DD HH:mm) : null; } }前端展示时间时我统一用 dayjs 再格式化一次双保险。另一个坑是文件上传。演出海报名为cover_url本质上是文件上传后返回的访问地址。开发环境下文件直接存在后端服务器的uploads目录里Nginx 需要额外配置一个静态目录映射否则图片 URL 会一直 404。我当时就忘了配置这一行联调了两小时一直在查接口为什么返回 500最后发现是上传目录的写权限没开Nginx 用户根本写不进去。这种问题排查起来逻辑简单但就是容易被忽视。6.2 生产环境部署要点部署架构不复杂一台云服务器用 Nginx 托管前端打包后的静态文件用 Node.js 进程托管后端 API用 MySQL 存数据。前端构建之后会生成dist目录把目录里的文件上传到服务器的/var/www/theatre-web然后在 Nginx 配置里加上server { listen 80; server_name your-domain.com; location / { root /var/www/theatre-web; index index.html; try_files $uri $uri/ /index.html; } location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /uploads { alias /var/www/theatre-server/uploads; } }try_files $uri $uri/ /index.html;这一行特别重要vue-router 如果是 history 模式不写这行的话刷新页面就会 404。后端进程我用 PM2 管理几条常用命令npm i -g pm2 pm2 start src/app.js --name theatre-server pm2 save pm2 startupPM2 会把 Node 进程注册成系统服务服务器重启后自动拉起不需要额外写 systemd 脚本。MySQL 的备份我用 crontab 每天凌晨做一次mysqldump保留最近 7 天的备份记录0 3 * * * mysqldump -u root theatre_db | gzip /backup/theatre_$(date \%Y\%m\%d).sql.gz find /backup -name *.sql.gz -mtime 7 -delete6.3 常见问题速查表项目跑完把过程中遇到的高频问题整理成一张表以后你要是也做同类系统可以直接对照排查现象可能原因解决办法npm install 卡住或下载失败网络问题或默认源不稳定配置 registry 为 npmmirror 镜像Vue 页面白屏但无报错路由模式用了 history刷新后 404Nginx 加上 try_files 配置选座时两个请求都能锁同一座位没有使用数据库行级锁事务内加lock: t.LOCK.UPDATE订单支付成功但座位没变已售座位状态更新和支付状态更新不在同一事务将支付状态更新和锁座操作放入同一事务上传图片 404Nginx 没有配置 uploads 目录映射配置location /uploads映射时间显示差 8 小时MySQL 时区默认 UTC连接串加timezone: 08:00或查询时转换Vue 打包后体积太大没有做按需加载路由级拆包 Element UI 按需引入场地预约短暂重复提交冲突检测未加事务和索引包事务 联合索引这里重点说一下 Element UI 按需引入的问题。如果你在 main.js 里全局引用了整个 Element UI打包体积会多出 300KB 以上首屏加载缓慢。按需引入的方式是借助babel-plugin-componentnpm install babel-plugin-component -D然后在 babel 配置里加 plugins这样只打包用到的组件。但注意一个坑全局引入 Element UI 的 CSS 和按需引入的 CSS 机制不同全部改完需要重新 build 并检查弹窗、消息提示的样式是否正常我遇到过 Message 组件样式丢失的问题就是因为没引入对应的样式文件。6.4 场景扩展微信小程序与移动端适配项目交付后剧院方又提了一个需求希望观众能在手机微信里直接买票而不是每次都要打开浏览器访问网页。当时我评估了两个方向一个是做小程序版本另一个是把现有的 Vue 站点改成响应式布局做 H5 端嵌入微信浏览器。考虑到时间成本和现有架构我选了第二条路。选座页原本的 SVG 座位图在手机屏幕上有缩放问题处理方式是给 SVG 加了一个viewBox保持座位图原始比例的情况下自动缩放适配屏幕宽度。同时把所有按钮的可点击区域加大满足移动端触屏的基本要求。这个方法虽然不能完全替代原生小程序体验但胜在零额外开发成本一周内就能上线一个可以用的 H5 版本。如果你真的要接微信生态前端这里要注意引入企业微信 JS-SDK 的场景需求。之前有同事做过类似的 H5 嵌入企业微信的页面需要在页面里配置wecom/jssdk来获取免登录身份依赖版本要求至少 2.3.2 以上低于这个版本的部分接口会有兼容问题。笔者的建议是做这类需求前先去官方文档确认 SDK 版本和接口签名规则不要凭经验自行实现签名算法搞错的概率极高。7. 一些踩坑之后的技术沉淀项目上线至今跑了一个多月售票模块经历了一场真实的小型音乐会大概 300 张票锁座正常没有出现超卖场地预约模块也有两个剧团在正常使用。回看整个开发过程几个地方如果当时能提前想清楚能少走很多弯路。一个是技术方案要敢于做减法。最初需求里还提到要做一个完整的会员积分系统、短信通知系统最后和甲方确认后砍掉了。砍掉之后系统结构清爽很多核心业务逻辑集中排查问题时不需要翻遍十几个模块找线索。做外包项目尤其要注意这一点——加一个功能很容易但每个功能上线后都要维护小团队根本扛不住无限膨胀的功能列表。另一个是数据库索引的重要性。售票高峰期会出现座位查询接口变慢的情况后来通过慢查询日志排查发现schedule_seats表查询时没有走索引每次都是全表扫描。加上(schedule_id, status)联合索引之后接口响应时间从 800ms 降到 50ms 左右。这不是什么高深技术纯粹是基础功夫但很多开发者前期建表时根本没想过索引这回事。最后想说的是自己手写一遍需求、建模、开发、部署、上线的完整流程比看一百遍教程都有用。选座并发、订单超时释放、时间冲突检测这几个点你在视频里看老师讲的时候觉得很简单只有真正自己写一遍并在压力测试下跑出问题才会理解事务和锁到底是怎么一回事。如果看完这篇你也准备动工建议先从数据库建模开始表结构想清楚了后面的代码写起来会顺手很多。
返回列表