From 6d0119103fb6a63d644965cd149818c22b7eff23 Mon Sep 17 00:00:00 2001 From: yuming Date: Sat, 15 Aug 2026 16:44:19 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3=EF=BC=9A?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E6=B6=88=E6=81=AF=E9=A2=9D=E5=BA=A6=E6=B2=BB?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录订阅消息额度供需倒挂问题的设计方案:服务端按 openid 记账、 用户点击时搭车补额度、额度不足时按优先级取舍并在首页预警。 核实并记录了几条决定设计走向的微信平台约束: - 额度是 (用户 × 模板) 维度且永久有效,但用户关通知总开关会全部清零 - 无官方接口可查余额,只能自行记账并靠 43101 校正 - requestSubscribeMessage 必须由真实点击手势触发,无法在 onLaunch 静默调用 - 长期订阅类目不符,申请不到 Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-08-15-订阅消息额度-design.md | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-15-订阅消息额度-design.md diff --git a/docs/superpowers/specs/2026-08-15-订阅消息额度-design.md b/docs/superpowers/specs/2026-08-15-订阅消息额度-design.md new file mode 100644 index 0000000..62b2e88 --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-订阅消息额度-design.md @@ -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)