在微信小程序开发生态里,“扫码点菜”是出场率最高的入门 Demo。

网上的视频教程通常只要半小时:新建一个 CloudBase 云开发模板,建一个 dishes 集合、一个 orders 集合,前端写个 wx.requestPayment 或直接把总价写入订单表,配上几个 wx.navigateTo,一个“麻雀虽小”的点菜系统就跑起来了。很多人甚至觉得,这种项目简单到不配被称为“工程”。

但在真实的餐饮堂食场景中,这种教程级 Demo 只要一进真实环境就会变成筛子:

  1. 桌号篡改:顾客改一下前端参数或扫描桌角破损的二维码,就把菜点到了隔壁桌上;
  2. 金额与商品篡改:顾客在前端本地存储改一个价格,或者直接调云函数传个 0.01 元,后台直接照单全收;
  3. 并发与网络抖动:地下商场网络信号差,顾客连点两次提交,同一个订单在数据库里落了两份;
  4. 状态撕裂:后厨刚刚点击“制作中”,前台顾客还在手机上点击了“取消订单”,最终导致做好的菜无人认领。

我最近为一个单门店堂食场景重构并推进了点菜小程序的 v1.2 MVP。没有会员营销,没有外卖配送,没有裂变优惠券,仅仅是为了把**“扫码入桌 → 选菜下单 → 后厨接单制作 → 上菜完成”**这条最基础的闭环做扎实,整个项目沉淀出了 9 个核心业务云函数、前端 7 个核心页面,以及本地 320 个自动化断言。

更关键的是:为了保障数据库与运维生命周期的绝对安全,我们专门设计了 4 个特权云函数,但它们的存活规则是——用完立即从云端物理删除。

这篇复盘讲讲这个点菜小程序背后的工程防御模型,以及为什么在全部测试亮绿灯、体验版真机跑通之后,我们依然坚持停在体验轨,克制住没有点击“提交审核”。


一、客户端零信任:整数分算价、32 位桌码与幂等键

在 Serverless / 云开发模式下,最危险的直觉是“把云函数当成无所不能的客户端 RPC,甚至把数据库直接开放给前端读写”。在 wx-menu 中,所有集合对客户端的权限一律设为:“禁止直接写,仅本人或全员只读”

一切数据变更,必须通过云函数,而在云函数入口处,奉行绝对的客户端零信任

1. 绝不信任前端传入的金额与菜名

顾客提交订单时,前端购物车页面唯一能传给服务端的只有三个字段:菜品 id、规格 specId 和购买数量 qty

// cloudfunctions/createOrder/index.js
// 1) 校验数量:必须为 1..MAX_QTY 的整数
for (const i of items) {
  const qty = Number(i && i.qty);
  if (!Number.isInteger(qty) || qty < 1 || qty > MAX_QTY) {
    return { success: false, message: `菜品数量不合法(需为 1-${MAX_QTY} 的整数)` };
  }
}

// 2) 以服务端 dishes 为唯一可信来源,回查真实价格/名称/图片,拒绝客户端篡改
const ids = items.map(i => i.id);
const dishRes = await db.collection("dishes").where({ _id: _.in(ids) }).get();
const dishMap = {};
dishRes.data.forEach(d => { dishMap[d._id] = d; });

服务端拿到这些 ID 后,会在受控的云数据库重新读取最新的菜品记录:

  • 菜品是否已经下架(saleStatus === "off_sale")?
  • 菜品是否刚刚被抢空售罄(saleStatus === "sold_out")?
  • 传过来的 specId 是否属于该菜品的启用规格?
  • 金额全部采用安全整数分(priceCentssubtotalCentstotalCents)在服务端重新计算

JS 的浮点数计算(例如 0.1 + 0.2 = 0.30000000000000004)在涉及账目核算时是灾难。整套系统在底层全面废弃了浮点“元”,全部改用非负整数“分”存储与校验。客户端传过来的任何价格、总计,云函数直接丢弃不看。

2. 桌台身份的物理隔离

传统小程序喜欢把桌号直接拼在 query 里:pages/menu/menu?table=8。稍微懂点技术的顾客改一下参数,就能替其他桌点单甚至恶作剧。

