简介:这份微信小程序源码包面向餐饮商家、小程序初学者与需要完成课程设计或毕业设计的开发者,提供一套可直接部署改造的餐饮点餐外卖解决方案,涉及菜单浏览、在线下单、叫号排队、微信支付、配送状态跟踪、用户账号及订单管理等业务场景,帮助读者理解餐饮小程序从页面展示到业务逻辑的完整实现路径。压缩包共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、sort |
skus | 规格与库存 | skuId、dishId、name、price、stock |
addonGroups | 加料分组 | groupId、dishId、name、required、max、options |
combos | 套餐组合 | 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 且无跨域报错 |
| 云环境 | 预览版和体验版指向同一环境,测试数据已清理 |
| 支付回调 | 重复回调不重复入账,金额不一致走异常单 |
| 库存扣减 | 并发下单不出现负库存,失败订单回滚库存 |
| 订单状态 | 白名单外跳转全部失败,终态不可再改 |
| 日志 | 不打印完整用户标识、地址和支付流水 |
从那以后我每次交付点餐类小程序,都会先跑一遍边界菜单和状态回放,再让真实设备下一单自提和一单外卖。这个习惯比改十遍样式更能保住上线当天。希望帮到你。
本文还有配套的精品资源,点击获取