如果你管过一家小餐馆的午市,大概率见过这种场面:服务员手里攥着三张手写单来回跑,后厨窗口有人扯着嗓子喊“三号桌少辣、五号桌不要香菜”,收银台那边还排着两个人等着结账。迦娃餐馆点餐系统这个小程序,最初就是被这样的场景逼出来的。我常去的一家家常菜馆,店主一直想上点餐系统,可市面上的方案要么按年收费,要么面板按钮多到服务员根本学不会。于是我从0开始设计和实现了一个专门适配这种中小餐馆的小程序点餐系统,覆盖从顾客扫码进店、浏览菜单、加入购物车、在线支付,到后厨接单、出餐状态更新的完整闭环。它不是一个堆功能的技术Demo,而是一套真的能跑起来的商业逻辑。这篇文章把从需求分析、架构选型、数据建模,到核心功能、商家工作台、上线排坑的整个过程完整记录下来,如果你打算自己做一套类似的系统,或者只是想把小程序开发的全链路走通一遍,可以参考这条路径。
1. 需求从哪来:迦娃餐馆的痛点清单与功能边界
1.1 纸质菜单与口头下单的混乱现场
在动手写代码之前,我花了整整一个周末下午泡在那家店里。店不算大,大厅大概18张桌子,中午高峰基本满座。顾客要加菜,得把服务员叫过来,服务员再去后厨口头交代;结账时服务员要先回吧台核对手写单,再把金额报给顾客。那天正好赶上周末,人一多,一桌的酸菜鱼被记成了水煮鱼,退菜、重做折腾了快二十分钟。店主跟我说:不是没想过用点餐系统,而是他见过同行买的那套,机器加软件花了不少钱,培训两天服务员还是搞不明白。
我把这些痛点整理成了五条,每条背后都对应一个需要系统去解决的问题:
| 门店痛点 | 实际影响 | 对应系统功能 |
|---|---|---|
| 手写单字迹潦草 | 菜品做错、客诉增加 | 线上菜单,标准化菜品选择 |
| 加菜、退菜靠口头传话 | 漏单、后厨顺序混乱 | 订单状态机,全程可追溯 |
| 招牌菜售罄不能及时同步 | 顾客点完才发现没货,体验差 | 菜品库存与售罄标记 |
| 结账需要来回核对 | 高峰翻台慢 | 在线支付 + 订单明细自动结算 |
| 不知道哪些菜卖得好 | 备货靠拍脑袋 | 订单数据沉淀,后续可出统计 |
做第一版最重要的不是功能多,而是把“点餐—支付—后厨”这条主链路跑通。会员、积分、优惠券这类东西,等系统有真实用户后再做也不迟。
1.2 功能边界:MVP 清单与主动砍掉的需求
很多第一次做小程序的人,容易把功能表画得很大。我第一版需求文档里也有“桌台互点”“预点餐”“菜品推荐”“会员等级”“消费积分”这些模块。后来冷静分析,迦娃餐馆排队最多五六桌,等位的人手机下单意义不大;社区店大部分是回头客,预点餐反而容易造成备餐节奏混乱。真正必须保留的只有四件事:桌台识别、菜单浏览、下单支付、商家订单管理。
砍需求不是妥协,而是对运营节奏的理解。哪怕到现在我依然认为,对一个社区家常菜馆来说,顾客打开小程序能在30秒内把菜点完,比任何花哨功能都有价值。我在需求文档里给每个功能标注了“必须做”“二期做”“不做”,最终MVP清单里只剩下18个页面和4个核心云函数,整个开发周期压缩到一个月左右,店主培训只需要十分钟。
2. 技术选型与总体架构:为什么选云开发,而不是自建服务器
2.1 三种后端方案的对比
确定功能清单后,第一个要回答的问题是:后端怎么办。我当时认真比较过三条路:租一台云服务器自己写接口、用低代码平台快速搭一个、用小程序自带的云开发能力。
| 方案 | 月成本 | 运维负担 | 开发周期 | 适合场景 |
|---|---|---|---|---|
| 自建服务器 + 后端接口 | 较高,还要考虑带宽 | 高,需要处理环境、部署、备份 | 约2到3个月 | 已有运维团队或复杂业务 |
| 低代码平台 | 中 | 中 | 约2周 | 业务简单且不需要深度定制 |
| 小程序云开发 | 低,按量计费 | 低,免运维 | 约1个月 | 中小规模点餐系统 |
最终选型的关键是规模。迦娃餐馆单店一天最多三四百单,峰值并发集中在午餐时段,这种流量对云函数来说压力不大,云开发的按量计费模式前期几乎没有成本。另外,不需要处理域名备案和 HTTPS 证书,云函数直接部署在平台侧,数据库、文件存储、云函数三者天然打通,适合快速迭代。
2.2 目录结构与模块划分
项目整体采用“单小程序 + 云函数”的瘦后端架构。小程序端只负责页面交互和本地缓存,涉及金额、库存、订单状态的操作全部收敛到云函数。这个分割意味着:即使客户端被绕过,也无法篡改核心数据。
miniprogram/ pages/ index/ // 扫码进入的落地页,负责解析桌台参数 menu/ // 菜品分类与列表页 cart/ // 购物车确认页 order/ // 订单详情与状态展示页 admin/ // 商家工作台页面 utils/ format.js // 金额、时间格式化 cart.js // 购物车本地缓存操作 cloudfunctions/ login/ // 用户登录并返回 openid getMenu/ // 获取菜单分类与菜品 createOrder/ // 校验库存、创建订单、发起支付 updateOrder/ // 商家更新订单状态 getOrders/ // 商家查询订单列表服务端“瘦”体现在并没有拆微服务,五个云函数互相独立、职责单一。对一个点餐系统来说,这种简单反而更稳定。云函数之间不直接调用,所有数据交互都通过数据库完成,排错时只要顺着订单状态流转一条线看下来就行。
2.3 环境准备与初始化步骤
如果你打算照着复现,环境准备上要留意这几步:注册小程序开发者账号,在后台开通云开发并创建环境,记下环境ID;然后用微信开发者工具导入项目根目录,在project.config.js里修改appid;最后需要在app.js中初始化云环境。
// app.js App({ onLaunch() { if (!wx.cloud) { console.error('当前基础库版本过低,请使用 2.2.3 以上版本'); return; } wx.cloud.init({ env: 'cawa-dining-xxxxx', // 云开发环境ID traceUser: true }); } });我一开始没有把环境ID放在配置文件里,直接在代码里写死,结果换环境测试时到处找字符串。后来统一改成在config.js中维护,不同环境只需改一个文件。这一步看起来不起眼,但后续联调、上线、切换正式环境时能省下大量时间。
3. 数据模型设计:四张表、一个状态机、购物车为什么不入库
3.1 核心数据表:四大集合与字段设计
云开发是文档型数据库,没法像关系型数据库那样先画 ER 图再建表。但表结构设计依然要先想清楚,第一版我建了四个集合:categories(菜品分类)、dishes(菜品)、orders(订单)、tables(桌台)。
| 集合名 | 关键字段 | 说明 |
|---|---|---|
| categories | _id, name, sort | 分类排序,sort 越小越靠前 |
| dishes | _id, name, categoryId, price, image, stock, status, desc | status 控制上架/下架 |
| orders | _id, tableNo, status, totalFee, items, remark, createTime, payTime | items 直接存菜品快照数组 |
| tables | _id, tableNo, qrScene | qrScene 用于扫码参数与桌台绑定 |
订单里的菜品明细为什么没有单独建一张表?因为订单是一次性单据,不需要做关联查询统计明细,直接用一个数组嵌套在订单文档里,读一次订单就能拿到全部菜品,逻辑更简单。这是文档数据库区别于关系型的核心差异,习惯了 MySQL 建模的人需要在这里切换一下思路。
3.2 订单状态机:五个状态与一条铁律
状态机是这套系统的骨架。我定义了五个状态:pending(待支付)、paid(已支付待接单)、preparing(备餐中)、served(已出餐)、completed(已完成),另外有一个 canceled(已取消)。流转方向是 pending → paid → preparing → served → completed,任何一步都可以走 canceled,但 canceled 只能从 pending 或 paid 转。
特别想强调“待支付”和“已支付”一定要分开。第一版我曾想偷懒,把订单在支付成功前就写成 paid,想着反正顾客付完款就结束了。结果有人下单后不支付,菜品却被后厨看到并备了出来,白备了好几份菜。后来改成:创建订单时先在数据库写 pending,等待用户支付成功后通过支付回调更新为 paid,再进入后厨队列。这条铁律保证后厨看到的订单一定是真金白银付过款的。
3.3 购物车为什么不入库:本地缓存的设计理由
购物车是用户高频操作的对象,加菜、减菜、改数量,每秒都可能发生。如果每次都写数据库,数据库读写次数会指数上涨,而购物车本质上只是用户在当前会话中的“草稿”。所以购物车数据放本地缓存,用 storage 存一个数组,每次操作后序列化写入。
// utils/cart.js const KEY = 'CART'; function getCart() { return wx.getStorageSync(KEY) || []; } function addItem(dish) { const cart = getCart(); const found = cart.find(item => item.dishId === dish._id); if (found) { found.count += 1; } else { cart.push({ dishId: dish._id, name: dish.name, price: dish.price, count: 1, image: dish.image }); } wx.setStorageSync(KEY, cart); } module.exports = { getCart, addItem };本地购物车唯一的问题是:用户不提交就退出,数据会丢。对堂食点餐来说这是可接受的,因为点餐流程通常几分钟之内完成。如果以后要做“先逛后买”的预点餐,可以把购物车平移到云端,但那是另一个复杂度的故事。
4. 核心链路实现:从扫码进店到支付完成的四个关键节点
4.1 扫码进店与桌台绑定
堂食点餐最难处理的不是菜单,而是“顾客到底坐在哪一桌”。我的做法是给每张桌子生成一张带参数的小程序码,把桌台短码塞进 scene 字段。比如 T02、T08。顾客扫码后进入落地页,页面在 onLoad 里取出 scene,再调用一次云函数确认桌台存在,并记录当前订单关联的桌号。
Page({ onLoad(options) { if (options.scene) { const scene = decodeURIComponent(options.scene); // scene 形如 tableNo=T02 const tableNo = scene.split('=')[1]; this.setData({ tableNo }); wx.setStorageSync('TABLE_NO', tableNo); } } });这里有个细节:桌台短码不要直接用数字 2、8,因为未来桌号可能重排。用 T02 这种短码,可以在数据库里随时改映射关系,不需要重新打印二维码。另外,小程序码的 scene 参数有长度限制,塞一个完整桌号没问题,但塞 JSON 就很容易超限,这也是用短码映射的原因。
4.2 菜单加载:缓存优先、远程兜底
菜单数据不是每次进页面都从云端拉。getMenu 云函数返回分类和菜品后,小程序端会写一份本地缓存,设置10分钟有效期。用户再次进入菜单页,先渲染缓存数据,让首屏秒开,同时后台静默刷新。
菜品图是体验的关键。云存储里的原图可能两三MB,直接在小程序里渲染会拖慢加载。我的做法是上传菜品图之前先压缩到宽度750px左右,体积控制在200KB以内。听起来是小事,但整页菜单在弱网环境下的加载速度完全不一样。第一次上线时我没做压缩,某用户用4G打开菜单,图片转圈转了快十秒,直接退出小程序,这个教训很直接。
4.3 加购与前端校验:快照数据必须进云函数再确认
购物车加减的逻辑很简单,但有一个容易被忽略的问题:前端显示的价格和库存都是快照,不是实时值。加购时只做基础校验,比如数量大于0、菜品状态为上架中。真正的库存和金额校验,必须在下单云函数里做,否则用户打开页面放十分钟,菜品早卖光了,前端还不知道。
购物车显示金额的时候统一用“分”为单位,到显示层才转换为元。这个习惯从购物车链路开始就要统一,不然到支付环节再处理浮点误差会很痛苦。前端代码里所有 price 字段都是整数分,比如一份回锅肉在数据库里存的是 2800,而不是 28.00。
4.4 下单云函数:一次完成的四步操作
用户点结算后,小程序调用 createOrder 云函数。这个云函数内部按顺序做了四件事:读取购物车数据、逐项校验菜品是否存在且上架、用原子操作扣减库存、生成订单并返回支付参数。
// 云函数 createOrder 核心片段 const cloud = require('wx-server-sdk'); cloud.init(); const db = cloud.database(); const _ = db.command; exports.main = async (event) => { const { OPENID } = cloud.getWXContext(); const { cart, tableNo, remark } = event; // 1. 重新计算金额,不信任前端 let totalFee = 0; const items = []; for (const item of cart) { const dishRes = await db.collection('dishes').doc(item.dishId).get(); const dish = dishRes.data; if (!dish || dish.status !== 'on') { throw new Error('菜品已下架:' + item.name); } totalFee += dish.price * item.count; items.push({ dishId: dish._id, name: dish.name, price: dish.price, count: item.count, image: dish.image }); } // 2. 原子扣库存 for (const item of items) { const res = await db.collection('dishes') .where({ _id: item.dishId, stock: _.gte(item.count) }) .update({ data: { stock: _.inc(-item.count) } }); if (res.stats.updated === 0) { throw new Error('库存不足:' + item.name); } } // 3. 创建订单 const orderRes = await db.collection('orders').add({ data: { openid: OPENID, tableNo, status: 'pending', totalFee, items, remark: remark || '', createTime: db.serverDate(), payTime: null } }); // 4. 返回订单号,继续拉起支付 return { orderId: orderRes._id, totalFee }; };注意,这里金额是云函数按数据库里的菜品价格重新算的,前端传的 total 直接被忽略。这个设计是为了防止用户改页面数据。菜品的价格、库存永远以数据库为准,这是一条铁律。
5. 商家工作台:一个菜单之外的核心模块
5.1 商家入口与页面结构
点餐系统很容易做成“顾客爽、老板累”。如果只做点餐不做出餐,后厨反而更乱。我做的商家工作台也是小程序页面,通过 openid 白名单判断身份,进入后默认展示“待接单”列表。
工作台页面的核心是一个订单卡片流。每张卡片显示桌号、菜品数量、备注、下单时间。顶部有 tab:待接单、备餐中、已出餐、全部。商家看到新订单,点“接单”,订单状态从 paid 变成 preparing;菜品做好后点“出餐”,状态改成 served;服务员上菜确认后,订单完成。按钮在卡片底部排成一排,字号刻意放大,方便后厨师傅带着油手也能点中。
5.2 新订单提醒:轮询与订阅消息的取舍
顾客下完单,商家怎么第一时间知道?我第一反应是做 WebSocket 实时推送。但在云开发环境下,自建 WebSocket 意味着要自己维护长连接服务器,成本和不稳定性都上来了。更合适的做法是“15秒轮询 + 微信订阅消息”的组合方案。
轮询的本质是定时器:商家工作台页面每隔15秒调用一次 getOrders 云函数,拉取状态为 paid 的订单,有新订单就响铃并把列表置顶。这个频率对一家18桌的餐馆来说足够,云函数按量计费也几乎可以忽略。如果想更省,可以把轮询频率改成动态的:非高峰时段30秒一次,高峰时段10秒一次。
订阅消息则负责触达顾客。顾客提交订单时,页面会弹出一个“允许接收上菜通知”的授权。商家点击“出餐”后,云函数通过订阅消息给顾客发一条通知。注意小程序订阅消息是“一次授权一次使用”的,所以每次下单都要重新唤起授权,不能像公众号模板消息那样长期有效。
5.3 订单日志与备注处理:容易被忽略的高频场景
备注字段是餐饮点餐里最容易忽略又最影响体验的坑。顾客写“不要香菜”,后厨真的会因为没看到而重做。我的做法是:商家工作台的订单卡片里把备注区单独放大,红色字体渲染;如果顾客下的订单里包含某些特殊做法,系统会在备餐阶段强制弹窗确认。
另外,我在订单文档里预留了 timeline 数组,每一次状态变更都追加一条记录,包含操作人、操作时间、变更前后状态。这样一旦出现纠纷,可以完整回溯是谁、在什么时候、把订单改成了什么状态。第一版我没有做乐观锁,只在更新订单时带上原状态条件:
const res = await db.collection('orders') .where({ _id: orderId, status: 'paid' }) .update({ data: { status: 'preparing' } }); if (res.stats.updated === 0) { throw new Error('订单状态已变化,请刷新后重试'); }这个写法能挡住大部分重复操作,比如两个服务员同时点了接单,只有一个人会成功。
6. 上线前躲过的坑:并发扣库存、金额精度与小程序更新机制
6.1 并发扣库存:从先查后改到原子操作
第一版扣库存我写的是:先查出来剩余库存,判断是否大于本次购买数量,再更新库存。单机测试一切正常,直到我做了个简单的并发模拟——用脚本同时发10个订单,每单都要2份某招牌菜,库存只有5份。结果8单都成功扣成负数。原因很简单:两个请求同时读到库存为4,都认为可以扣2,然后都执行了更新,库存直接变成负数。
正确做法是用数据库原子操作。上面 createOrder 里用的就是.where({ stock: _.gte(item.count) }).update({ stock: _.inc(-item.count) }),让数据库保证扣减只在库存足够时成功。如果多个菜品都涉及库存,还要注意补偿:第二个菜品扣失败时,第一个已经被扣掉了,所以上线前最好用数据库事务包住整段扣减逻辑,或者准备一个反向补偿云函数,在捕获异常时把已扣的库存加回去。我在模拟环境里验证过事务方案,稳定性明显更好。
6.2 金额精度:为什么所有价格都用分
浮点数误差是大家中学就学过、但写代码时总忘的问题。前端把价格按元计算,购物车3个菜品价格分别是 0.1、0.2、0.3,合计经常显示成 0.6000000000000001。微信支付下单接口要求的金额单位是“分”,如果前端直接把这种浮点数传上去,支付金额可能差一分钱,订单就对不上账。
所以迦娃餐馆点餐系统里,所有价格字段在数据库里就是整数分,菜品定价、购物车合计、订单金额、支付金额全部用分计算,只在展示时除以100并保留两位小数。这样绕开了浮点误差,而且和支付接口的单位保持一致。如果你要加满减或者优惠券,这个习惯能帮你少踩很多坑。
6.3 小程序更新机制与首屏优化
小程序有个特性:用户打开的是上次下载的版本,新版本不会自动立即生效。上线后我遇到过最尴尬的场景:开发机上功能都正常,用户手机上却还是老版本,商家端没有“出餐”按钮。解决办法是用wx.getUpdateManager监听更新,下载完成后提示用户重启小程序。
const updateManager = wx.getUpdateManager(); updateManager.onUpdateReady(function () { wx.showModal({ title: '更新提示', content: '新版本已准备好,是否重启小程序?', success(res) { if (res.confirm) { updateManager.applyUpdate(); } } }); });首屏方面,菜单页第一版是从云函数拉数据,白屏时间长达两秒。后来改成“先渲染本地缓存 + 后台刷新”,并把图片换成压缩后的缩略图,首屏能压到一秒以内。别小看这一秒,食客在店里拿起手机的耐心通常只有两秒。缓存逻辑也要注意失效时间,避免菜品下架后用户本地还存着旧数据,我的做法是加一个版本号字段,每次发布新菜单时递增,客户端对比版本号决定是否丢弃缓存。
做完这个项目再回头看,技术上的难点其实都不是难点,真正决定成败的反而是那些不起眼的细节:桌号用短码而不是写死、金额用分、购物车不入库、出餐按钮要够大、备注字要够红。如果让我重新做一遍,我会第一时间把商家工作台的体验放在和顾客端一样高的优先级,并且从一开始就用数据库事务管理库存扣减,而不是等到并发测试发现问题后再补。最后分享一个现在还保留的习惯:每次改完代码,都在真机上走一遍“扫码—点餐—支付—接单—出餐”的完整流程。小程序开发很容易陷入“开发工具上能跑就行”的错觉,而真实餐馆里的网络、手机型号和使用场景,远比模拟器复杂。