ARTICLE DETAIL

资讯详情

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

基于Node.js+Vue的公园门票预订系统开发实践

基于Node.js+Vue的公园门票预订系统开发实践 做公园门票预订这套系统起因挺实在的——周末去本地公园转一圈售票窗口前永远排着长队手机信号一差连扫码支付都卡在付款页面转圈。我想了很久与其等园区升级设备不如自己撸一套管理系统把购票、退票、验票全流程搬到线上。这个项目我全程用 Node.js 做后端、Vue 做前端从零开始搭到能跑通下单流程前后花了一个多月。如果你也在做类似的信息管理系统、想入门前后端分离开发或者正准备把一个普通 CRUD 项目做成真正能上线的产品这篇内容应该能帮你少踩几条弯路。1. 项目整体设计与技术选型思路1.1 为什么这套系统要选 Node.js Vue先说结论这个组合不一定在所有场景都是最优解但在“公园门票预订系统”这个需求下它是我能想到的性价比最高的方案。第一全栈都是 JavaScript。前端 Vue 写页面后端 Node.js 写接口语法同源上下文切换成本极低。一个页面从浏览器交互到数据库读写链路里所有代码我都能看懂不需要在前端 JS 和后端 Java 之间来回切换心智模型。对于个人开发者或者三五人的小团队来说这一点能省下非常多沟通成本。第二Node.js 本身就很适合这个系统的业务特征。门票查询、余票检查、下单提交、订单状态变更这些操作都是典型的 I/O 密集型短任务几乎没有 CPU 密集的计算正好是 Node.js 异步事件模型的强项。你不用担心什么“Node 性能差”的传言在这个量级的公园业务下单台 Node 服务撑住几千个并发查询余票毫无压力。真正需要担心的反而是数据库连接和缓存设计。第三Vue 的渐进式特性太适合管理类系统了。公园门票系统虽然叫“综合管理系统”但本质上是几个核心流程的组合游客端浏览购票、订单管理管理员端维护公园和门票信息、处理订单。这种系统用 Vue 的组件化开发非常顺手一个门票卡片组件能复用在首页、公园详情页、推荐位三个地方而且生态成熟路由、状态管理、UI 组件库都有现成方案根本不用从零造轮子。当然选型时我也纠结过要不要上 Spring Boot Vue。后来对比下来当时项目组里没人写 Java为一个普通管理系统引入整套 JVM 技术栈学习的性价比太低。如果你的团队已经熟练掌握 Java后端用 Spring Boot 完全没问题但如果是从零起步、资源有限Node.js Vue 就是最务实的路线。1.2 业务功能拆开看C端游客、B端管理员任何管理系统第一步都是把需求拆清楚。我把这套系统拆成“游客端”和“管理端”两个大模块每个模块再往下细分功能点。这里先列一张总表端功能模块具体功能点C端游客账号体系注册、登录、退出登录、找回密码C端游客公园浏览公园列表、公园详情、公告信息C端游客门票预订选择门票类型、选择游玩日期、提交订单、在线支付C端游客订单管理查看订单列表、查看订单详情、取消未支付订单、申请退票B端管理员内容管理公园信息增删改查、门票类型管理、公告发布B端管理员订单处理查看全部订单、按状态筛选、核销入园、退票审核B端管理员数据统计每日订单量、入园人数、门票销售排行先把功能模块画出来再动手写代码这个习惯帮我避开了很多“做到一半发现缺页面”的问题。尤其是退票这个需求最初我以为很简单后来发现它牵扯到库存回补、支付渠道原路退回、订单状态流转多个环节如果前期不把流程理清后期改起来非常痛苦。1.3 数据库表结构与订单状态设计数据库我用的是 MySQL表结构没有设计得很复杂核心就是五张表用户表、公园表、门票表、订单表、公告表。因为每个订单可能包含多张不同门票理论上还需要一张订单明细表但为了控制复杂度我直接把下单粒度做成“一次订单对应一种门票”这样订单表自己就能承载明细也能避免联表查询时的额外复杂度。核心表设计大致是这样的表名核心字段说明usersid, username, password, phone, role, created_atrole 区分普通用户和admin管理员parksid, name, address, description, image_url, status公园基本信息ticketsid, park_id, name, price, stock, sale_status某一公园下的门票类型ordersid, order_no, user_id, ticket_id, quantity, total_amount, status, book_date, created_at订单主表noticesid, title, content, created_at公告订单状态我用了 tinyint 枚举0 待支付1 已支付2 已使用3 已取消4 已退款。为什么不用字符串而是数字因为数字在数据库里占用空间小、查询快而且程序里定义常量后语义也清晰。刚开始写的时候我也嫌数字可读性差后来通过后端统一返回状态描述字段前端直接渲染文字问题就解决了。另外订单表里我特意设计了order_no这个字段用时间戳加随机数生成比如20240521143000123456。这个订单号虽然没有业务含义但在排查问题、对接客服、对账的时候非常重要千万不要偷懒直接用自增 ID 当订单号暴露给用户。2. 前端 Vue 开发从搭脚手架到路由守卫2.1 用 Vite 搭项目目录从一开始就规划好前端我用的是 Vue 3 Vite没有选 Vue CLI因为 Vite 在启动速度和依赖预构建上优势太明显了。开发环境下热更新几乎是秒级的改完代码切回浏览器就能看到效果这种即时反馈对开发效率的提升是实打实的。创建项目的命令很简单npm create vitelatest park-ticket-frontend -- --template vue cd park-ticket-frontend npm install npm run dev但真正重要的是目录结构。很多新手喜欢把所有组件堆在components里把所有请求写在业务组件中项目一复杂就完全没法维护。我的目录规划是这样的src/ ├── api/ # 接口请求封装按模块拆文件 │ ├── auth.js │ ├── park.js │ └── order.js ├── assets/ # 静态资源 ├── components/ # 通用组件 ├── router/ # 路由配置 ├── stores/ # Pinia 状态管理 ├── views/ # 页面组件 │ ├── park/ # 游客端页面 │ ├── order/ # 订单相关页面 │ └── admin/ # 管理端页面 ├── utils/ # 工具函数、axios 实例等 ├── App.vue └── main.jsapi目录的拆分很重要。比如park.js里只放公园相关的接口函数页面组件调用时引入对应函数请求的 url、参数、返回值都集中在一处管理。这样后端接口一旦改动只需要改一个文件而不是把所有页面翻一遍。2.2 路由拆分与登录守卫别小看路由参数这套系统有两种身份普通游客和管理员路由需要根据登录状态和角色做隔离。我用 Vue Router 4 配置了完整的路由表并给需要权限的页面加了 meta 标记{ path: /order/list, name: OrderList, component: () import(../views/order/OrderList.vue), meta: { requiresAuth: true } }, { path: /admin/orders, name: AdminOrders, component: () import(../views/admin/AdminOrders.vue), meta: { requiresAuth: true, role: admin } }然后写一个全局前置守卫在每次路由跳转前检查登录状态和角色router.beforeEach((to, from, next) { const authStore useAuthStore() if (to.meta.requiresAuth !authStore.token) { next({ path: /login, query: { redirect: to.fullPath } }) } else if (to.meta.role authStore.userInfo.role ! admin) { next({ path: /403 }) } else { next() } })redirect这个参数容易被忽略但体验影响非常大。游客随便点进一个订单页面被拦下来去登录等登录完再手动找原来的页面这体验太糟糕了。加了 redirect 后登录成功时可以跳回原页面整个流程就顺了。再说一个我踩过的坑Vue Router 4 里params传参有个坑如果路径是/park/:id用router.push({ name: ParkDetail, params: { id: 1 } })没问题但如果用router.push({ path: /park, params: { id: 1 } })params 会被直接忽略id 丢失。我刚开始经常在这个问题上翻车后来统一改成通过路由query或者带参 path 的方式才彻底消停。2.3 axios 封装与 Pinia 状态管理前端和后端的通信我用 axios 封装了一个统一实例。为什么一定要封装而不是每个页面直接import axios from axios因为统一实例能做三件很重要的事注入认证头、统一处理错误码、统一解析响应结构。我的 axios 实例大概是这样封装的import axios from axios import { useAuthStore } from /stores/auth import router from /router const service axios.create({ baseURL: /api, timeout: 15000 }) // 请求拦截器自动携带 token service.interceptors.request.use((config) { const authStore useAuthStore() if (authStore.token) { config.headers.Authorization Bearer ${authStore.token} } return config }, (error) Promise.reject(error)) // 响应拦截器统一处理业务错误码 service.interceptors.response.use((response) { const res response.data if (res.code ! 0) { ElMessage.error(res.message || 请求失败) if (res.code 401) { authStore.logout() router.push(/login) } return Promise.reject(new Error(res.message)) } return res }, (error) { ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) }) export default service状态管理我用的 Pinia。相比 VuexPinia 的 API 更简洁TS 支持更好而且去掉了 mutations 这层概念直接用state和actions就能完成所有状态变更。我的 auth store 里存了 token 和用户信息登录、退出登录、从 localStorage 恢复状态这些 action 都集中写在 store 里组件里只需要一行调用即可。这里要特别提醒一个容易出问题的点useAuthStore()如果在 axios 模块的顶层调用会因为 Pinia 实例还没初始化而报错。所以拦截器里面必须在函数执行时再调用而不是在模块顶层调用。这个坑在我第一次重构时差点踩进去。2.4 新手最容易卡住的环境问题npm 脚本被禁止、Devtools 连不上好多人在系统上第一次运行npm run dev时会碰到一个非常挫败的报错npm : 无法加载文件 D:\nodejs\npm.ps1因为在此系统上禁止运行脚本。这个问题的原因不是 npm 坏了而是 Windows PowerShell 的执行策略默认不允许运行.ps1脚本。解决方法很简单以管理员身份打开 PowerShell执行Set-ExecutionPolicy -ExecutionPolicy RemoteSigned然后重新打开终端npm run dev就正常了。这个命令的意思是“只允许本机创建的脚本和下载后经过签名的脚本运行”既能解决 npm 脚本执行问题又不会把安全策略完全放开。我后来在好几台新电脑上配置环境都第一时间先改这个设置。另外很多新手安装 Vue Devtools 之后发现“没有检测到 Vue”90% 的情况是打开了普通浏览器标签页而不是本地项目的页面。Vue Devtools 是一个浏览器扩展它只会在检测到 Vue 应用运行的页面自动激活图标打开http://localhost:5173后再确认通常就没有问题。如果还是不行检查一下项目里是否同时混入了 Vue 2 和 Vue 3 的依赖Devtools 对两个版本的协议是分开识别的混用会导致检测异常。3. 后端 Node.js接口设计、鉴权与下单事务3.1 Express 项目骨架与数据库连接池后端我采用的是 Express 4 mysql2项目结构直接按接口模块划分没有引入太重的东西server/ ├── app.js # 应用入口注册中间件 ├── config.js # 配置文件数据库、JWT 密钥等 ├── routes/ # 路由定义 │ ├── auth.js │ ├── park.js │ ├── order.js │ └── admin.js ├── controllers/ # 业务逻辑层 ├── middlewares/ # 中间件JWT 校验、角色校验 └── utils/ # 工具函数订单号生成、加解密等数据库连接我用的是连接池而不是每次请求都新建连接const mysql require(mysql2/promise) const pool mysql.createPool({ host: process.env.DB_HOST || localhost, user: process.env.DB_USER || root, password: process.env.DB_PASSWORD || 123456, database: process.env.DB_NAME || park_ticket, waitForConnections: true, connectionLimit: 10, queueLimit: 0 })为什么必须用连接池因为创建 MySQL 连接是一个相对昂贵的操作涉及 TCP 握手、权限验证。如果每个请求都新建连接高并发下数据库会立刻成为瓶颈还可能报 “Too many connections” 错误。连接池相当于提前准备好一批连接请求来了就复用用完归还效率完全不是一个量级。这个设计在我的压力测试里效果很明显没有连接池的时候 100 个并发请求有 5% 左右直接超时加了连接池之后成功率接近 100%。3.2 JWT 认证注册登录的完整链路用户认证我选了 JWT没做传统的 Session。原因很实在JWT 是无状态的服务器不需要存储 session 数据接口自然就天然支持横向扩展以后多开几个 Node 进程做负载均衡也不用考虑 session 同步问题。注册接口的处理流程是先查用户是否已存在然后对密码用 bcryptjs 做哈希最后插入数据库。密码绝对不能明文存这个安全意识必须从一开始就建立。bcrypt 是专门为密码哈希设计的算法自带盐值处理暴力破解成本极高。登录成功的核心逻辑是签发 JWTconst jwt require(jsonwebtoken) const token jwt.sign( { userId: user.id, username: user.username, role: user.role }, process.env.JWT_SECRET, { expiresIn: 2h } )这个 token 后端不会记忆前端拿到后存储在本地每次请求通过Authorization: Bearer token带回来。后端写一个统一的校验中间件在每个需要登录的接口前解析并校验 tokenfunction authMiddleware(req, res, next) { const authHeader req.headers.authorization if (!authHeader || !authHeader.startsWith(Bearer )) { return res.status(401).json({ code: 401, message: 未登录 }) } try { const payload jwt.verify(authHeader.slice(7), process.env.JWT_SECRET) req.userId payload.userId req.userRole payload.role next() } catch (err) { return res.status(401).json({ code: 401, message: 登录已过期请重新登录 }) } }注意中间件解析出来的userId和role我挂到了req对象上这样后续的业务接口直接读req.userId即可不用再从 token 里解一遍。这是一种很常见的约定能让代码清爽不少。管理员接口还需要再包一层角色校验比如删除用户、修改门票库存这类操作普通用户绝对不能调用。我写了一个adminOnly中间件在authMiddleware之后执行逻辑就是判断req.userRole是否等于admin。不要把这个判断散落在每个 controller 里统一走中间件防止漏保护某些接口。3.3 门票下单必须上事务回滚比想象中重要整个系统里最核心、最容易出问题的接口是“创建订单”。正常的购票流程是这样用户选择一个公园的门票提交预订请求。后端检查门票是否还有库存、是否在售。扣减库存。生成订单状态置为待支付。返回订单号和支付信息。看起来步骤简单但里面藏着一个巨大的陷阱如果第 3 步扣了库存第 4 步写订单却失败了库存就“凭空消失”了。反过来如果先写订单再扣库存则可能出现订单写好了但没有库存的脏数据。解决这个问题必须使用数据库事务const connection await pool.getConnection() try { await connection.beginTransaction() const [tickets] await connection.query( SELECT * FROM tickets WHERE id ? FOR UPDATE, [ticketId] ) const ticket tickets[0] if (!ticket || ticket.stock quantity) { throw new Error(门票库存不足) } await connection.query( UPDATE tickets SET stock stock - ? WHERE id ?, [quantity, ticketId] ) const orderNo generateOrderNo() await connection.query( INSERT INTO orders (order_no, user_id, ticket_id, quantity, total_amount, status, book_date) VALUES (?, ?, ?, ?, ?, ?, ?), [orderNo, userId, ticketId, quantity, ticket.price * quantity, 0, bookDate] ) await connection.commit() res.json({ code: 0, data: { orderNo, totalAmount: ticket.price * quantity } }) } catch (err) { await connection.rollback() res.status(400).json({ code: 400, message: err.message }) } finally { connection.release() }FOR UPDATE是行锁的意思目的就是防止并发下两个人同时看到剩下最后 1 张票同时下单导致超卖。我实际测试过不加行锁的情况下用并发脚本同时提交 10 个请求真的会出现 2 个订单抢到同一张票的情况加了FOR UPDATE后后请求会排队等待前一个事务完成超卖问题彻底消失。关于“支付”环节我做的是一个模拟支付的接口。真实对接微信/支付宝需要商户号、证书、回调域名个人开发时很难一步到位。我的做法是订单先保持待支付状态提供一个POST /api/orders/:orderNo/pay接口在开发环境下直接把订单状态置为已支付。虽然不涉及真实资金流但完整的订单状态机、支付后的流程全都保留了以后要接入真实支付只需要替换掉这个接口内部的实现。3.4 管理后台接口分页、统计与权限隔离管理端接口和游客端接口最大的不同除了权限就是数据维度的多样性。第一个核心接口是订单列表必须支持条件搜索和分页。我实现的查询条件是按订单号模糊搜索、按用户手机号搜索通过联表 users 表、按订单状态筛选、按日期区间筛选。这里有一个很实用的技术细节分页查询必须同时返回“列表数据”和“总条数”否则前端无法计算总页数。我用了COUNT(*)一次性查询总数再用LIMIT和OFFSET拉当前页数据const pageSize Number(req.query.pageSize) || 10 const page Number(req.query.page) || 1 const offset (page - 1) * pageSize const [rows] await pool.query( SELECT o.*, u.username, u.phone, t.name as ticket_name, p.name as park_name FROM orders o JOIN users u ON o.user_id u.id JOIN tickets t ON o.ticket_id t.id JOIN parks p ON t.park_id p.id WHERE (? IS NULL OR o.status ?) ORDER BY o.created_at DESC LIMIT ? OFFSET ?, [status ?? null, status ?? null, pageSize, offset] )注意分页参数不能直接拼在 SQL 字符串里而是用占位符传入这能有效防止 SQL 注入。凡是涉及数据库接口参数校验和 SQL 注入防范是一根必须时刻绷紧的弦。我见过很多教程代码直接把req.query.id拼到 SQL 里这是极其危险的做法。第二个核心接口是数据统计。运营者最关心的是“今天卖了多少票、入园多少人、哪张门票卖得最好”。这些统计用一条 SQL 就能算出来SELECT DATE(created_at) AS days, COUNT(*) AS order_count, SUM(quantity) AS ticket_count FROM orders WHERE status IN (1, 2) GROUP BY DATE(created_at) ORDER BY days DESC放到接口里返回给前端再用 VUE 的表格组件渲染就能很直观地看到一段时间的销售走势。如果你还想看“每小时入园人数”改写 GROUP BY 为HOUR(created_at)即可。这里我就不展开全部代码了但思路是一致的。后端控制层我统一规定响应格式为{ code, message, data }code 为 0 表示成功非 0 表示业务错误。前端拦截器只需要判断这一套约定不用针对每个接口写错误处理分支。团队协作时一定要先定这个接口规范否则前后端联调会非常痛苦。4. 前后端联调、打包与上线部署4.1 开发期跨域与 Vite 代理配置开发的时候前端跑在http://localhost:5173后端跑在http://localhost:3000端口不同浏览器会直接拦截跨域请求。解决跨域有前后端两种方案我采用的是前端 Vite 代理而不是在后端开cors中间件。原因很简单生产环境我用 Nginx 做反向代理前端和后端在同一个域下根本不涉及跨域。开发环境用代理模拟生产环境的路径提交代码时不用为环境切换写两套请求地址更加干净。在vite.config.js里配置export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } })这样一来前端请求/api/park/listVite 开发服务器会把请求转发到http://localhost:3000/api/park/list。浏览器里的网络请求地址始终是同源的不会有跨域问题。当然如果后端要提供接口给其他非浏览器客户端调用比如未来的小程序、APP那还是得在后端加上cors中间件并配置允许的域名白名单。这个要看你的实际场景两者并不冲突。4.2 上线部署Nginx 托静态页面Node 服务交给 pm2项目最终上线时我把服务器环境搭成了这个结构组件作用Nginx托管前端打包后的静态文件反向代理接口请求到 Node 服务Node.js 服务运行 Express 后端接口监听 3000 端口MySQL独立数据库服务pm2守护 Node 进程崩溃自动重启前端打包非常简单npm run build生成dist目录后把里面的文件上传到服务器 Nginx 配置的站点根目录比如/var/www/park-web。Nginx 的关键配置有两块。第一块是处理前端路由的 history 模式因为刷新一个深层链接时Nginx 得把路径重新交给 index.html 处理否则会 404location / { root /var/www/park-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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }Node 服务端我用 pm2 启动pm2 start app.js --name park-server pm2 savepm2 的核心价值在于进程崩溃后能自动拉起、开机自启、统一查看日志。我把pm2 save和pm2 startup配置好后即使服务器重启Node 服务也会跟着自动恢复完全不用人工干预。这些工具和机制在我做项目的早期阶段完全没概念直到有一次手动启动的 Node 进程半夜宕机第二天早上数据全停了才明白生产环境的进程守护不是可有可无。4.3 真实踩坑清单乱码、端口占用、Node 版本与依赖冲突开发这套系统过程中我记了不少踩坑笔记挑几个最有代表性的分享。现象原因解决方案中文写入数据库后显示???数据库字符集不是 utf8mb4建库时指定CHARACTER SET utf8mb4连接串加charsetutf8mb4EADDRINUSE: address already in use :::3000端口被占用netstat -ano查看占用进程任务管理器结束进程或者换端口npm run dev报 npm.ps1 禁止运行PowerShell 执行策略限制管理员执行Set-ExecutionPolicy RemoteSignedNode 版本太高导致依赖安装失败部分依赖只支持 Node 16 以下用nvm切换 Node 版本或者升级依赖版本前端请求接口 401但后端日志一切正常token 没被传到后端检查请求拦截器是否注入了 Authorization 头用浏览器开发者工具查看请求头跨域请求在预检 OPTIONS 阶段失败后端没有处理 OPTIONS 请求或 CORS 配置缺失开发环境用 Vite 代理解决生产环境靠 Nginx 解决乱码的问题特别有代表性。我一开始建表时随口用了默认字符集结果前端提交中文公园名称到数据库存进去的全是问号。排查到后面发现是数据库连接串少了charsetutf8mb4。记住一个公式MySQL 8 下数据库表字符集、连接串字符集、页面编码三处必须统一。这件事困扰了我一下午以后凡是新项目我会在初始化时就把字符集定死。Node 版本的问题也值得单独说。Windows 上我自己最推荐用nvm-windows管理多个 Node 版本装好之后一条nvm install 16.20.2、nvm use 16.20.2就能自由切换避开了直接装最新版导致老项目跑不起来的尴尬。Ubuntu 服务器上则推荐用nvm或直接用 apt 装 LTS 版本不要追新。4.4 从预订到入园体验还能扩展什么这套系统做到“在线预订 订单管理”其实只完成了骨架真正让门票系统“活”起来的是验票环节。我后续规划里排了几个扩展方向。第一是二维码入园。订单支付成功后后端用qrcode这个库把订单号编码成二维码图片返回给前端。游客在订单详情页展示二维码管理员入口处用手机扫一扫后端解析出订单号校验状态是已支付后改为已使用同时放行。这一步做出来整个“在线购票 - 现场核销”闭环才算真正跑通。第二是余票提醒。当某一日期门票库存低于阈值时通过邮件或站内通知提醒管理员补库存。实现起来也不复杂下单接口扣减库存后顺手查一下剩余量低于阈值就触发提醒。第三是数据可视化。当前管理端的统计只是表格展示后续可以引入 ECharts 画折线图和柱状图让每日订单趋势、门票占比一目了然。ECharts 和 Vue 配合非常成熟管理系统的价值往往就在这些可视化报表里。我不建议一上来就把所有扩展功能全部做完先把核心交易链路打磨扎实再逐步加这些“锦上添花”的能力。门票系统的本质是交易系统稳定性和一致性永远排在第一位。做这个项目我最大的体会是一套管理系统前端页面的美观程度只是表面功夫真正考验人的是后端的数据一致性、权限设计、异常处理以及前后端联调时的那份耐心。如果你也准备做类似的系统我建议不要直接照搬现成模板亲手把下单事务、JWT 鉴权、分页查询这三个核心点写明白你对全栈开发的理解会上一个台阶。遇到环境配置或者依赖问题别慌绝大多数都能在日志和官方文档里找到答案。
返回列表