
最近在整理之前做的一个线下演出售票管理系统正好把整个设计和实现过程重新梳理了一遍。这个系统基于 Vue 和 Node.js 开发核心就一件事把线下演出的票务流转管起来。和普通电商系统不一样它天然带着三个身份——买家、卖家、管理员每个角色面对的业务逻辑完全不同。买家关心的是怎么快速找到演出、选到好座位、顺利支付拿到票卖家关心的是怎么发布场次、配置票价、核销订单管理员则要盯着整个平台的审核、数据、用户与风险控制。很多人一上来就问这种系统是不是很难做其实拆分下来就是一个标准的前后端分离项目难点集中在座位库存的并发控制、订单状态流转和角色权限边界。我选择 Vue 加 Node.js 的组合核心原因是前后端都是 JavaScript数据格式不用来回转换团队沟通成本也低。这篇文章我会从需求拆解、数据库设计、核心功能实现、权限控制到环境配置踩坑完整讲一遍我在实操中的做法和取舍。不管你是刚入门想拿项目练手还是准备做毕业设计这套思路都值得参考。1. 项目概览与需求拆解1.1 三个角色三条业务线先明确一下三个角色的边界。这个系统里买家就是普通观众他操作的核心路径是注册登录、浏览演出列表、查看演出详情、选择场次、进入选座页面、下单支付、查看电子票、申请退票。这条链路是典型的前台交易流程对页面交互要求最高尤其是选座环节需要座位图能够真实反映场馆座位位置和已被占用的状态。卖家在这里指的是演出主办方或者场馆运营方他们要做的不是简单的商品上架而是管理演出项目。具体操作包括提交演出信息、配置演出场次、划分票档和价格、设置座位库存、查看已售订单、处理退票审核。和买家不同卖家的页面更偏后台管理需要清晰的报表结构和批量操作能力。管理员是平台的管理者负责对卖家提交的演出内容进行审核。因为涉及线下演出审核环节很重要不能什么演出项目都直接上架。管理员还需要管理用户状态比如封禁恶意用户、冻结异常卖家账号另外要能实时看到平台的订单量、交易金额、热门演出排行等统计数据为运营决策提供依据。三个角色的权限是严格区分的。买家不能看到卖家的销售数据卖家不能修改其他卖家的演出信息管理员虽然能看到全部数据但不应该直接介入订单交易。这套权限模型在系统设计一开始就要定好否则后面加需求会改得很痛苦。1.2 为什么选 Vue 加 Node.js 这套组合选型的时候我也犹豫过要不要用 Spring Boot 加 Vue毕竟这种组合在招聘市场上更常见。但考虑到这个项目的定位是快速落地、中小规模并发Node.js 的异步非阻塞特性在处理下单场景时其实很有优势。更重要的是前端用 Vue后端用 Node.js前后端可以共用一套数据校验逻辑甚至一些工具函数可以直接互相拷贝开发效率提升非常明显。Vue 这边我用的技术栈是 Vue 3 加 Vite状态管理用 Pinia路由用 Vue RouterUI 组件库选了 Element Plus。Vue 的组件化开发方式非常适合这种业务模块清晰的管理系统买家端、卖家端、管理员端虽然核心逻辑不同但很多基础组件可以复用比如数据表格、表单校验、弹窗确认、分页组件。Node.js 后端我选择 Express 框架原因就是简单直接中间件机制灵活对应这种业务导向型的系统完全够用。很多人会纠结要不要上 Nest.js但如果项目不是特别复杂Express 反而更容易理解排错也更直接。数据库用的是 MySQL订单明细、演出信息这些结构化数据用关系型数据库管理最稳妥。另外引入了 Redis 做座位的临时锁定和热门演出的缓存后面会细说。2. 系统架构与数据库设计2.1 前后端分离的整体分层系统整体采用前后端分离架构前端项目单独跑在 5173 端口后端接口跑在 3000 端口。开发环境下前端通过 Vite 的 proxy 配置把接口请求代理到后端避免跨域问题。生产环境下前端打包成静态文件可以用 Nginx 托管再把 API 也反代到后端进程。后端按照业务模块做了分层。最外层是路由层负责接收 HTTP 请求中间是控制器层做参数校验和业务编排然后是服务层存放具体业务逻辑比如创建订单、扣减库存、更新演出状态最底层是数据访问层封装 SQL 操作或者用 Sequelize 这类 ORM。我这次为了减少依赖直接用 mysql2 连接池写 SQL好处是执行计划可控坏处是手写代码量会多一点。另外我单独抽了一个中间件层专门处理登录鉴权、角色权限、统一错误返回、请求日志记录。这样业务接口里不需要重复写鉴权代码只要在路由注册时挂载对应的中间件就完成了。2.2 数据库表设计数据表设计是整个系统的地基这里我把核心的表列出来方便参考。用户表 users 是三个角色共同的账号体系靠 role 字段区分角色。关键字段包括 id、username、password_hash、real_name、phone、role、status、created_at。password_hash 存的是加盐后的哈希值千万不要明文存密码。role 我用了 0、1、2 分别代表买家、卖家、管理员也可以用字符串枚举看团队习惯。演出表 shows 记录一个演出项目的基本信息比如演出名称、类型、封面图、演出简介、主办方 ID、审核状态。审核状态我设计了四个值0 草稿、1 待审核、2 审核通过、3 已下架。这里有一个容易被忽略的点一个演出项目会有多个场次所以演出表和场次表必须分开。场次表 sessions 关联 shows一个演出在不同时间、不同场馆会有不同场次。关键字段是 show_id、venue_id、start_time、sell_status。因为线下演出可能在不同城市巡演所以还要加一个城市和场馆字段。选座和下单都是关联到具体的 session_id而不是 show_id。订单表 orders 是整个系统的核心交易记录。字段包括 order_no、buyer_id、session_id、total_amount、status、pay_time、refund_time。订单号要唯一通常用时间戳加随机数生成也可以接入雪花算法。订单状态我定义为待支付、已支付、已出票、已退票、已关闭。已出票和已消费我合在一起如果需要精确核销记录可以再加核销表。订单明细表 order_items 保存每张票的信息包括 order_id、session_id、ticket_type_id、seat_row、seat_col、price。为什么要单独一张表因为一个订单可能包含多张票比如买家同时买两张连座这时候明细表就能记录每张票对应的座位号方便后续核销。座位表 seats 和票档表 ticket_types 需要配合设计。如果是固定座位场馆需要提前生成座位数据如果是自由座或站票可以不使用座位表只靠票档库存数控制。票档表包含 session_id、type_name、price、total_stock、sold_count。下面给出关键表的建表 SQL 示例字段已经精简过。CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, real_name VARCHAR(50), phone VARCHAR(20), role TINYINT NOT NULL DEFAULT 0 COMMENT 0买家 1卖家 2管理员, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE shows ( id INT PRIMARY KEY AUTO_INCREMENT, seller_id INT NOT NULL, title VARCHAR(200) NOT NULL, cover_url VARCHAR(500), description TEXT, category VARCHAR(50), status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1待审核 2通过 3下架, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE sessions ( id INT PRIMARY KEY AUTO_INCREMENT, show_id INT NOT NULL, venue_name VARCHAR(200) NOT NULL, city VARCHAR(100), start_time DATETIME NOT NULL, sell_status TINYINT NOT NULL DEFAULT 1 COMMENT 1在售 0停售 ); CREATE TABLE ticket_types ( id INT PRIMARY KEY AUTO_INCREMENT, session_id INT NOT NULL, type_name VARCHAR(50), price DECIMAL(10,2), total_stock INT, sold_count INT DEFAULT 0 ); CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL UNIQUE, buyer_id INT NOT NULL, session_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已出票 3已退票 4已关闭, pay_time DATETIME, refund_time DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_buyer (buyer_id), KEY idx_session (session_id) ); CREATE TABLE order_items ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, ticket_type_id INT NOT NULL, seat_row VARCHAR(10), seat_col VARCHAR(10), price DECIMAL(10,2) NOT NULL );设计的时候我踩过一个坑不要把座位信息直接存在订单主表里否则一个订单买了五张票座位字段会变得非常臃肿。拆成明细表之后后续退票、改签都能定位到具体的票。2.3 核心状态机与流转逻辑系统里有两条状态流转线一条是演出信息的审核流转另一条是订单的生命周期。演出信息的流转比较简单卖家创建演出时状态是草稿提交审核变成待审核管理员审核通过后状态变为已上架这时候买家才能在前端看到该演出。如果演出有问题管理员可以下架已下单的买家会收到通知这个过程需要提前想清楚不能简单直接删数据。订单流转是真正考验设计的地方。买家下单后初始状态是待支付支付成功变成已支付系统根据订单明细生成电子票状态变成已出票观众到场核销后变成已完成。如果买家在规定时间内申请退票状态会进入退票审核卖家同意后状态变成已退票同时库存要回补。这里比较关键的是支付和出票之间的一致性我用了事务操作确保扣款成功和生成票务同时完成避免出现钱扣了票没出或者票出了钱没扣的问题。3. 核心功能实现与实操细节3.1 买家端选座、下单、支付买家端最重要的页面就是选座页。我在前端用 Canvas 绘制座位图也可以用 div 网格模拟但 Canvas 在大量座位渲染时性能更好。座位从后端接口获取接口返回每个座位的位置坐标和状态比如空位、已售、锁定中。状态为锁定的座位要灰掉防止其他人重复选择。这里有一个并发问题需要重点处理。两个买家同时点击同一个座位如果不做任何处理两个人都能下单成功但座位只有一张这就是超卖。我的方案是引入 Redis 座位锁。当用户点击某个座位时后端接口尝试向 Redis 写入一个 key比如 seat:lock:sessionId:row:col写入成功说明锁定成功如果 key 已存在说明别人已经锁住直接返回失败。锁定后不能一直占着不放所以要给锁设置过期时间通常设 5 分钟。用户在这段时间内完成下单支付支付成功后释放锁。如果超时未支付锁自动消失其他人就能继续选择。这种方式类似于现实中的购物车占座既保证公平又避免恶意占座。后端下单接口的核心代码如下这是一个最简版本实际项目里还要处理事务和库存回补。app.post(/api/order, async (req, res) { const { buyerId, sessionId, seatList } req.body; // 1. 检查座位是否被锁 for (const seat of seatList) { const lockKey seat:lock:${sessionId}:${seat.row}:${seat.col}; const locked await redisClient.get(lockKey); if (locked locked ! req.userId) { return res.status(409).json({ message: ${seat.row}排${seat.col}座已被锁定 }); } } // 2. 生成订单号计算总价 const orderNo generateOrderNo(); const totalAmount await calculateAmount(sessionId, seatList); // 3. 创建订单和订单明细事务 const connection await mysql.getConnection(); await connection.beginTransaction(); try { const orderId await insertOrder(connection, { orderNo, buyerId, sessionId, totalAmount }); for (const seat of seatList) { await insertOrderItem(connection, { orderId, ...seat }); await lockSeat(connection, sessionId, seat); } await connection.commit(); // 4. 返回订单号引导支付 res.json({ orderNo, totalAmount }); } catch (err) { await connection.rollback(); throw err; } });这段代码还有优化空间。真正的高并发场景下座位锁的检查和建议、订单创建、座位占用更新应当用 Lua 脚本来保证原子性避免在竞态条件下出现重复检查。Redis 的 setnx 指令也可以用 set ... ex nx 的方式一步完成。支付环节我建议先对接沙箱环境比如支付宝或微信支付的沙箱重点测试回调验签和异步通知处理。支付回调是后端接口需要从通知中取出订单号确认后把订单状态改成已支付并生成电子票记录。要给买家返回一个 ticket_no后续核销时只需要扫这个票号。3.2 卖家端发布演出与票档配置卖家端的核心功能是发行演出的完整流程创建演出项目、添加演出介绍和封面然后为演出添加场次每个场次设置票档和座位。这个流程在技术实现上其实就是一组表单提交但交互细节很多。演出信息提交后的审核状态会直接显示在列表里卖家能看到待审核或审核不通过的反馈如果不通过需要支持修改后重新提交。票档配置我采用了标准做法一个场次可以设置多个票档比如早鸟票、普通票、VIP票。每个票档有自己的价格和总库存。对于固定座位场馆还需要将座位和票档关联。我提供了一个批量设置的入口卖家可以选中某个区域整体关联到某个票档不用一个个座位去设置否则几百个座位会让人崩溃。库存管理要特别留意。票档的 total_stock 是初始库存sold_count 是已售数量剩余库存等于 total_stock 减 sold_count。每当有订单生成对应票档的 sold_count 就要增加。这个操作必须在事务里做并且给这条票档记录加行锁防止并发时库存数量不一致。卖家端还有一个高频功能就是查看订单。我会支持按场次筛选、按订单状态筛选以及导出 Excel 报表。Node.js 生态里导出 Excel 可以用 exceljs前端发送请求后端动态生成文件流返回不需要额外搞复杂的导出任务。实测下来只要数据量不超过几万行内存足够就不用担心。卖家端处理退票的逻辑也不能忽略。买家提交退票申请后卖家看到待处理列表同意后系统自动把订单状态改成已退票把对应票档的 sold_count 减回去同时触发退款流程。退款接口要调用支付平台的退款 API这个流程比支付更严格最好加上操作日志记录操作人信息方便以后查账。3.3 管理员端审核与控制台管理员端是一个典型的中后台管理界面。我用 Element Plus 的表格组件和表单组件搭建核心功能包括演出审核、用户管理、订单查看和销量统计。演出审核列表展示所有待审核的演出信息。为了提升操作效率我在详情页里用了 Tabs 结构一个 Tab 看演出基本介绍另一个 Tab 看票档和场次配置。管理员审核的主要是演出内容是否合规、票价设置是否合理、场次信息是否真实。审核通过后演出上架审核不通过必须填写驳回原因。用户管理这块管理员可以对异常账号进行禁用操作。禁用后该用户无法登录已有订单不受影响。如果卖家账号被禁用其名下在售的演出需要批量下架这里建议加一个逻辑判断避免禁用后演出还在继续售卖产生无法履约的问题。数据统计我用了 ECharts 绘制柱状图和折线图统计每日订单量、每日销售额、热门演出排行。这些统计接口需要编写聚合 SQL。这里给一个简单的统计示例统计最近七天每日订单量SELECT DATE(created_at) AS day, COUNT(*) AS order_count, SUM(total_amount) AS total_amount FROM orders WHERE created_at DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE(created_at) ORDER BY day;管理员端对性能要求不高但要注意查询语句不能写得太随意。数据量大了之后聚合查询很容易拖慢页面建议给 orders 表的 created_at 字段建索引并限制查询时间范围。4. 权限控制与安全处理4.1 JWT 登录鉴权与用户信息存储我使用的是 JWTJSON Web Token做登录鉴权。用户登录成功后后端签发一个 token 返回给前端前端在后续请求的请求头里带上 Authorization: Bearer token。token 的有效期我设置为 24 小时过期后可以通过 refresh token 刷新当然为了简化小项目也可以直接让用户重新登录。使用 JWT 时要特别注意不要在 token 里塞敏感信息比如密码、手机号、余额。我的做法是只放 user_id 和 role其他用户信息需要时再查数据库。这样即使 token 泄漏损失也能降到最小。前端我用 Axios 统一做了拦截。请求发出前从 localStorage 取出 token 并注入请求头响应回来后如果状态码是 401 表示 token 失效就清除本地登录信息并跳转到登录页。这样业务代码里就不需要关心 token 是否存在了。4.2 前端路由守卫加后端中间件双重校验前端的 Vue Router 配置很简单在路由的 meta 字段里声明需要的角色比如 meta: { requiresAuth: true, roles: [buyer, seller, admin] }然后在全局前置守卫里判断用户角色是否匹配。这么做可以让页面级的访问控制更直观但是前端控制并不能真正防住恶意请求所以后端必须再做一次校验。后端我做了一个 restrictTo 中间件。在需要限制角色的接口上挂载这个中间件如果 token 解析出的 role 不在允许列表内就直接返回 403。比如发布演出的接口只有卖家能调用买家调用时虽然 token 合法但角色不匹配。这个中间件的代码逻辑大概是这样const restrictTo (...roles) { return (req, res, next) { if (!roles.includes(req.user.role)) { return res.status(403).json({ message: 没有权限执行该操作 }); } next(); }; }; // 使用示例 router.post(/shows, authenticate, restrictTo(seller), createShow); router.get(/all-orders, authenticate, restrictTo(admin), adminListOrders);除了角色限制还有所有者限制。比如卖家 A 不能编辑卖家 B 的演出信息接口在更新前要查询演出数据的 seller_id如果和当前登录用户不匹配就拒绝操作。这类问题容易忽略很多人只做了角色判断忽略了数据归属判断结果导致越权漏洞。4.3 防超卖与接口幂等设计防超卖是售票系统绕不开的话题。除了前面提到的 Redis 锁座位之外在订单创建和库存扣减上也需要做保护。我采取的方式是在更新票档销量时使用乐观锁执行类似下面的 SQLUPDATE ticket_types SET sold_count sold_count 1 WHERE id ? AND sold_count 1 total_stock受影响行数为 0 说明库存不足或并发冲突此时返回票已售罄。这是最简单且有效的防超卖手段。接口幂等同样重要。买家可能因为网络问题重复点击提交订单按钮或者支付回调在超时后重试。我在订单创建时使用了前端生成的 requestId后端以 requestId 作为唯一索引重复请求直接返回已创建的订单号不会生成重复订单。支付回调里则通过订单状态检查如果订单已经变成已支付后续回调直接忽略。5. 环境配置与常见问题排查5.1 Node.js 与 Vue 环境搭建这个项目在环境配置阶段新手踩坑率非常高。Node.js 安装后第一件事是在命令行执行 node -v 和 npm -v 检查是否安装成功。如果提示找不到命令通常是环境变量没有配好。Windows 安装时我记得勾选 Add to PATH 就行如果是 msi 安装包安装完成后可能需要重启终端。国内开发时npm 官方源速度不太稳定建议配置淘宝镜像执行 npm config set registry https://registry.npmmirror.com。这个配置是全局的不会影响项目代码。接着创建 Vue 项目我推荐用 Vite 初始化命令 npm create vitelatest选择 Vue 模板。初始化后安装依赖npm install 如果经常卡住可以试试使用 pnpm 或者 cnpm。后端项目从一个空目录开始新建 package.json然后安装 express、cors、mysql2、jsonwebtoken、redis 等包。有一点需要提醒安装的包版本要固定下来否则过一段时间项目可能因为依赖升版而跑不起来。package.json 里的版本号建议不要用 ^ 前缀或者直接使用 package-lock.json 锁定版本。5.2 高频报错npm 脚本无法加载这个问题在 Windows 上遇到概率极高。执行 npm run dev 或者 npm run build 时终端提示类似npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。这个报错是 PowerShell 的执行策略限制不是 npm 本身坏了。解决办法有两个任选一个就行。一个是以管理员身份打开 PowerShell执行 Set-ExecutionPolicy RemoteSigned然后输入 Y 确认最后再试试 npm run dev。另一个更省事的方法是不用 PowerShell改成用 CMD 或者 Git Bash 来运行命令或者安装跨平台工具如 pnpmpnpm 的脚本执行流程不同受 Windows 的限制更少。我之前有次开发环境一直报这个错排查了很久才发现是系统执行策略问题配置好之后整个开发流程顺畅了很多。5.3 跨域问题与接口代理前后端分离必然遇到跨域。前端访问 http://localhost:5173后端是 http://localhost:3000浏览器会拦截不同端口下的请求。最直接的办法是后端启用 cors 中间件const cors require(cors); app.use(cors());如果生产环境部署在不同域名下cors 中间件要配置允许的 origin 白名单不能直接开放所有来源不然容易被其他网站恶意调用接口。前端开发时也可以配置 Vite 代理在 vite.config.ts 里设置export default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } });这样前端请求 /api 前缀的路径会自动转发到后端。代理的好处是浏览器的地址始终指向前端服务看不到跨域请求Cookie 等数据传递也更方便。5.4 部署与打包部署前前端先执行 npm run build 生成 dist 目录。后端代码如果是 Express 项目可以用 pm2 启动pm2 start npm --name ticket-api -- run start。pm2 的好处是进程崩溃后会自动重启后台运行还可以查看日志。如果一台服务器上部署前后端可以用 Nginx 托管前端静态文件并反代 API 请求。Nginx 配置里关键部分如下server { listen 80; server_name your-domain.com; location / { root /var/www/ticket-front/dist; 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; } }需要注意前端使用 Vue Router 的 history 模式时刷新页面会出现 404所以要加上 try_files 那条规则把所有请求回退到 index.html让前端路由接管页面。数据库部署时建议关闭 MySQL 远程直连只允许本机访问。Redis 也要设置密码和绑定本机这些安全细节不能省尤其是在云服务器上。6. 测试心得与扩展思路整个系统跑起来之后我建议先做一轮功能测试再做一轮并发测试。功能测试可以借助 Postman 或者 Apifox把每个接口按角色分类建好集这样能够保证三个角色的权限边界没有漏空。比如买家调用卖家接口返回 403管理员能看到的接口买家看不到这些用例用自动化工具跑一遍效率非常高。并发测试我用的简单工具是 Apache Bench或者直接写一段脚本模拟同时选座。重点观察两个场景同一个座位被两个买家同时锁定时只允许一个人成功票档剩余 1 张时两个买家同时下单只允许一个支付成功。这两个场景能暴露绝大多数超卖问题。这个系统后续扩展空间很大。线下演出售票最核心的延伸是电子票核销可以给每张票生成一个二维码场馆入口扫码核销需要增加一个售票员角色或者小程序核销端。另外还可以做演出推荐、短信通知、订单发票、优惠券营销这些运营功能。技术上都不是瓶颈真正要守住的是订单数据和库存数据的一致性这块一旦出问题线下演出就变成线下翻车了。我个人在实际操作中的体会是这种业务系统先别急着堆功能把最核心的买家购票、卖家管理、管理员审核这条主链路跑通数据一致性和权限边界守住剩下的功能都可以后续慢慢加。做完整套系统之后我对 Vue 组件通信、Node.js 中间件、事务处理和并发标记这几个知识点都有了更深的理解这些才是做项目最大的收获。