最近在整理之前做的一个线下演出售票管理系统,正好把整个设计和实现过程重新梳理了一遍。这个系统基于 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 Router,UI 组件库选了 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 登录鉴权与用户信息存储
我使用的是 JWT(JSON 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 锁座位之外,在订单创建和库存扣减上也需要做保护。我采取的方式是:在更新票档销量时使用乐观锁,执行类似下面的 SQL:
UPDATE 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 vite@latest,选择 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 来运行命令,或者安装跨平台工具如 pnpm,pnpm 的脚本执行流程不同,受 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 中间件、事务处理和并发标记这几个知识点都有了更深的理解,这些才是做项目最大的收获。