记录订阅消息额度供需倒挂问题的设计方案:服务端按 openid 记账、 用户点击时搭车补额度、额度不足时按优先级取舍并在首页预警。 核实并记录了几条决定设计走向的微信平台约束: - 额度是 (用户 × 模板) 维度且永久有效,但用户关通知总开关会全部清零 - 无官方接口可查余额,只能自行记账并靠 43101 校正 - requestSubscribeMessage 必须由真实点击手势触发,无法在 onLaunch 静默调用 - 长期订阅类目不符,申请不到 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13 KiB
订阅消息额度治理 — 设计文档
日期:2026-08-15 状态:待实现
1. 背景与问题
小程序的生日提醒依赖微信「一次性订阅消息」下发。当前实现存在结构性的供需倒挂:
- 供给:只有
pages/add-anniversary/add-anniversary.js:375一个调用点会产生额度,即用户保存一条纪念日时授权一次,得 1 次下发机会。 - 消耗:
server/src/reminder.js:93的规则是daysUntil === 0 || daysUntil === remindDays,即每条纪念日每年消耗 2 次(提前 N 天一条 + 当天一条)。
于是用户存 N 条纪念日只攒了 N 次额度,一年却要发约 2N 条。第一年就不够,之后基本全部返回 43101 静默失败。用户完全无感知——既不知道提醒没发出去,也不知道该怎么办。
本地 server/data/birthday.db 副本中 remind_logs 仅 4 条记录(1 成 3 败,失败均为 TLS 网络问题),样本太小,不足以证实额度耗尽已实际发生;上述结论由规则推导得出。
2. 微信平台约束(已核实)
| 事实 | 影响 |
|---|---|
| 订阅 1 次 = 下发 1 次,可累积 | 供给只能靠用户授权次数堆积 |
| 额度永久有效,不过期 | 记账无需做时间衰减 |
| 额度是 (用户 × 模板) 维度,不是小程序级的固定池 | 记账必须按 openid 存 |
| 无官方接口可查剩余额度 | 必须开发者自行记账,且只能是估算 |
| 用户关闭「接收通知」总开关 → 该小程序下所有模板额度清零 | 记账必须有自愈机制 |
wx.requestSubscribeMessage 必须由真实点击手势直接触发(基础库 2.8.2+),且调用前不能有 await 等异步操作 |
无法在 onLaunch/onShow 里静默补额度 |
| 勾选「总是保持以上选择」后调用不弹窗,但仍需点击手势,且每次仍 +1 | 对这类用户可在任意点击上「搭车」静默补 |
wx.getSetting({withSubscriptions:true}) 的 itemSettings 只包含勾选过「总是保持」的模板 |
可据此区分用户状态 |
| 长期订阅仅向政务民生、医疗、交通、金融、教育等公共服务开放 | 本项目申请不到,此路不通 |
| 服务端日发送上限 1000 万条/日(未开通支付) | 对本项目不构成瓶颈 |
现有调用点 onSubmit 经检查是合规的:由 bindtap 绑定(add-anniversary.wxml:109),且 await this.requestSubscribe() 之前只有同步校验,手势上下文未丢失。本次不改动它。
3. 目标与非目标
目标
- 把「静默失败、用户永不知情」变成「提前预警 + 一键补救」
- 额度不足时按明确优先级取舍,而非随机丢失
- 不在明知没额度时继续发无谓请求
非目标(明确不做)
- 不做未送达补发:当天的提醒漏发,次日补发已无意义;提前提醒漏发,当天那条本就会发。收益不足以支撑复杂度。
- 不申请长期订阅:类目不符,申请不到。
- 不改提醒节奏:用户明确要求保留「提前 N 天 + 当天」两条,消耗侧不动。
- 不追求零遗漏:受微信规则限制,供需可能长期倒挂,本设计只改善不根治。
4. 已拍板的决策
| 决策点 | 结论 |
|---|---|
| 提醒节奏 | 保留两条(提前 + 当天),不降消耗 |
| 补额度时机 | 已勾选「总是保持」的用户在日常点击时搭车静默补;未勾选的用户只在主动点击首页提示卡片时补 |
| 额度不足时的优先级 | 当天 > 提前;同级按 importance 降序(high → medium → low) |
| 首页提示措辞 | 说「谁的提醒有风险」,不说「还剩几次额度」 |
5. 架构总览
用户点击(首页提示卡片 / 人员卡片)
↓ 同步调用,不得有前置 await
wx.requestSubscribeMessage
↓ accept
POST /api/subscribe { action: 'grant', data: { count: 1 } }
↓
subscribe_quota.balance += 1
┌─────────────────────────┐
首页 onShow │ reminder.js 定时任务 │
↓ │ 按 openid 分组 │
POST /api/subscribe { action:'get' } │ 组内按优先级排序 │
↓ │ 逐条发送,balance -= 1 │
{ balance, atRisk: [...] } │ 余额耗尽 → 记 skipped │
↓ │ 43101 → balance = 0 │
风险提示卡片(有风险时才显示) └─────────────────────────┘
6. 数据模型
新表(server/src/db.js):
CREATE TABLE IF NOT EXISTS subscribe_quota (
openid TEXT PRIMARY KEY,
balance INTEGER NOT NULL DEFAULT 0,
grantedTotal INTEGER NOT NULL DEFAULT 0,
sentTotal INTEGER NOT NULL DEFAULT 0,
updateTime INTEGER
);
balance 是估算值,微信端才是真值。此定位必须写进表注释和代码注释,避免后续被当作权威数据使用。grantedTotal / sentTotal 仅用于排查,不参与业务判断。
remind_logs 表复用现有结构,status 增加取值 skipped,error 字段记原因(quota_exhausted)。无需改表。
7. 接口设计
新增 POST /api/subscribe,沿用现有 action 协议,openid 走 x-openid 头。
action: grant
请求 { action: 'grant', data: { count: 1 } },返回 { success: true, balance: N }。
纯累加,不做去重——微信侧确实每次授权都 +1,如实记录即可。
action: get
返回:
{
success: true,
balance: 3,
atRiskCount: 2, // 有风险的提醒「条数」(同一人可能占 2 条)
atRiskNames: ['张三', '李四'] // 去重后的「人名」,按发生先后排序,不截断
}
atRiskCount 与 atRiskNames.length 含义不同且可能不相等:一个人的「提前」和「当天」两条提醒都告急时,占 2 条但只有 1 个名字。首页文案用的是人名,所以展示逻辑以 atRiskNames 为准。后端返回完整列表不截断,截断由前端负责。
atRisk 计算规则(后端算,前端只负责展示):
- 取该 openid 下所有
remindEnabled = 1的纪念日 - 为每条展开未来
LOOKAHEAD_DAYS = 60天内的提醒事件:- 当天事件:
fireDate= 下一次发生日 - 提前事件:
fireDate= 下一次发生日 −remindDays天(仅当remindDays > 0) - 只保留
fireDate ∈ [今天, 今天 + 60天]的事件
- 当天事件:
- 按
fireDate升序排序(这是真实的消耗顺序) - 前
balance条视为安全,其余为 at risk - 返回 at risk 事件中去重后的
personName(保持顺序)与总条数
LOOKAHEAD_DAYS = 60 的取值理由:太短则预警不及时,太长会拿几个月后的事惊扰用户。约两个月是合理平衡。
注意此处排序用时间先后而非优先级——跨天的额度是先到先消耗的。优先级排序只在同一天内生效(见第 9 节)。
8. 前端设计
新增 utils/subscribe.js
集中管理订阅逻辑,顺带消灭 add-anniversary.js:376 里硬编码的模板 ID。
TEMPLATE_ID— 常量,唯一来源getStatus()— 封装wx.getSetting({ withSubscriptions: true }),返回:'silent'—itemSettings[TEMPLATE_ID] === 'accept',调用不弹窗,可搭车'willPrompt'—itemSettings[TEMPLATE_ID] === undefined,调用会弹窗'rejected'—itemSettings[TEMPLATE_ID] === 'reject',调用无效,需引导设置页'mainSwitchOff'—mainSwitch === false,需引导设置页'unknown'— 查询失败,按willPrompt保守处理
requestAndReport()— 必须在 tap handler 中同步调用。第一行即wx.requestSubscribeMessage,accept后再上报grant。上报失败走现有sync队列兜底。
首页 pages/index
onShow:并行做两件事- 调
/api/subscribe的get,setData({ atRiskCount, atRiskNames }) - 调
subscribe.getStatus(),把结果缓存到this.data.subscribeStatus
- 调
wxml:atRiskCount > 0时显示提示卡片,bindtap="onTopUp"- 文案:
⚠️ 张三、李四的生日提醒可能发不出去,点这里补上 atRiskNames超过 3 个时,取前 3 个后接等 X 人,其中X = atRiskNames.length(总人数,非剩余人数),例:张三、李四、王五 等 6 人
- 文案:
onTopUp():第一行同步调subscribe.requestAndReport();rejected/mainSwitchOff状态下改为引导wx.openSetting({ withSubscriptions: true })onPersonTap():搭车点。若缓存状态为'silent',先同步调一次requestAndReport(),再navigateTo
关键约束:状态必须在 onShow 预取并缓存。若在 tap handler 里 await getStatus() 再调订阅,手势上下文会丢失,调用必然失败。这是本设计最容易踩的坑。
app.js
不做订阅相关调用(无点击手势,调用必失败)。保持现状。
9. 后端发送侧改造(server/src/reminder.js)
现为扫全表逐条发送。改为按 openid 分组处理:
对每个 openid:
1. 读取 balance
2. 收集该用户今天应发的提醒事件
3. 组内排序:当天事件(daysUntil=0) 优先于提前事件;
同级按 importance 降序(high > medium > low)
4. 依次发送:
balance <= 0 → 剩余全部记 skipped(quota_exhausted),不发请求
发送成功 → balance -= 1, sentTotal += 1, 记 success
返回 43101 → balance = 0,本用户本轮立即中止,
剩余全部记 skipped(quota_exhausted)
其他错误 → 记 failed,不动 balance
5. 回写 subscribe_quota
alreadySentToday 的按日去重保留,防止同一天重复扣减。
10. 错误处理与自愈
| 情形 | 处理 |
|---|---|
43101(用户拒绝 / 次数不足) |
balance = 0,中止该用户本轮,剩余记 skipped。这是记账与微信侧对账的唯一信号 |
43104 / 40003 等配置错误 |
记 failed,不动 balance(不是额度问题) |
| 网络 / TLS 错误 | 记 failed,不动 balance |
grant 上报失败 |
走现有 pending_sync_queue 队列,下次启动 flush |
getSetting 查询失败 |
按 willPrompt 保守处理(宁可弹窗也不要静默失败) |
记账会漂移是设计上接受的:平时靠加减维护,靠 43101 归零校正。
11. 验证方案
后端(临时库,不碰生产数据)
- 构造
balance = 1+ 3 条今日应发 → 断言 1 条 success、2 条 skipped,且 skipped 的是优先级低的两条 - 构造当天事件与提前事件混合 → 断言当天的先发
- mock
sendSubscribeMessage抛 43101 → 断言 balance 归零、后续全部 skipped grant幂等性:连续调用 3 次 → 断言 balance 累加为 3(不去重)
前端(miniprogram-automator)
- mock
wx.getSetting返回四种状态 → 断言getStatus()分类正确 - mock
wx.requestSubscribeMessage返回 accept → 断言上报了grant - 构造
atRiskCount > 0→ 断言提示卡片出现;补额度后断言消失
端到端
- 模拟器 + 本地后端(临时库),走一遍「看到提示 → 点击补额度 → 提示消失」
无法自动验证、须真机确认的
- 点击手势是否被正确识别(开发者工具的行为与真机有差异)
- 「总是保持」勾选后的静默调用是否真的无感
12. 已知局限
balance是估算值,与微信侧可能不一致,只能靠 43101 事后校正- 用户关闭通知总开关会使额度瞬间清零,我们无法主动感知,只能在下次发送失败时才发现
- 搭车补额度只对已勾选「总是保持」的用户有效;未勾选的用户完全依赖其主动点击提示卡片
- 若用户存了很多纪念日却极少打开小程序,供需仍然倒挂——本设计只能提前告知,无法凭空创造额度