Files
wxserver/docs/superpowers/specs/2026-08-15-订阅消息额度-design.md
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

234 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 订阅消息额度治理 — 设计文档
日期: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)