Node.js + Vue 景点门票销售系统全栈实战与架构复盘
我先说明整体的思路这篇博文要把一个普通的“Node.js Vue 景点门票销售管理系统”拆成一份项目复盘式的实战分享既讲架构与选型逻辑也讲业务建模、接口设计、联调部署以及开发中真正会踩的坑。内容围绕标题关键词展开保持从业者交流的口吻用表格、代码块和步骤来提升可操作性。1. 为什么这套票务系统用 Node.js Vue 来搭接到这个项目的时候客户的需求其实很朴素景区线下售票排队时间长票务数据靠 Excel 统计节假日高峰期经常出现“票卖超了”或者“窗口不知道还剩多少票”的尴尬局面。他们想要一个能把景点、门票、订单、核销串起来的线上销售系统。技术选型的时候我第一个念头就是 Node.js Vue 的组合理由有三个。第一个理由是开发效率。这个系统的核心是业务逻辑和前后端数据打通并不需要重度计算或高性能并发。Node.js 的异步 I/O 模型在处理订单创建、库存扣减这类轻量级接口时完全够用而且 JavaScript 语言从前端到后端一统全栈开发者不需要在脑子里面切换两套语言体系。Vue 作为前端框架组件化开发方式非常适合这种“后台管理 用户端”双层结构景点列表、门票卡片、订单详情这些模块天然就是一个个独立组件。第二个理由是生态成熟度。Node.js 的 npm 生态给了很多现成方案Express 或 Koa 做路由和中间件jsonwebtoken 做鉴权sequelize 做 ORM。Vue 生态也很完整Vue Router 负责页面路由Pinia 或 Vuex 做状态管理Element Plus 提供后台管理需要的表格、表单和弹窗组件。这意味着整个项目的大部分基础能力都不用从零造轮子踩坑成本低。第三个理由涉及团队协作。项目是前后端分离的前端小组同事都在用 Vue后端如果用 Java Spring Boot光是接口联调和部署环境就能多花两周时间。Node.js 可以让他们用同一套思维去理解请求处理、中间件和异步逻辑配合起来顺畅很多。这里也回应一个常见的质疑为什么不用 Spring Boot如果系统未来要接入复杂的人工智能算法、超大规模并发或者团队本身就是 Java 背景那 Spring Boot 当然更合适。但我面对的是一个中小型景区日订单量在千级Node.js 完全扛得住。技术选型这件事脱离业务场景谈“谁更强大”没有意义能快速交付、稳定运行、方便维护就是合理的选型。可以看一下整体的系统架构层级技术选型职责前端Vue 3 Vue Router Pinia Element Plus Axios用户端页面、管理后台页面、状态管理后端Node.js Express业务接口、鉴权、数据库操作数据存储MySQL景点、门票、订单、用户等核心数据部署Nginx PM2前端静态资源托管、后端进程守护2. 门票业务的数据建模与订单状态机设计做这类系统最难的不是把页面画出来而是把数据模型理清楚。我一开始也走过弯路把“景点”和“门票”混在一张表里结果后续做库存、价格、销售时间控制的时候几乎每写一个接口都要推翻重来。所以数据建模这块是值得花时间重点讲的。2.1 核心表结构拆解整个系统涉及五个核心实体景点、门票、订单、用户、核销记录。景点和门票分离是第一条铁律原因是同一个景点在不同季节可能有不同的门票规格比如“成人票”“学生票”“亲子套票”它们价格不同、库存独立、销售时间也可能不一样。如果塞在一张表里扩展性会很差。景点表的设计CREATE TABLE scenic_areas ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, description TEXT, address VARCHAR(255), open_time VARCHAR(50), cover_image VARCHAR(500), status TINYINT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );门票表的设计要注意几个字段库存、销售开始时间、销售结束时间。这个表是和订单联动最紧密的。CREATE TABLE tickets ( id INT PRIMARY KEY AUTO_INCREMENT, scenic_id INT NOT NULL, name VARCHAR(100) NOT NULL, price DECIMAL(10, 2) NOT NULL, stock INT NOT NULL, sale_start DATETIME, sale_end DATETIME, status TINYINT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_scenic (scenic_id) );订单表是核心状态字段必须定义清楚。我在设计订单状态的时候用了整数枚举而不是字符串原因很简单字符串语义虽然可读性好但容易出现“已支付”“支付成功”“Paid”“PAID”这类同义不同写的脏数据。整数枚举配合后端常量定义校验起来更严格。CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, ticket_id INT NOT NULL, quantity INT NOT NULL, total_amount DECIMAL(10, 2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, pay_time DATETIME, use_time DATETIME, expire_time DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_status (status) );2.2 预占库存与超时释放票务系统的库存扣减是最容易出问题的地方。如果采用“下单成功后再扣库存”的方式会出现一个严重问题用户下单后不支付票就被占住了其他用户买不到商家却收不到钱。我在第一版就是这样做的上线测试当天就被吐槽“订单创建了十几单实际支付只有两三单”。正确的做法是预占库存加超时释放。用户在提交订单的时候先把库存扣掉订单状态设为待支付同时设置一个过期时间比如 15 分钟。如果在到期时间内用户没有支付系统自动取消订单把库存加回去。这就像电影院选座后锁定座位一样给用户预留一个合理的时间窗口。这个逻辑在后端的实现中我会在创建订单的接口里用事务同时处理订单插入和库存扣减避免两个操作之间出现异常导致数据不一致。事务提交的伪代码const transaction await sequelize.transaction(); try { const ticket await Ticket.findByPk(ticketId, { transaction }); if (ticket.stock quantity) { throw new Error(库存不足); } await Ticket.update( { stock: ticket.stock - quantity }, { where: { id: ticketId }, transaction } ); const order await Order.create({ order_no: generateOrderNo(), user_id: userId, ticket_id: ticketId, quantity, total_amount: ticket.price * quantity, status: 0, expire_time: Date.now() 15 * 60 * 1000 }, { transaction }); await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; }超时释放的实现我用了定时任务扫描每两分钟查一次待支付订单如果创建时间超过 15 分钟就取消订单并恢复库存。这种方案比 Redis 过期键监听更简单而且在千级订单量下没有性能压力。对于中小型系统不要为了追求技术炫技去引入不必要的复杂度。2.3 订单状态机谁负责流转订单状态我定义成了六个待支付、已取消、已支付、已使用、已过期、已退款。状态流转的规则必须收敛在后端不能让前端随意跳转状态。前端所有的状态展示都依赖后端返回的状态码这样即使有人绕过页面直接调接口也无法把订单状态乱改。状态流转的核心规则是待支付关联到已支付条件是用户完成支付动作支付回调成功。待支付关联到已取消条件是用户主动取消或超时未支付。已支付关联到已使用条件是景区核销人员验票通过。已支付关联到已退款条件是用户在有效期内发起退款申请。已支付关联到已过期条件是超出门票有效期仍未使用。3. 后端接口设计与前端路由、组件联动的落地过程数据模型定完之后下一步就是接口和页面怎么配合。实际开发中如果接口设计不好前端和后端会互相拖累。我在这个项目里坚持了一个原则接口先出文档页面再动手开发。这里的文档不是 Word 文档而是 Swagger 或者简单的 Markdown 接口说明约定好 URL、请求参数、返回结构、错误码。3.1 接口清单与返回结构约定这个系统的接口数量并不多我用一个表格把它们列出来方便前后端对照开发功能方法路径说明景点列表GET/api/scenic/list分页返回景点信息景点详情GET/api/scenic/:id包含该景点下可售门票列表创建订单POST/api/order/create预占库存返回订单号支付订单POST/api/order/pay模拟支付更新状态取消订单POST/api/order/cancel释放库存订单列表GET/api/order/list按用户查询订单验票核销POST/api/order/verify工作人员扫码验票用户登录POST/api/user/login签发 JWT用户注册POST/api/user/register创建用户账号返回结构的约定是整个联调环节最容易忽略的细节。如果前后端各写各的A 接口返回{code: 0, data: {...}}B 接口返回{status: 200, result: {...}}前端就疯了。我统一使用这样的结构{ code: 0, message: success, data: {} }其中code为 0 表示业务成功非 0 表示业务失败message是给用户看的提示信息data是业务数据。HTTP 状态码只负责表示请求本身是否成功200、404、500真正的业务错误统一用code来表达。比如库存不足时HTTP 状态是 200但code是 10001前端的 Axios 拦截器统一处理弹窗提示。这样做的好处是前端不需要在每个请求里写一堆if (response.status ! 200)的判断。3.2 登录鉴权与路由守卫的配合用户系统用了 JWT 而不是 Session主要是考虑到前后端分离和移动端扩展。Session 需要维护服务端存储不利于横向扩展JWT 无状态用户登录后拿到一个 token前端存在 localStorage 里请求时放在请求头Authorization字段中。后端用一个中间件统一校验。前端路由守卫的作用是在页面跳转前判断用户是否登录。Vue Router 的beforeEach钩子里读取 token没有 token 就跳转登录页。管理后台的路由还需要判断角色我用了动态路由的思路用户登录后根据后端返回的角色信息动态添加路由表。管理后台和用户端要分成两套路由布局避免在同一个页面组件里做复杂的条件判断。用户端有首页、景点详情页、订单确认页、订单列表页管理后台有景点管理、门票管理、订单管理、数据统计。两套布局共用同一个登录逻辑但是页面结构和导航完全不同这样拆分维护起来更清晰。3.3 组件复用与插槽的实际场景Vue 组件化的价值在这个项目里体现得很充分。门票卡片组件就是一个典型案例用户端展示门票信息管理后台也要展示门票信息两者的数据来源和样式相似但操作按钮完全不同。用户端有“立即购买”管理后台有“编辑”和“上下架”。我用插槽来剥离差异公共部分放在组件内部操作区域留给父组件传入。门票卡片组件的大致结构template div classticket-card img :srcticket.coverImage / div classinfo h3{{ ticket.name }}/h3 p{{ ticket.description }}/p span classprice{{ ticket.price }}/span /div div classactions slot nameactions/slot /div /div /template script setup defineProps({ ticket: { type: Object, required: true } }); /script父组件使用插槽template TicketCard :ticketticket template #actions el-button typeprimary clickbuyTicket(ticket.id)立即购买/el-button /template /TicketCard /template这样门票卡片组件在用户端和管理后台都能复用只是操作按钮不同。当初设计这个组件的时候还没有意识到插槽会有这么大作用后来新增了“分销商”角色又加了一组分销操作按钮父组件直接换插槽内容就完事了不用改动公共组件。4. 开发环境配置与前后端联调经常踩的坑整个项目开发过程中花在“跑通环境”上的时间比想象中多。很多刚入门的朋友会被环境问题劝退其实这些坑都有固定的排查路径。我把自己踩过的真实问题写出来给后来者一个参考。4.1 Node.js 环境变量配置与 npm 脚本执行策略开发这类的 Vue 项目的第一步就是安装 Node.js。安装完成后环境变量配置是第一个坑。如果你在 cmd 里输入node -v提示“不是内部或外部命令”说明 Node.js 安装目录没有加入 PATH。正常情况下安装包会自动配置环境变量但有些精简版的安装包不会。手动配置的时候把 Node.js 安装路径添加到系统环境变量的 PATH 中配置完成后重新打开终端窗口才会生效。更常见的问题是 npm 脚本执行报错提示“npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本”。这个问题的根源是 PowerShell 的执行策略默认禁止运行 .ps1 脚本文件。解决办法是在 PowerShell 中以管理员身份运行执行Set-ExecutionPolicy RemoteSigned选择“是”确认即可。如果不想修改全局执行策略也可以临时指定Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass这个设置只在当前终端窗口生效关掉就失效。我个人推荐用后者因为它不会影响系统的安全策略。4.2 前后端联调跨域问题Vue 前端开发时默认跑在localhost:5173后端 Node.js 服务跑在localhost:3000两个端口不同浏览器会触发跨域限制。解决跨域有两个方案一是在后端加 CORS 中间件二是用 Vue 的 devServer 代理。我推荐在后端引入cors中间件然后在开发环境允许特定来源。Vite 或者 Vue CLI 的 devServer 代理也可以但生产环境部署的时候还要再做一次 Nginx 反向代理配置不如后端直接处理 CORS 更省事。后端配置示例const cors require(cors); app.use(cors({ origin: [http://localhost:5173, https://your-domain.com], credentials: true }));4.3 Vue 路由参数传递的两种写法景点详情页需要接收景点 ID这里涉及 Vue Router 路由参数的传递。很多初学者会把参数放在query里URL 变成/#/scenic?id3这种方式在刷新页面时参数还在但不利于语义化。我更推荐使用动态路由路径是/scenic/:id参数直接作为路径的一部分刷新页面后依然能正确获取。传参的两种方式// 方式一query 参数 router.push({ path: /scenic, query: { id: 3 } }); // 方式二动态路由 const routes [ { path: /scenic/:id, component: ScenicDetail } ]; router.push({ name: scenicDetail, params: { id: 3 } });在组件中获取参数import { useRoute } from vue-router; const route useRoute(); // 方式一拿 query const id route.query.id; // 方式二拿 params const id route.params.id;两种方式没有绝对的优劣之分但一个项目里要保持统一避免前端同事一会儿用 query 一会儿用 params排查问题的时候搞混。4.4 Axios 拦截器的统一处理Axios 拦截器是前后端联调中必不可少的工具。我在请求拦截器里统一加 token在响应拦截器里统一处理业务错误码和登录态失效。这样业务代码里只需要关心data部分不需要每个页面都写重复的错误处理逻辑。import axios from axios; import { ElMessage } from element-plus; import router from ./router; const service axios.create({ baseURL: /api, timeout: 10000 }); service.interceptors.request.use((config) { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); service.interceptors.response.use( (response) { const res response.data; if (res.code ! 0) { ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res; }, (error) { if (error.response error.response.status 401) { ElMessage.error(登录已过期请重新登录); router.push(/login); } return Promise.reject(error); } );响应拦截器统一处理登录态失效这个细节在实际运营中非常重要。用户的 token 过期后任何接口请求都会返回 401如果每个页面自己判断一遍逻辑分散且容易漏。统一拦截跳转登录页既省心又安全。5. 并发扣库存、核销验票与数据一致性的实战处理这个项目上线运行后遇到的最严重的一类问题集中在“库存扣减不准确”和“验票状态不同步”上。这类问题在测试环境很难发现因为测试的时候同时下单的人很少但一旦真实用户涌入问题就暴露了。5.1 并发场景下的库存扣减方案我最初写的库存扣减逻辑是“先查后改”先查出当前库存如果库存大于购买数量就更新库存为stock - quantity。在并发环境下两个请求同时查出库存都是 10都认为可以购买于是都更新库存为 9实际应该更新为 8这就造成了超卖。解决思路是让库存扣减操作本身带上条件用数据库的行锁语义来保证原子性UPDATE tickets SET stock stock - ? WHERE id ? AND stock ?这条语句使用了行级锁数据库会在更新前锁定这一行其他事务必须等待当前事务提交后才能继续操作。stock ?这个条件确保库存不足时更新影响的行数为 0通过判断影响行数来确定是否库存不足。这种写法比“先查后改”可靠得多不需要额外的乐观锁版本号字段代码也简洁。如果后续订单量增长到万级可以考虑接入 Redis 做库存预扣减用 Lua 脚本保证原子性。但对于当前项目规模数据库行的原子更新已经足够。5.2 验票核销的幂等设计验票核销接口是景区工作人员扫描游客的电子票据二维码后调用的。如果这个接口没有幂等设计工作人员手滑点了两次按钮或者扫码器网络重试游客的订单状态就会从“已使用”变成别的异常状态甚至产生重复核销。核销接口的设计思路是查询订单状态如果已经是“已使用”直接返回成功但不做任何状态修改。只有从“已支付”流转到“已使用”才执行真正的更新操作。const order await Order.findByPk(orderId); if (order.status 1) { // 已支付允许核销 await Order.update( { status: 2, use_time: new Date() }, { where: { id: orderId, status: 1 } } ); return { code: 0, message: 核销成功 }; } else if (order.status 2) { // 已核销不能重复核销但返回成功避免工作人员困惑 return { code: 0, message: 该门票已核销 }; } else { return { code: 10002, message: 该订单不可核销 }; }重点在于更新语句里的status: 1条件这样即使两个核销请求同时进来也只有一个请求能真正把状态从 1 改成 2另一个请求更新影响行数为 0不会产生脏数据。这种“条件更新”的思路在处理状态流转时非常通用强烈推荐。5.3 数据一致性的兜底策略即使有了事务和条件更新我还是在支付回调这个环节加了补偿机制。支付回调成功后后端先更新订单状态为已支付然后尝试发送通知给前端。如果通知发送失败用户已经支付成功但页面还是待支付就会引起投诉。补偿方案是前端在订单列表页定期轮询订单状态发现订单已经是已支付就自动刷新界面。这个兜底策略在开发时感觉“多此一举”但上线后确实起作用了。某次支付回调的网络通道出现抖动部分订单的状态更新失败了幸好订单列表页有轮询刷新用户刷新页面后看到了正确状态避免了大量客诉。6. 部署上线阶段的配置要点与项目复盘开发环境跑通只是完成了三分之一部署上线才是真正的考验。我把这个项目部署到一台 2 核 4G 的云服务器上用的是 Nginx 托管前端静态资源、PM2 守护后端进程的方式。这个组合在中小型项目中非常常见成本低维护也简单。6.1 Nginx 配置的几个关键点前端项目构建后生成 dist 目录里面是静态文件。Nginx 的作用是把请求分发到不同服务静态资源直接返回文件API 请求反向代理到 Node.js 服务。我用的 Nginx 配置要点如下前端路由使用 history 模式时必须配置try_files $uri $uri/ /index.html;否则刷新二级页面会报 404。API 路径以/api开头的请求代理到http://127.0.0.1:3000。静态资源开启 gzip 压缩图片这类体积较大的文件能够显著减小传输量。server { listen 80; server_name your-domain.com; root /var/www/ticket-system/dist; index index.html; location / { 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; } gzip on; gzip_types text/css application/javascript application/json image/svgxml; }6.2 后端进程管理与环境变量配置后端服务启动不能直接使用node app.js这样终端关闭服务就停了。我用 PM2 来做进程守护pm2 start app.js --name ticket-server pm2 save pm2 startupPM2 的好处是进程崩溃后会自动重启服务器重启后也会自动拉起服务。数据库连接、JWT 密钥、支付回调地址这些敏感配置不要硬编码在代码里而是放到.env文件通过dotenv读取。这样在测试环境、生产环境之间切换配置的时候只需要替换.env文件不需要改代码。6.3 上线后的整体复盘这个项目从需求确认到上线前后大约花了六周时间。复盘下来有一些做得不错的地方也有一些值得改进的地方。做得不错的地方是数据库建模阶段花了足够多的时间后续业务扩展几乎没有推倒重来接口返回结构统一约定前后端联调基本没有因为格式问题返工订单状态机的流转治理严格收敛在后端线上极少出现状态错乱。可以改进的地方是库存预占的超时释放用的是定时扫描虽然当前量级没有问题但如果未来订单量增长要切换到更实时的过期事件机制支付环节当前是模拟支付真正接入微信或支付宝时需要处理回调签名验证核销验票还没有对接硬件扫码枪目前是手动输入凭证码后续可以做二维码识别和摄像头扫码。回头来看这类系统最大的难点从来不是技术框架本身而是业务状态的管理和数据一致性的保障。Node.js Vue 的组合让我把精力集中在业务逻辑上而不是和环境、编译、兼容性缠斗。如果你也要做类似的票务、预约、库存类系统我建议先把订单状态机和库存扣减方案设计透再动手写页面否则后面返工的代价会非常大。这才是这个项目总结下来最核心的一句话。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →