Files
wxserver/docs/superpowers/specs/2026-08-15-订阅消息额度-design.md
T
yuming 6d0119103f 设计文档:订阅消息额度治理
记录订阅消息额度供需倒挂问题的设计方案:服务端按 openid 记账、
用户点击时搭车补额度、额度不足时按优先级取舍并在首页预警。

核实并记录了几条决定设计走向的微信平台约束:
- 额度是 (用户 × 模板) 维度且永久有效,但用户关通知总开关会全部清零
- 无官方接口可查余额,只能自行记账并靠 43101 校正
- requestSubscribeMessage 必须由真实点击手势触发,无法在 onLaunch 静默调用
- 长期订阅类目不符,申请不到

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 16:44:19 +08:00

13 KiB
Raw Blame History

订阅消息额度治理 — 设计文档

日期: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. 目标与非目标

目标

  1. 把「静默失败、用户永不知情」变成「提前预警 + 一键补救」
  2. 额度不足时按明确优先级取舍,而非随机丢失
  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 增加取值 skippederror 字段记原因(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: ['张三', '李四']     // 去重后的「人名」,按发生先后排序,不截断
}

atRiskCountatRiskNames.length 含义不同且可能不相等:一个人的「提前」和「当天」两条提醒都告急时,占 2 条但只有 1 个名字。首页文案用的是人名,所以展示逻辑以 atRiskNames 为准。后端返回完整列表不截断,截断由前端负责。

atRisk 计算规则(后端算,前端只负责展示):

  1. 取该 openid 下所有 remindEnabled = 1 的纪念日
  2. 为每条展开未来 LOOKAHEAD_DAYS = 60 天内的提醒事件:
    • 当天事件:fireDate = 下一次发生日
    • 提前事件:fireDate = 下一次发生日 remindDays 天(仅当 remindDays > 0
    • 只保留 fireDate ∈ [今天, 今天 + 60天] 的事件
  3. fireDate 升序排序(这是真实的消耗顺序)
  4. balance 条视为安全,其余为 at risk
  5. 返回 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.requestSubscribeMessageaccept 后再上报 grant。上报失败走现有 sync 队列兜底。

首页 pages/index

  • onShow:并行做两件事
    1. /api/subscribegetsetData({ atRiskCount, atRiskNames })
    2. subscribe.getStatus(),把结果缓存this.data.subscribeStatus
  • wxmlatRiskCount > 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. 已知局限

  1. balance 是估算值,与微信侧可能不一致,只能靠 43101 事后校正
  2. 用户关闭通知总开关会使额度瞬间清零,我们无法主动感知,只能在下次发送失败时才发现
  3. 搭车补额度只对已勾选「总是保持」的用户有效;未勾选的用户完全依赖其主动点击提示卡片
  4. 若用户存了很多纪念日却极少打开小程序,供需仍然倒挂——本设计只能提前告知,无法凭空创造额度

13. 参考来源