---
title: "微信扫码点菜小程序开发"
description: "单门店堂食点菜小程序的 v1.2 重构：服务端零信任算价、32 位桌码、幂等下单，以及为了在别人的生产数据上安全操作而写的四个「用完即删」临时云函数。本地 320 项测试全部通过，十七道发布门禁一道都没填上结果——这篇讲中间的六个卡点，和它最后停在哪里。"
pubDate: 2026-07-13
tags: ["工程复盘", "微信小程序", "CloudBase", "Serverless", "架构"]
---

wx-menu 是一个单门店堂食点菜小程序，微信小程序加 CloudBase 云开发。v1.2 要做的事是把「扫码入桌 → 选菜下单 → 管理员接单制作 → 上菜完成」这条链路做成真能跑的。

最后的状态是：本地 320 项测试全部通过，9 个业务云函数部署到位，发布门禁列了十七道，仓库里没有一道填上结果。

这篇讲中间发生了什么，以及为什么卡住的地方几乎都不在业务逻辑上。

## 一、v1.1 撑不住的地方

上一个版本不是裸奔的。服务端已经按 `_id` 回查菜品算价、校验数量、拒绝未知菜品，管理员白名单在云函数里校验，用户资料按 `_openid` upsert，订单状态更新用的是原子条件更新。这些在 v1.1 基线上就有，73 项本地测试守着。

它缺的是履约本身。需求文档把问题写成三句：管理员没有真正的订单处理入口；所有用户都能看到管理入口；桌号、订单状态、金额和重复提交不足以支撑真实堂食。

拆开是四件具体的事。**订单只有三个状态**——待处理、完成、取消——而且把待处理推进到完成的是顾客自己，后厨在这条链路里根本不存在。**桌号是购物车里的一个选填输入框**，顾客手填，填错填别人的都拦不住。**金额用「元」存浮点数**，这条当时是写在需求文档「待确认事项」里的公开问题。**防重复只有页面级的提交中标志**，页面一重建就没了。

v1.2 的范围就是这四件加上管理员订单中心。不做支付、不做配送、不做会员，也不做多门店。

## 二、做出来的东西

9 个业务云函数，1010 行；7 个页面，1437 行 JS。`createOrder` 一个函数就占 303 行，是全部后端里最大的一块。

测试 8766 行、320 项，全部通过。测试代码是业务代码的三倍多。

另外还有四个临时运维云函数，788 行——接近 9 个长期函数的八成。纯 Serverless 环境下没有宿主机控制台，初始化数据、备份、迁移和清理测试数据这些事都得有个入口；把它们做成常驻的 `admin-tools` 加一句 `if (isAdmin)`，就等于给生产环境挂一个永久高权限写接口。这四个函数的设计前提因此是：用时部署，用完立刻从云端删除。

## 三、服务端零信任的三个决定

**金额只认整数分。** 顾客提交时，前端能传的只有菜品 `id`、规格 `specId` 和数量 `qty`。服务端拿 id 回查 `dishes`，重新读价格、名称、图片和售卖状态，客户端报上来的金额直接丢弃不看。价格字段全线换成非负安全整数的「分」，JS 浮点的 `0.1 + 0.2` 在账目里没有位置。

规格采用替代语义而不是加价叠加：菜品没有规格就用菜品价，有规格就必须选一个启用的规格，用规格价**替换**基础价。

**桌台身份不可猜。** 每个桌台启用时服务端生成一个 32 位随机十六进制 token，通过 `wxacode.getUnlimited` 编进小程序码。顾客扫码只拿到这个不透明串，调 `resolveTable` 换回桌名。桌台列表和接口响应都不返回原始 token，停用桌台后旧码立即失效。

**幂等键由服务端加作用域。** 客户端生成原始 key 并写进本地 Storage，服务端算 `sha256(OPENID + ":" + idempotencyKey)` 作为 `idempotencyScopeKey`，在这个字段上建唯一索引。同一个作用域键下，请求内容一致就回放原订单，内容不一致返回 `IDEMPOTENCY_CONFLICT`。

这里有一处顺序是刻意的：幂等回查排在桌码校验**之前**。所以一次重放即使发生在桌台已经停用之后，返回的仍是那张原始订单，而不是一个「桌码无效」的错误。把校验放前面会让重试的语义随时间漂移。

并发落单最终靠数据库兜底。捕获到唯一约束冲突时，代码不先看报错文案，而是先回查幂等作用域——因为撞的可能是订单号索引，也可能是幂等索引，回查能同时处理两种。

## 四、六个卡点

### 1. 要不要把线上停掉

做数据迁移的标准流程是冻结业务写入、备份、dry-run、迁移、写后校验。按这个流程推下去，第一步就是停掉线上那个还在跑的旧版本。

我当时提的问题是：我们不是在搞开发测试版本吗，为什么要把线上版本停掉。

