做外卖点餐这个方向的时候,我一开始其实纠结过一阵。市面上现成的开源外卖系统不少,但大多数要么是PHP系、要么是Java系,前端和后台耦合得很死,真要改起来等于重写。后来接了一个课程设计和毕设辅导的项目,需求方指定要Node.js做后端、Vue做前端,数据库用MySQL,我直接把技术栈定成了Node.js + Express + MySQL + Vue + ElementUI这套组合。今天把这个项目的设计思路和实现过程完整拆一遍,包括数据库怎么建模、接口怎么设计、前端页面怎么组织、购物车和订单状态怎么流转,还有我在环境配置上踩过的那些坑。无论是正在做类似毕设的同学,还是想快速搭一套Web外卖点餐系统的开发者,都应该能从这篇文章里拿到可以直接落地的方案。
1. 技术选型的真实逻辑:这套组合到底解决了什么问题
1.1 为什么不是Java、不是PHP,而是Node.js
先聊选型。外卖点餐系统的核心场景是高频读、低频写:用户打开小程序/网页浏览菜单,选完商品,提交订单。这个业务模型天然适合Node.js的非阻塞I/O模型。Node.js单线程配合事件循环,处理大量并发读请求时表现很稳定,而且和前端同样是JavaScript,数据类型不需要来回转换,前端同学上手后端几乎零成本。
Express作为Node.js生态里最成熟的Web框架,中间件机制非常灵活。项目里要做登录鉴权、跨域处理、日志记录、参数校验,每个功能都是一个中间件函数,按顺序挂载就行。相比Koa那套基于async/await的洋葱模型,Express虽然老一点,但资料多、踩坑记录多,遇到问题基本都能搜到现成答案,对做项目来说这比框架新特性更重要。
MySQL这边没什么好争议的,订单、商品、用户这些数据都是强结构化数据,需要事务保证一致性。下单这个动作涉及扣库存、生成订单、更新购物车多个步骤,必须放在数据库事务里执行,MySQL的InnoDB引擎在这块非常成熟。
1.2 整体架构:前后端分离,接口驱动开发
项目的整体架构采用了前后端完全分离的模式:
code复制前端(Vue 2 + ElementUI + Vuex + Vue Router)
│ HTTP/JSON + JWT Token
▼
后端(Node.js + Express)
│ mysql2 连接池
▼
数据库(MySQL 5.7+)
后端只提供RESTful API,不关心页面长什么样。前端所有数据都通过axios请求后端接口获取。这样做的好处是,以后如果要把Web端迁到微信小程序,后端接口层完全不用动,只需要重写视图层。整个项目分成了user端(用户点餐页面)和admin端(商家管理页面)两个前台入口,通过路由和权限控制区分。
1.3 项目目录结构设计
项目用了monorepo管理,后端和前端放在同一个仓库下,目录清晰:
code复制ordering-system/
├── server/ # 后端服务
│ ├── app.js # Express入口
│ ├── routes/ # 路由定义
│ ├── controllers/ # 控制器(业务逻辑)
│ ├── db/ # 数据库连接配置
│ ├── middleware/ # 鉴权、错误处理中间件
│ └── utils/ # 工具函数(订单号生成等)
├── web/ # 前端工程(Vue2 + ElementUI)
│ ├── src/
│ │ ├── api/ # 后端接口封装
│ │ ├── assets/ # 静态资源
│ │ ├── components/ # 公共组件
│ │ ├── router/ # 前端路由表
│ │ ├── store/ # Vuex状态管理
│ │ └── views/ # 页面组件
│ └── package.json
└── sql/ # 数据库初始化脚本
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库建模与Express后端:把点餐业务的骨架搭起来
2.1 核心表结构设计:从用户到订单的完整链路
数据库是外卖系统的地基,表结构设计直接决定后面业务逻辑的复杂度。我设计了一套6表方案:
用户表(user)
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 主键,自增 |
| username | varchar(50) | 用户名,唯一 |
| password | varchar(255) | 密码(MD5加密) |
| phone | varchar(20) | 手机号 |
| avatar | varchar(255) | 头像地址 |
| role | tinyint | 角色:0用户,1商家管理员 |
| create_time | datetime | 注册时间 |
商品分类表(category) 和 商品表(menu)
分类表很简单:id、name、sort_order。重点是商品表,字段包括:
sql复制CREATE TABLE `menu` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL COMMENT '商品名称',
`description` varchar(255) DEFAULT '' COMMENT '商品描述',
`price` decimal(10,2) NOT NULL COMMENT '售价',
`image` varchar(255) DEFAULT '' COMMENT '图片地址',
`category_id` int(11) NOT NULL COMMENT '所属分类',
`status` tinyint(4) DEFAULT '1' COMMENT '1上架 0下架',
`stock` int(11) DEFAULT '100' COMMENT '库存',
`sales` int(11) DEFAULT '0' COMMENT '销量',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
价格字段必须用decimal(10,2),不能图省事用float——浮点数在MySQL里做比较和累加会有精度问题,订单金额差一毛钱这种问题排查起来非常痛苦。
购物车表(cart)
购物车设计成了一张关联表:user_id + menu_id + quantity。每次加购前先查一下是否已有同款商品,如果有就做数量累加,没有才插入新记录。这个逻辑虽然简单,但能避免购物车里出现两行完全相同的商品。
订单主表(orders)和订单明细表(order_detail)
订单是分表设计的核心。订单主表存一次下单的汇总信息(订单号、用户ID、总金额、状态),明细表存每道菜的数量、单价。为什么要分两张表?因为外卖订单有"拆单"的可能——比如一个订单里既有快餐又有饮品,以后要分店铺结算,明细表能灵活支撑。
订单状态字段是外卖系统的灵魂,我定义了一套状态机:
javascript复制// 订单状态
const ORDER_STATUS = {
PENDING: 0, // 待支付
PAID: 1, // 已支付
PROCESSING: 2, // 商家接单/制作中
DELIVERING: 3, // 配送中
COMPLETED: 4, // 已完成
CANCELLED: 5 // 已取消
};
2.2 连接池与数据访问层封装:避免数据库连接爆炸
直接用mysql包连接数据库,每次请求都新建连接,并发稍微上来一点就会出现Too many connections的报错。正确的做法是使用mysql2的连接池:
javascript复制// server/db/index.js
const mysql = require('mysql2');
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: '123456',
database: 'ordering_system',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// 包装成Promise方法
const query = (sql, params) => {
return new Promise((resolve, reject) => {
pool.getConnection((err, conn) => {
if (err) {
reject(err);
return;
}
conn.query(sql, params, (error, results) => {
conn.release(); // 关键:释放连接回连接池
if (error) {
reject(error);
return;
}
resolve(results);
});
});
});
};
module.exports = { query };
这里有个非常容易踩的坑:查询完成后必须调用conn.release()把连接释放回池子。我刚开始写的时候漏了这行,跑了一个小时接口全部卡死,因为连接池里的10个连接全被占满了。排查方法也简单,在MySQL里执行SHOW PROCESSLIST;能看到大量Sleep状态的连接,基本就可以判断是连接没有释放。
2.3 核心API设计与JWT鉴权思路
后端接口我按业务模块划分,一共设计了20多个接口。这里列几个核心的:
用户认证相关
POST /api/user/register:注册,密码用md5加盐存储POST /api/user/login:登录成功返回JWT token
商品与分类
GET /api/category/list:获取全部分类GET /api/menu/list?category_id=1:按分类获取商品GET /api/menu/detail/:id:商品详情
购物车
GET /api/cart/list:获取当前用户购物车POST /api/cart/add:添加商品到购物车PUT /api/cart/update:修改商品数量DELETE /api/cart/remove/:id:移除购物车项
订单
POST /api/order/create:创建订单GET /api/order/list?status=0:按状态查询订单GET /api/order/detail/:id:订单详情PUT /api/order/status:更新订单状态(商家端操作)
鉴权这块用了JWT(JSON Web Token)。用户登录成功后,后端用jsonwebtoken生成一个带有效期的token返回给前端。前端把token存在localStorage里,每次请求在axios拦截器中加到Authorization头。后端写一个auth中间件,所有需要登录的接口先校验token,再把用户信息挂到req.user上。
javascript复制// server/middleware/auth.js
const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ code: 401, msg: '未登录' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'ordering_secret');
req.user = decoded;
next();
} catch (err) {
return res.status(401).json({ code: 401, msg: '登录已过期' });
}
};
下单接口要重点说,因为它涉及事务。如果用query方法一条条执行SQL,中间任何一步出错都可能导致数据不一致——比如订单生成了但商品库存没扣。我封装了一个带事务的执行函数:
javascript复制// server/utils/transaction.js
const mysql = require('mysql2');
const pool = mysql.createPool({ /* 配置 */ });
const runTransaction = async (callback) => {
const conn = await pool.promise().getConnection();
try {
await conn.beginTransaction();
const result = await callback(conn); // 在事务中执行所有sql
await conn.commit();
return result;
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release();
}
};
创建订单的逻辑就是三步:生成唯一订单号、插入订单主表和明细表、扣减商品库存。订单号我用时间戳 + 4位随机数生成,格式类似OD202406121530123456,保证高并发下也不会重复。
3. Vue+ElementUI前端落地:从零搭建可操作的点餐界面
3.1 前端工程初始化和路由设计
前端用Vue CLI 4.x创建项目,依赖安装时建议先切换淘宝镜像源,否则装ElementUI和vue-router这些包能急死人:
bash复制npm config set registry https://registry.npm.taobao.org
vue create web
cd web
npm install element-ui vue-router@3 vuex axios
需要注意,Vue 2项目要用vue-router@3,不能装最新的4.x版本。这是我浪费了半小时排查路由白屏后得出的教训。ElementUI同理,Vue 2对应的是element-ui,Vue 3对应的是element-plus,千万别搞混。
前端路由分成两套布局,用户端和管理端,用嵌套路由实现:
javascript复制// web/src/router/index.js
const routes = [
{
path: '/',
component: () => import('../layout/UserLayout.vue'),
children: [
{ path: '', component: () => import('../views/Home.vue') },
{ path: 'menu', component: () => import('../views/Menu.vue') },
{ path: 'cart', component: () => import('../views/Cart.vue') },
{ path: 'orders', component: () => import('../views/OrderList.vue') },
{ path: 'order/:id', component: () => import('../views/OrderDetail.vue') }
]
},
{
path: '/admin',
component: () => import('../layout/AdminLayout.vue'),
children: [
{ path: '', component: () => import('../views/admin/Dashboard.vue') },
{ path: 'menu', component: () => import('../views/admin/MenuManage.vue') },
{ path: 'orders', component: () => import('../views/admin/OrderManage.vue') }
]
}
];
3.2 点餐页面的核心交互:菜单列表和分类联动
点餐页面是用户端的核心,我参考了主流外卖App的交互:左侧是商品分类,右侧是对应分类下的商品列表,点击分类右侧自动滚动到对应区域。
这里的实现思路是先用el-tabs或左侧el-menu做分类导航,商品列表用el-card展示封面图、名称、价格和"加入购物车"按钮。加购操作会调用后端接口,同时更新Vuex里购物车徽标数量。
vue复制<!-- web/src/views/Menu.vue 简化版 -->
<template>
<div class="menu-page">
<div class="category-side">
<div
v-for="item in categories"
:key="item.id"
:class="['category-item', { active: currentCategory === item.id }]"
@click="switchCategory(item.id)"
>{{ item.name }}</div>
</div>
<div class="menu-list">
<el-card
v-for="menu in menuList"
:key="menu.id"
class="menu-card"
>
<div class="menu-info">
<h3>{{ menu.name }}</h3>
<p class="desc">{{ menu.description }}</p>
<div class="price-row">
<span class="price">¥{{ menu.price }}</span>
<el-input-number
v-model="cartMap[menu.id]"
:min="0"
:max="menu.stock"
size="mini"
@change="handleCartChange(menu)"
/>
</div>
</div>
</el-card>
</div>
</div>
</template>
这里用el-input-number做数量选择,支持加减,用户体验和原生App没区别。每次数量变化调用handleCartChange,这个函数会判断当前数量是0还是大于0——等于0就调删除接口,大于0就调新增或更新接口,前端同时更新Vuex里的购物车数据。
3.3 ElementUI组件在项目里的实战用法:表格、弹窗和表单校验
管理端大量用到ElementUI的表格、表单和弹窗组件。商品管理页的表格用el-table渲染,操作列放"上架/下架"和"编辑"按钮,点编辑弹出一个el-dialog,里面嵌el-form做商品信息的增改。
这里分享几个我在实际项目中摸索出来的经验:
表格列宽自适应:不要把每列宽度写死,关键列用min-width,让表格在窄屏下自动压缩。外卖管理端经常要在手机上打开看数据,列宽写死会导致表格溢出。
表单校验:商品价格必填并且大于0,库存必须是非负整数。ElementUI自带校验规则写起来很方便:
javascript复制rules: {
name: [
{ required: true, message: '请输入商品名称', trigger: 'blur' }
],
price: [
{ required: true, message: '请输入价格', trigger: 'blur' },
{ pattern: /^\d+(\.\d{1,2})?$/, message: '价格格式不正确(最多两位小数)', trigger: 'blur' }
]
}
图片上传:商品图片上传用el-upload组件,action指向后端的/api/upload接口。后端需要先配置静态资源目录:
javascript复制// server/app.js
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
前端上传成功后拿到返回的URL,拼上服务器地址存到商品数据里。图片路径我建议存相对路径,不要存完整的http://localhost:3000/uploads/xxx.jpg,否则服务器IP或端口一变,所有图片链接都会失效。
3.4 购物车与订单状态的Vuex管理
购物车的数据量小但交互频繁,适合放在Vuex里做全局状态管理。我在store里维护了一个cartList数组和cartCount数字,用户端所有页面共享这份数据。加购、减购、清空购物车都是commit对应的mutation。
订单状态在用户端和管理端都需要展示,我在前端也定义了一套和后端一一对应的状态枚举,并用el-tag展示不同颜色:
javascript复制const orderStatusMap = {
0: { text: '待支付', type: 'warning' },
1: { text: '已支付', type: 'primary' },
2: { text: '制作中', type: 'info' },
3: { text: '配送中', type: 'primary' },
4: { text: '已完成', type: 'success' },
5: { text: '已取消', type: 'danger' }
};
4. 打通点餐闭环:购物车结算到商家接单的完整流程
4.1 用户下单流程:一个按钮后面的四步操作
用户点"去结算"按钮,前端要做四件事:校验购物车非空、弹出确认订单弹窗、展示收货地址和商品清单、确认后调下单接口。
下单接口后端逻辑再展开一下。创建订单时前端会把购物车所有商品列表传到后端,后端在事务里循环处理每件商品:
javascript复制// server/controllers/order.js
async createOrder(req, res) {
const { items, addressId, totalPrice } = req.body;
const userId = req.user.id;
try {
const orderId = await runTransaction(async (conn) => {
// 1. 插入订单主表
const orderNo = generateOrderNo();
const [orderResult] = await conn.execute(
'INSERT INTO orders (order_no, user_id, total_price, status, address_id, create_time) VALUES (?,?,?,?,?,NOW())',
[orderNo, userId, totalPrice, ORDER_STATUS.PENDING, addressId]
);
const newOrderId = orderResult.insertId;
// 2. 插入订单明细
for (const item of items) {
await conn.execute(
'INSERT INTO order_detail (order_id, menu_id, menu_name, price, quantity) VALUES (?,?,?,?,?)',
[newOrderId, item.menuId, item.name, item.price, item.quantity]
);
// 3. 扣减库存
await conn.execute(
'UPDATE menu SET stock = stock - ? WHERE id = ? AND stock >= ?',
[item.quantity, item.menuId, item.quantity]
);
}
// 4. 清空购物车
await conn.execute('DELETE FROM cart WHERE user_id = ?', [userId]);
return newOrderId;
});
res.json({ code: 0, data: { orderId }, msg: '下单成功' });
} catch (err) {
res.status(500).json({ code: 500, msg: '库存不足或下单失败' });
}
}
扣减库存的SQL用了WHERE stock >= ?这个条件,这是防止超卖的关键。库存不够时影响行数为0,事务会回滚,下单失败。
4.2 商家端接单与状态流转
管理端的订单管理页面用el-tabs按状态分类展示,商家看到新订单后点击"接单"按钮,订单状态从"已支付"变成"制作中";菜品做好后点"开始配送",状态变"配送中";骑手送达后点"完成订单",状态变"已完成"。
状态流转的前端界面要处理好按钮的显隐逻辑:只有当前状态为"已支付"时才显示"接单"按钮,为"制作中"时才显示"开始配送"按钮。我是用v-if搭配订单状态字段控制的,逻辑简单但容易漏。建议把订单状态流转图画在项目文档里,编码时对着图写,就不容易乱了。
用户端订单列表需要定时刷新或下拉刷新。我用setInterval每10秒轮询一次订单列表,保证用户在订单状态变化后能自动看到最新进度。这对小项目完全够用,不需要引入WebSocket这种重型方案。
4.3 联调经验:前后端对接时的数据契约
前后端联调最容易出问题的是字段名不一致。前端要menuName,后端返回的是name,前端拿不到数据但接口返回200,这种问题排查起来非常耗时间。
我的做法是在代码里强制统一。所有接口返回格式一律是:
json复制{
"code": 0,
"message": "success",
"data": {}
}
code为0表示成功,非0表示业务错误。前端axios响应拦截器统一处理:
javascript复制// web/src/utils/request.js
instance.interceptors.response.use(
res => {
if (res.data.code !== 0) {
Message.error(res.data.message);
return Promise.reject(new Error(res.data.message));
}
return res.data.data;
},
err => {
Message.error('网络请求失败,请检查后端服务是否启动');
return Promise.reject(err);
}
);
这样做的好处是业务代码里不用每个接口都写错误处理,只管拿到data后正常渲染。
5. 环境配置与上线避坑:从Node安装到npm脚本权限报错
5.1 Node.js安装和环境变量配置
这个项目第一步是装Node.js。有很多同学卡在"装完Node.js后npm用不了"这一步。我见过最多的报错是:npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。
这个问题的本质是Windows PowerShell的执行策略默认禁止运行.ps1脚本。解决办法有两种:
方法一:以管理员身份打开PowerShell,执行:
powershell复制Set-ExecutionPolicy -ExecutionPolicy RemoteSigned
之后选择Y确认。这个命令的意思是:本地脚本可以运行,远程下载的脚本需要签名。
方法二:干脆不用PowerShell,直接用CMD(命令提示符)操作npm。CMD不受这个执行策略限制。如果只是跑项目,用方法二最简单;如果打算长期做开发,建议用方法一。
Node.js安装完成后,可以验证一下环境变量是否配置成功,在终端输入node -v和npm -v,能输出版本号就说明PATH配好了。如果提示"不是内部或外部命令",检查系统环境变量里有没有C:\Program Files\nodejs\这个路径。
5.2 npm安装依赖慢和版本冲突的排查
项目根目录会同时有后端server和前端web两个子项目,各有各的package.json。建议分别在子目录下执行npm install,不要在根目录统一安装。
安装依赖慢是个老生常谈的问题,我一般直接在用户目录下新建.npmrc文件,写一行:
code复制registry=https://registry.npm.taobao.org
这样所有项目的npm安装都会走淘宝镜像,不用每次都在命令行加--registry参数。
版本冲突的问题在Express 5和mysql2组合时遇到过。Express 5在2024年底已经正式发布,但部分中间件生态还没完全跟上。我建议稳妥起见用Express 4.x版本,功能和稳定性都足够这个项目用。如果非要用Express 5,注意app.use的参数解析方式有变化,部分老中间件会报类型错误。
5.3 数据库连接和生产部署常见问题
MySQL 8.0的认证插件坑:如果用MySQL 8.0,连接时会出现ER_NOT_SUPPORTED_AUTH_MODE错误。原因是MySQL 8.0默认用caching_sha2_password认证,而Node.js的mysql2老版本默认用mysql_native_password。解决办法是在MySQL里执行:
sql复制ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码';
或者在连接配置里加上authPlugins相关配置。最省事的方案是安装最新版mysql2,它已经支持sha2认证。
端口被占用:后端默认跑在3000端口,前端开发服务器跑在8080端口。如果遇到EADDRINUSE错误,说明端口被其他进程占用了。Windows下用netstat -ano | findstr 3000找到占用进程PID,然后到任务管理器结束进程。
前端跨域问题:开发环境下前端在8080端口,后端在3000端口,浏览器会拦截跨域请求。后端用cors中间件解决:
javascript复制const cors = require('cors');
app.use(cors({
origin: 'http://localhost:8080',
credentials: true
}));
生产环境部署:前端npm run build生成的dist目录是纯静态文件,可以用Nginx托管,然后配置反向代理把/api开头的请求转发到Node.js服务。这个方案比用Express直接托管静态文件更符合生产环境实践,也方便后续做负载均衡。
6. 一些实在话:做完这个项目后我的几点感受
这个项目做完给我最大的感触是,外卖点餐系统虽然看起来业务简单,但真正把订单闭环跑通需要考虑的细节非常多。光一个订单状态流转,就涉及用户、商家、系统三方协作:用户要能取消未支付的订单,商家要能拒单,系统要保证库存不超卖,每一个分支都要在代码里体现清楚。
如果看文章的你正在做类似的系统,我强烈建议动手之前先把状态流转图画出来。不需要多正式,一张纸一支笔就行,把商家、用户、系统每个角色在什么状态下能做什么事列清楚。我第一版代码就是因为没画这个图,写到订单管理时反复改状态判断逻辑,浪费了大量时间。
另外一个小建议:项目里可以加一个简单的数据统计接口,统计每日订单量、营业额、热门菜品Top10。理由有二:一是管理端首页有个数据看板会显得项目完整性高很多,答辩或交付时是加分项;二是这个需求会用到GROUP BY聚合查询和JOIN多表关联,对SQL能力是很好的锻炼。
技术栈方面,这套Node.js + Vue + ElementUI + Express + MySQL的组合在中小型Web项目中非常能打。核心是生态成熟,遇到问题基本都能在社区找到答案。把外卖点餐这一个项目做完做透,前后端的技术栈、数据库设计、接口设计这些基本功都会得到很完整的训练。后面再去做其他管理系统、小程序应用,思路和套路都是相通的。
