中小餐馆小程序点餐系统开发实战:从需求到上线全流程
如果你管过一家小餐馆的午市大概率见过这种场面服务员手里攥着三张手写单来回跑后厨窗口有人扯着嗓子喊“三号桌少辣、五号桌不要香菜”收银台那边还排着两个人等着结账。迦娃餐馆点餐系统这个小程序最初就是被这样的场景逼出来的。我常去的一家家常菜馆店主一直想上点餐系统可市面上的方案要么按年收费要么面板按钮多到服务员根本学不会。于是我从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, descstatus 控制上架/下架orders_id, tableNo, status, totalFee, items, remark, createTime, payTimeitems 直接存菜品快照数组tables_id, tableNo, qrSceneqrScene 用于扫码参数与桌台绑定订单里的菜品明细为什么没有单独建一张表因为订单是一次性单据不需要做关联查询统计明细直接用一个数组嵌套在订单文档里读一次订单就能拿到全部菜品逻辑更简单。这是文档数据库区别于关系型的核心差异习惯了 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 形如 tableNoT02 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(); } } }); });首屏方面菜单页第一版是从云函数拉数据白屏时间长达两秒。后来改成“先渲染本地缓存 后台刷新”并把图片换成压缩后的缩略图首屏能压到一秒以内。别小看这一秒食客在店里拿起手机的耐心通常只有两秒。缓存逻辑也要注意失效时间避免菜品下架后用户本地还存着旧数据我的做法是加一个版本号字段每次发布新菜单时递增客户端对比版本号决定是否丢弃缓存。做完这个项目再回头看技术上的难点其实都不是难点真正决定成败的反而是那些不起眼的细节桌号用短码而不是写死、金额用分、购物车不入库、出餐按钮要够大、备注字要够红。如果让我重新做一遍我会第一时间把商家工作台的体验放在和顾客端一样高的优先级并且从一开始就用数据库事务管理库存扣减而不是等到并发测试发现问题后再补。最后分享一个现在还保留的习惯每次改完代码都在真机上走一遍“扫码—点餐—支付—接单—出餐”的完整流程。小程序开发很容易陷入“开发工具上能跑就行”的错觉而真实餐馆里的网络、手机型号和使用场景远比模拟器复杂。