这个问题改变了整个工程结构。结论是把工作拆成两条互不折算的轨道：E 轨是开发/体验版，用一个独立环境，允许清空非生产数据写固定种子，不碰历史数据；R 轨是未来正式发布，才需要备份、逐文档迁移和回滚演练。两轨的验收结果不能互相抵扣——E 轨里记 `N/A` 的历史备份和回滚演练，在评估正式发布时不得折算成通过。

### 2. 独立环境要收费

E 轨需要一个隔离环境，而现有环境里已经有 9 条菜品和真实订单，不能直接当测试环境用。

在腾讯云侧新建环境的表单标价首月约 19.9 元。付费这一步没有走，改为在微信侧用小程序已有账号建第二个免费环境。这条路走通了，代价是这个环境属于另一个自动创建的账号——这个代价在第 6 条兑现。

### 3. 备份函数在真实调用里连撞四次

`v12BackupAdmin` 是只读的五集合导出函数，默认禁用，要三方环境一致、15 分钟内过期的时间戳、管理员身份同时成立才工作。本地单测早就绿了。真跑的时候，同一个下午连撞四堵墙，每一堵都不在业务逻辑里。

**第一堵是类型丢在协议边界上。** 导出要保留 Date 这类 BSON 类型，所以云函数返回的是 canonical EJSON。但这个对象要经过 `miniprogram-automator` 的 `mini.evaluate` 从小程序运行时传回主进程，跨进程协议把它压成了普通 JSON。修法是在小程序侧先 `JSON.stringify`，只把字符串传出来，主进程再 `JSON.parse` 并校验协议和摘要。

**第二堵是开发者工具解析不了单参数 async 箭头函数。** `async data => {...}` 这种写法，工具取不出参数名，payload 传不进去。改成同步 evaluator 返回 Promise 链：`data => wx.cloud.callFunction(...).then(...)`。

**第三堵是微信平台会往 event 里注入字段。** 这个函数为了收窄攻击面，对请求做了严格白名单：`Object.keys(event)` 排序后必须精确等于 `["action", "collection", "environmentId"]`（带游标时多一个 `cursor`）。真实调用时平台注入了 `userInfo`，白名单直接判定请求非法。

修法是把 `userInfo` 从键名比对里过滤掉。但这等于自己开了一个口子：既然 event 里可以出现 `userInfo`，调用方就能伪造一个 `userInfo.openId` 冒充管理员。紧接着补的测试就是堵这个口子的——用顾客身份调用、事件里塞一个管理员 `openId`，断言仍然返回 `BACKUP_FORBIDDEN`。身份判断始终只信 `cloud.getWXContext()`，注入字段只是被忽略，不参与鉴权。

**第四堵是同类问题的第二个字段。** `tcbContext` 也会被注入。这次没有再加一个 `filter`，而是收敛成一个 `PLATFORM_EVENT_KEYS` 集合。

第二堵墙的修法后来推广到了所有 `mini.evaluate` 调用点——迁移执行器、隔离执行器、集成测试和两个 E2E 脚本。配套写了一个扫描测试：它用开发者工具同款的参数解析库，先在本地断言「不加括号的写法确实解析不出参数名」，再扫描 `scripts/` 和 `tests/` 下所有 JS，要求这种写法一处都不存在。这条测试拦的不是逻辑错误，是一个只在别人的运行时里才会暴露的语法陷阱。

四堵墙都修完之后，五集合冻结备份跑通了：`dishes` 9 条、`orders` 14 条、`admins` 1 条、`users` 1 条、`tables` 1 条，两遍完整遍历摘要一致，17 个 Date 字段从 canonical EJSON 完整还原，目录和文件权限落在 0700 和 0600，没有 partial 残留。授权窗口到期后函数按代码 fail-closed，探针确认返回 `BACKUP_AUTH_EXPIRED`，随后从云端删除。

### 4. dry-run 撞上一条负数量订单

拿着这份备份跑严格迁移规划器，退出码 1，没有生成计划。

14 条历史订单里，13 条满足新契约。剩下那一条状态是已取消、所有已存在的金额字段都是 0、明细里有一个负数量。规划器在校验订单明细时对 `qty` 要求正整数，直接抛错终止。

这个设计是刻意的：规划器不静默修正，也不允许通过过滤掉这条订单来绕过。金额和数量的事实不能在迁移里被悄悄改写。

代价是整条 R 轨在这里停住了——不处理这一条记录，就生成不了计划，也就没有 apply 和 rollback 可谈。

### 5. 授权之后，执行没走到删除

处置这条异常订单需要明确授权。我最后给的是最小路径：只删这一条，允许临时部署并撤除 `v12MigrationAdmin`，删完重新备份再跑一次只读 dry-run。

隔离删除的门禁做得很重：要用独立脚本从完整订单快照生成一份 1 到 15 分钟有效、锁定单个 ID 和原文档哈希的授权，云函数端还要再核一遍状态是已取消、所有已存在的总额字段为 0、至少一个数量严格小于 0，原因码也要对得上。正常的迁移授权不能隐式包含删除。

