设计文档:订阅消息额度治理
记录订阅消息额度供需倒挂问题的设计方案:服务端按 openid 记账、 用户点击时搭车补额度、额度不足时按优先级取舍并在首页预警。 核实并记录了几条决定设计走向的微信平台约束: - 额度是 (用户 × 模板) 维度且永久有效,但用户关通知总开关会全部清零 - 无官方接口可查余额,只能自行记账并靠 43101 校正 - requestSubscribeMessage 必须由真实点击手势触发,无法在 onLaunch 静默调用 - 长期订阅类目不符,申请不到 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
# 订阅消息额度治理 — 设计文档
|
||||
|
||||
日期: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)
|
||||
Reference in New Issue
Block a user