# 订阅消息额度治理 — 设计文档 日期: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`): ```sql 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 返回: ```js { success: true, balance: 3, atRiskCount: 2, // 有风险的提醒「条数」(同一人可能占 2 条) atRiskNames: ['张三', '李四'] // 去重后的「人名」,按发生先后排序,不截断 } ``` `atRiskCount` 与 `atRiskNames.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.requestSubscribeMessage`,`accept` 后再上报 `grant`。上报失败走现有 `sync` 队列兜底。 ### 首页 `pages/index` - `onShow`:并行做两件事 1. 调 `/api/subscribe` 的 `get`,`setData({ atRiskCount, atRiskNames })` 2. 调 `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. 已知局限 1. `balance` 是估算值,与微信侧可能不一致,只能靠 43101 事后校正 2. 用户关闭通知总开关会使额度瞬间清零,我们无法主动感知,只能在下次发送失败时才发现 3. 搭车补额度**只对已勾选「总是保持」的用户有效**;未勾选的用户完全依赖其主动点击提示卡片 4. 若用户存了很多纪念日却极少打开小程序,供需仍然倒挂——本设计只能提前告知,无法凭空创造额度 ## 13. 参考来源 - [微信开放文档 — 订阅消息](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/subscribe-message-overview.html) - [微信开放文档 — wx.getSetting](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/setting/wx.getSetting.html) - [微信开放文档 — SubscriptionsSetting](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/setting/SubscriptionsSetting.html) - [微信开放社区 — 小程序一次性订阅消息详解](https://developers.weixin.qq.com/community/develop/article/doc/00006c4ea5ca80d4bcde43a755c813) - [微信开放社区 — 单用户下发次数限制](https://developers.weixin.qq.com/community/develop/doc/000a8498b786383747a10bf636bc00) - [TAP gesture 报错分析](https://blog.csdn.net/weixin_38091174/article/details/123667867)