临时函数部署命令成功了，但部署之后的严格验收在进入删除调用之前就失败了。本地授权被故障保护自动恢复成禁用模板，线上函数携带的短时授权自动过期。那条订单没有被删掉。

失败原因当时没有定位到是函数就绪、列表解析还是远端下载比对——在确认之前没有重试。

### 6. AppID 绑不上那个独立环境

E 轨剩下的步骤都指向同一个前置条件：把小程序 AppID 绑定到独立体验环境。

只读查询返回的是这个环境所属账号的 `UserInfo.WxAppId` 为空串。而这个小程序已有的生产环境，绑的是另一个自动创建的腾讯云账号。官方换绑要求先销毁原账号下的全部环境——那会直接威胁到还在跑的线上版本。

CloudBase CLI 3.6.1 里也没有绑定微信公众平台账号的命令，这个入口不在环境设置里。

当时找到的可行路径是在微信开发者工具里，用小程序已有账号再建一个微信侧环境。这条路没有走下去。

## 五、十七道门禁，一道都没填

E 轨的发布门禁写在人工清单里，共十七行，从环境隔离、固定种子初始化、集合权限、三个唯一索引、四个订单复合索引，一路排到双账号真机旅程和体验版真码扫码。每一行都有「必须证据」和「结果」两列。

已经形成证据的只有部分子项：独立环境里恰好部署了 9 个业务函数，运行时、超时、内存、依赖安装状态逐一核对过，远端入口文件、依赖声明和锁文件与本地候选逐字节一致；桌码函数的 trial 环境变量已设置。但 AppID 没绑上，这 9 个函数从来没有被真实调用过一次。

剩下的项目——集合权限、索引、固定种子初始化、两个账号的真云集成与 E2E、跨账号履约、体验版上传——全部未完成。开发版从未上传，体验版从未设置。

那四个「用完即删」的临时函数，最终只有一个走完了完整生命周期。

`v12BackupAdmin`（168 行）部署、使用、验证、删除，四步齐全。`v12MigrationAdmin`（190 行）部署了，但没走到它要执行的那一步。`v12ExperienceAdmin`（连同种子文件 336 行）写完并测过，固定种子一次都没初始化过。`v12TestDataAdmin`（94 行）是给双账号测试做清理的，双账号测试没跑成，它也就没被调用。

## 六、停在一个没查实的问题上

我问的是线上那套资源是不是 17 号就到期。这个日期始终没有确认下来。

能确认的只有套餐是个人版、状态正常、环境创建于 2026 年 6 月 7 日；环境对象上根本没有到期、计费或周期字段。要拿准确日期就得实时查一次，而那个账号的命令行凭据已经失效——而且本机登录着的身份属于独立测试环境，不是生产小程序自动创建的那个账号。

这条问题之所以要紧，是因为免费云环境有一条规则贯穿了整个 v1.2 的设计：小程序一旦正式发布，免费期就改为上线后第 15 天。所以「发布」是这个项目里唯一不可逆的动作，而所有门禁的存在都是为了不让它提前发生。

最后一条待办是去控制台截一张带完整到期时刻的图。之后这个仓库没有再收到提交。

## 七、如果重来，先定后端归属

这个项目里工程强度最高的部分，全部花在了「在别人托管的生产数据上安全地操作」这件事上：四个临时特权函数、双遍原子导出、逐文档哈希迁移、逆序补偿、短时封印授权、单条隔离删除。它们解决的是同一个问题——我没有宿主机，所有特权操作都必须变成一个临时暴露在公网的云函数，然后再想办法把它收回去。

而在 v1.2 动工之前，仓库里已经躺着一份 198 行的自建服务器迁移方案，写明了完整的替代路径：集合到关系表的 DDL、云函数到 REST 接口的映射、`wx.login` 加 `code2session` 加 JWT 的登录链路、图片存储替代、前端改造点和分阶段步骤。它自己在开头注明是「方案与清单，不是已实施代码」，给出的动机就是免费环境那条 15 天规则。

那份方案当时的定位是「后续的独立项目」，明确排在 v1.2 范围之外，一行代码也没有实施。现在回头看，这个先后顺序是反的。自建之后确实要背新的负担——权限边界从「集合权限规则自动生效」变成「后端每条查询手动按 user 过滤」，这是最容易出事的一处。但它换掉的是四个临时特权函数、一整套哈希迁移工具链，以及一条根本绑不上的 AppID 链路。

方案里的硬门槛也写清楚了：一台服务器、一个域名加 ICP 备案（约 1 到 2 周）、HTTPS 证书、小程序后台配置合法域名。备案那一到两周压不动，所以它只能排在最前面——一旦等到被到期日推着走，这条路就已经来不及了。