wx-menu 中,顾客不能手填桌号,也无法从 URL 猜测桌台:

  • 每一个桌台在启用时,服务端会生成一个 32 位的随机十六进制 Token(qrToken);
  • 调用微信 OpenAPI wxacode.getUnlimited 时,将该 Token 编码进小程序码;
  • 顾客扫码打开小程序,前端仅能拿到这个 opaque 的 32 位 Token,随后调用 resolveTable 云函数;
  • 服务端核对该 Token 确实对应处于 enabled === true 状态的桌台,才在会话中绑定桌名;
  • 在整个过程中,响应数据与日志中绝不暴露原始 Token,防止重放或遍历。

3. 幂等防护与两级防重

弱网环境下,顾客点了“提交订单”,前端菊花转了 3 秒,顾客急躁地多按了几次;或者手机息屏后网络超时,页面重载重新请求——如何保证不生成两张相同的订单?

很多人的做法是前端按钮加一个 submitting: true 的防抖标志。但这只能防“君子”和“好网络”,一旦页面刷新或进程重启,内存标志荡然无存。

wx-menu 采用了端云结合的双层幂等机制:

// 前端:本地 Storage 持久化待确认下单尝试
const PENDING_ORDER_STORAGE_KEY = "pendingOrderAttemptV1"; // gitleaks:allow -- local storage key, not a credential

// pages/cart/cart.js
// 待确认下单会把幂等键和规范请求签名写入本地 Storage;
// 网络结果未知或页面重建后,相同请求复用原键,内容变化则生成新键。

当请求到达服务端 createOrder 时:

  1. 服务端将调用者的微信 OPENID 与客户端传来的 idempotencyKey 拼接,计算 SHA-256 哈希作为 idempotencyScopeKey
    idempotencyScopeKey = sha256(OPENID + ":" + idempotencyKey)
  2. 数据库集合 orders 上建立了唯一索引 uniq_idempotencyScopeKey
  3. 即使两个并发请求以毫秒级的间隔同时穿透到数据库,底层数据库的唯一索引会直接抛出约束异常,保证只落一单。
  4. 如果是同一订单因网络未知而重试,服务端识别到相同的请求哈希(requestHash),直接返回先前已创建成功的订单快照;若是不同请求恶意复用相同的 Key,则明确返回 IDEMPOTENCY_CONFLICT 错误。

二、四个用完即删的特权云函数

在传统的独立服务器或容器架构中,运维人员可以通过 SSH、跳板机或受限的内网 API 执行数据初始化、备份和数据修正。

但在纯 Serverless(微信小程序云开发)环境下,你没有宿主机的控制台。为了完成“初始化开发环境”、“备份五大核心集合”、“执行版本数据迁移”以及“清理自动化测试产生的订单”,很多人图省事,会在云函数里写一个 admin-tools,留一个 if (isAdmin) 校验,然后就永远挂在线上。

这是一个极其危险的坏习惯。 任何拥有写库权限的特权函数,长驻在生产环境中就是永恒的攻击面——哪怕有白名单保护,也可能面临权限越界、参数拼接注入或内部配置漂移的风险。

wx-menu 中,我们制定了一条严格的原则:特权运维接口必须被封装为独立的临时云函数,并受到严格的物理生命周期管理。用时部署,用完立刻删除。

在整个体系中,共有 4 个这样的“短命特权函数”:

cloudfunctions/
 ├── v12ExperienceAdmin/  临时体验环境清空和固定种子初始化接口;完成后必须删除
 ├── v12BackupAdmin/     临时只读五集合备份接口;导出后必须从线上删除
 ├── v12MigrationAdmin/  临时迁移接口;验证完成后必须从线上删除
 └── v12TestDataAdmin/   临时验收清理接口;测试完成后必须从线上删除

这 4 个函数每个都带有严苛的自我销毁契约:

1. v12ExperienceAdmin:体验环境重置器

  • 作用:为测试和体验成员准备一个干净的环境,清空 ordersusersdishestables 四个业务集合,但绝对不动 admins 白名单,随后写入固定的 4 菜 2 桌体验种子。
  • 门禁:仅限独立测试环境;函数运行时强制校验环境变量中的随机操作标识(operationId)与调用者传参一致;写入后即刻计算四集合的 SHA-256 数据摘要,必须与仓库硬编码的固定摘要 09c081926dcddeb110f7854d9ab87197f8b41fc930b2cae4bbe65c36563fa0f4 完全吻合。
  • 生命周期:重置完成后,本地脚本断言成功,运维人员立即在云端关闭环境变量并直接删除该函数

