订单提醒功能扩展 · 工作纪要
时间跨度:2026-10-12 22:17 ~ 2026-10-13 23:07
涉及仓库:langbi25/puppy-canteen(三分支:cloud/selfhost/main)
主用分支:cloud
0. 一句话概括
把订单提醒从「只有下单人能收到、且只发得出 1 条」,
扩展成「4 个事件(下单 / 开始制作 / 已完成 / 已取消)× 两个人各收一条」。
1. 起点:一开始坏在哪
用户的原话是:
「只有点了开始制作才有通知,完成制作没有通知;为什么不是 4 个状态各一条?」
「用这个小程序的两夫妻都能有消息提醒」
三个叠加的根因
| # | 根因 | 后果 |
|---|---|---|
| 1 | 微信一次性订阅「授权 1 次 = 只能发 1 条」;下单时只申请了 1 个模板 | 只有 1 条额度,发完「制作中」就清零,后面「已完成」「已取消」被微信拒收(43101) |
| 2 | 旧日志把43101当「用户未订阅」这种正常情况静默掉 | 失败完全不可见,一直以为"微信不让发" |
| 3 | 收件人只从profiles表取,而另一半没有 profiles 行(她从没在「我的」页填过昵称) | 即使额度够,也永远发不到她那边 |
第 3 条是查库查出来的实据:
profiles: 1 行 → wx:...gmnuVpOk(栗帆) orders: 2 个 owner_id wx:...gmnuVpOk 15 单(栗帆) wx:...Qiy8Q 2 单(家人)profiles只有saveMyProfile一个写入点 —— 只下过单、没填过资料的人,
在profiles里就是不存在。
2. 时间线总览
| 时间 | 阶段 | 关键动作 | cloud 提交 |
|---|---|---|---|
| 10-12 22:17 | 单模板 → 多模板 | 模板注册表 + 按状态拆模板 | d15dbe9 |
| 10-12 22:28 | 拿到 2 个模板 ID | 用户申请「已完成」「已取消」 | f8e6e8a |
| 10-12 22:41 | 字段配齐,3 模板就绪 | 照抄详情页字段名 | 3bd0f23 |
| 10-13 00:14 | 三事件 × 两个人 | 多收件人 + 通知开关 + DB 加列 | 59b5ab3 |
| 10-13 22:35 | 「开始制作」也通知 | 4 事件拆两批授权 | 69b51d0 |
| 10-13 22:55 | 第 4 个模板配齐 | 填「接单提醒」ID 与字段 | 5f16424 |
| 10-13 23:07 | 真机首测 | 定位到容器跑的还是旧版 | — |
3. 每一轮具体做了什么
3.1 多模板架构(10-12 22:17)
改法:utils/notify.js从「一个模板 ID」改成模板注册表:
TEMPLATES={pending:{id,fields},// 每个状态一个独立模板 IDcooking:{id,fields},// 每个模板一套独立字段映射done:{id,fields},cancelled:{id,fields}}为什么必须按状态拆模板:额度按「模板」独立计数。共用一个模板 = 互相抢额度,
这是当时反复翻车的根源。
关键设计——「半配置 = 安全状态」
configStage()把配置状态分成三档,中间态不会发错消息:
| stage | 含义 | 行为 |
|---|---|---|
not-applied | ID 还是占位符 | 跳过授权、跳过发送 |
need-fields | 有 ID 但字段没填 | 不发送,打 ⚠️ 告警 |
ready | 两者都齐 | 正常发送 |
这样「先填 ID、隔几天再补字段」的中间态,不会发出空data去撞47003 参数不合法。
3.2 三事件 × 两个人(10-13 00:14,最核心的一轮)
这一轮把「通知」从「发给下单人」改成「发给所有开了通知的人」。
收件人来源修正(关键)
// utils/api.jsfetchNotifyTargets()=profiles(带开关) ∪ orders.owner_id(去重)只按profiles取会漏掉另一半;补上orders.owner_id立刻覆盖两个人。
容器端支持多收件人
cloudrun/notify-service/server.js:
normalizeRecipients(body)// 支持 recipients 数组,兼容旧的单个 touser// 串行逐个发送,回 { ok, sent, failed, total, results: [{touser, errcode, errmsg}] }一次网络往返发完所有人 —— 避免前端循环调用反复撞冷启动。
数据库加开关列
ALTERTABLEpublic.profilesADDCOLUMNIFNOTEXISTSnotify_enabledBOOLEANNOTNULLDEFAULTtrue;- 已直连云服务数据库执行(不用手工贴 SQL)
backend/schema.sql+selfhost/sql/00-init.sql已同步- 容错:这列查不到时前端按
true处理(= 都发),漏执行 SQL 不会导致漏发
小程序端新增通知开关
「我的」页加了一个卡片 +<switch>:
- 打开时先弹授权;被拒就把开关拨回去并说明原因(不会出现"显示开着但实际没授权"的假象)
- 顺带修掉一条误导性兜底文案(“预览环境无法完成微信授权”)
3.3 「开始制作」也要通知(10-13 22:35)
用户推翻了之前「开始制作不发」的决定。4 个事件 = 4 个独立模板,
于是必须先确认一次弹窗能带几个模板。
查证结果:官方口径不一致
| 来源 | 口径 |
|---|---|
官方wx.requestSubscribeMessage页面 | 「一次调用最多可订阅5条消息」 |
| 腾讯云文档 / 开放社区镜像 / 大量存量代码 | 3条 |
| 超限后果 | 返回errCode 20003,整个调用失败,一张券都拿不到 |
风险不对称 → 保守按 3 设计,把 4 个模板拆成两批。
(代码里是常量MAX_TMPL_PER_REQUEST = 3,日后确认线上稳定支持 5,改常量即可。)
分批原则(只有一条)
谁触发事件,就在那个 tap 上把对应模板的券要过来。
| 批次 | 模板 | 挂在哪个 tap |
|---|---|---|
order | 新订单 + 开始制作 + 已完成 | 点「提交订单」+「我的 → 订单通知」开关 |
cancel | 已取消 | 点「取消订单」(弹确认框之前) |
这样做的好处:不是"授权一次然后慢慢用光",而是每次动作都自动补券——
一次性订阅「1 次授权 = 1 条」,只有靠高频动作反复补券才撑得住。
一个时序坑
requestSubscribeMessage必须由用户点击直接触发。
所以「取消订单」的流程必须写成:
// ✅ 先要券,再弹确认框if(status==='cancelled')awaitnotify.requestOrderNoticeAuth('cancel')wx.showModal({...success:async(res)=>{/* 改状态 */}})不能等showModal的success里才调 —— 那时已经错过时机,券要不到。
另外同一个 tap 里只能调一次,第二次会被静默忽略。
3.4 第 4 个模板配齐(10-13 22:55)
用户申请到「接单提醒」,字段名照抄详情页:
| 模板字段 | 占位符 | 填什么 | 说明 |
|---|---|---|---|
| 接单编号 | number1 | 订单 ID | number类型只能填纯数字,订单 ID 正好是 |
| 接单时间 | time11 | 通知发出的此刻 | 比下单时间更符合"接单"语义 |
| 单号 | character_string12 | 订单 ID | 模板有两个编号位,系统只有一个订单号 → 共用同值 |
| 接单人 | thing10 | 下单人 | 系统没有"是谁点了开始制作"的字段 → 用下单人顶上 |
四个模板标题互不相同:下单完成通知 / 接单提醒 / 订单完成通知 / 订单取消通知。
一次授权里模板标题不能相同,相同会被后台过滤(返回'filter')。
重要发现:占位符编号完全不规律
| 语义 | 完成模板 | 取消模板 | 接单模板 |
|---|---|---|---|
| 时间 | time1 | time5 | time11 |
| 菜品 | thing4 | thing2 | — |
必须逐模板照抄详情页,绝不能凭上一个模板推断。
3.5 真机首测翻车:容器还在跑旧版(10-13 23:07)
症状
授权全部accept(券都拿到了),但发送全部返回:
{ ok: false, error: "missing touser or data" }定位(10 秒破案:文案比对法)
| 对比项 | 值 |
|---|---|
本地新版server.js的缺参文案 | missing recipients or data |
| 线上容器实际返回 | missing touser or data |
touser这个词只存在于旧版代码。新版支持recipients数组,文案早改了。
小程序端发的是新格式 → 旧容器不认识recipients,只找touser→ 报缺参。
根因
改了容器代码、打了 zip ≠ 部署。
多收件人改造(10-13 00:14)改完server.js并重新打了server.zip,
但云托管控制台上从来没有上传发布过这个包,线上一直跑旧版。
三重验证(确认本地代码与 zip 没问题)
zip 内 server.js : 2a2677bced54e9ba 6071 bytes 磁盘 server.js : 2a2677bced54e9ba 6071 bytes ← 哈希完全相同 工作区 vs cloud 分支 : 无差异 本地服务自检 : 全通过正确做法
微信云托管控制台 → 服务列表 →notify-service→发布新版本→ 上传server.zip。
配置不用动,但确认实例副本数最小值 = 1(避免冷启动撞callContainer的 15 秒上限)。
教训:凡是动过
cloudrun/**的轮次,收尾清单必须包含
「重新上传 zip 并发布版本」,并且要注明是哪一轮改的代码。
4. 平台知识点(已查证,不必重查)
4.1 微信订阅消息
| 项 | 值 |
|---|---|
| 一次性订阅额度 | 授权 1 次 = 只能发 1 条 |
| 授权有效期 | 7 天(过期未用自动作废) |
| 一次弹窗模板数上限 | 官方写 5,存量写 3 →保守按 3,超限20003整批失败 |
| 长期订阅类目 | 仅政务/医疗/交通/金融/教育(餐饮/工具申请不到) |
| 模板总数上限 | 25 |
| 模板创建后字段 | 不能改,只能新建 |
| 一次授权里模板标题 | 不能相同,相同会被过滤 |
授权侧错误码
| 码 | 含义 |
|---|---|
20001 | 模板不存在 |
20003 | 模板数超上限(整批失败) |
20004 | 用户关了订阅主开关 |
'filter' | 该模板因标题重名被后台过滤 |
发送侧错误码
| 码 | 含义 |
|---|---|
43101 | 某个收件人没额度 → 让那个人重新授权(不是"正常情况",日志必须点名是谁) |
47003 | 参数不合法(字段名错/为空/超长),errmsg 会点名哪个字段 |
41030 | page 路径在现网版本不存在 |
字段类型限制:thing≤20 字符;phrase≤5 汉字;number只能纯数字;character_string≤32 字符。
⚠️额度按「人 × 模板」独立计数(最容易误解的一条):
发通知给别人,用的是别人的额度。我有券不代表对方有券。
所以「两个人都要收到」的现实含义是:两个人都得各自授权一次。
4.2 微信云托管
| 项 | 值 |
|---|---|
callContainer的path | 容器内裸路径(/notify),不带服务名 |
| 服务名写哪 | headerX-WX-SERVICE |
| 云调用白名单 | 填URL 路径(/cgi-bin/message/subscribe/send),带 query 就不匹配(→85107) |
| 实例缩容 | 最小副本 0 时半小时无请求缩容;callContainertimeout上限 15 秒 |
| 版本生效 | 改代码/配置后必须重新发布版本,不会热更新 |
| 识别线上是旧版 | 拿容器报错文案和本地代码比对,对不上就是旧版 |
5. 关键设计决策(别再推翻)
| 决策 | 选择 | 理由 |
|---|---|---|
| 回退模板 | FALLBACK_KEY = null(不设回退) | 回退会让不同状态消息长得一样、还互相抢额度 —— 两次翻车都源于此。没配就不发并告警 |
sendOrderNotice | 一律不 await | 通知要走一次云托管往返(可能撞冷启动,最长 15s),绝不能拖住下单跳转或列表刷新 |
requestOrderNoticeAuth | 必须 await 且紧贴 tap | 弹窗必须由用户点击直接触发 |
| 授权分批 | order(3 个)+cancel(1 个) | 4 个装不下一次弹窗,超限整批失败 |
| 批次内未配置模板 | 自动剔除 | 某个模板没配好时,同批其他模板照样能拿券 |
| 收件人来源 | profiles∪orders.owner_id | 另一半没有 profiles 行 |
| 通知对象 | 两个人各一条(含操作者本人) | 需求如此;不判断"谁是操作者" |
| 日志纪律 | 禁止兜底文案 | 兜底文案会把真错误伪装成正常情况,让人停止排查 |
6. 费用:微信云托管怎么收费
6.1 结论先说
是收费的,按量付费、先用后付,每个环境独立计费。
但首个环境赠送 3 个月免费额度,按我们目前的用法大概率用不完。
6.2 单价与免费额度(官方刊例价)
| 计费项 | 单价 | 首环境免费额度(有效期 3 个月) |
|---|---|---|
| 容器 CPU 使用量 | 0.055 元/(核·小时) | 720 核·小时 |
| 容器内存使用量 | 0.032 元/(GB·小时) | 1440 GB·小时 |
| 构建时长 | 0.05 元/分钟 | 600 分钟 |
| 公网流量 | 0.8 元/GB | 5 GB |
6.3 什么时候产生用量(这条最影响成本)
- CPU / 内存:环境中任意服务下任意版本,实例数量不为 0就持续产生
→ 换句话说,只要有一个实例常驻在跑,24 小时都在计费 - 公网流量:只有通过公网域名访问才产生 →
我们全程用callContainer,这项用量为 0(官方原文:只通过 callContainer 访问服务不产生用量) - 构建时长:只有发布时用「本地代码/代码库拉取」方式才计费
6.4 我们的场景估算
按「副本数最小 = 1」(防冷启动超时)+ 单实例默认规格 0.25 核 / 0.5 GiB 估算:
| 项 | 每月用量 | 每月费用 |
|---|---|---|
| CPU | 0.25 × 720 = 180 核·小时 | 9.9 元 |
| 内存 | 0.5 × 720 = 360 GB·小时 | 11.52 元 |
| 合计 | 约 21 元/月 |
对比免费额度(720 核·小时 + 1440 GB·小时,3 个月):
3 个月实际消耗约 540 核·小时 + 1080 GB·小时 →都在免费额度内。
具体规格以控制台实际配置为准。若单实例是 0.5 核 / 1 GiB,
费用约翻倍(约 43 元/月),3 个月也算得过来(1080 / 2160,仍在额度内)。
6.5 省钱的关键开关
实例副本数最小值:
| 设置 | 费用 | 风险 |
|---|---|---|
| 1 | 约 21 元/月(免费额度内则 0) | 无,稳定 |
| 0 | 近乎 0(空闲不产生用量) | 冷启动可能超过callContainer的 15 秒上限 → 通知发不出去 |
家庭场景消息量极低,保持最小副本 1是最稳妥的选择。
6.6 在哪里看账单和用量
微信云托管控制台 → 费用中心 → 充值与账单
(入口:微信公众平台 / 云托管控制台首页的「费用中心」)
7. 当前状态
代码侧:已全部就绪 ✅
| 项 | 状态 |
|---|---|
| 4 个事件模板 ID | ✅ 全部配置完成 |
| 字段映射 | ✅ 逐一对照详情页校验 |
| 多收件人 | ✅ 前端 + 容器 |
| 通知开关 | ✅ 「我的」页 |
DB 列notify_enabled | ✅ 已执行 |
| 小程序端自检 | ✅ 全通过 |
| 容器端自检 | ✅ 全通过 |
| 三分支同步 | ✅ 无 ahead |
剩余步骤:全部是用户操作
- ⚠️ 云托管重新上传
server.zip并发布新版本- 控制台 → 服务列表 →
notify-service→ 发布新版本 - 文件:
canteen-miniprogram-v2/cloudrun/notify-service/server.zip - 确认实例副本数最小值 = 1
- 🔴这一步不做,通知一条都发不出去
- 控制台 → 服务列表 →
- 重新上传小程序代码(体验版,水印应为
notify6) - 两个人各自在「我的 → 订单通知」把开关关掉再打开,勾「允许」+ 勾「总是保持以上选择,不再询问」
- 真机验证 4 条:下单 → 开始制作 → 已完成 → 取消,每一条两人都应收到
- 发正式版前:
server.js的MINIPROGRAM_STATE从trial改formal,并重传容器包
8. 提交记录
| 分支 | 用途 | 最近提交 |
|---|---|---|
cloud | 主用(连云服务,体验版) | 0456e5c |
main | 备份 | 3021888 |
selfhost | 自建后端 | ba7175e |
发版水印:cloud-/main-/selfhost-2026-10-13-notify6(前缀各留各的)。
9. 相关文档
| 文档 | 内容 |
|---|---|
docs/order-notify-templates-guide.md | 模板申请、字段映射、授权分批、部署清单 |
docs/order-notify-setup.md | 整体链路、错误码判读 |
tools/notify-selfcheck.js | 小程序端离线自检(打桩,不发真实请求) |
tools/server-selfcheck.js | 容器端离线自检 |