微信小程序点餐外卖源码:SKU、购物车合并与订单状态机 简介这份微信小程序源码包面向餐饮商家、小程序初学者与需要完成课程设计或毕业设计的开发者提供一套可直接部署改造的餐饮点餐外卖解决方案涉及菜单浏览、在线下单、叫号排队、微信支付、配送状态跟踪、用户账号及订单管理等业务场景帮助读者理解餐饮小程序从页面展示到业务逻辑的完整实现路径。压缩包共521个文件整体约839KB主要由137个JavaScript业务脚本、108个WXSS样式表、104个JSON配置、102个WXML页面结构及44个WXS辅助模块构成并含少量PNG图片资源前端页面、交互逻辑与全局配置层次较清晰。已有315人学习下载。读者可结合bee-master源码结构定位菜单、订单、用户与支付相关模块学习WXML/WXSS页面搭建、JavaScript数据处理及第三方接口对接思路为定制点餐流程、搭建外卖MVP或进行二次开发提供可复用的代码基础。1. 从一份能跑的点餐小程序开始它到底替你省了哪几段活微信小程序做餐饮点餐外卖最耗时的往往不是写一个菜单页而是把菜品规格、购物车合并、下单金额、订单状态、支付回调和后台改单串成一条能跑的链路。开箱即用的价值就在这里页面骨架、常用组件、云函数和数据表先摆好不用从空白工程搭第一版。它适合接门店点餐、外卖自提、食堂档口这类项目的人也适合刚接触小程序、想先跑通完整业务流的新手。别把它当成不用配置就能收钱的成品支付、域名、门店数据仍要自己接。2. 拆开目录看骨架页面、组件、云函数与数据表怎么对上拿到这类源码包先别急着改颜色和文案。第一遍要确认的是主链路有没有断用户从菜单页选菜进入规格弹层加入购物车去确认单提交订单拉起支付回到订单详情后台接单改状态。这条链路涉及页面路由、组件通信、云函数调用和数据库集合四层任何一层命名对不上开发者工具里就会报“找不到页面”或“集合不存在”。我一般会先画一张很土的对应表哪个页面读哪个集合哪个按钮调哪个云函数哪个状态由谁写。画完再动手比一上来全局搜索改字段稳得多。2.1 页面路由先画主链路菜单、购物车、确认单、订单、我的小程序页面入口由app.json的pages数组决定数组第一项就是启动页。点餐类项目常见做法是把菜单页放第一项订单列表和我的放tabBar确认单、订单详情、规格编辑这类页面不放 tabBar靠navigateTo进入。分包不是越多越好后台改菜、打印设置这类低频页面可以放分包主包只保留点餐链路首屏加载会轻一些。{ pages: [ pages/menu/menu, pages/cart/cart, pages/confirm/confirm, pages/order/list, pages/order/detail, pages/my/my ], subPackages: [ { root: packageAdmin, pages: [pages/dish-edit/dish-edit] } ], window: { navigationBarTitleText: 点餐, enablePullDownRefresh: false }, tabBar: { list: [ { pagePath: pages/menu/menu, text: 点餐 }, { pagePath: pages/order/list, text: 订单 }, { pagePath: pages/my/my, text: 我的 } ] } }这段配置里pages的顺序影响启动页tabBar.list里的pagePath必须已经在pages中注册否则真机点击会白屏。subPackages.root是分包根目录分包页面路径不能重复注册到主包。enablePullDownRefresh在菜单页通常关掉避免用户下拉时和规格弹层手势冲突订单列表页可以单独在页面 JSON 里打开。提示页面路径大小写要和磁盘目录完全一致。开发者工具在部分系统上不敏感真机可能直接报路径不存在。2.2 组件与云函数分工展示层不碰价格计算开箱包最容易出现的问题是“什么逻辑都塞页面”。菜单页既算价格又拼购物车确认单再算一遍订单详情又算一遍最后三处金额对不上。比较稳的分法是dish-card只负责展示菜品和触发选规格sku-popup只负责收集 SKU 和加料cart-bar只展示数量和合计真正的金额复算放到云函数。前端算出来的金额只用于展示提交订单时服务端按数据库里的价格重新算。. ├── miniprogram/ │ ├── app.js │ ├── app.json │ ├── pages/ │ │ ├── menu/ │ │ ├── cart/ │ │ ├── confirm/ │ │ └── order/ │ ├── components/ │ │ ├── dish-card/ │ │ ├── sku-popup/ │ │ └── cart-bar/ │ └── utils/ │ ├── cart.js │ └── price.js └── cloudfunctions/ ├── login/ ├── getMenu/ ├── createOrder/ ├── payCallback/ └── initData/这个结构不是唯一答案但边界清楚utils/cart.js管合并键和数量增减utils/price.js只做展示价格格式化cloudfunctions/createOrder才是订单金额的最终裁判。getMenu按shopId和分类查菜品payCallback只处理支付结果initData只在本地或测试环境初始化演示数据。门店多的时候shopId要从登录态、扫码参数或页面参数带进来不能写死在页面里。2.3 本地跑通三步导入、配置、初始化第一步是导入项目。微信开发者工具里选择源码包里的miniprogram目录填入自己的 AppID如果源码使用云开发cloudfunctions目录要单独指定云函数根目录。第二步是改环境 ID常见位置在app.js的wx.cloud.init。第三步是初始化菜单和门店数据否则菜单页会空白控制台只报“集合不存在”或“查询结果为空”。// miniprogram/app.js App({ onLaunch() { if (!wx.cloud) { console.error(当前基础库不支持云开发请切换基础库版本); return; } wx.cloud.init({ env: your-env-id, // 替换成自己的云环境 ID traceUser: true }); }, globalData: { shopId: shop_demo_001 // 单店可固定多店从扫码参数或登录态取 } });env填错是最常见的白屏原因traceUser打开后可以在云开发控制台看到用户访问记录生产环境是否开启看隐私要求。shopId是业务字段不是小程序配置单店项目可以先用固定值跑通多店项目要避免在页面里硬编码否则换店要改代码。// cloudfunctions/initData/index.js const cloud require(wx-server-sdk); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db cloud.database(); exports.main async () { const dishes [ { _id: dish_001, shopId: shop_demo_001, name: 招牌套餐, status: on, minPrice: 1800, sort: 10 } ]; for (const item of dishes) { await db.collection(dishes).doc(item._id).set({ data: item }); } return { ok: true, count: dishes.length }; };这段初始化用doc(_id).set而不是add同一个_id重复执行不会产生重复菜品适合本地反复跑。minPrice用“分”存避免18.00这种浮点数在后续计算里出现17.999999。status: on表示上架前端只查上架菜品。initData不要在生产环境开放给任意用户调用至少加管理员校验或只在测试环境部署。注意如果源码是自带后端接口而不是云开发app.js里通常换成baseUrl配置。真机请求要走 HTTPS并且域名要在小程序后台配到 request 合法域名里本地开发者工具勾了“不校验合法域名”不代表真机能跑。3. 菜单与规格建模SKU、加料、套餐怎么落表点餐小程序的数据建模决定了后面购物车和订单好不好写。最粗糙的做法是把“大份微辣加蛋”拼成一个字符串当菜品 ID前端能跑后台改价和库存就开始翻车。比较稳的拆法是三层菜品dishes管分类、名称、上下架和展示价规格skus管份量、价格和库存加料addonGroups管辣度、加料、必选和最多选几个。套餐再单独一层不要和普通 SKU 混在一起。金额统一用分库存扣减放在服务端前端只负责选择。3.1 菜品、规格、加料拆三层不做字符串拼接菜品文档里可以内嵌 SKU 数组和加料组也可以拆成独立集合。单店、菜品量不大的项目内嵌更省事连锁多店、后台频繁改价拆集合更好查。关键是每个 SKU 有稳定的skuId每个加料有稳定的addonId购物车和订单都引用 ID不引用显示名称。显示名称会改ID 不改。集合用途关键字段dishes菜品主数据_id、shopId、name、categoryId、status、minPrice、sortskus规格与库存skuId、dishId、name、price、stockaddonGroups加料分组groupId、dishId、name、required、max、optionscombos套餐组合comboId、dishIds、comboPrice、status// dishes 文档示例金额单位分 { _id: dish_001, shopId: shop_demo_001, name: 招牌套餐, categoryId: cat_hot, status: on, // on | off | soldout minPrice: 1800, // 列表页展示用取最低 SKU 价格 skus: [ { skuId: sku_small, name: 小份, price: 1800, stock: 100 }, { skuId: sku_large, name: 大份, price: 2600, stock: 50 } ], addonGroups: [ { groupId: g_spicy, name: 辣度, required: true, max: 1, options: [ { addonId: a_mild, name: 微辣, price: 0 }, { addonId: a_hot, name: 重辣, price: 0 } ] } ], sort: 10 }minPrice只用于菜单卡片展示不能让前端拿它当订单金额。stock放在 SKU 上因为小份和大份库存可能不同。required: true表示必须选一个辣度max: 1表示这一组最多选一个如果是“加料”组max可以设 3 或 5前端按这个限制勾选。status除了上下架还要有soldout售罄和手动下架在界面上的提示不一样后台操作也不一样。3.2 购物车合并键同菜不同规格不能只看菜品 ID购物车合并是最容易写错的地方。用户点了“招牌套餐小份微辣”和“招牌套餐小份重辣”如果只按dishId合并两行会变成一行辣度只剩一个。正确合并键至少要包含dishId skuId 排序后的 addonIds。加料 ID 排序是为了让“加蛋、加肠”和“加肠、加蛋”落到同一个键上否则用户点两次会出两行。// miniprogram/utils/cart.js function buildCartKey(item) { const addonIds [...(item.addonIds || [])].sort().join(,); return [item.dishId, item.skuId, addonIds].join(|); } function mergeCart(cart, next) { const key buildCartKey(next); const index cart.findIndex(it buildCartKey(it) key); if (index -1) { cart.push({ ...next, key, count: next.count || 1 }); } else { cart[index].count next.count || 1; } return cart; } module.exports { buildCartKey, mergeCart };buildCartKey里addonIds先展开再排序避免原数组被sort改掉。mergeCart找到同键就累加数量找不到就新增一行。count要做上限和下限控制不能小于 1也不能超过库存或限购数这部分校验建议在加入购物车和提交订单时各做一次前端拦一次服务端再拦一次。提示购物车如果存在本地缓存升级版本后旧缓存可能没有key字段。读取缓存后要用buildCartKey重算一遍别直接信任缓存里的结构。3.3 金额服务端复算前端只做展示订单金额必须在服务端重新算。前端传来的price、total只能用于对比和提示不能直接入库。服务端按dishId查菜品按skuId找规格按addonId找加料再乘数量累加。所有金额用整数分最后展示时除以 100。下面这段是简化版复算逻辑真实项目还要加门店配送费、打包费、优惠券和起送价但顺序一样先校验菜品状态和库存再算单项再算总和。// cloudfunctions/createOrder/price.js function calcOrderAmount(items, dishMap, addonMap) { let total 0; for (const item of items) { const dish dishMap[item.dishId]; const sku dish dish.skus.find(s s.skuId item.skuId); if (!sku || dish.status ! on || sku.stock item.count) { throw new Error(菜品已下架或库存不足); } total sku.price * item.count; for (const addonId of item.addonIds || []) { const addon addonMap[addonId]; if (!addon) throw new Error(加料不存在); total addon.price * item.count; } } if (total 0) throw new Error(订单金额异常); return total; // 单位分 }dishMap和addonMap要在一次请求里按 ID 批量查出来不要在循环里逐条查数据库否则菜品一多云函数就超时。sku.stock item.count只是预检查真正扣库存要用条件更新防止两个用户同时下单把库存扣成负数。total 0是兜底0 元单、负数单、优惠后为 0 的订单要走单独开关不要默认放行。4. 下单与履约链路从购物车到订单状态机订单链路的核心不是“插入一条订单”而是“谁在什么条件下能把订单从 A 状态改到 B 状态”。前端按钮、支付回调、后台接单、超时取消都会写订单状态如果没有白名单和条件更新重复回调会把已支付订单改回去后台误点会把已完成订单改成制作中。比较稳的做法是订单主表存汇总和状态订单明细表存菜品快照状态流转用一张白名单表所有更新带where status from条件。4.1 订单主表与明细表快照字段必须留订单里要存菜品名称、规格名称、加料名称、单价和数量快照。后台改菜名、改价格、删菜品之后历史订单还要能看懂。主表存orderNo、shopId、用户标识、总金额、订单状态、下单时间、支付时间、桌号或配送地址明细表存每一行的dishId、skuId、nameSnapshot、priceSnapshot、count、addonSnapshots。orderNo要唯一常见做法是时间戳加随机数或自增序列不要只用_id给用户看。// orders 主表示例 { _id: order_20250101_001, orderNo: 202501011230001, shopId: shop_demo_001, openid: user_openid, status: PENDING_PAY, amount: 3600, itemCount: 2, snapshot: { shopName: 模拟门店, tableNo: A03 }, createdAt: Date.now(), paidAt: null }amount是服务端复算后的总分单位分。itemCount是总件数用于列表页展示和后台统计不要和明细行数混为一谈。snapshot存门店名和桌号这类下单时信息门店改名不影响历史订单。status只存枚举值不存中文前端用映射表转“待支付”“制作中”。4.2 状态流转用白名单不用 if/else 堆点餐订单常见状态可以定为PENDING_PAY待支付、PAID已支付、MAKING制作中、READY待取餐或待配送、COMPLETED已完成、CANCELED已取消、REFUNDING退款中、REFUNDED已退款。状态流转写成对象白名单比在业务代码里到处写if (status PAID)好维护。更新时带上前置状态数据库层面就能挡住并发改单。// cloudfunctions/order/state.js const TRANSITIONS { PENDING_PAY: [PAID, CANCELED], PAID: [MAKING, REFUNDING], MAKING: [READY, REFUNDING], READY: [COMPLETED], COMPLETED: [], CANCELED: [], REFUNDING: [REFUNDED] }; function canTransit(from, to) { return (TRANSITIONS[from] || []).includes(to); } module.exports { canTransit };TRANSITIONS只允许白名单里的方向COMPLETED和CANCELED是终态不能再改。实际更新时不要先查再改要用条件更新const res await db.collection(orders) .where({ _id: orderId, status: from }) .update({ data: { status: to, updatedAt: Date.now() } }); if (res.stats.updated ! 1) { throw new Error(订单状态已变化请刷新后重试); }from是前端或回调认为的当前状态to是目标状态。stats.updated不等于 1 说明订单已经被别人改过可能是后台接单和支付回调同时到达。这个判断比“先读出来再比较”可靠因为读和写之间还有时间窗。4.3 支付回调、打印、超时取消三条异步链路要幂等支付回调可能重复到达打印小票可能失败重试超时取消可能和用户支付同时发生。这三条链路都要幂等。支付回调用支付流水号做唯一键先写pay_logs重复流水号直接返回成功订单更新用PENDING_PAY - PAID条件更新已经支付过的订单不重复加钱。打印任务单独建集合状态用PENDING / PRINTED / FAILED失败重试不要影响订单状态。// cloudfunctions/payCallback/index.js const cloud require(wx-server-sdk); cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }); const db cloud.database(); exports.main async (event) { const { orderNo, transactionId } event; const exist await db.collection(pay_logs).where({ transactionId }).count(); if (exist.total 0) return { code: OK, duplicated: true }; await db.collection(pay_logs).add({ data: { transactionId, orderNo, createdAt: Date.now() } }); const res await db.collection(orders) .where({ orderNo, status: PENDING_PAY }) .update({ data: { status: PAID, paidAt: Date.now() } }); return { code: OK, updated: res.stats.updated }; };transactionId是支付平台流水号必须唯一。pay_logs先写再改订单重复回调会在第一步被挡住。真实环境还要验签不能直接信任event里的金额和订单号回调金额要和服务端订单金额比对不一致走异常单。超时取消用定时触发器扫PENDING_PAY且超过 15 分钟的订单更新条件同样带status: PENDING_PAY避免把刚支付的订单取消掉。注意打印和支付回调不要串在一个云函数里同步执行。打印失败不应该让支付回调返回失败否则支付平台会一直重试。拆成独立任务用状态和重试次数控制。5. 避坑与排查开箱即用最容易翻车的五处开箱包能省掉搭骨架的时间但省不掉环境配置和边界校验。下面五类问题在点餐小程序里出现频率很高排查时先看控制台和云函数日志再看数据库记录最后才怀疑代码逻辑。每条都按“现象、原因、解决”走一遍基本能覆盖第一轮联调。5.1 现象开发者工具能跑真机白屏现象是开发者工具里菜单正常真机打开白屏或一直转圈控制台可能没有明显报错。原因通常是请求域名没配、云环境 ID 不一致、基础库版本太低或者图片资源用了本地绝对路径。开发者工具勾了“不校验合法域名”只对工具生效真机请求必须走已配置的 HTTPS 域名云开发项目如果env写的是测试环境真机预览也要确认环境权限。解决顺序是先看真机调试控制台的第一个报错再看网络面板里请求是否发出最后检查app.json页面路径和图片路径。域名问题去小程序后台配 request 和 download 合法域名云环境问题把wx.cloud.init的env换成当前环境 ID图片路径尽量用云存储cloud://或完整 HTTPS 地址不要用../../assets这种相对路径跨页面引用。5.2 现象支付回调重复入账现象是同一笔订单出现两条支付成功记录或者订单金额被加了两次。原因通常是支付平台重试回调而回调函数没有幂等键每次收到都执行一遍update。还有一种情况是前端支付成功回调里也改了订单状态和服务端回调同时写。解决是给支付流水号建唯一索引或先查pay_logs重复流水号直接返回成功订单状态更新带status: PENDING_PAY条件已支付订单不再重复处理。前端支付成功只做页面跳转和刷新不直接改订单状态订单状态以服务端回调为准。5.3 现象购物车数量对不上现象是用户加了两次“小份微辣”购物车显示 1 份或者改了辣度之后原来那行没消失多出一行。原因多半是合并键只用了dishId没有带skuId和加料 ID也可能加料数组顺序不同导致同一个组合被算成两个键。解决是用dishId skuId 排序后的 addonIds作为合并键每次读写购物车都重算键。修改规格时先删旧键再插新键不要只改显示名称。购物车缓存要做版本号升级后旧结构要么迁移要么清空重加。5.4 现象云函数超时或数据库权限报错现象是菜单页偶发加载失败云函数日志显示超时或者本地能查真机提示“permission denied”。原因常见于一次性get()全表、没有按shopId过滤、没有分页数据量一上来就慢数据库集合权限设成了“仅创建者可读写”但菜单是公共数据普通用户读不到。解决是给shopId、status、sort建组合查询条件列表分页每页 20 到 50 条公共菜单集合用“所有用户可读仅管理员可写”的权限模型写操作放云函数里做管理员校验。云函数里不要console.log整条订单对象容易把用户信息打进日志。5.5 现象订单金额和前端显示不一致现象是确认单显示 36 元提交后订单变成 35.99 或 36.01。原因是前端用浮点数算钱或者前端用了菜品列表价服务端用了 SKU 价两边取价口径不同。优惠券、打包费、配送费的计算顺序不一致也会造成差异。解决是所有金额用整数分前端展示时统一格式化提交订单只传dishId、skuId、addonIds、count不传单价和总价服务端复算后返回最终金额前端以服务端返回为准。差异超过 1 分就拦截下单提示刷新菜单不要静默按前端金额入库。6. 上线前压一遍用三类数据把点餐小程序验穿上线前我一般不只点一遍正常流程。正常流程只能证明页面能跳证明不了边界。更有效的做法是准备三类数据一类专门恶心菜单一类专门恶心订单状态一类专门恶心并发。压完这三类开箱包才算真正变成能交付的项目。6.1 边界菜单回归0 元、售罄、限购、多规格菜单侧至少准备这些用例0 元加料、售罄 SKU、限购 1 份的菜品、必选加料组、最多选 3 个的加料组、只有一种规格的菜品、图片加载失败的菜品。逐个加入购物车看合计、库存提示、提交拦截是否符合预期。尤其注意售罄菜品要在详情和提交两处都拦不能只靠前端灰按钮。6.2 订单状态回放别只点正常流程订单侧用脚本或手动回放这些路径待支付取消、支付后取消、支付回调重复、后台先接单再收到回调、制作中退款、已完成退款。每次改状态后查数据库确认status、paidAt、updatedAt都对。状态机白名单以外的跳转必须失败失败要给出可读提示。6.3 我每次上线前必做的一张检查表检查项通过标准真机请求域名菜单、下单、订单查询全部走 HTTPS 且无跨域报错云环境预览版和体验版指向同一环境测试数据已清理支付回调重复回调不重复入账金额不一致走异常单库存扣减并发下单不出现负库存失败订单回滚库存订单状态白名单外跳转全部失败终态不可再改日志不打印完整用户标识、地址和支付流水从那以后我每次交付点餐类小程序都会先跑一遍边界菜单和状态回放再让真实设备下一单自提和一单外卖。这个习惯比改十遍样式更能保住上线当天。希望帮到你。本文还有配套的精品资源点击获取