2. v12BackupAdmin:五集合双遍原子导出器

  • 作用:在生产迁移前,以只读方式导出 dishesordersadminsuserstables 五大核心集合。
  • 门禁:必须在环境配置 WX_V12_BACKUP_ENABLED=EXPORT_V1_2,且附带未来不超过 15 分钟的过期时间戳 WX_V12_BACKUP_EXPIRES_AT;本地导出脚本采用游标严格分页,并执行两遍完整抓取与 SHA-256 双向比对;导出的文件在本地直接锁死为 0700/0600 目录与文件权限。
  • 生命周期:备份落盘并完成摘要核验后,立即从远端删除。

3. v12MigrationAdmin:逐文档哈希迁移与逆序补偿

  • 作用:执行数据模型从 v1.1 到 v1.2 的结构平滑迁移(如历史订单补充 idempotencyScopeKey、金额字段升级等)。
  • 门禁:不接受模糊的批量 SQL 式更新。输入是由离线规划器生成的确定性执行计划,包含每个受影响文档的 beforeHashafterHash。执行前在线预检所有文档当前哈希,只要有 1 个文档与计划不一致,立刻全量放弃(Zero Write)。一旦中途异常,通过逆序补偿恢复。
  • 生命周期:迁移与验收结束后,立即从远端删除。

4. v12TestDataAdmin:测试数据精准擦除

  • 作用:配合集成测试和 E2E 自动化测试,清理测试过程中写入的订单。
  • 门禁:客户端绝不拥有 .remove() 权限。该函数只接受当前调用者 _openid 和具备唯一随机标记(如 INT_V12_CUSTOMER_...)的订单删除请求。
  • 生命周期:每次跑完测试,断言 remaining === 0,随后清理下线。

特权能力是代码,但不是长期服务。它们以代码形式受 Git 版本控制,但在线上只拥有几分钟的短暂生命。


三、两条互不相通的部署轨道

在工程发布管理上,很多团队最容易踩的坑是:把“测试通过”与“可以发布到生产”混为一谈。

wx-menu 的规划手册中,明确定义了两条完全绝缘的轨道:

维度 开发/体验轨(E 轨,当前执行) 未来生产迁移轨(R 轨,保留但未放行)
目标环境 独立体验环境 wx-menu-d1gl... 真实生产环境 cloud1-d2g2...
数据策略 允许清空非生产数据,写入固定种子 必须保留并备份真实历史数据,逐文档验证
临时工具 v12ExperienceAdmin v12BackupAdminv12MigrationAdmin
回滚承诺 不做数据恢复,异常时停止并核对 必须在隔离克隆库完成全量 rollback 演练
终态 上传开发版,微信公众平台设为体验版 审核通过,正式全量发布

两条轨道之间设立了极度严密的逻辑防火墙:

  1. 代码层面锁死环境 IDapp.js 与全部自动化测试脚本强制比对当前指向的环境 ID,只要检测到环境不匹配,进程直接退出;
  2. 体验轨的通过绝不等于生产就绪:手册中明确规定,体验轨中的所有 N/A(如跳过历史数据备份与 rollback 演练),在评估正式发布时一律不得折算为 PASS

正是这种轨道隔离,让开发团队在体验版调试、真机扫码、频繁重置测试数据时,不会对生产数据库产生哪怕一丝一毫的误伤可能。


四、双账号真云 E2E 与 remaining === 0 的执念

单元测试通过(Mock 各种接口)往往给人一种虚假的安全感。特别是微信小程序这类强依赖微信客户端容器、OpenAPI、微信登录态和底层数据库索引的场景,不跑在真实云环境里的测试,基本只能验证语法错误。

wx-menu 搭建了一套基于 miniprogram-automator 的自动化真机与真云测试体系。

1. 严格区分管理员 A 与顾客 C

很多小程序的权限测试,只是在代码里把 isAdmin 改成 false 然后跑一跑。但在真机环境里,权限是绑定在微信底层的 _openid 上的。

