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 证书、小程序后台配置合法域名。备案那一到两周压不动,所以它只能排在最前面——一旦等到被到期日推着走,这条路就已经来不及了。