
小碗菜外卖订单系统这名字听上去挺垂直的但真做起来它几乎覆盖了一个完整全栈项目需要面对的所有问题用户端点餐、商家接单、订单状态流转、购物车幂等、库存防超卖、实时推送甚至支付回调。市面上这类毕业设计和课程项目很多但大多数代码质量惨不忍睹要么只有前端静态页面要么后端只有一个简单的CRUD。我今天想拆的这套基于Node.js Vue的小碗菜外卖订单系统是能真正跑起来、能联调、能上线演示的完整前后端分离项目技术栈选的是Express Vue 3数据库用MySQL全程用REST API WebSocket驱动业务。无论你是准备用它做毕业设计还是想通过一个实战项目搞懂Node.js生态怎么落地这篇内容都值得你花十分钟看完。我会从架构选型、数据库设计到核心接口实现、前端交互再到部署时那些恶心人的坑一条线讲清楚。1. 项目整体设计与技术选型1.1 先搞清楚这个系统到底要做什么小碗菜这个场景和正餐外卖不一样它的特点是菜品种类多、单价低、出餐快、拼单多。用户一次性会选好几个菜类似食堂打饭的逻辑而不是西式快餐那种单汉堡单可乐。所以订单系统的核心不是点菜本身而是订单与菜品、口味、份量之间的多对多关系以及商家侧如何快速处理多订单并发。整套系统按角色拆分成四类人用户浏览菜品、加购、下单、支付、催单、确认收货。商家维护菜品、上下架、处理订单接单/出餐/完成。骑手接单配送更新配送状态。管理员管理用户、商家、品类、统计营收。我做的这套系统没有一刀切把所有页面都塞给用户端而是按登录身份动态渲染路由同一套Vue代码里分出用户端、商家端、管理端三个工作台。实际开发中这一步很关键很多初学者喜欢把页面堆成一坨菜单栏里全是角色功能看着热闹真演示起来自己都不知道该点哪个。项目是给人用的不是给人逛的。1.2 为什么选Node.js Vue而不是Spring Boot先说结论这套业务用Spring Boot Vue也能做但Node.js方案更适合这种偏教学、偏快速交付的项目。核心原因有三个。第一Node.js对前端开发者几乎没有心智负担你会JavaScript就会写后端不用切Java那一套注解、IOC、Maven。第二Express生态轻中间件简单一个十万行项目用Express搭起来比Spring Boot清爽得多。第三这个项目里面有大量的即时通讯场景比如用户下单后商家实时收到提醒这类功能用Socket.IO天然顺手而如果后端是Java你还得额外搞WebSocket的握手鉴权、心跳重连复杂度上去一截。当然Node.js方案也有代价比如CPU密集型的统计报表性能一般但小碗菜外卖这种量级完全够用。项目核心是订单流和状态机不是算法竞赛。技术选型跟着场景走不跟着情怀走。1.3 技术栈全景与版本选择我用的是下面这套组合版本都是经过实测能稳定联调的层级技术说明后端Node.js 16 / Express 4提供REST API与静态托管数据库MySQL 5.7/8.0存用户、菜品、订单等核心数据ORMSequelize 6模型定义、关联查询、事务实时推送Socket.IO 4商户端新订单提醒、订单状态广播前端Vue 3 Vite 3组合式API开发开发体验好路由/状态Vue Router 4 Pinia 2动态路由用户状态管理UIElement Plus后台管理界面组件库支付支付宝沙箱模拟真实支付回调流程为什么前端用Vite不用WebpackVite冷启动速度快配置少。Vue3全家桶现在已经在前端圈形成事实标准Element Plus也让管理端页面不用从零写组件。后端框架也有人建议用NestJS说它结构更工程化。如果你追求代码分层规范NestJS确实更好但Express对新手更直白中间件机制一看就懂。这套项目我追求的是能一口气跑通的交付感选Express更稳。2. 数据库设计与核心模型2.1 业务实体拆解从用户到订单的建模思路数据库设计是整个系统的地基。很多初学者喜欢一上来就建几十张表比如把收货地址单独拆出三张表把购物车建成复杂的关联结构。我的建议是小碗菜外卖的核心实体五到六张表就够了复杂表结构只会让Sequelize关联查询变得让人头大。我实际落的表结构是users用户表字段包含用户名、密码、手机号、角色user/shop/admin、头像、余额。shops商家表关联user_id包括店铺名称、公告、起送价、配送费、状态。categories菜品分类表关联shop_id。dishes菜品表关联shop_id、category_id包含名称、图片、价格、库存、销量、上下架状态。orders订单表包含订单号、用户ID、商家ID、总金额、状态、收货信息、备注、支付方式。order_items订单明细表记录订单包含的菜品、数量、单价、快照。carts购物车表关联用户和菜品存数量。之所以要把订单明细抽出来单独建表是因为菜品价格和文案是会变的。用户下单那一刻的菜品名称、价格必须做快照存到order_items里否则商家改价后历史订单显示的数据就乱了。这是做外卖系统最容易忽略的细节之一。2.2 订单状态机设计别把状态做成字符串乱飞订单状态是整个系统的灵魂。我见过太多项目订单状态用一串看不懂的字符串代码里到处是order.status 3到头来没人能说清3代表什么。我推荐用枚举常量统一管理状态并且把状态机的流转规则写清楚订单生命周期是这样一条链待支付(0) → 已支付/待接单(1) → 已接单/制作中(2) → 配送中(3) → 已完成(4)另外还有两个旁路状态用户支付前主动取消(5)商家超时未接单系统自动取消(6)。每个状态节点能触发什么操作在代码里就写好映射关系防止用户端和商家端出现状态漂移。比如用户在待支付状态可以取消订单在已完成状态就不能。商家只能在待接单状态才能确认接单骑手只能在制作中状态才能改成配送中。这些规则如果不做成统一校验后期前后端各写一套判断必然出bug。2.3 订单号生成策略与库存扣减设计订单号不用雪花算法那么复杂但也不能就用时间戳加随机数糊弄。我用的生成规则是日期时间 用户ID末四位 随机四位比如SH2024061210301234567890。这样一个订单号里能直接看出下单时间、用户维度排查问题的时候一眼就能定位。库存扣减必须放在事务里做这里我踩过坑。常规流程是用户下单 → 扣库存 → 创建订单 → 清空购物车。如果直接把库存减掉但事务提交失败了库存就莫名其妙少了一份用户以为没下单成功商家这边却缺货了。正确做法是用Sequelize事务包裹整条链路任何一步失败全部回滚。另外扣库存的SQL语句要写成原子更新UPDATE dishes SET stock stock - 1 WHERE id ? AND stock 0这样写的好处是数据库行锁会保证并发下不会出现超卖。我曾经用先查库存再判断再更新的写法并发压测直接库存变负数。后来改成原子更新加条件判断再在代码里检测affectedRows是不是0如果为0说明库存不足直接抛异常回滚。这才是防超卖的真正解法。3. 后端核心接口与业务逻辑3.1 项目结构这样组织目录三个月后还能看懂后端项目我按模块化方式组织而不是把所有路由堆在一个app.js里。这里贴一下核心结构照着搭能省掉很多后期重构的麻烦server/ ├── app.js # 入口配置中间件与路由挂载 ├── config/ │ └── db.js # 数据库配置 ├── models/ # Sequelize模型定义 │ ├── index.js │ ├── user.js │ ├── dish.js │ └── order.js ├── routes/ # 路由层 │ ├── auth.js │ ├── dish.js │ ├── cart.js │ └── order.js ├── controllers/ # 控制层处理请求逻辑 ├── middlewares/ # 鉴权、错误处理中间件 ├── utils/ # 工具函数 └── socket/ └── index.js # Socket.IO事件处理app.js里做的事情很简单启用cors中间件、express.json()解析请求体、挂载路由前缀/api、启动HTTP服务、再把Socket.IO绑定到同一个HTTP实例上。注意Socket.IO不能单独另开端口要和Express共享同一个server实例不然跨域和握手都会出问题。3.2 鉴权中间件JWT到底该存哪这套系统的登录鉴权用的是JWT。很多项目会把token直接丢到localStorage里图方便。但如果用户是商家或管理员角色信息需要频繁读取每次都让前端把token发过来后端再解析有点绕。我的做法是登录成功后把JWT返回前端前端存在localStorage后续请求放在Authorization: Bearer token头里。后端写一个auth中间件统一处理const jwt require(jsonwebtoken); function authGuard(req, res, next) { const header req.headers.authorization || ; const token header.split( )[1]; if (!token) return res.status(401).json({ message: 未登录 }); try { req.user jwt.verify(token, process.env.JWT_SECRET); next(); } catch (e) { return res.status(401).json({ message: 登录已过期 }); } }角色权限的校验就是在这个中间件基础上再加一层校验比如商家接口的中间件判断req.user.role ! shop就返回403。这样的代码哪个节点出问题都一目了然。3.3 菜品数据接口与图片处理菜品接口是所有动作的数据源用户端每次刷新页面都会请求。性能优化点在于菜品列表需要连表查出分类名称和商家名称SQL层面用include就好避免在代码里循环查库造成N1问题。图片处理这块小碗菜项目不需要搞复杂的云存储开发阶段直接在服务端用multer接收文件存到public/uploads目录下再把图片URL拼到接口数据里。但是有个坑Vite前端跑在5173端口图片URL是相对路径比如/uploads/xx.jpg时前端页面会去5173端口找图片找不到。解决办法是前端创建图片地址时拼上后端接口域名或者在Nginx统一做转发。开发阶段最简单的方式是定义一个全局baseURL变量把图片路径转成http://localhost:3000/uploads/xx.jpg。3.4 下单接口事务与幂等控制下单是整个项目最核心的接口坏一点就会出大事故。我实际开发中写了差不多两百行逻辑核心步骤是校验用户登录态、收货地址是否为空。根据用户ID查出购物车数据和菜品表关联校验菜品是否上架、库存是否充足。计算订单总金额校验是否达到商家起送价。事务内创建订单主表和订单明细表。原子更新菜品库存。清空购物车。事务提交通过Socket.IO广播通知对应商家。这个流程里有一个很隐蔽的问题用户连续快速点击两次提交按钮就会产生两笔相同订单。解决办法是前端按钮加loading状态加防抖后端在用户维度做幂等判断例如Redis里存一个order:userId:timestamp的标识重复订单直接拒绝。如果没有Redis用MySQL的唯一索引也能兜底订单号生成时带上随机串只要订单号唯一重复提交就会因为唯一索引报错事务回滚后第二个请求返回友好错误用户体验不会太差。3.5 WebSocket实时推送从下单到出餐的链路HTTP接口解决的是命令请求实时推送这类需求必须用WebSocket。我用的Socket.IO业务事件设计如下newOrder用户支付成功后服务端往商家绑定的房间推送新订单提醒。orderStatus商家接单/出餐/配送状态变更时服务端推送给用户端和骑手端。broadcast商家上下架菜品后通知用户端刷新菜品列表。连接鉴权不能省。Socket.IO握手时浏览器会先发一个HTTP请求我在io.use()里从请求头拿token验证身份把用户信息和角色挂到socket.data上。这样后续事件里可以直接判断角色做权限控制。商家端默认进入以自己的shopId命名的房间比如room_shop_3。用户下单后服务端执行io.to(room_shop_${shopId}).emit(newOrder, { orderId, totalAmount });用户端则在订单详情页监听自己的事件使用room_user_${userId}区分房间这样下单用户和商家都能实时看到对方操作。这里有个常见误区socket事件命名要统一不要一个用下划线一个用驼峰。前后端各写各的最后联调时发现事件对不上排查半天还以为是网络问题。4. 前端Vue实现要点4.1 项目初始化与动态路由设计前端用Vite创建项目npm create vitelatest frontend -- --template vue cd frontend npm install vue-router4 pinia element-plus axios socket.io-client然后删除模板多余的组件把项目结构改成src/ ├── router/ │ └── index.js ├── stores/ │ ├── user.js │ └── cart.js ├── views/ │ ├── Home.vue │ ├── Shop.vue │ ├── Cart.vue │ ├── OrderList.vue │ ├── admin/ │ │ ├── Dashboard.vue │ │ └── Dishes.vue │ └── shop/ │ ├── ShopOrders.vue │ └── ShopDishes.vue ├── api/ │ └── request.js # axios封装 └── App.vue动态路由是我后期加上的功能。一开始是写死在router表里管理员登录也能看到用户端菜单体验很怪。后来改成登录后根据角色去后端拉取路由表用router.addRoute()动态添加。核心逻辑是在路由守卫beforeEach里判断用户信息如果用户已登录且当前路由表还未初始化就请求后端拿到菜单结构再动态注册。4.2 菜品列表与购物车状态管理用户端首页的核心交互是点菜购物车。我用Pinia维护一个cartstore里面存两个关键数据items{ dishId, name, price, count }shopId当前购物车所属商家ID为什么要管shopId因为小碗菜场景里用户可能同时看了好几个店铺但购物车必须区分商家不同商家的菜不能混在一个购物车里。用户切换店铺加菜时如果购物车已有其他商家的菜前端要弹窗提示清空或保留。这个交互是外卖产品的常见体验但很多课程项目压根不管导致用户下单时数据错乱。加购菜品时数量直接在前端累加同时把变更同步到后端POST /api/cart { dishId: 12, count: 1 }后端存的是用户名下的购物车记录。下单成功后清掉这一商家的购物车记录。4.3 订单列表实时刷新抛弃定时器方案用户下单后订单状态从待支付变成已接单如果靠轮询接口刷新体验和资源消耗都很难看。我用Socket.IO在前端监听const socket io(http://localhost:3000, { auth: { token: localStorage.getItem(token) } }); socket.on(orderStatus, (payload) { const orderList useOrderStore(); orderList.updateOrderStatus(payload.orderId, payload.status); });监听事件时要把接口返回的对象和数据列表匹配起来。这里的关键是updateOrderStatus找的是前端列表里已存在的那条订单数据而不是重新拉全量列表。这样界面只局部更新不会闪烁跳动。如果你想做得更细可以在订单卡片上根据状态显示进度条已支付亮第一步、已接单亮第二步、配送中亮第三步。这些数据都来自同一个status字段前端写个映射函数即可。4.4 商家端接单视图一个被很多人做砸的页面商家端最核心的页面是接单页。很多项目就是把所有订单列成一个表格加个确认按钮毫无使用价值。我做的改进是分组视图待接单新订单高亮背景色显示下单时间倒计时。制作中操作按钮变成完成出餐。配送中显示骑手信息等待完成。整个页面的数据流很简单进入页面时拉一次待接单订单列表然后监听服务端推来的newOrder事件把新订单预插入列表头部。接单按钮点击后调用POST /api/shop/orders/:id/accept接口成功后再从待接单分组移除到制作中分组。需要注意的一个坑新订单事件和当前页面拉取的列表可能重复。如果新订单已经通过接口包含在列表里socket事件又来一次列表就会出现两条相同订单。我在前端处理时引入了orderId去重插入前先检查列表里是否已存在。4.5 管理端Dashboard用可视化呈现数据管理端不是核心业务但能撑门面。我用ECharts做了三个图表近7天订单量折线图。各分类菜品销量饼图。商家排行柱状图。数据接口在后端写一个/api/admin/stats聚合查询用SQL的GROUP BY按日期分组统计前端直接渲染图表。这部分没有太多技术难点但后期答辩或者演示时这些图表比任何效果图都直观。5. 环境配置与本地跑通全流程5.1 Node.js安装与npm的PowerShell脚本限制问题Node.js安装本身不复杂去官网下载LTS版本一路Next就好。但Windows上安装完以后几乎每个人都会踩同一个坑报错长这样npm : 无法加载文件 D:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。这其实是PowerShell的执行策略不允许运行npm.ps1脚本跟Node本身没关系。解决办法有两个第一个以管理员身份打开PowerShell执行Set-ExecutionPolicy -Scope CurrentUser RemoteSigned输入Y确认。这样只对当前用户放开有限权限比较安全。第二个不想动策略的话直接用命令提示符cmd运行npm命令而不是PowerShell也能绕开。但要记住以后所有npm命令都在cmd里跑别在VS Code默认的PowerShell终端里挣扎。安装完成后验证版本node -v npm -v网上教程还喜欢让人配置npm镜像我自己的经验国内网络环境下npm install如果慢成蜗牛直接设置淘宝镜像npm config set registry https://registry.npmmirror.com这个配置可以放心用它只是把包下载源换成国内镜像不改任何依赖逻辑。5.2 数据库初始化与种子数据项目跑起来之前数据库得先准备好。我提供了初始化SQL脚本内容包括建库命令CREATE DATABASE takeout DEFAULT CHARACTER SET utf8mb4;建表语句。种子数据两个模拟商家、四五个菜品分类、十几个菜品、一个测试用户。为什么用utf8mb4不是utf8因为utf8在MySQL里存不了emoji用户备注里只要输入了表情写入就报错。外卖场景里用户备注真的会写多加辣这种内容字符集没选对线上就会掉链子。Sequelize连接配置里要设置时区dialectOptions: { dateStrings: true, typeCast: true }, timezone: 08:00不加时区配置数据库里的时间会比实际少8小时所有订单时间看起来都是凌晨排查问题时会怀疑人生。5.3 前后端联调代理与跨域开发时前端跑在5173后端跑在3000端口不同必然有跨域问题。后端我已经加了cors()中间件浏览器层面不会拦截。但更推荐的做法是在Vite里配置代理把请求转发到后端// vite.config.js export default defineConfig({ plugins: [vue()], server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true }, /uploads: { target: http://localhost:3000, changeOrigin: true } } } });这样前端请求/api/xxx时Vite开发服务器会自动把请求代理到3000端口看起来就像同源一样。上传的图片路径也能直接用/uploads/xxx访问不用拼一串完整URL。有一点要注意配置代理后axios的baseURL就别写死成http://localhost:3000了直接用相对路径/api。不然代理形同虚设还容易把Cookie和请求头搞乱。5.4 支付宝沙箱接入支付环节的模拟姿势小碗菜外卖项目如果要演示完整闭环支付这环建议用支付宝沙箱。沙箱环境允许开发者模拟真实支付流程不需要真实商户资质。接入步骤很固定打开支付宝开放平台进入沙箱环境拿到APPID、应用私钥、支付宝公钥。后端安装alipay-sdk前端把订单号传给后端。后端拼装支付参数返回一个跳转链接或二维码给前端。用户支付成功后支付宝异步通知给后端notify_url后端验签成功后修改订单状态为已支付。注意沙箱环境的APPID和公钥密钥是测试专用上线前必须替换成正式密钥。支付回调处理时一定要先验签再改状态否则任何伪造请求都能把订单置为已支付这是安全底线。如果项目评比时间紧张支付环节也可以做成模拟支付按钮直接把订单状态改成已支付。但模拟支付只适合纯展示答辩时老师问一句那你真实支付怎么接还是得能说出沙箱流程。6. 常见问题与排查技巧实录6.1 启动时报端口被占用这个坑几乎人人都会碰到。node app.js后提示端口3000被占用一般是上一轮项目没关干净。Windows下两步搞定netstat -ano | findstr :3000 taskkill /PID 具体进程号 /F如果是Linux服务器用lsof -i:3000找出PID再kill。我自己习惯在Node启动脚本里加一个小处理端口被占用时自动换一个开发期能节约一点重复操作的时间但上线版本不要这么干服务端口必须固定。6.2 Vite代理不生效配置完代理后接口还是404最常见的原因是你把axios的baseURL写成了http://localhost:3000/api。代理只对相对路径/api生效一旦写了完整地址请求就直连3000端口了跟Vite代理没有任何关系。把baseURL删掉请求路径从/api/xxx开始写即可。另外修改vite.config.js后需要重启dev server这个很多人会忘。6.3 Socket.IO连不上或者反复断连前端连接Socket.IO报跨域错误的先检查后端是否在创建Socket实例时配置了corsconst io new Server(httpServer, { cors: { origin: [http://localhost:5173], credentials: true } });不配这个浏览器里的跨域握手请求直接被浏览器拦死。反复断开重连的问题一是检查服务器负载二是看心跳机制。Socket.IO默认有心跳如果网络不稳定会自动重连。前端建议实现连接状态提示断线时显示连接中...减少用户困惑。6.4 中文乱码问题数据库能查到数据接口返回也正常但前端页面显示???或者乱码这是字符集不一致的典型表现。检查三个地方MySQL表结构的ENGINE确保表是utf8mb4。接口响应头是不是Content-Type: application/json; charsetutf-8。前端静态文件HTML的meta标签charset是不是utf-8。后端Express只要用了express.json()响应头默认带utf-8。如果还乱码优先怀疑数据库字符集和连接字符集。6.5 表格常见问题速查现象原因解决npm命令无法运行PowerShell执行策略限制Set-ExecutionPolicy RemoteSigned或改用cmdnpm install超时默认源慢设置淘宝镜像登录后刷新页面路由丢失动态路由未持久化刷新时重新获取路由表/或存store图片404前端请求了相对路径配置Vite代理/uploads下单提示库存不足库存扣减逻辑错误使用原子更新SQL订单状态不一致前后端各自维护状态枚举统一常量定义后端为唯一依据Socket连不上未配置cors在io创建里配置origin时间差8小时未配置时区Sequelize设置timezone: 08:006.6 一些来自实操的补充提醒很多新手在部署项目时习惯把数据库账号密码、JWT密钥直接写进代码里。这个习惯得改。至少把敏感信息放进.env文件项目代码里通过process.env读取。虽然课程设计不要求高安全等级但这是一个专业习惯写在简历上也是加分项。另外联调过程中我强烈建议先在浏览器F12的Network面板里看接口状态码和响应体。后端报500时控制台打印的堆栈信息里通常会有Sequelize的原始SQL错误比如字段不存在、字段名写错。经验法则是先看SQL再看代码最后才怀疑前端。这套小碗菜外卖订单系统做到最后其实已经从一个课程设计变成了一个能支撑小范围真实营业的最小产品。我个人在开发过程中最深的一点体会是外卖这类系统真正难的不是某个页面或者某个算法而是订单状态在每个环节的衔接。只要把状态机理清楚、把事务边界画明白再把实时推送接顺这个项目基本就成了。最后分享一个我能节省大量时间的小技巧在Sequelize模型里给每个表加一个version字段做乐观锁。虽然小学期项目并发量不高但万一演示时多人同时测试下单乐观锁能避免很多莫名其妙的更新丢失问题。这个细节做出来懂行的人一眼就知道你不是照着网课敲的。