在微信小程序开发生态里,“扫码点菜”是出场率最高的入门 Demo。
网上的视频教程通常只要半小时:新建一个 CloudBase 云开发模板,建一个 dishes 集合、一个 orders 集合,前端写个 wx.requestPayment 或直接把总价写入订单表,配上几个 wx.navigateTo,一个“麻雀虽小”的点菜系统就跑起来了。很多人甚至觉得,这种项目简单到不配被称为“工程”。
但在真实的餐饮堂食场景中,这种教程级 Demo 只要一进真实环境就会变成筛子:
- 桌号篡改:顾客改一下前端参数或扫描桌角破损的二维码,就把菜点到了隔壁桌上;
- 金额与商品篡改:顾客在前端本地存储改一个价格,或者直接调云函数传个
0.01元,后台直接照单全收; - 并发与网络抖动:地下商场网络信号差,顾客连点两次提交,同一个订单在数据库里落了两份;
- 状态撕裂:后厨刚刚点击“制作中”,前台顾客还在手机上点击了“取消订单”,最终导致做好的菜无人认领。
我最近为一个单门店堂食场景重构并推进了点菜小程序的 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是否属于该菜品的启用规格? - 金额全部采用安全整数分(
priceCents、subtotalCents、totalCents)在服务端重新计算。
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 时:
- 服务端将调用者的微信
OPENID与客户端传来的idempotencyKey拼接,计算 SHA-256 哈希作为idempotencyScopeKey:idempotencyScopeKey = sha256(OPENID + ":" + idempotencyKey) - 数据库集合
orders上建立了唯一索引uniq_idempotencyScopeKey。 - 即使两个并发请求以毫秒级的间隔同时穿透到数据库,底层数据库的唯一索引会直接抛出约束异常,保证只落一单。
- 如果是同一订单因网络未知而重试,服务端识别到相同的请求哈希(
requestHash),直接返回先前已创建成功的订单快照;若是不同请求恶意复用相同的 Key,则明确返回IDEMPOTENCY_CONFLICT错误。
二、四个用完即删的特权云函数
在传统的独立服务器或容器架构中,运维人员可以通过 SSH、跳板机或受限的内网 API 执行数据初始化、备份和数据修正。
但在纯 Serverless(微信小程序云开发)环境下,你没有宿主机的控制台。为了完成“初始化开发环境”、“备份五大核心集合”、“执行版本数据迁移”以及“清理自动化测试产生的订单”,很多人图省事,会在云函数里写一个 admin-tools,留一个 if (isAdmin) 校验,然后就永远挂在线上。
这是一个极其危险的坏习惯。 任何拥有写库权限的特权函数,长驻在生产环境中就是永恒的攻击面——哪怕有白名单保护,也可能面临权限越界、参数拼接注入或内部配置漂移的风险。
在 wx-menu 中,我们制定了一条严格的原则:特权运维接口必须被封装为独立的临时云函数,并受到严格的物理生命周期管理。用时部署,用完立刻删除。
在整个体系中,共有 4 个这样的“短命特权函数”:
cloudfunctions/
├── v12ExperienceAdmin/ 临时体验环境清空和固定种子初始化接口;完成后必须删除
├── v12BackupAdmin/ 临时只读五集合备份接口;导出后必须从线上删除
├── v12MigrationAdmin/ 临时迁移接口;验证完成后必须从线上删除
└── v12TestDataAdmin/ 临时验收清理接口;测试完成后必须从线上删除
这 4 个函数每个都带有严苛的自我销毁契约:
1. v12ExperienceAdmin:体验环境重置器
- 作用:为测试和体验成员准备一个干净的环境,清空
orders、users、dishes、tables四个业务集合,但绝对不动admins白名单,随后写入固定的 4 菜 2 桌体验种子。 - 门禁:仅限独立测试环境;函数运行时强制校验环境变量中的随机操作标识(
operationId)与调用者传参一致;写入后即刻计算四集合的 SHA-256 数据摘要,必须与仓库硬编码的固定摘要09c081926dcddeb110f7854d9ab87197f8b41fc930b2cae4bbe65c36563fa0f4完全吻合。 - 生命周期:重置完成后,本地脚本断言成功,运维人员立即在云端关闭环境变量并直接删除该函数。
2. v12BackupAdmin:五集合双遍原子导出器
- 作用:在生产迁移前,以只读方式导出
dishes、orders、admins、users、tables五大核心集合。 - 门禁:必须在环境配置
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 式更新。输入是由离线规划器生成的确定性执行计划,包含每个受影响文档的
beforeHash与afterHash。执行前在线预检所有文档当前哈希,只要有 1 个文档与计划不一致,立刻全量放弃(Zero Write)。一旦中途异常,通过逆序补偿恢复。 - 生命周期:迁移与验收结束后,立即从远端删除。
4. v12TestDataAdmin:测试数据精准擦除
- 作用:配合集成测试和 E2E 自动化测试,清理测试过程中写入的订单。
- 门禁:客户端绝不拥有
.remove()权限。该函数只接受当前调用者_openid和具备唯一随机标记(如INT_V12_CUSTOMER_...)的订单删除请求。 - 生命周期:每次跑完测试,断言
remaining === 0,随后清理下线。
特权能力是代码,但不是长期服务。它们以代码形式受 Git 版本控制,但在线上只拥有几分钟的短暂生命。
三、两条互不相通的部署轨道
在工程发布管理上,很多团队最容易踩的坑是:把“测试通过”与“可以发布到生产”混为一谈。
在 wx-menu 的规划手册中,明确定义了两条完全绝缘的轨道:
| 维度 | 开发/体验轨(E 轨,当前执行) | 未来生产迁移轨(R 轨,保留但未放行) |
|---|---|---|
| 目标环境 | 独立体验环境 wx-menu-d1gl... |
真实生产环境 cloud1-d2g2... |
| 数据策略 | 允许清空非生产数据,写入固定种子 | 必须保留并备份真实历史数据,逐文档验证 |
| 临时工具 | v12ExperienceAdmin |
v12BackupAdmin、v12MigrationAdmin |
| 回滚承诺 | 不做数据恢复,异常时停止并核对 | 必须在隔离克隆库完成全量 rollback 演练 |
| 终态 | 上传开发版,微信公众平台设为体验版 | 审核通过,正式全量发布 |
两条轨道之间设立了极度严密的逻辑防火墙:
- 代码层面锁死环境 ID:
app.js与全部自动化测试脚本强制比对当前指向的环境 ID,只要检测到环境不匹配,进程直接退出; - 体验轨的通过绝不等于生产就绪:手册中明确规定,体验轨中的所有
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集合中查无此人。
测试覆盖了非常苛刻的跨账号边界:
- 顾客越权 100% 拒绝:顾客 C 调用
manageDishes、manageOrders、manageTables,服务端无一例外拦截并返回失败; - 状态机单向流转:
- 顾客只能执行
pending → canceled(仅限待接单状态取消自己桌台的订单); - 管理员才能执行
pending → preparing → ready → done; - 任何试图将
done逆转为canceled,或者跨状态越级推进的操作,在原子更新条件where({ _id: orderId, status: order.status })层面直接被拒;
- 顾客只能执行
- 前台即时提醒:管理员订单中心在前台保持打开时,底层通过稳定游标
(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 分钟过期的临时函数、每一次断言为零的清理,就都是完全值得的。