wx-menu 的集成测试必须由两个真实的物理账号配合执行:

  • 管理员 A:在云端 admins 集合中拥有唯一的 enabled: true 记录;
  • 普通顾客 C:普通微信号,admins 集合中查无此人。

测试覆盖了非常苛刻的跨账号边界:

  1. 顾客越权 100% 拒绝:顾客 C 调用 manageDishesmanageOrdersmanageTables,服务端无一例外拦截并返回失败;
  2. 状态机单向流转
    • 顾客只能执行 pending → canceled(仅限待接单状态取消自己桌台的订单);
    • 管理员才能执行 pending → preparing → ready → done
    • 任何试图将 done 逆转为 canceled,或者跨状态越级推进的操作,在原子更新条件 where({ _id: orderId, status: order.status }) 层面直接被拒;
  3. 前台即时提醒:管理员订单中心在前台保持打开时,底层通过稳定游标 (createdAt DESC, _id DESC) 每 5 秒轮询,一旦有顾客 C 下单,必须在 10 秒内听到提示音并触发震动。

2. 自动化清理与 remaining === 0

写过自动化测试的人都知道,在真实数据库跑集成测试最讨厌的就是“留下一堆垃圾数据”。

wx-menu 的所有 E2E 和集成测试,在 finally 阶段都内嵌了不可绕过的清理契约:

// tests/integration/customer-permissions.int.js
async function cleanupOwnOrder(mini, marker) {
  const result = await callFn(mini, "v12TestDataAdmin", {
    action: "cleanupOwnOrders",
    marker
  });
  assert.equal(result.success, true, result.message || "顾客集成测试清理失败");
  assert.equal(result.remaining, 0, "顾客集成测试订单仍有残留");
}

每个测试用例启动时,都会生成毫秒级且包含随机字节的唯一业务标记(marker),例如 INT_V12_CUSTOMER_...,写入订单的 remark

测试执行完毕,进入 finally 块,自动化脚本调用 v12TestDataAdmin,只按当前调用者的 _openid 与这个精确的 marker 执行删除。

最狠的是那行断言:assert.equal(result.remaining, 0)。 如果数据库里还留有一条残留数据,哪怕测试前面的所有业务流程都完美通过,整个测试套件依然直接报红判挂


五、为什么我们停在体验版,不点击“正式发布”

在很多人的认知里,工程项目的节奏是:写完代码 → 本地测试跑通 → 上传代码 → 点击提交审核

但在 wx-menu v1.2 的版本里程碑里,文档最后定格的状态是:

“当前工作目标仅为完成开发版/体验版部署与双账号验收,不包含正式发布;不要把‘代码存在’‘本地测试通过’或‘体验版可用’解释为生产版本已经迁移或上线。”

为什么?

在准备生产迁移的 dry-run(演练)过程中,我们拿着当时的严格规划器跑了一次历史五集合的快照数据,规划器在解析某一条历史异常订单时,因为捕获到了一个不符合新规格的字段而抛出了 INVALID_ORDER_ITEM_QTY,直接退出了进程。

这说明:在线上现存的历史数据中,存在早于规范确立前写入的脏数据。

如果这是一个赶进度的商业项目,很多人可能会在代码里加一个 if (!qty) qty = 1; 的兼容容错,或者强行把字段抹平,直接把新版推上线。

但在 wx-menu 的设计体系中,这种行为被严格叫停:

  • 脏数据必须生成带数字签名的单条异常隔离授权seal:quarantine:v1.2);
  • 迁移回滚方案必须在克隆库完成真实验证;
  • 在生产轨门禁没有逐项打勾前,当前产物只能被称为“体验版”

在软件工程中,最容易让人膨胀的是“代码全在我掌控之中”的幻觉。但面对真实世界里的弱网、并发、历史包袱与用户输入,知道自己还没有准备好什么,比盲目宣称“已经全部搞定”重要得多。

一个不到千行核心代码的单门店点菜系统,配得上这样严肃的工程防御吗?

我们的答案是:如果你不想在周末餐厅满座、顾客手机点不进菜单、老板在收银台焦头烂额时接到紧急求救电话,那么每一行防线、每一个 15 分钟过期的临时函数、每一次断言为零的清理,就都是完全值得的。