两个月前,一个做线下旅游业务的朋友跟我吐槽,说他们还在用Excel登记线路订单,客户微信转账后经常漏记,旺季一天几十张单子全靠人工核对,错单、漏单成了常态。我听完就动了手,基于Node.js给他搭了一套旅游服务管理系统,内部代号“飞鸽”。这一整套系统包含用户端和管理端,覆盖注册登录、线路展示与检索、模拟下单结算、订单管理、评论评价,以及后台的线路维护、订单审核、数据统计等模块。
这个项目做完之后,我最大的感受是:Node.js做业务管理系统其实非常合适,尤其适合中小规模、需要快速上线、前后端同用一个语言的团队。下面我就按我真实的开发顺序,从需求切分、技术选型、数据库设计、核心接口、后台管理,一路讲到部署上线和排障,把关键代码和踩坑过程都摊开来讲。无论你是拿它当毕业设计,还是想给某个实体业务做信息化底座,都可以直接借鉴这套思路。
1. 需求切割与技术选型:先把“飞鸽系统”的服务边界划清楚
1.1 三类核心用户,对应三套操作场景
我拿到需求之后没有急着写代码,而是先把“谁来用、怎么用、管什么”给列清楚了。“飞鸽”系统不是一个大而全的电商平台,它要解决的是线下旅行社的信息化问题,所以核心用户只有三类:
- 游客(普通用户):注册登录后浏览线路、按目的地和价格筛选、查看线路详情、提交订单、模拟支付、查看订单状态、对已出行线路发表评价。
- 运营人员(管理员):维护线路上下架、处理订单退款或改期、审核评论、管理用户状态。
- 系统管理员(超级管理员):在运营人员权限之上,拥有用户角色分配和数据统计查看权限。
这三类用户对应的场景很清晰:游客端做“找线路、下订单、写评价”,运营端做“管线路、管订单、管内容”,系统端做“管权限、看数据”。这三条线互不交叉,就能避免后期功能越做越乱。
1.2 为什么核心后端选Node.js而不是Spring Boot或PHP
这是我被问得最多的问题。坦白讲,如果一个项目有几十个微服务、复杂的事务一致性要求极高,那我可能也会犹豫。但“飞鸽”这种体量的旅游业务管理系统,Node.js的优势非常明确:
| 对比维度 | Node.js方案 | 传统Java方案 |
|---|---|---|
| 开发效率 | 前后端同用JavaScript,类型心智负担小,一个人就能全栈开发 | 前后端语言割裂,接口联调成本更高 |
| 高并发I/O | 事件驱动模型,面对大量读请求(查线路、查订单)表现稳定 | 线程池模型成熟,但配置和调优门槛更高 |
| 部署运维 | 单进程启动,PM2守护即可,环境依赖少 | 需要配置JVM参数、容器环境,更重 |
| 生态匹配 | Express/Koa中间件丰富,JWT、文件上传、Redis接入都很成熟 | 生态也很成熟,但样板代码更多 |
旅游业务的核心特征是“读多写少”,用户大部分时间在刷线路、看攻略、比价格,下单动作反而低频。Node.js的异步非阻塞模型正好适合这种读密集型场景。再加上我后面接了一个模拟支付和Redis缓存,整个系统的吞吐量在同配置服务器上表现比传统同步模型好不少。
1.3 技术栈清单
最终落地的技术栈如下,都是社区里非常成熟、资料好找的组件:
- 后端:Node.js + Express 4 + MySQL 8 + Redis 6
- 鉴权:JWT(jsonwebtoken)+ bcryptjs密码哈希
- 文件上传:multer中间件,封面图和评价图片统一保存到本地static目录
- 前端用户端:Vue 3 + Vite + Element Plus,用axios调接口
- 后台管理端:Vue 3 + ECharts 5做数据可视化
- 部署:Nginx做反向代理,PM2做进程守护
这套组合对个人开发者非常友好,开发周期短,踩坑资料也丰富,遇到问题基本都能搜到解决方案。
2. 工程化结构与数据库建模:把旅游六要素拆成可落地的表
2.1 项目目录结构
“飞鸽”采用前后端分离,但不搞微服务那一套,保持一个后端服务承载所有接口。后端目录结构我尽量让职责单一:
feigon-server/ ├── app.js # Express入口 ├── config/ │ ├── db.js # MySQL连接池 │ └── redis.js # Redis客户端 ├── routes/ │ ├── user.routes.js # 注册、登录、用户信息 │ ├── travel.routes.js # 线路列表、详情、搜索 │ ├── order.routes.js # 下单、支付、订单查询 │ ├── review.routes.js # 评论 │ └── admin.routes.js # 后台管理 ├── middleware/ │ ├── auth.js # JWT鉴权中间件 │ └── admin.js # 管理员权限中间件 ├── controllers/ │ ├── userController.js │ ├── travelController.js │ ├── orderController.js │ └── adminController.js ├── uploads/ # 上传图片目录 └── package.json入口文件只做三件事:加载中间件、挂载路由、启动服务。业务逻辑全部拆到controller里,路由文件只负责“接收请求→调对应controller→返回响应”,这样后期加接口很清晰。
2.2 数据表设计与关系梳理
旅游业务离不开六要素:吃、住、行、游、购、娱。落到“飞鸽”这个系统里,我先抽出了四个核心实体:用户表、线路表(含住宿、行程简介等冗余字段)、订单表、评价表。为了让线路有更丰富的筛选维度,我再建了目的地表和标签关联表。
建表SQL节选如下:
CREATE TABLE `users` ( `user_id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password_hash` VARCHAR(100) NOT NULL, `real_name` VARCHAR(50) DEFAULT '', `phone` VARCHAR(20) DEFAULT '', `avatar` VARCHAR(255) DEFAULT '', `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0普通用户 1运营 2超级管理员', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用', `created_at` DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `travel_routes` ( `route_id` INT PRIMARY KEY AUTO_INCREMENT, `route_name` VARCHAR(120) NOT NULL, `cover_img` VARCHAR(255) DEFAULT '', `destination` VARCHAR(100) NOT NULL, `departure_city` VARCHAR(50) NOT NULL, `days` TINYINT NOT NULL COMMENT '行程天数', `starting_price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0 COMMENT '剩余可报名名额', `sales_count` INT NOT NULL DEFAULT 0 COMMENT '累计销量', `route_intro` TEXT COMMENT '行程亮点介绍', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `created_at` DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `orders` ( `order_id` INT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(40) NOT NULL UNIQUE, `user_id` INT NOT NULL, `route_id` INT NOT NULL, `qty` INT NOT NULL DEFAULT 1, `amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已完成 3已取消', `travel_date` DATE NOT NULL, `contact_name` VARCHAR(50) NOT NULL, `contact_phone` VARCHAR(20) NOT NULL, `created_at` DATETIME NOT NULL, `paid_at` DATETIME DEFAULT NULL, KEY `idx_user_status` (`user_id`, `status`), KEY `idx_route_sales` (`route_id`, `sales_count`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;表关系上,用户对订单是1:N,线路对订单是1:N,用户和线路通过订单、评价产生间接关系。这里有一个我特别留意的点:订单表里一定要冗余contact_name和contact_phone,不能下单后临时去关联用户表。道理很简单,用户以后可能改手机号,但历史订单的联系方式必须保持下单那一刻的原始状态。
2.3 索引与写入性能考量
订单表我建了idx_user_status联合索引,因为高频查询就是“某个用户的所有订单”以及“按状态过滤”。线路表则利用sales_count做热门排序。这里不想无脑加索引,因为索引会拖慢写入速度。订单是低频写入,所以给它多加几个索引没问题;评价表是高频插入,我只给route_id加了普通索引,保证详情页拉评论够快就行。
另外,所有金额字段都用DECIMAL,千万别用FLOAT或DOUBLE,否则遇到0.1+0.2这种精度问题,结算会出现一分钱的差错,这在真实旅游业里是要被骂的。
3. 用户登录取向关键代码:从JWT到Redis缓存,一套亲测可靠的方案
3.1 注册接口与密码安全
用户密码绝对不能明文存储。我用bcryptjs做哈希加盐,虽然比md5慢,但慢反而是一种安全特性,可以拖慢暴力破解速度。注册接口片段如下:
const bcrypt = require('bcryptjs'); async function register(req, res) { const { username, password, phone } = req.body; if (!username || !password || !phone) { return res.status(400).json({ code: 400, msg: '缺少必要参数' }); } const [exists] = await pool.query('SELECT user_id FROM users WHERE username=?', [username]); if (exists.length > 0) { return res.status(400).json({ code: 400, msg: '用户名已存在' }); } const saltRounds = 10; const passwordHash = await bcrypt.hash(password, saltRounds); await pool.query( 'INSERT INTO users (username, password_hash, phone, created_at) VALUES (?,?,?,NOW())', [username, passwordHash, phone] ); res.json({ code: 0, msg: '注册成功' }); }这里有一个很容易忽略的点:saltRounds选10已经足够,选太高(比如12以上)在密码强度本身不够高时反而会拖垮CPU,注册接口高并发场景下可能被打满。
3.2 登录鉴权与JWT中间件
登录成功之后签发JWT,有效期我设7天。对管理端来说7天偏长,但配合Redis黑名单也够用。实际代码是这样一个结构:
const jwt = require('jsonwebtoken'); function signToken(user) { return jwt.sign( { uid: user.user_id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '7d' } ); } function authRequired(req, res, next) { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : ''; if (!token) return res.status(401).json({ code: 401, msg: '未登录' }); try { const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = decoded; next(); } catch (e) { return res.status(401).json({ code: 401, msg: '登录状态已过期,请重新登录' }); } }所有会操作用户数据的接口都挂authRequired,管理员接口再叠加adminRequired中间件。JWT的好处是服务端无状态,横向扩展时不需要共享Session,这对以后要拆多个Node实例很有用。
3.3 用Redis缓存热点线路,保护MySQL
“飞鸽”系统首页和列表页会频繁读取热门线路。直接打到MySQL虽然也能扛,但没必要。我加了一层Redis缓存,逻辑只有两步:先查缓存,缓存没命中再查数据库,并将结果写入Redis并设置5分钟过期时间。
const redis = require('../config/redis'); const cacheKey = 'hot_routes'; async function getHotRoutes() { const cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached); const [rows] = await pool.query( `SELECT route_id, route_name, cover_img, destination, days, starting_price FROM travel_routes WHERE status = 1 ORDER BY sales_count DESC LIMIT 8` ); await redis.set(cacheKey, JSON.stringify(rows), 'EX', 300); return rows; }这个方案简单有效,但要注意“缓存穿透”问题:如果有人恶意请求一个不存在的线路ID,每次都查不到数据,也永远不会写缓存,Redis就起不到保护作用了。我的处理方式是对空结果也缓存一个空数组,过期时间缩短到60秒,代价很小,收益却很大。
4. 线路检索与下单结算链路:事务、库存扣减和状态机的实际操作
4.1 线路检索接口:多条件组合查询
旅游线路的筛选条件通常有三个维度:目的地、天数、价格区间。接口签名我设计为GET /api/travel/list?keyword=&minPrice=&maxPrice=&days=&page=&pageSize=。SQL部分用动态拼接,但要使用预处理参数防止注入:
async function listRoutes(req, res) { const { keyword = '', minPrice = 0, maxPrice = 99999, days = 0, page = 1, pageSize = 10 } = req.query; const offset = (page - 1) * pageSize; const conditions = ['status = 1']; const params = []; if (keyword) { conditions.push('(route_name LIKE ? OR destination LIKE ?)'); params.push(`%${keyword}%`, `%${keyword}%`); } if (Number(minPrice) > 0) { conditions.push('starting_price >= ?'); params.push(minPrice); } if (Number(maxPrice) < 99999) { conditions.push('starting_price <= ?'); params.push(maxPrice); } if (Number(days) > 0) { conditions.push('days = ?'); params.push(days); } const whereSql = conditions.join(' AND '); const countSql = `SELECT COUNT(*) AS total FROM travel_routes WHERE ${whereSql}`; const listSql = `SELECT route_id, route_name, cover_img, destination, departure_city, days, starting_price, sales_count FROM travel_routes WHERE ${whereSql} ORDER BY sales_count DESC LIMIT ? OFFSET ?`; const [countRows] = await pool.query(countSql, params); params.push(Number(pageSize), offset); const [listRows] = await pool.query(listSql, params); res.json({ code: 0, data: { list: listRows, total: countRows[0].total, page: Number(page), pageSize: Number(pageSize) } }); }这里“关键经验”是排序不要默认按价格,而要按sales_count降序,因为旅游线路的决策更依赖口碑和热度,价格排序应该让用户自己去切。
4.2 下单接口与库存保护:事务是底线
下单是整个系统里最强调一致性的接口。用户提交订单,系统要同时做三件事:扣减线路库存、生成订单记录、计算实际金额。任何一个环节失败,都不能留下半截数据。所以必须用MySQL事务。
我使用连接池手动获取连接,开启事务,SELECT ... FOR UPDATE锁行,扣库存,插入订单,再提交。代码片段:
async function createOrder(req, res) { const { routeId, qty, travelDate, contactName, contactPhone } = req.body; const uid = req.user.uid; const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( 'SELECT stock, starting_price FROM travel_routes WHERE route_id=? FOR UPDATE', [routeId] ); if (rows.length === 0) { throw new Error('线路不存在'); } if (rows[0].stock < qty) { throw new Error('库存不足'); } const amount = rows[0].starting_price * qty; const orderNo = 'FD' + Date.now() + Math.floor(Math.random() * 10000); await conn.query( `INSERT INTO orders (order_no, user_id, route_id, qty, amount, travel_date, contact_name, contact_phone, created_at, status) VALUES (?,?,?,?,?,?,?,?,NOW(),0)`, [orderNo, uid, routeId, qty, amount, travelDate, contactName, contactPhone] ); await conn.query( 'UPDATE travel_routes SET stock = stock - ? WHERE route_id = ?', [qty, routeId] ); await conn.commit(); res.json({ code: 0, msg: '下单成功', data: { orderNo, amount } }); } catch (e) { await conn.rollback(); res.status(400).json({ code: 400, msg: e.message }); } finally { conn.release(); } }FOR UPDATE这一步很多人容易漏。没有它,两个用户同时下单时可能读到同一个库存值,最后超卖。加了行锁之后,同一线路的并发下单串行化,虽然牺牲了一点点吞吐,但换来了绝对的数据正确。
4.3 订单状态机:支付、取消、完成
订单状态我用数字枚举:0待支付、1已支付、2已完成、3已取消。流转规则必须严格限制:
- 待支付 → 已支付:模拟支付回调
- 待支付 → 已取消:用户主动取消,同时恢复库存
- 已支付 → 已完成:出发日后由管理员标记
支付接口这里我不接真实支付通道,用模拟支付代替,核心流程是:把订单状态从0改成1,记录paid_at时间。取消接口则需要同时恢复库存,并且不能把已支付的订单直接取消,要先做状态校验,否则会破坏状态机。
4.4 评价与评分:列表聚合查询
评价表只存N条数据和评分,线路的平均分我是在查询线路详情时用一条聚合SQL算出来的:
SELECT r.*, (SELECT COUNT(*) FROM orders o WHERE o.route_id = r.route_id AND o.status = 2) AS sold_count, (SELECT IFNULL(AVG(v.rating), 0) FROM reviews v WHERE v.route_id = r.route_id) AS avg_rating FROM travel_routes r WHERE r.route_id = ?这种冗余查询在数据量小时完全够用,等评价数据超过几万条再考虑单独维护评分汇总表。这里的原则就是:不要提前优化。
5. 后台管理端设计:RBAC权限、运营维护和数据可视化
5.1 角色权限控制
后台管理端不能只靠前端隐藏按钮来“保密”,真正的防线在后端。我在users表里用role字段区分权限:0普通用户、1运营、2超级管理员。中间件实现很简洁:
function adminRequired(req, res, next) { if (!req.user) { return res.status(401).json({ code: 401, msg: '未登录' }); } if (req.user.role < 1) { return res.status(403).json({ code: 403, msg: '没有后台访问权限' }); } next(); } function superAdminRequired(req, res, next) { if (!req.user || req.user.role !== 2) { return res.status(403).json({ code: 403, msg: '仅超级管理员可操作' }); } next(); }线路管理接口全部挂adminRequired,调整用户角色的接口挂superAdminRequired。前端菜单再根据角色做动态渲染,双保险。
5.2 运营人员的日常工作流
后台管理端我做了三个主页面:线路管理、订单管理、评价管理。
线路管理页需要支持:新增线路(含上传封面图)、编辑价格和库存、上下架。这里我给multer配了文件上传,限制图片大小不超过5MB,保存路径为uploads/cover/。前端上传成功后拿到图片URL填入表单,再保存到线路表。
订单管理页是运营使用频率最高的页面。我筛选出所有待支付和已支付订单,运营可以按订单号、用户名、线路名搜索,也可以直接点“完成”按钮把已出行订单置为已完成状态。退款操作的实现是“取消订单并恢复库存”,逻辑上复用订单状态机那套接口。
5.3 数据可视化:用ECharts画月度趋势与热门目的地
管理首页放了三块数据:近7天订单量趋势(折线图)、热门线路Top10(横向柱状图)、目的地订单占比(饼图)。后端统计接口长这样:
async function getStatistics(req, res) { const [dailyOrders] = await pool.query( `SELECT DATE(created_at) AS date, COUNT(*) AS order_count FROM orders WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE(created_at) ORDER BY date` ); const [hotRoutes] = await pool.query( `SELECT route_name, sales_count FROM travel_routes ORDER BY sales_count DESC LIMIT 10` ); const [destStats] = await pool.query( `SELECT destination, COUNT(*) AS cnt FROM travel_routes GROUP BY destination ORDER BY cnt DESC` ); res.json({ code: 0, data: { dailyOrders, hotRoutes, destStats } }); }前端用ECharts把数据填充到图表里,运营每天早上打开就能看到前一日订单情况。数据可视化不一定要多高级,能把运营关心的问题呈现出来,指标清楚,就是合格的。
6. 部署上线、性能保障与踩坑复盘:跑通只是第一步
6.1 Nginx反向代理与PM2进程守护
后端服务跑在Node进程里,裸奔很容易挂,所以我用PM2做守护,Nginx做反向代理和静态资源托管。部署时我的ecosystem.config.js配置如下:
module.exports = { apps: [ { name: 'feigon-server', script: 'app.js', instances: 'cluster', exec_mode: 'cluster', max_memory_restart: '512M', env: { NODE_ENV: 'production', JWT_SECRET: 'your-secret-here' } } ] };启动命令很简单:
pm2 start ecosystem.config.js pm2 save pm2 startupNginx配置我把重点放在两件事:/api/请求代理到Node端口,静态图片目录单独用alias暴露出去,避免让Node去处理图片这种高IO静态请求。
server { listen 80; server_name travel.example.com; 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; } location /uploads/ { alias /var/www/feigon/uploads/; } location / { root /var/www/feigon/dist; try_files $uri /index.html; } }try_files $uri /index.html是SPA应用部署的关键,否则用户直接刷新某个路由会得到404。
6.2 MySQL连接池溢出,问题出在请求没释放连接
第一个线上问题出现在上线第三天,后台订单接口开始随机超时,报错信息是Can't connect to local MySQL server。查日志发现是连接数被耗尽。
根因很典型:某处查询里我用了pool.query,但某个手动获取连接的函数在catch里忘记调用conn.release(),连接被永久占用。MySQL默认连接池只有10个连接,高峰期几个慢查询就把池子占满了。
修复方式有两步:第一,所有pool.getConnection()的代码统一用try...catch...finally保证conn.release();第二,把连接池connectionLimit调大到30,同时给查询加上acquireTimeout。顺手又做了监控脚本,每5分钟检查一次连接池占用率。这个坑看似简单,但如果不排查到根因,只是重启服务,过几天还会复发。
6.3 时区问题导致的日期错乱
订单统计分析里出现过当天数据对不上的问题:下午6点下的订单,统计进到了第二天。原因是MySQL连接时间时区跟部署服务器的时区不一致。
MySQL 8默认用的会话时区是系统时区,而Node连接串没有显式指定timezone。解决方式很简单,在连接池配置里加上:
const pool = mysql.createPool({ host: 'localhost', user: 'root', password: '...', database: 'feigon_db', timezone: '+08:00', connectionLimit: 30, acquireTimeout: 5000 });同时建表字段建议统一用DATETIME,不要用TIMESTAMP,因为TIMESTAMP的取值上限是2038年,虽然离现在还远,但没必要给自己埋雷。
6.4 Redis缓存穿透与热Key问题
旅游平台在节假日之前会出现明显的流量尖峰,比如国庆长假前一周,搜索“国庆”“云南”的请求量是平日的几十倍。这种时候Redis的“热Key”问题就出现了:热门线路的缓存Key在失效瞬间,大量并发请求同时打到MySQL。
我的缓解方案有两个:一是给缓存过期时间加一个随机值,比如300 + Math.floor(Math.random() * 60)秒,避免所有Key同时失效;二是在业务上做“逻辑过期”兜底,即使缓存过期了,也先用旧数据返回,后台异步更新缓存。这个方案虽然会带来最多几十毫秒的旧数据,但对旅游线路这种实时性要求不高的数据完全够用。
6.5 文件上传路径的坑:服务器重启后图片全失效
我遇到过最哭笑不得的问题是:运营上传的线路封面图,在服务重启后全部显示404。原因是开发机直接把图片存在了项目uploads目录里,但部署机上Nginx的alias路径是/var/www/feigon/uploads,目录不存在服务就自动返回404。
排查半天才意识到,app.js里虽然用app.use('/uploads', express.static('uploads'))做了本地映射,但生产环境我部署在了不同用户目录下,相对路径有问题。最后我改成绝对路径,并把上传目录单独挂载到数据盘,和代码目录解耦:
const path = require('path'); const UPLOAD_DIR = '/data/feigon-uploads'; app.use('/uploads', express.static(UPLOAD_DIR));这是一个很小的配置问题,但很典型:本地开发和生产环境的路径差异,不能靠“本地能跑就行”来掩盖。
写在最后的项目复盘
整套“飞鸽”系统从需求梳理到上线,前后大概用了三周。现在回头复盘,最让我受益的不是某个框架的用法,而是很多细节在开始写代码前就要想清楚:订单状态机怎么流转、库存如何防超卖、文件存储路径在生产环境怎么规划、Redis缓存如何防穿透和热Key。
如果现在让我再做一次,我会把评价模块和消息通知拆成独立服务,用一套简单的队列来做“下单后短信通知”的异步任务。但这是业务量上来之后的事,对当前这个阶段,单体Node服务加MySQL加Redis的组合足够稳定,也足够清晰。
最后分享一个我自己的习惯:每做完一个项目,我会把排查过的所有坑都整理成一个PROBLEM.md放进项目根目录,内容包括现象描述、根因分析、解决步骤、验证方式。这个文件在后续维护和新人上手时价值极大,很多时候比一堆设计文档都有用。你如果也在做类似的管理系统,强烈建议试试这个习惯。