Compare commits

..

24 Commits

Author SHA1 Message Date
yuming 3bf46f1114 版本号显示 v2.1.8 → v2.1.9
部署到群晖 / deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 11:11:22 +08:00
yuming 6e89ec8ea7 合并订阅消息额度治理
后端按 openid 记账订阅消息下发次数,发送时按优先级取舍、余额耗尽即停;
前端在用户点击时补额度,并在首页预告哪些人的提醒有风险。

微信一次性订阅消息「授权一次才能发一条」且不提供余额查询接口,原先每条纪念日
每年要发 2 条提醒、用户却只在保存时授权一次,供需倒挂导致提醒大量静默失败。

主要内容:
- 新增 subscribe_quota 表与 /api/subscribe 接口(授权上报 + 风险查询)
- 定时任务改为按用户分组,当天优先于提前、同级按重要程度,43101 时归零并中止
- 余额为 0 时发探针(每人每轮一条),成功则继续发,保证不比改动前更差
- 存量用户初始额度做成 db.js 里的幂等自动迁移,无需手工执行 SQL
- 删除/更新接口改为幂等,避免同步队列永久重试
- 首页新增风险提示卡片作为补额度主入口,文案说人名而非抽象额度
- 从零搭建后端测试基建,48 个测试

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:39:20 +08:00
yuming 23278fee06 补最终修复报告,并标注计划文档里已过时的手工 SQL
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:28:29 +08:00
yuming 7bf84bff61 保存纪念日去掉 await 上报并加防重入,避免弱网下存出重复数据
onSubmit 原本 await requestSubscribe(),而它内部的 grant 上报走 api.request、
超时 10 秒。对已勾「总是保持以上选择」的用户连订阅弹窗都不会出现,点「保存」后
界面最长 10 秒毫无反馈;期间又没有任何提交中标志,重复点击会走两遍
storage.addAnniversary(),generateId() 每次生成新 id,直接存出两条重复纪念日。

改为:订阅授权 fire-and-forget(上报失败本就有 pending_sync_queue 兜底),
onSubmit 去掉 async、加实例级 submitting 标志防重入,本地写失败时放开标志并提示。

手势铁律未破:onSubmit 由 bindtap 直接绑定,方法体去掉了 async,从入口到
requestSubscribe 之间只有 if 判断、解构和三个同步校验,无任何 await 或 then。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:25:05 +08:00
yuming e47a399ad5 余额为 0 时改为发探针,不再无条件全部跳过
balance 只有前端上报这一条上升通道,没升级小程序的老用户在微信侧仍在
真实累积额度却永远不上报,记账会系统性低估。低估到 0 就一条不发,比
改造前更糟——改造前至少还会试。

改为:余额为 0 时仍发出优先级最高的那一条当探针。成功则证实微信侧有
额度,本轮继续发;43101 则立即中止(与原行为一致);非额度错误记 failed
且不再重复探。每用户每轮最多探一次。

探针成功仍走 quota.consume:内部 MAX(0, balance-1) 扣不出负数,净效果是
balance 保持 0、sentTotal +1。不趁机把 balance 调高——探针只证明「至少
还有 1 条」,凭空补猜出来的数字会让首页风险预告变成乐观的假话。

注:三个原有用例断言的是「余额 0 就一个请求都不发」这一被本次刻意推翻的
旧契约,已按新行为改写;改写后仍用变异测试确认能抓到探针被关掉。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:23:11 +08:00
yuming 30a40252c6 存量用户初始额度改为 db.js 里的自动迁移
原计划是部署后上服务器手工执行一条 SQL 补额度,漏执行的后果是所有
存量用户的提醒立刻大面积降级。改成 PRAGMA user_version 驱动的一次性
自动迁移,部署即执行、不可能漏。

幂等性两层保证:user_version 版本号 + INSERT OR IGNORE 只补尚无记录的
openid,已有记录一律不覆盖。整段包在事务里。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:19:47 +08:00
yuming 12b146c2c9 两处提醒查询补 ORDER BY id,保证平局取舍可复现
reminder.js 扫全表和 index.js 的 getQuotaStatus 都没有 ORDER BY,
两边的 sort 又都是稳定排序,同一天、同 kind、同 importance 的条目
先后完全由 SQLite 返回顺序决定。一个走全表扫、一个可能走索引,
顺序一致纯属巧合——一旦不一致,首页预告的人名就和实际被跳过的人对不上。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 10:19:47 +08:00
yuming 981e802532 已长期订阅的用户在点击人员时静默补额度 2026-08-16 09:45:27 +08:00
yuming 233a872c89 修复补额度反馈自相矛盾及订阅状态未就绪兜底显式化 2026-08-16 09:41:52 +08:00
yuming 7f420762ef 首页新增额度风险提示卡片,作为补额度主入口 2026-08-16 09:34:55 +08:00
yuming bc4fb7445d 修复保存纪念日时授权订阅消息未上报额度的缺口
requestSubscribe() 改为委托给 utils/subscribe.js 的
requestAndReport(),用户同意授权后会把新增额度同步上报后端,
避免最主要的授权入口被漏记导致余额系统性低估、定时任务拒发。
onSubmit 里原来依赖 reject 的 try/catch 改为直接判断布尔返回值,
消除死代码分支,"拒绝不阻塞保存"的行为保持不变。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 09:31:03 +08:00
yuming 1142346e24 新增前端订阅模块,收敛模板 ID 到唯一来源
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 09:25:12 +08:00
yuming d8573359a2 修复首页风险预告与实际发送的排序不一致问题
atRisk.js 之前只按 fireInDays 排序,同一天内多条提醒的先后完全未定义,
和 reminder.js 实际发送时「当天>提前、重要程度降序」的规则对不上,导致
额度卡在中间时首页提示的风险人名和定时任务实际跳过的人不一致。

- 把 importanceRank 从 reminder.js 抽到共享的 src/importance.js,两处复用
- atRisk.js 的 computeAtRisk 排序改为三段式:fireInDays 升序 →
  同天内 kind(当天优先于提前)→ 同 kind 内重要程度降序
- 补充 atRisk.test.js 用例覆盖「同一天多条事件、额度只够一部分」场景
2026-08-16 09:21:09 +08:00
yuming c7bdd3770a 补上43101保留原始错误信息的区分性断言,修复wx测试的恢复时机
- reminder.test.js:43101用例新增对 remind_logs.error 的断言,区分
  「真发送失败」(应为微信原始错误信息)与「因halted被跳过」(应为
  quota_exhausted兜底文案),此前该区别完全没有测试覆盖
- wx.test.js:把恢复 axios.get/post 的语句移入 try/finally,避免断言
  先抛错时污染同进程内后续用例

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 09:14:50 +08:00
yuming 57470ade4f 补充发送侧分组与错误分支的测试覆盖,保留43101原始错误信息的修复
- reminder.js:43101 跳过日志改为记录微信原始错误信息,便于和「额度耗尽」的
  兜底文案区分排查(前一实施者已验证,此次原样带入提交)
- reminder.test.js:新增 runOnce 按 openid 分组结算用例,验证多用户额度互不
  串号;将「非额度类错误」用例扩到 2 条数据,证明该分支不会误中止后续发送
- 新建 wx.test.js:验证 sendSubscribeMessage 在微信返回非 0 errcode 时,
  抛出的 Error 对象确实带有正确的 errcode 属性

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 09:07:46 +08:00
yuming 6548577a98 定时任务改为按用户分组、按优先级发送,额度耗尽即停 2026-08-15 18:04:12 +08:00
yuming b1949ac73e 新增 /api/subscribe 接口:授权上报与风险查询
- grantQuota/getQuotaStatus 从 index.js 导出供测试直接调用
- app.listen 加 require.main 守卫,避免测试 require 本文件时
  真的起服务占用端口、cron 定时器让进程无法退出
2026-08-15 17:55:40 +08:00
yuming 3a61b7952b 新增订阅额度风险预警计算 2026-08-15 17:43:19 +08:00
yuming 07ee98f838 修复 consume 缺行不建行导致 sentTotal 丢失的问题 2026-08-15 17:40:31 +08:00
yuming 8d63b47577 新增订阅消息额度记账表与模块 2026-08-15 17:34:46 +08:00
yuming 68acbb3809 抽出日期计算纯函数模块,并搭建后端测试基建
- 新增 server/src/occurrence.js:startOfDay/getNextOccurrence/daysBetween,today 可注入
- reminder.js 改为调用新模块,删除内联的 getThisYearDate/daysBetween
- 引入 node:test 作为测试框架,加 npm test 脚本
- 新增 test/helper.js(临时库辅助,供后续任务用)和 test/occurrence.test.js(7 个用例,覆盖农历跨年等边界)
2026-08-15 17:30:42 +08:00
yuming c92df874d1 忽略子代理执行过程的临时产物目录 2026-08-15 17:27:37 +08:00
yuming 955c80e221 实现计划:订阅消息额度治理
9 个任务,含单测代码与端到端验证脚本。用 Node 18 内置的 node:test
做后端单测(零新依赖),前端沿用 miniprogram-automator。

Task 1 是前置重构:把 reminder.js 内联的日期计算抽成可注入 today 的
纯函数模块,让核心逻辑第一次变得可单测,也供风险预警计算复用。

计划里记录了上线首日的一个真空风险:subscribe_quota 是新表,存量用户
初始余额均为 0,而新版 reminder 在余额为 0 时不发任何请求——后端一上线
存量用户提醒会立刻全部停发,且此时新版小程序还在审核中,用户无从补救。
附了按纪念日条数补初始余额的 SQL,须在部署后、当天 9 点定时任务前执行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 16:55:18 +08:00
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
28 changed files with 3244 additions and 110 deletions
+3
View File
@@ -17,3 +17,6 @@ node_modules/
server/.env server/.env
server/data/ server/data/
server/node_modules/ server/node_modules/
# 子代理执行过程的临时产物(ledger、任务简报、评审包)
.superpowers/
File diff suppressed because it is too large Load Diff
@@ -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)
+47 -20
View File
@@ -3,8 +3,13 @@ const storage = require('../../utils/storage')
const dateUtils = require('../../utils/date') const dateUtils = require('../../utils/date')
const sync = require('../../utils/sync') const sync = require('../../utils/sync')
const lunar = require('../../utils/lunar') const lunar = require('../../utils/lunar')
const subscribe = require('../../utils/subscribe')
Page({ Page({
// 保存中标志。放在实例上而不是 data 里:它只用于防重入,不参与渲染,
// 走 setData 反而会多一次没必要的视图层通信。
submitting: false,
data: { data: {
anniversaryId: null, anniversaryId: null,
personId: null, personId: null,
@@ -275,8 +280,21 @@ Page({
/** /**
* 提交 * 提交
*
* ⚠️ 本方法绝对不能加 async,方法体内在调用 requestSubscribe 之前也不能出现任何 await
* 或其他异步跳跃(Promise.then、wx 异步 API 的回调里再调等):
* wx.requestSubscribeMessage 必须由用户的真实点击手势同步触发,一旦中间断开,
* 手势上下文丢失,真机上必然报 "fail can only be invoked by user TAP gesture"。
* 从 bindtap="onSubmit" 进来到 requestSubscribe 之间,只有 setData 读取和几个同步校验。
*/ */
async onSubmit() { onSubmit() {
// 防重入。
// Why:保存链路本身是同步的,但保存成功后要等 1.5 秒的 toast 才 navigateBack
// 这段时间用户完全可能再点一次「保存」;而 storage.addAnniversary 每次都会
// generateId() 生成一个新 id,重复点击的结果就是存出两条一模一样的纪念日。
// 注:这一句是同步的 if 判断,不影响下面订阅调用的手势上下文。
if (this.submitting) return
const { formData, anniversaryId, inputName } = this.data const { formData, anniversaryId, inputName } = this.data
let { personId } = this.data let { personId } = this.data
@@ -299,19 +317,26 @@ Page({
return return
} }
// 如果开启了提醒,请求订阅消息授权 // 如果开启了提醒,请求订阅消息授权
// 这里是 fire-and-forget,故意不 await
// 1) await 会切断手势上下文,订阅调用必然失败(见方法头注释);
// 2) requestAndReport 内部的 grant 上报走 api.request,超时长达 10 秒。对已勾选
// 「总是保持以上选择」的用户连订阅弹窗都不会出现,await 会让用户点完「保存」后
// 最长干等 10 秒、界面毫无反馈;
// 3) 上报失败本来就有 pending_sync_queue 兜底,下次启动会自动 flush,等它没有意义。
// requestAndReport 只 resolve、不 reject,所以这里不挂 catch 也不会产生未处理拒绝。
if (formData.remindEnabled) { if (formData.remindEnabled) {
try { this.requestSubscribe()
await this.requestSubscribe()
} catch (err) {
console.log('用户拒绝订阅消息')
}
} }
// 订阅调用已经发出,后面就没有手势上下文的顾虑了,可以安全地置位防重入标志
this.submitting = true
// 新增模式且没绑定 personId → 按姓名查找或自动创建 // 新增模式且没绑定 personId → 按姓名查找或自动创建
if (!anniversaryId && !personId) { if (!anniversaryId && !personId) {
const person = storage.ensurePerson(name) const person = storage.ensurePerson(name)
if (!person) { if (!person) {
this.submitting = false
wx.showToast({ title: '保存失败', icon: 'none' }) wx.showToast({ title: '保存失败', icon: 'none' })
return return
} }
@@ -348,6 +373,10 @@ Page({
wx.showToast({ title: '保存成功', icon: 'success' }) wx.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => wx.navigateBack(), 1500) setTimeout(() => wx.navigateBack(), 1500)
} else {
// 写本地失败:必须放开防重入标志,否则用户连重试的机会都没有
this.submitting = false
wx.showToast({ title: '保存失败', icon: 'none' })
} }
} else { } else {
// 新增模式 // 新增模式
@@ -363,27 +392,25 @@ Page({
wx.showToast({ title: '添加成功', icon: 'success' }) wx.showToast({ title: '添加成功', icon: 'success' })
setTimeout(() => wx.navigateBack(), 1500) setTimeout(() => wx.navigateBack(), 1500)
} else {
// 同上:写本地失败要放开标志,让用户能重试
this.submitting = false
wx.showToast({ title: '保存失败', icon: 'none' })
} }
} }
}, },
/** /**
* 请求订阅消息授权 * 请求订阅消息授权
* 委托给 utils/subscribe.js 的 requestAndReport:同意后会自动把新增额度
* 上报给后端(走同步队列,失败自动入队)。
* 注意:本方法不能加 async,且内部不能在调用 requestAndReport 之前插入
* 任何 await —— wx.requestSubscribeMessage 必须由用户点击手势同步触发,
* 一旦中间出现异步跳跃,手势上下文丢失会导致调用必然失败。
* @returns {Promise<boolean>} 用户是否同意
*/ */
requestSubscribe() { requestSubscribe() {
return new Promise((resolve, reject) => { return subscribe.requestAndReport()
wx.requestSubscribeMessage({
tmplIds: ['6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw'],
success: (res) => {
console.log('订阅消息授权结果:', res)
resolve(res)
},
fail: (err) => {
console.error('订阅消息授权失败:', err)
reject(err)
}
})
})
}, },
/** /**
+85 -2
View File
@@ -2,6 +2,8 @@
const storage = require('../../utils/storage') const storage = require('../../utils/storage')
const dateUtils = require('../../utils/date') const dateUtils = require('../../utils/date')
const fmt = require('../../utils/format') const fmt = require('../../utils/format')
const api = require('../../utils/api')
const subscribe = require('../../utils/subscribe')
Page({ Page({
data: { data: {
@@ -11,7 +13,8 @@ Page({
currentFilter: 'all', currentFilter: 'all',
totalCount: 0, totalCount: 0,
upcomingCount: 0, upcomingCount: 0,
todayText: '' todayText: '',
atRiskText: ''
}, },
onLoad() { onLoad() {
@@ -20,6 +23,9 @@ Page({
onShow() { onShow() {
this.loadPersons() this.loadPersons()
this.refreshQuota()
// 提前查好订阅状态缓存到实例上:点击处理函数里不能再 await,否则手势上下文会丢
subscribe.getStatus().then(status => { this.subscribeStatus = status })
}, },
/** /**
@@ -150,15 +156,92 @@ Page({
}, },
/** /**
* 点击人员 * 点击人员进详情
* 顺带搭车补额度:仅对已勾选「总是保持以上选择」的用户,此调用不弹窗、完全无感。
* 必须同步调用且放在 navigateTo 之前,否则手势上下文丢失。
*/ */
onPersonTap(e) { onPersonTap(e) {
if (this.subscribeStatus === 'silent') subscribe.requestAndReport()
const id = e.currentTarget.dataset.id const id = e.currentTarget.dataset.id
wx.navigateTo({ wx.navigateTo({
url: `/pages/person-detail/person-detail?id=${id}` url: `/pages/person-detail/person-detail?id=${id}`
}) })
}, },
/**
* 拉取额度风险,拼成给用户看的文案
* 说「谁的提醒有风险」而不是「还剩几次额度」——用户理解不了额度这种抽象概念
* @returns {Promise<boolean>} 本次刷新是否成功拿到了最新数据。
* 失败时不清空/覆盖 atRiskText——网络抖动时清空反而会掩盖真实存在的风险,
* 比"文案没更新"更糟;调用方需要这个返回值来判断该给用户什么反馈(见 onTopUp)。
*/
async refreshQuota() {
try {
const res = await api.subscribe('get')
if (!res || !res.success) return false
const names = res.atRiskNames || []
let text = ''
if (names.length > 0 && names.length <= 3) {
text = names.join('、')
} else if (names.length > 3) {
text = names.slice(0, 3).join('、') + ' 等 ' + names.length + ' 人'
}
this.setData({ atRiskText: text })
return true
} catch (e) {
// 查询失败就不显示提示,静默处理,不打扰用户;atRiskText 保留旧值不清空(理由见上)
console.warn('[index] 额度查询失败', e)
return false
}
},
/**
* 点击风险提示卡片补额度
* 注意:wx.requestSubscribeMessage / wx.openSetting 必须在这里同步调用,
* 前面绝不能有 await,否则手势上下文丢失、调用必然失败
*/
onTopUp() {
const status = this.subscribeStatus
if (status === 'rejected' || status === 'mainSwitchOff') {
wx.openSetting({ withSubscriptions: true })
return
}
// 显式处理 status === undefined,不让它靠"没匹配上第一个 if"隐式落地:
// undefined 出现在 onShow 里 subscribe.getStatus() 还没返回、用户就点了卡片的情况;
// 这里没法现查状态兜底——getStatus() 是异步的,哪怕 await 一下也会丢失手势上下文,
// 所以有意让 undefined 和 unknown(查询失败)一样,都保守地按 willPrompt 处理,
// 与 utils/subscribe.js 里「unknown 按 willPrompt 保守处理」的取向一致。
// 代价:如果用户真实状态其实是 mainSwitchOff(通知总开关关了),这里不会拦截去设置页,
// 只会静默调用 wx.requestSubscribeMessage,微信不弹窗、直接判定未同意,
// 用户只会看到下面「未开启提醒」的兜底提示,不会被引导去设置页——
// 这是已知且可接受的体验降级,不在本次修复范围内。
const isConservativeFallback = status === undefined || status === 'unknown'
if (isConservativeFallback) {
// 不阻断流程,只留一条日志方便排查——命中这条分支时用户体验会比正常
// 情况弱一点(见上面的注释),出问题时能在控制台里定位到具体是哪种取舍
console.warn('[index] onTopUp 命中保守兜底分支,status =', status)
}
subscribe.requestAndReport().then(accepted => {
if (!accepted) {
wx.showToast({ title: '未开启提醒', icon: 'none' })
return
}
// 补额度这步已经成功(微信侧确实同意了),但下面刷新风险文案是另一次独立请求,
// 可能失败;两者不能混为一谈,否则会出现"已补充"toast 和纹丝不动的风险卡片
// 同时出现的自相矛盾画面。用刷新结果决定 toast 措辞,不对刷新结果做绝对断言。
this.refreshQuota().then(refreshed => {
if (refreshed) {
wx.showToast({ title: '已补充提醒次数', icon: 'success' })
} else {
wx.showToast({ title: '已补充,稍后自动更新', icon: 'none' })
}
})
})
},
/** /**
* 点击添加按钮:直接进添加纪念日(方案 B:流程合并,姓名不存在自动建人) * 点击添加按钮:直接进添加纪念日(方案 B:流程合并,姓名不存在自动建人)
*/ */
+6
View File
@@ -18,6 +18,12 @@
</view> </view>
</view> </view>
<!-- 额度风险提示卡片:谁的提醒可能发不出去,同时是补额度的主入口 -->
<view wx:if="{{atRiskText}}" class="quota-warn" bindtap="onTopUp">
<text class="quota-warn-icon">⚠️</text>
<text class="quota-warn-text">{{atRiskText}}的生日提醒可能发不出去,点这里补上</text>
</view>
<!-- 搜索栏 --> <!-- 搜索栏 -->
<view class="search-wrap"> <view class="search-wrap">
<view class="search-box"> <view class="search-box">
+24
View File
@@ -391,3 +391,27 @@ page {
line-height: 1; line-height: 1;
margin-top: -4rpx; margin-top: -4rpx;
} }
/* 额度风险提示卡片:仅在有提醒可能发不出去时出现 */
.quota-warn {
display: flex;
align-items: center;
margin: 0 32rpx 24rpx;
padding: 24rpx 28rpx;
background: #FBF3E4;
border: 2rpx solid #E0C89A;
border-radius: 12rpx;
}
.quota-warn-icon {
flex-shrink: 0;
margin-right: 16rpx;
font-size: 32rpx;
}
.quota-warn-text {
flex: 1;
font-size: 26rpx;
line-height: 1.5;
color: #8A5A1F;
}
+1 -1
View File
@@ -19,7 +19,7 @@ Page({
personsCount: 0, personsCount: 0,
anniversariesCount: 0, anniversariesCount: 0,
lastBackupText: '从未备份', lastBackupText: '从未备份',
version: 'v2.1.8' version: 'v2.1.9'
}, },
onLoad() { onLoad() {
+2 -1
View File
@@ -5,7 +5,8 @@
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "node --watch src/index.js" "dev": "node --watch src/index.js",
"test": "node --test test/"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.7", "axios": "^1.7.7",
+74
View File
@@ -0,0 +1,74 @@
/**
* 风险预警计算:算出未来一段时间内,有哪些提醒会因额度不足发不出去
*
* 首页拿这个结果给用户看「谁的提醒有风险」,而不是「还剩几次额度」——
* 用户理解不了额度这种抽象概念,但看得懂人名。
*/
const { getNextOccurrence, daysBetween } = require('./occurrence')
const { importanceRank } = require('./importance')
// 预警窗口。太短则提示不及时,太长会拿几个月后的事惊扰用户,两个月是折中。
const LOOKAHEAD_DAYS = 60
/**
* 把一条纪念日展开成未来窗口内的提醒事件
* 每条纪念日最多产生两个事件:提前 N 天一个、当天一个
*/
function expandEvents(anniv, today = new Date()) {
const next = getNextOccurrence(anniv, today)
const onDayIn = daysBetween(next, today)
const remindDays = anniv.remindDays || 0
const events = [{
anniversaryId: anniv.id,
personName: anniv.personName,
kind: 'onDay',
fireInDays: onDayIn,
importance: anniv.importance
}]
if (remindDays > 0) {
events.push({
anniversaryId: anniv.id,
personName: anniv.personName,
kind: 'ahead',
fireInDays: onDayIn - remindDays,
importance: anniv.importance
})
}
// 已经过去的(如提前提醒的时点早已过)和超出窗口的都不算
return events.filter(e => e.fireInDays >= 0 && e.fireInDays <= LOOKAHEAD_DAYS)
}
/**
* 算出有风险的提醒
*
* 排序必须与 reminder.js 的 runForUser 完全一致,否则首页提示的人名会跟
* 定时任务实际跳过的人对不上:条数一样、人名却错了,用户会去补错人的额度。
* 排序规则:先按 fireInDays 升序(跨天的额度是先到先消耗),
* 同一天内再按「当天 > 提前」,然后按重要程度降序(重要程度权重见 importance.js)。
*/
function computeAtRisk(anniversaries, balance, today = new Date()) {
const events = (anniversaries || [])
.filter(a => a && a.remindEnabled)
.flatMap(a => expandEvents(a, today))
.sort((x, y) => {
if (x.fireInDays !== y.fireInDays) return x.fireInDays - y.fireInDays
if (x.kind !== y.kind) return x.kind === 'onDay' ? -1 : 1
return importanceRank(x.importance) - importanceRank(y.importance)
})
const safeCount = Math.max(0, balance)
const atRisk = events.slice(safeCount)
const names = []
for (const e of atRisk) {
if (e.personName && !names.includes(e.personName)) names.push(e.personName)
}
return { atRiskCount: atRisk.length, atRiskNames: names }
}
module.exports = { LOOKAHEAD_DAYS, expandEvents, computeAtRisk }
+54
View File
@@ -62,6 +62,14 @@ db.exec(`
); );
CREATE INDEX IF NOT EXISTS idx_log_anniv ON remind_logs(anniversaryId); CREATE INDEX IF NOT EXISTS idx_log_anniv ON remind_logs(anniversaryId);
CREATE INDEX IF NOT EXISTS idx_log_date ON remind_logs(sendDate); CREATE INDEX IF NOT EXISTS idx_log_date ON remind_logs(sendDate);
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
);
`) `)
// 旧库迁移:CREATE TABLE IF NOT EXISTS 不会给已存在的表加列,需要手动 ALTER // 旧库迁移:CREATE TABLE IF NOT EXISTS 不会给已存在的表加列,需要手动 ALTER
@@ -78,4 +86,50 @@ tryAddColumn('anniversaries', 'lunarMonth', 'INTEGER')
tryAddColumn('anniversaries', 'lunarDay', 'INTEGER') tryAddColumn('anniversaries', 'lunarDay', 'INTEGER')
tryAddColumn('anniversaries', 'isLeapMonth', 'INTEGER DEFAULT 0') tryAddColumn('anniversaries', 'isLeapMonth', 'INTEGER DEFAULT 0')
// ---- 版本化数据迁移 ----
//
// 用 SQLite 的 PRAGMA user_version 记录「这个库已经跑到第几版迁移」。
// 它是存在数据库文件头里的一个整数,读写都随事务提交/回滚,天生适合做迁移标记
// (见 MAINTENANCE.md 场景 11)。新增迁移的做法:写一个 migrateVN()
// 在 runMigrations 里加一行 `if (current < N) migrateVN()`,并把 SCHEMA_VERSION 改成 N。
const SCHEMA_VERSION = 1
/**
* v1:给存量用户补订阅额度初始值
*
* Whysubscribe_quota 是随「订阅消息额度治理」新建的表,所有存量用户余额都是 0。
* 而 reminder.js 在余额为 0 时只会发一条探针,其余按优先级取舍——不补的话,
* 后端一上线,老用户的提醒会立刻大面积降级,而他们手机上还是旧版小程序,
* 根本没有补额度的入口。
* 存量用户过去每存一条纪念日就授权过一次订阅,所以按「该用户的纪念日条数」
* 估算初始余额是合理的(这也是原计划文档里那条手工 SQL 的口径)。
* 做成代码里的自动迁移是为了「部署即执行」,不依赖人记得上服务器敲命令。
*
* 幂等性有两层保证:外层的 user_version 版本号(跑过就不再进来),
* 以及 INSERT OR IGNORE(只给 subscribe_quota 里尚无记录的 openid 补,绝不覆盖已有记录)。
*/
function migrateV1() {
db.prepare(`
INSERT OR IGNORE INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime)
SELECT openid, COUNT(*), COUNT(*), 0, ?
FROM anniversaries
WHERE openid IS NOT NULL
GROUP BY openid
`).run(Date.now())
}
function runMigrations() {
const current = db.pragma('user_version', { simple: true })
if (current >= SCHEMA_VERSION) return
// 整段包在事务里:迁移和版本号必须同生共死,中途崩了要能整体回滚重来
db.transaction(() => {
if (current < 1) migrateV1()
db.pragma(`user_version = ${SCHEMA_VERSION}`)
})()
console.log(`[db] 数据迁移完成: user_version ${current} -> ${SCHEMA_VERSION}`)
}
runMigrations()
module.exports = db module.exports = db
+14
View File
@@ -0,0 +1,14 @@
/**
* 重要程度排序权重(纯函数)
*
* 从 reminder.js 抽出,供「定时发送」(reminder.js)与「风险预警」(atRisk.js
* 共用同一套排序权重,避免两处各写一套、后续改一处忘了改另一处导致排序不一致。
*/
// 重要程度排序权重,未知值排最后
const IMPORTANCE_RANK = { high: 0, medium: 1, low: 2 }
function importanceRank(v) {
return IMPORTANCE_RANK[v] === undefined ? 3 : IMPORTANCE_RANK[v]
}
module.exports = { importanceRank }
+44
View File
@@ -4,6 +4,8 @@ const express = require('express')
const db = require('./db') const db = require('./db')
const wx = require('./wx') const wx = require('./wx')
const reminder = require('./reminder') const reminder = require('./reminder')
const quota = require('./quota')
const atRisk = require('./atRisk')
const app = express() const app = express()
app.use(express.json({ limit: '1mb' })) app.use(express.json({ limit: '1mb' }))
@@ -221,6 +223,41 @@ function getPersons(openid) {
return { success: true, data: rows } return { success: true, data: rows }
} }
// ---- 订阅额度 ----
// 前端授权成功后上报。纯累加、不去重:微信侧确实每次授权都 +1,如实记录即可。
function grantQuota(openid, data) {
const count = (data && data.count) || 1
return { success: true, balance: quota.grant(openid, count) }
}
// 首页查询:返回余额和「哪几个人的提醒有风险」
function getQuotaStatus(openid) {
const balance = quota.getBalance(openid)
// ORDER BY id 必须与 reminder.js 的取数保持一致:平局(同日、同 kind、同 importance)时
// 先后顺序完全由这里决定,两边不一致就会出现「预告的人名不是真正被跳过的人」
const rows = db.prepare('SELECT * FROM anniversaries WHERE openid = ? AND remindEnabled = 1 ORDER BY id').all(openid)
const { atRiskCount, atRiskNames } = atRisk.computeAtRisk(rows.map(normalize), balance)
return { success: true, balance, atRiskCount, atRiskNames }
}
app.post('/api/subscribe', (req, res) => {
try {
const openid = req.headers['x-openid'] || req.body.openid
if (!openid) return res.json({ success: false, error: '缺少 openid' })
const { action, data } = req.body
switch (action) {
case 'grant': return res.json(grantQuota(openid, data))
case 'get': return res.json(getQuotaStatus(openid))
default: return res.json({ success: false, error: '未知操作' })
}
} catch (err) {
console.error('订阅额度操作失败:', err.message)
res.json({ success: false, error: err.message })
}
})
// 手动触发提醒任务(便于调试,无需等到定点) // 手动触发提醒任务(便于调试,无需等到定点)
app.post('/api/reminder/run', async (req, res) => { app.post('/api/reminder/run', async (req, res) => {
try { try {
@@ -231,10 +268,17 @@ app.post('/api/reminder/run', async (req, res) => {
} }
}) })
module.exports = { grantQuota, getQuotaStatus }
// ---- 启动 ---- // ---- 启动 ----
// 只在直接执行本文件(node src/index.js / npm start)时才真正监听端口、注册定时任务。
// 测试用 require('../src/index') 只是拿函数引用,不需要(也不能)真的起服务——
// 否则会占住端口且 cron 定时器让进程一直不退出,测试永远卡住。
if (require.main === module) {
const PORT = process.env.PORT || 3000 const PORT = process.env.PORT || 3000
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`生日提醒后端已启动,监听 ${PORT}`) console.log(`生日提醒后端已启动,监听 ${PORT}`)
reminder.start() reminder.start()
}) })
}
+48
View File
@@ -0,0 +1,48 @@
/**
* 纪念日日期计算(纯函数,today 可注入以便测试)
*
* 从 reminder.js 抽出,供定时发送与「风险预警」计算共用,避免两处各写一套算错。
*/
const lunar = require('./lunar')
// 把日期归到当天 00:00:00,消除时分秒对比较和天数差的干扰
function startOfDay(date) {
const d = new Date(date)
d.setHours(0, 0, 0, 0)
return d
}
/**
* 算出某条纪念日的下一次发生日(今天或以后最近的一次)
*
* 农历分支以「农历年」为基准递推:公历年和农历年不对齐,农历腊月通常落在下一个公历年,
* 直接拿公历年当农历年传会整整错一年。
* isLunar 为真但缺 lunarMonth/lunarDay 的老数据,安全降级按公历算。
*/
function getNextOccurrence(anniv, today = new Date()) {
const base = startOfDay(today)
if (anniv.isLunar && anniv.lunarMonth && anniv.lunarDay) {
const wantLeap = !!anniv.isLeapMonth
const todayLunar = lunar.solarToLunar(base)
let target = startOfDay(lunar.lunarToSolar(todayLunar.year, anniv.lunarMonth, anniv.lunarDay, wantLeap))
if (target < base) {
target = startOfDay(lunar.lunarToSolar(todayLunar.year + 1, anniv.lunarMonth, anniv.lunarDay, wantLeap))
}
return target
}
let target = startOfDay(new Date(base.getFullYear(), anniv.solarMonth - 1, anniv.solarDay))
if (target < base) {
target = startOfDay(new Date(base.getFullYear() + 1, anniv.solarMonth - 1, anniv.solarDay))
}
return target
}
// 距离目标日还有多少天(负数表示已过)
function daysBetween(target, today = new Date()) {
return Math.round((startOfDay(target) - startOfDay(today)) / 86400000)
}
module.exports = { startOfDay, getNextOccurrence, daysBetween }
+65
View File
@@ -0,0 +1,65 @@
/**
* 订阅消息额度记账
*
* ⚠️ balance 是估算值,不是权威数据。真值只存在于微信服务器,且没有接口可查。
* 我们靠「前端授权成功上报 +1、发送成功 -1」维护,并在发送返回 43101 时归零校正。
* 任何地方都不要把 balance 当作可信的强校验依据。
*
* 额度是「每用户 × 每模板」维度的,所以以 openid 为主键;本项目只有一个模板,故不再分列。
*/
const db = require('./db')
const MAX_GRANT_PER_CALL = 50 // 单次上报上限,防御异常入参
function getBalance(openid) {
const row = db.prepare('SELECT balance FROM subscribe_quota WHERE openid = ?').get(openid)
return row ? row.balance : 0
}
// 把任意入参收敛成 1..MAX_GRANT_PER_CALL 的整数
function _normalize(count) {
const n = parseInt(count, 10)
if (!Number.isFinite(n) || n < 1) return 1
return Math.min(n, MAX_GRANT_PER_CALL)
}
// 授权上报:纯累加、不去重——微信侧确实每次授权都 +1,如实记录即可
function grant(openid, count = 1) {
const n = _normalize(count)
db.prepare(`
INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime)
VALUES (@openid, @n, @n, 0, @now)
ON CONFLICT(openid) DO UPDATE SET
balance = balance + @n,
grantedTotal = grantedTotal + @n,
updateTime = @now
`).run({ openid, n, now: Date.now() })
return getBalance(openid)
}
// 消费:即便该 openid 从未 grant 过也要建行,否则 sentTotal 会静默丢失
// (新建行 balance 直接落 0,等价于「从 0 扣减再取 MAX(0, ...)」)
function consume(openid, count = 1) {
const n = _normalize(count)
db.prepare(`
INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime)
VALUES (@openid, 0, 0, @n, @now)
ON CONFLICT(openid) DO UPDATE SET
balance = MAX(0, balance - @n),
sentTotal = sentTotal + @n,
updateTime = @now
`).run({ openid, n, now: Date.now() })
return getBalance(openid)
}
// 余额归零:发送返回 43101 时调用,说明微信侧实际已无额度(或用户关了通知总开关)
function reset(openid) {
db.prepare(`
INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime)
VALUES (@openid, 0, 0, 0, @now)
ON CONFLICT(openid) DO UPDATE SET balance = 0, updateTime = @now
`).run({ openid, now: Date.now() })
}
module.exports = { getBalance, grant, consume, reset }
+134 -74
View File
@@ -1,7 +1,9 @@
const cron = require('node-cron') const cron = require('node-cron')
const db = require('./db') const db = require('./db')
const wx = require('./wx') const wx = require('./wx')
const lunar = require('./lunar') const occurrence = require('./occurrence')
const quota = require('./quota')
const { importanceRank } = require('./importance')
const TEMPLATE_ID = process.env.WX_TEMPLATE_ID const TEMPLATE_ID = process.env.WX_TEMPLATE_ID
const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal' const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal'
@@ -27,40 +29,6 @@ function formatDate(date) {
return `${y}${m}${d}` return `${y}${m}${d}`
} }
// 算出"距离今年(或明年)这个纪念日还有多少天"
// 农历纪念日按 lunarMonth/lunarDay 计算每年对应的公历日期,否则按 solarMonth/solarDay
function getThisYearDate(anniv) {
const today = new Date()
today.setHours(0, 0, 0, 0)
if (anniv.isLunar && anniv.lunarMonth && anniv.lunarDay) {
// 闰月生日按 A 方案处理:若当年无对应闰月,lunarToSolar 内部已自动按普通月份算
const todayLunar = lunar.solarToLunar(today)
const wantLeap = !!anniv.isLeapMonth
let target = lunar.lunarToSolar(todayLunar.year, anniv.lunarMonth, anniv.lunarDay, wantLeap)
target.setHours(0, 0, 0, 0)
if (target < today) {
target = lunar.lunarToSolar(todayLunar.year + 1, anniv.lunarMonth, anniv.lunarDay, wantLeap)
target.setHours(0, 0, 0, 0)
}
return target
}
let target = new Date(today.getFullYear(), anniv.solarMonth - 1, anniv.solarDay)
target.setHours(0, 0, 0, 0)
if (target < today) {
target = new Date(today.getFullYear() + 1, anniv.solarMonth - 1, anniv.solarDay)
target.setHours(0, 0, 0, 0)
}
return target
}
function daysBetween(target) {
const today = new Date()
today.setHours(0, 0, 0, 0)
return Math.round((target - today) / 86400000)
}
// 检查今天是否已经给这条纪念日发过提醒 // 检查今天是否已经给这条纪念日发过提醒
function alreadySentToday(anniversaryId) { function alreadySentToday(anniversaryId) {
const start = new Date() const start = new Date()
@@ -76,71 +44,163 @@ const insertLog = db.prepare(`
VALUES (@anniversaryId, @personName, @typeName, @daysUntil, @sendDate, @status, @error) VALUES (@anniversaryId, @personName, @typeName, @daysUntil, @sendDate, @status, @error)
`) `)
async function runOnce() { // 43101 = 用户拒收或下发次数不足,是我们与微信侧对账的唯一信号
console.log('[reminder] 开始扫描纪念日...') function isQuotaError(err) {
return err && err.errcode === 43101
}
const list = db.prepare('SELECT * FROM anniversaries WHERE remindEnabled = 1').all() /**
console.log(`[reminder] 启用提醒的纪念日 ${list.length}`) * 处理单个用户的当日提醒
*
* 按「当天 > 提前」、同级「重要程度降序」排序后依次发送。
* 记账余额耗尽时不会直接全部放弃,而是发一条「探针」向微信求证(见下方 probe 注释)。
*/
async function runForUser(openid, items, today = new Date()) {
const due = []
for (const anniv of items) {
const target = occurrence.getNextOccurrence(anniv, today)
const daysUntil = occurrence.daysBetween(target, today)
const remindDays = anniv.remindDays || 0
let ok = 0 const isOnDay = daysUntil === 0
let fail = 0 const isAhead = remindDays > 0 && daysUntil === remindDays
if (!isOnDay && !isAhead) continue
if (alreadySentToday(anniv.id)) continue
for (const anniv of list) { due.push({ anniv, target, daysUntil, kind: isOnDay ? 'onDay' : 'ahead' })
try { }
const target = getThisYearDate(anniv)
const daysUntil = daysBetween(target)
const shouldRemind = (daysUntil === 0) || (daysUntil === (anniv.remindDays || 0)) due.sort((x, y) => {
if (!shouldRemind) continue if (x.kind !== y.kind) return x.kind === 'onDay' ? -1 : 1
return importanceRank(x.anniv.importance) - importanceRank(y.anniv.importance)
})
if (alreadySentToday(anniv.id)) { let balance = quota.getBalance(openid)
console.log(`[reminder] 今天已发过,跳过: ${anniv.personName}`) let halted = false
let ok = 0, fail = 0, skipped = 0
// ---- 探针机制 ----
// balance 只有「前端授权后上报 +1」这一条上升通道。没升级小程序的老用户在微信侧
// 仍在真实累积额度(每存一条纪念日就授权一次)却永远不会上报,于是我们的记账会
// 系统性低估。如果低估到 0 就一条都不发,反而比改造前更糟——改造前至少还会试一下。
//
// 所以余额为 0 时仍然把「优先级最高的那一条」发出去,拿它当探针问微信要个答案:
// 探针成功 → 证实微信侧确实还有额度、是我们低估了,本轮继续往下发,
// 直到遇到 43101 或发完
// 探针返回 43101 → 证实确实没额度,立即中止,剩余全部记 skipped(与原行为一致)
// 探针遇非额度错误 → 什么都没证明(网络/配置问题),记 failed、不动余额、不中止
// 每个用户每轮最多探一次:在「有额度」这个结论被证实之前,不值得再赌第二个请求。
let probeUsed = false // 本轮已经用掉探针机会
let quotaProven = false // 探针已证实微信侧有额度,后续不必再受记账余额约束
// errorMsg 默认是「根本没发请求」的兜底文案;真正打了请求并被 43101 拒绝的那一条
// 会传入微信返回的原始错误信息,两者都记为 skipped,但 error 字段要能区分开
const logSkip = (item, errorMsg = 'quota_exhausted') => insertLog.run({
anniversaryId: item.anniv.id,
personName: item.anniv.personName,
typeName: getTypeName(item.anniv.type, item.anniv.customTypeName),
daysUntil: item.daysUntil,
sendDate: Date.now(),
status: 'skipped',
error: errorMsg
})
for (const item of due) {
if (halted) {
logSkip(item); skipped++
continue continue
} }
const typeName = getTypeName(anniv.type, anniv.customTypeName) // 记账余额为 0 且尚未证实微信侧有额度时,只允许发一条探针
let isProbe = false
if (balance <= 0 && !quotaProven) {
if (probeUsed) {
logSkip(item); skipped++
continue
}
isProbe = true
probeUsed = true
}
const anniv = item.anniv
const typeName = getTypeName(anniv.type, anniv.customTypeName)
try {
await wx.sendSubscribeMessage({ await wx.sendSubscribeMessage({
touser: anniv.openid, touser: openid,
page: 'pages/index/index', page: 'pages/index/index',
templateId: TEMPLATE_ID, templateId: TEMPLATE_ID,
miniprogramState: MINIPROGRAM_STATE, miniprogramState: MINIPROGRAM_STATE,
data: { data: {
name1: { value: anniv.personName }, name1: { value: anniv.personName },
thing2: { value: daysUntil === 0 ? '今天' : `还有${daysUntil}` }, thing2: { value: item.daysUntil === 0 ? '今天' : `还有${item.daysUntil}` },
thing6: { value: formatDate(target) }, thing6: { value: formatDate(item.target) },
thing5: { value: anniv.remark || '别忘了准备一份礼物哦!' } thing5: { value: anniv.remark || '别忘了准备一份礼物哦!' }
} }
}) })
// 探针成功时余额怎么记:照常 consume。
// consume 内部是 MAX(0, balance - 1),余额本来就是 0,扣不出负数,
// 净效果是「balance 保持 0、sentTotal +1」。
// 之所以不趁机把 balance 调高:微信不提供余额查询,探针只证明「至少还有 1 条」,
// 凭空补一个猜出来的数字会让首页的风险预告变成乐观的假话。宁可让 balance 保持
// 保守的 0,靠每轮的探针去发现真实额度——账面继续低估是安全的,因为低估不再等于停发。
quota.consume(openid, 1)
balance = Math.max(0, balance - 1)
if (isProbe) {
quotaProven = true
console.log(`[reminder] ${openid} 探针发送成功,微信侧仍有额度,记账偏低,本轮继续发送`)
}
insertLog.run({ insertLog.run({
anniversaryId: anniv.id, anniversaryId: anniv.id, personName: anniv.personName, typeName,
personName: anniv.personName, daysUntil: item.daysUntil, sendDate: Date.now(), status: 'success', error: null
typeName,
daysUntil,
sendDate: Date.now(),
status: 'success',
error: null
}) })
ok++ ok++
console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${daysUntil}天)`) console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${item.daysUntil}天)`)
} catch (err) { } catch (err) {
if (isQuotaError(err)) {
// 微信侧实际已无额度(或用户关了通知总开关),归零并停止骚扰
quota.reset(openid)
balance = 0
halted = true
logSkip(item, err.message); skipped++
console.warn(`[reminder] ${openid} 额度已耗尽,本轮剩余全部跳过: ${err.message}`)
} else {
insertLog.run({
anniversaryId: anniv.id, personName: anniv.personName, typeName: null,
daysUntil: null, sendDate: Date.now(), status: 'failed', error: err.message
})
fail++ fail++
console.error(`[reminder] 发送失败: ${anniv.personName}`, err.message) console.error(`[reminder] 发送失败: ${anniv.personName}`, err.message)
insertLog.run({ }
anniversaryId: anniv.id,
personName: anniv.personName,
typeName: null,
daysUntil: null,
sendDate: Date.now(),
status: 'failed',
error: err.message
})
} }
} }
console.log(`[reminder] 完成: 成功 ${ok}, 失败 ${fail}`) return { ok, fail, skipped }
return { total: list.length, ok, fail } }
async function runOnce() {
console.log('[reminder] 开始扫描纪念日...')
// ORDER BY id 不是为了排序好看,而是为了「平局可复现」:
// 同一天、同 kind、同 importance 的条目在 sort 里比不出先后(JS sort 是稳定的),
// 最终取舍就取决于 SQLite 的返回顺序。这里和 index.js 的 getQuotaStatus 必须用同一个
// 兜底顺序,否则首页预告「谁有风险」的人名会和实际被跳过的人对不上。
const list = db.prepare('SELECT * FROM anniversaries WHERE remindEnabled = 1 ORDER BY id').all()
console.log(`[reminder] 启用提醒的纪念日 ${list.length}`)
// 额度是按用户算的,所以必须分组处理
const byOpenid = new Map()
for (const a of list) {
if (!byOpenid.has(a.openid)) byOpenid.set(a.openid, [])
byOpenid.get(a.openid).push(a)
}
let ok = 0, fail = 0, skipped = 0
for (const [openid, items] of byOpenid) {
const r = await runForUser(openid, items)
ok += r.ok; fail += r.fail; skipped += r.skipped
}
console.log(`[reminder] 完成: 成功 ${ok}, 失败 ${fail}, 跳过 ${skipped}`)
return { total: list.length, ok, fail, skipped }
} }
function start() { function start() {
@@ -153,4 +213,4 @@ function start() {
console.log(`[reminder] 定时任务已注册: ${expr} (Asia/Shanghai)`) console.log(`[reminder] 定时任务已注册: ${expr} (Asia/Shanghai)`)
} }
module.exports = { start, runOnce } module.exports = { start, runOnce, runForUser }
+3 -1
View File
@@ -49,7 +49,9 @@ async function sendSubscribeMessage({ touser, page, data, templateId, miniprogra
} }
const { data: res } = await axios.post(url, body, { timeout: 8000 }) const { data: res } = await axios.post(url, body, { timeout: 8000 })
if (res.errcode !== 0) { if (res.errcode !== 0) {
throw new Error(`发送订阅消息失败: ${JSON.stringify(res)}`) const err = new Error(`发送订阅消息失败: ${JSON.stringify(res)}`)
err.errcode = res.errcode // 让调用方能精确判断 43101,而不是靠字符串匹配
throw err
} }
return res return res
} }
+125
View File
@@ -0,0 +1,125 @@
const test = require('node:test')
const assert = require('node:assert')
const { expandEvents, computeAtRisk, LOOKAHEAD_DAYS } = require('../src/atRisk')
const TODAY = new Date(2026, 7, 15) // 2026-08-15
// 距今 10 天的公历纪念日,提前 3 天提醒
const near = {
id: 'a1', personName: '张三', remindEnabled: 1, importance: 'high',
isLunar: false, solarMonth: 8, solarDay: 25, remindDays: 3
}
// 距今 40 天
const mid = {
id: 'a2', personName: '李四', remindEnabled: 1, importance: 'low',
isLunar: false, solarMonth: 9, solarDay: 24, remindDays: 7
}
test('LOOKAHEAD_DAYS 为 60', () => {
assert.strictEqual(LOOKAHEAD_DAYS, 60)
})
test('一条纪念日展开出「当天」和「提前」两个事件', () => {
const events = expandEvents(near, TODAY)
assert.strictEqual(events.length, 2)
const onDay = events.find(e => e.kind === 'onDay')
const ahead = events.find(e => e.kind === 'ahead')
assert.strictEqual(onDay.fireInDays, 10)
assert.strictEqual(ahead.fireInDays, 7)
})
test('remindDays 为 0 时只有当天事件', () => {
const events = expandEvents({ ...near, remindDays: 0 }, TODAY)
assert.strictEqual(events.length, 1)
assert.strictEqual(events[0].kind, 'onDay')
})
test('超出 60 天窗口的事件被过滤掉', () => {
const far = { ...near, solarMonth: 12, solarDay: 25, remindDays: 3 }
assert.strictEqual(expandEvents(far, TODAY).length, 0)
})
test('提前日已过、当天未到时,只剩当天事件', () => {
// 距今 2 天,但提前 7 天的时点早已过去
const soon = { ...near, solarMonth: 8, solarDay: 17, remindDays: 7 }
const events = expandEvents(soon, TODAY)
assert.strictEqual(events.length, 1)
assert.strictEqual(events[0].kind, 'onDay')
})
test('余额足够时没有风险', () => {
const r = computeAtRisk([near, mid], 10, TODAY)
assert.strictEqual(r.atRiskCount, 0)
assert.deepStrictEqual(r.atRiskNames, [])
})
test('余额为 0 时全部有风险', () => {
const r = computeAtRisk([near, mid], 0, TODAY)
assert.strictEqual(r.atRiskCount, 4)
assert.deepStrictEqual(r.atRiskNames, ['张三', '李四'])
})
test('按时间先后消耗额度,后面的才有风险', () => {
// 张三的两个事件在第 7、10 天,李四的在第 33、40 天
const r = computeAtRisk([near, mid], 2, TODAY)
assert.strictEqual(r.atRiskCount, 2)
assert.deepStrictEqual(r.atRiskNames, ['李四'])
})
test('人名去重,同一人占两条只出现一次', () => {
const r = computeAtRisk([near], 0, TODAY)
assert.strictEqual(r.atRiskCount, 2)
assert.deepStrictEqual(r.atRiskNames, ['张三'])
})
test('未开启提醒的纪念日不参与计算', () => {
const r = computeAtRisk([{ ...near, remindEnabled: 0 }], 0, TODAY)
assert.strictEqual(r.atRiskCount, 0)
})
// ------ 同一天内排序需与 reminder.js 的 runForUser 完全对齐 ------
// 四条纪念日都恰好在第 5 天产生一个事件:
// 甲(当天/high) 乙(当天/low) 丙(提前/high) 丁(提前/medium)
// 丙、丁的「当天」事件被安排在第 65 天(超出 60 天窗口),不会进入计算,
// 这样每人恰好只贡献一个事件,排序结果可以精确断言。
const onDayHigh = {
id: 'e1', personName: '甲', remindEnabled: 1, importance: 'high',
isLunar: false, solarMonth: 8, solarDay: 20, remindDays: 0
}
const onDayLow = {
id: 'e2', personName: '乙', remindEnabled: 1, importance: 'low',
isLunar: false, solarMonth: 8, solarDay: 20, remindDays: 0
}
const aheadHigh = {
id: 'e3', personName: '丙', remindEnabled: 1, importance: 'high',
isLunar: false, solarMonth: 10, solarDay: 19, remindDays: 60
}
const aheadMedium = {
id: 'e4', personName: '丁', remindEnabled: 1, importance: 'medium',
isLunar: false, solarMonth: 10, solarDay: 19, remindDays: 60
}
// 刻意打乱输入顺序(不按「当天>提前、重要程度降序」排列),
// 这样如果排序逻辑退化成只按 fireInDays 排(Array.sort 是稳定排序,
// 同值会保留输入顺序),测试才能真正暴露出排序规则失效,而不是被输入顺序碰巧掩盖。
const sameDayGroup = [aheadMedium, aheadHigh, onDayLow, onDayHigh]
test('同一天内,当天事件排在提前事件之前(与 reminder.js 一致)', () => {
// 额度只够 2 条:应先消耗「当天」的甲、乙,风险留给「提前」的丙、丁
const r = computeAtRisk(sameDayGroup, 2, TODAY)
assert.strictEqual(r.atRiskCount, 2)
assert.deepStrictEqual(r.atRiskNames, ['丙', '丁'])
})
test('同一天内,当天事件按重要程度降序(high 先于 low)', () => {
// 额度只够 1 条:当天里 high 的甲应排在 low 的乙前面,先被消耗
const r = computeAtRisk(sameDayGroup, 1, TODAY)
assert.strictEqual(r.atRiskCount, 3)
assert.deepStrictEqual(r.atRiskNames, ['乙', '丙', '丁'])
})
test('同一天内,提前事件按重要程度降序(high 先于 medium', () => {
// 额度够 3 条:甲、乙(当天)先消耗,提前事件里 high 的丙应先于 medium 的丁被消耗
const r = computeAtRisk(sameDayGroup, 3, TODAY)
assert.strictEqual(r.atRiskCount, 1)
assert.deepStrictEqual(r.atRiskNames, ['丁'])
})
+13
View File
@@ -0,0 +1,13 @@
const fs = require('fs')
const os = require('os')
const path = require('path')
// 把 DB_PATH 指向临时目录,避免测试碰到真实库。
// 必须在 require('../src/db') 之前调用,因为 db.js 在 require 时就读取 DB_PATH。
function useTempDb() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bday-test-'))
process.env.DB_PATH = path.join(dir, 'test.db')
return dir
}
module.exports = { useTempDb }
+112
View File
@@ -0,0 +1,112 @@
const test = require('node:test')
const assert = require('node:assert')
const { execFileSync } = require('node:child_process')
const Database = require('better-sqlite3')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
// db.js 的迁移是在 require 时执行一次的(模块单例),所以没法在同一个进程里反复触发。
// 这里用子进程来「启动一次后端的数据层」,父进程只用 better-sqlite3 直接开同一个文件做
// 前置造数和结果断言。全程使用系统临时目录,绝不碰 server/data/birthday.db。
const DB_JS = path.join(__dirname, '..', 'src', 'db.js')
function bootDb(dbPath) {
execFileSync(process.execPath, ['-e', 'require(process.env.MIGRATION_ENTRY)'], {
env: { ...process.env, MIGRATION_ENTRY: DB_JS, DB_PATH: dbPath },
stdio: 'ignore'
})
}
function tempDbPath() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bday-mig-'))
return path.join(dir, 'mig.db')
}
// 造一个「老库」:表结构齐全、有纪念日数据,但 subscribe_quota 为空、user_version 还是 0。
// 这正是本次改造上线时线上生产库的样子。
function makeLegacyDb(rows) {
const dbPath = tempDbPath()
bootDb(dbPath) // 先借 db.js 建出完整表结构
const raw = new Database(dbPath)
const insert = raw.prepare(`
INSERT INTO anniversaries (id, openid, personName, type, solarMonth, solarDay, remindEnabled, remindDays)
VALUES (?, ?, ?, 'birthday', 1, 1, 1, 3)
`)
for (const [id, openid] of rows) insert.run(id, openid, '某人')
raw.prepare('DELETE FROM subscribe_quota').run()
raw.pragma('user_version = 0')
raw.close()
return dbPath
}
function readQuota(dbPath) {
const raw = new Database(dbPath, { readonly: true })
const list = raw.prepare('SELECT * FROM subscribe_quota ORDER BY openid').all()
const version = raw.pragma('user_version', { simple: true })
raw.close()
return { list, version }
}
test('迁移 v1:按纪念日条数给存量用户补初始余额', () => {
const dbPath = makeLegacyDb([
['a1', 'uOld1'], ['a2', 'uOld1'], ['a3', 'uOld1'], // 3 条
['b1', 'uOld2'] // 1 条
])
bootDb(dbPath)
const { list, version } = readQuota(dbPath)
assert.strictEqual(version, 1, 'user_version 应被推进到 1')
assert.deepStrictEqual(
list.map(r => [r.openid, r.balance, r.grantedTotal, r.sentTotal]),
[['uOld1', 3, 3, 0], ['uOld2', 1, 1, 0]],
'每个存量用户的初始余额应等于其纪念日条数'
)
})
test('迁移 v1:重复启动不会重复累加(幂等)', () => {
const dbPath = makeLegacyDb([['a1', 'uIdem'], ['a2', 'uIdem']])
bootDb(dbPath) // 第一次:补 2
bootDb(dbPath) // 第二次:user_version 已是 1,应直接跳过
bootDb(dbPath) // 第三次:同上
const { list } = readQuota(dbPath)
assert.strictEqual(list.length, 1)
assert.strictEqual(list[0].balance, 2, '反复启动后余额仍应是 2,不能累加成 4 或 6')
assert.strictEqual(list[0].grantedTotal, 2)
})
test('迁移 v1:已有额度记录的用户不被覆盖', () => {
const dbPath = makeLegacyDb([['a1', 'uHas'], ['a2', 'uHas'], ['c1', 'uNone']])
// 模拟「已经用新版小程序补过额度」的用户:subscribe_quota 里已有一行
const raw = new Database(dbPath)
raw.prepare(`
INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime)
VALUES ('uHas', 7, 9, 2, 0)
`).run()
raw.close()
bootDb(dbPath)
const { list } = readQuota(dbPath)
const has = list.find(r => r.openid === 'uHas')
const none = list.find(r => r.openid === 'uNone')
assert.deepStrictEqual(
[has.balance, has.grantedTotal, has.sentTotal], [7, 9, 2],
'已有记录必须原样保留,不能被按纪念日条数估算出来的值覆盖'
)
assert.strictEqual(none.balance, 1, '尚无记录的用户照常补')
})
test('迁移 v1:全新空库也能正常推进版本号', () => {
const dbPath = tempDbPath()
bootDb(dbPath)
const { list, version } = readQuota(dbPath)
assert.strictEqual(version, 1)
assert.strictEqual(list.length, 0, '没有纪念日就没有要补的用户')
})
+45
View File
@@ -0,0 +1,45 @@
const test = require('node:test')
const assert = require('node:assert')
const { getNextOccurrence, daysBetween, startOfDay } = require('../src/occurrence')
const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
test('公历纪念日:今年还没到就取今年', () => {
const anniv = { isLunar: false, solarMonth: 12, solarDay: 25 }
assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2026-12-25')
})
test('公历纪念日:今年已过就取明年', () => {
const anniv = { isLunar: false, solarMonth: 5, solarDay: 20 }
assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2027-05-20')
})
test('农历腊月十五:跨公历年不会算错一年', () => {
const anniv = { isLunar: true, lunarMonth: 12, lunarDay: 15, isLeapMonth: false }
// 2027-01-20 时,下一次应是 2 天后的 2027-01-22,而不是 2028 年
assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2027, 0, 20))), '2027-01-22')
// 过了之后应跳到下一个农历年
assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2027, 0, 23))), '2028-01-11')
})
test('农历四月十七', () => {
const anniv = { isLunar: true, lunarMonth: 4, lunarDay: 17, isLeapMonth: false }
assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2027-05-22')
})
test('isLunar 但缺农历字段时降级按公历算,不抛错', () => {
const anniv = { isLunar: true, lunarMonth: null, lunarDay: null, solarMonth: 6, solarDay: 5 }
assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2027-06-05')
})
test('daysBetween 忽略时分秒', () => {
const today = new Date(2026, 7, 15, 23, 59, 0)
const target = new Date(2026, 7, 17, 0, 1, 0)
assert.strictEqual(daysBetween(target, today), 2)
})
test('startOfDay 归零时分秒', () => {
const d = startOfDay(new Date(2026, 7, 15, 13, 45, 30))
assert.strictEqual(d.getHours(), 0)
assert.strictEqual(d.getMinutes(), 0)
})
+63
View File
@@ -0,0 +1,63 @@
const test = require('node:test')
const assert = require('node:assert')
const { useTempDb } = require('./helper')
useTempDb() // 必须在 require quota/db 之前
const quota = require('../src/quota')
const db = require('../src/db')
test('未记录过的用户余额为 0', () => {
assert.strictEqual(quota.getBalance('u_new'), 0)
})
test('grant 纯累加,不去重', () => {
quota.grant('u1', 1)
quota.grant('u1', 1)
quota.grant('u1', 1)
assert.strictEqual(quota.getBalance('u1'), 3)
})
test('consume 扣减余额', () => {
quota.grant('u2', 5)
quota.consume('u2', 2)
assert.strictEqual(quota.getBalance('u2'), 3)
})
test('consume 不会把余额扣成负数', () => {
quota.grant('u3', 1)
quota.consume('u3', 10)
assert.strictEqual(quota.getBalance('u3'), 0)
})
test('reset 把余额归零', () => {
quota.grant('u4', 8)
quota.reset('u4')
assert.strictEqual(quota.getBalance('u4'), 0)
})
test('grant 对非法 count 做兜底', () => {
quota.grant('u5', 'abc')
assert.strictEqual(quota.getBalance('u5'), 1)
quota.grant('u5', -5)
assert.strictEqual(quota.getBalance('u5'), 2)
quota.grant('u5', 9999)
assert.strictEqual(quota.getBalance('u5'), 52) // 单次上限 50
})
test('各用户额度互不影响', () => {
quota.grant('a', 3)
quota.grant('b', 1)
assert.strictEqual(quota.getBalance('a'), 3)
assert.strictEqual(quota.getBalance('b'), 1)
})
test('consume 对从未 grant 过的 openid 也要建行,不丢失 sentTotal', () => {
quota.consume('u6', 3)
// 仅断言 getBalance 为 0 不足以证明建行了(缺行时本来就返回 0),
// 必须直接查表,确认行确实被创建且 sentTotal 记录了这次消费
assert.strictEqual(quota.getBalance('u6'), 0)
const row = db.prepare('SELECT * FROM subscribe_quota WHERE openid = ?').get('u6')
assert.ok(row, 'u6 对应的记录行应当被创建')
assert.strictEqual(row.balance, 0)
assert.strictEqual(row.sentTotal, 3)
})
+315
View File
@@ -0,0 +1,315 @@
const test = require('node:test')
const assert = require('node:assert')
const { useTempDb } = require('./helper')
useTempDb()
const db = require('../src/db')
const wx = require('../src/wx')
const quota = require('../src/quota')
const reminder = require('../src/reminder')
// 用「今天」为基准造数据,保证 daysUntil 落在期望值上
const TODAY = new Date()
const inDays = (n) => new Date(TODAY.getTime() + n * 86400000)
function makeAnniv(id, openid, personName, importance, offsetDays, remindDays) {
const d = inDays(offsetDays)
return {
id, openid, personId: 'p', personName, type: 'birthday',
isLunar: 0, solarYear: d.getFullYear(), solarMonth: d.getMonth() + 1, solarDay: d.getDate(),
importance, remindEnabled: 1, remindDays
}
}
function resetLogs() {
db.prepare('DELETE FROM remind_logs').run()
}
test('额度够时全部发出', async () => {
resetLogs()
quota.grant('uA', 5)
const sent = []
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async (p) => { sent.push(p.data.name1.value); return { errcode: 0 } }
const items = [
makeAnniv('A1', 'uA', '甲', 'low', 0, 3),
makeAnniv('A2', 'uA', '乙', 'high', 0, 3)
]
const r = await reminder.runForUser('uA', items, TODAY)
wx.sendSubscribeMessage = restore
assert.strictEqual(r.ok, 2)
assert.strictEqual(r.skipped, 0)
assert.strictEqual(quota.getBalance('uA'), 3)
})
test('额度不足时当天优先、同级按重要程度', async () => {
resetLogs()
quota.grant('uB', 1)
const attempts = [] // 所有真正打出去的请求
const sent = [] // 其中发送成功的
const restore = wx.sendSubscribeMessage
// 第一条放行、之后一律 43101。
// Why 要让微信亲口说 43101:记账余额只有 1,但第二条会被当作「探针」发出去(见探针机制),
// 只有微信侧确认没额度,「额度不足时的取舍」才真正成立。
wx.sendSubscribeMessage = async (p) => {
attempts.push(p.data.name1.value)
if (attempts.length > 1) {
const e = new Error('发送订阅消息失败')
e.errcode = 43101
throw e
}
sent.push(p.data.name1.value)
return { errcode: 0 }
}
try {
const items = [
makeAnniv('B1', 'uB', '提前的', 'high', 3, 3), // 提前事件(daysUntil=3=remindDays
makeAnniv('B2', 'uB', '当天低', 'low', 0, 3), // 当天事件
makeAnniv('B3', 'uB', '当天高', 'high', 0, 3) // 当天事件,重要程度更高
]
const r = await reminder.runForUser('uB', items, TODAY)
assert.strictEqual(r.ok, 1)
assert.strictEqual(r.skipped, 2)
assert.deepStrictEqual(sent, ['当天高'], '应当只发出当天且最重要的那条')
assert.deepStrictEqual(attempts, ['当天高', '当天低'], '探针也要按优先级挑下一条,且被拒后不再打第三个请求')
} finally {
wx.sendSubscribeMessage = restore
}
})
test('记账余额为 0 但微信侧仍有额度:探针成功后本轮继续发送', async () => {
resetLogs()
// uP1 从未 grant 过,记账余额为 0——模拟「没升级小程序、授权从不上报」的老用户
assert.strictEqual(quota.getBalance('uP1'), 0)
const sent = []
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async (p) => { sent.push(p.data.name1.value); return { errcode: 0 } }
try {
const items = [
makeAnniv('P1', 'uP1', '甲', 'high', 0, 3),
makeAnniv('P2', 'uP1', '乙', 'medium', 0, 3),
makeAnniv('P3', 'uP1', '丙', 'low', 0, 3)
]
const r = await reminder.runForUser('uP1', items, TODAY)
assert.strictEqual(r.ok, 3, '探针证实微信侧有额度后,剩下两条也应照常发出')
assert.strictEqual(r.skipped, 0)
assert.deepStrictEqual(sent, ['甲', '乙', '丙'])
assert.strictEqual(quota.getBalance('uP1'), 0, '探针成功不凭空补余额,账面保持保守的 0')
const row = db.prepare('SELECT sentTotal FROM subscribe_quota WHERE openid = ?').get('uP1')
assert.strictEqual(row.sentTotal, 3, 'sentTotal 要如实记录每一次成功发送,便于排查')
} finally {
wx.sendSubscribeMessage = restore
}
})
test('记账余额为 0 且微信侧确实没额度:探针被 43101 拒绝后立即中止', async () => {
resetLogs()
const WX_ERROR_MESSAGE = '发送订阅消息失败: {"errcode":43101}'
let called = 0
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async () => {
called++
const e = new Error(WX_ERROR_MESSAGE)
e.errcode = 43101
throw e
}
try {
const items = [
makeAnniv('Q1', 'uP2', '甲', 'high', 0, 3),
makeAnniv('Q2', 'uP2', '乙', 'high', 0, 3),
makeAnniv('Q3', 'uP2', '丙', 'high', 0, 3)
]
const r = await reminder.runForUser('uP2', items, TODAY)
assert.strictEqual(called, 1, '只探一次,被拒后不该继续打请求')
assert.strictEqual(r.ok, 0)
assert.strictEqual(r.skipped, 3, '含探针那条在内全部计为 skipped')
assert.strictEqual(quota.getBalance('uP2'), 0)
const q1 = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'Q1'").get()
const q2 = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'Q2'").get()
assert.strictEqual(q1.error, WX_ERROR_MESSAGE, '探针那条真打了请求,应保留微信原始错误信息')
assert.strictEqual(q2.error, 'quota_exhausted', '后续条目根本没发请求,记兜底文案')
} finally {
wx.sendSubscribeMessage = restore
}
})
test('探针遇非额度错误:记 failed、不动余额、本轮不再重复探', async () => {
resetLogs()
let called = 0
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async () => {
called++
const e = new Error('网络炸了')
e.errcode = 40003
throw e
}
try {
const items = [
makeAnniv('R1', 'uP3', '甲', 'high', 0, 3),
makeAnniv('R2', 'uP3', '乙', 'high', 0, 3),
makeAnniv('R3', 'uP3', '丙', 'high', 0, 3)
]
const r = await reminder.runForUser('uP3', items, TODAY)
// 网络/配置错误什么都没证明,既不能当作「有额度」继续发,也不该反复赌请求
assert.strictEqual(called, 1, '探针机会用掉就没了,不该对同一个用户反复重试')
assert.strictEqual(r.fail, 1, '探针那条记 failed,不是 skipped')
assert.strictEqual(r.skipped, 2)
assert.strictEqual(quota.getBalance('uP3'), 0, '非额度错误不动余额')
const r1 = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'R1'").get()
assert.strictEqual(r1.status, 'failed')
assert.strictEqual(r1.error, '网络炸了')
} finally {
wx.sendSubscribeMessage = restore
}
})
test('遇到 43101 立即归零并中止本用户剩余发送', async () => {
resetLogs()
quota.grant('uD', 9)
let called = 0
const WX_ERROR_MESSAGE = '发送订阅消息失败'
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async () => {
called++
const e = new Error(WX_ERROR_MESSAGE)
e.errcode = 43101
throw e
}
const items = [
makeAnniv('D1', 'uD', '甲', 'high', 0, 3),
makeAnniv('D2', 'uD', '乙', 'high', 0, 3),
makeAnniv('D3', 'uD', '丙', 'high', 0, 3)
]
const r = await reminder.runForUser('uD', items, TODAY)
wx.sendSubscribeMessage = restore
assert.strictEqual(called, 1, '失败一次就该停,不该继续打请求')
assert.strictEqual(quota.getBalance('uD'), 0, '余额应被归零校正')
assert.strictEqual(r.skipped, 3, '含失败那条在内全部计为 skipped')
// D1 是真正打了请求、被微信 43101 拒绝的那一条:error 字段应保留微信原始错误信息,
// 而不是「根本没发请求」时用的兜底文案 'quota_exhausted'。
const d1Log = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'D1'").get()
assert.ok(d1Log, 'D1 应写入 remind_logs')
assert.strictEqual(d1Log.status, 'skipped')
assert.strictEqual(d1Log.error, WX_ERROR_MESSAGE, 'D1 应记录微信原始错误信息,而不是 quota_exhausted 兜底文案')
// D2、D3 是因为 halted 而被跳过、根本没发起请求的,error 字段应保持默认兜底文案。
const d2Log = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'D2'").get()
const d3Log = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'D3'").get()
assert.strictEqual(d2Log.error, 'quota_exhausted', 'D2 从未真正发起请求,应为兜底文案')
assert.strictEqual(d3Log.error, 'quota_exhausted', 'D3 从未真正发起请求,应为兜底文案')
})
test('非额度类错误只记 failed,不中止后续发送,也不动余额', async () => {
resetLogs()
quota.grant('uE', 4)
let called = 0
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async () => {
called++
const e = new Error('网络炸了')
e.errcode = 40003
throw e
}
// 至少 2 条数据:如果有人误把 halted = true 加进非额度错误分支,
// 第二条就不会被尝试,called 会停在 1,这条断言就能抓到
const items = [
makeAnniv('E1', 'uE', '甲', 'high', 0, 3),
makeAnniv('E2', 'uE', '乙', 'high', 0, 3)
]
const r = await reminder.runForUser('uE', items, TODAY)
wx.sendSubscribeMessage = restore
assert.strictEqual(called, 2, '第二条也应被尝试,证明非额度错误不会误中止后续发送')
assert.strictEqual(r.fail, 2)
assert.strictEqual(quota.getBalance('uE'), 4, '配置/网络错误不该动余额')
})
test('skipped 会写入 remind_logs 便于排查', async () => {
resetLogs()
// 余额为 0 时第一条会被当探针发出去,所以要放两条,第二条才是「压根没发请求」的 skipped
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async () => {
const e = new Error('发送订阅消息失败')
e.errcode = 43101
throw e
}
try {
const items = [
makeAnniv('F1', 'uF', '甲', 'high', 0, 3),
makeAnniv('F2', 'uF', '乙', 'high', 0, 3)
]
await reminder.runForUser('uF', items, TODAY)
const row = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'F2'").get()
assert.ok(row, '应写入 skipped 日志')
assert.strictEqual(row.status, 'skipped')
assert.strictEqual(row.error, 'quota_exhausted')
} finally {
wx.sendSubscribeMessage = restore
}
})
// runOnce 是从数据库读数据的(SELECT * FROM anniversaries WHERE remindEnabled = 1),
// 不能像 runForUser 那样直接传数组,必须真的往临时库插行
function insertAnniv(id, openid, personName, offsetDays, remindDays) {
const d = inDays(offsetDays)
db.prepare(`
INSERT INTO anniversaries
(id, openid, personId, personName, type, isLunar, solarYear, solarMonth, solarDay,
importance, remindEnabled, remindDays, createTime, updateTime)
VALUES (?, ?, 'p', ?, 'birthday', 0, ?, ?, ?, 'high', 1, ?, 0, 0)
`).run(id, openid, personName, d.getFullYear(), d.getMonth() + 1, d.getDate(), remindDays)
}
test('runOnce 按 openid 分组结算,各用户额度互不影响', async () => {
resetLogs()
// 会读到库里所有 remindEnabled=1 的行,先清空避免受其它用例残留数据干扰
db.prepare('DELETE FROM anniversaries').run()
quota.grant('uG', 5) // uG 有额度
// uH 不 grant,余额保持 0,且微信侧也确实没额度(下面 mock 里对 uH 回 43101
insertAnniv('G1', 'uG', '甲', 0, 3) // 今天到期
insertAnniv('H1', 'uH', '乙', 0, 3) // 今天到期
const attempts = []
const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async (p) => {
attempts.push(p.touser)
if (p.touser === 'uH') {
const e = new Error('发送订阅消息失败')
e.errcode = 43101
throw e
}
return { errcode: 0 }
}
try {
const r = await reminder.runOnce()
assert.strictEqual(r.total, 2)
assert.strictEqual(r.ok, 1, '有额度的 uG 应正常发出')
assert.strictEqual(r.skipped, 1, '微信侧确认没额度的 uH 应被跳过')
assert.strictEqual(r.fail, 0)
assert.deepStrictEqual(attempts, ['uG', 'uH'], 'uH 只有一次探针请求,不能借用 uG 的额度')
assert.strictEqual(quota.getBalance('uG'), 4, 'uG 消费后余额正确减少')
assert.strictEqual(quota.getBalance('uH'), 0, 'uH 的余额不受 uG 影响')
} finally {
wx.sendSubscribeMessage = restore
}
})
+58
View File
@@ -0,0 +1,58 @@
const test = require('node:test')
const assert = require('node:assert')
const { useTempDb } = require('./helper')
useTempDb()
const db = require('../src/db')
const { grantQuota, getQuotaStatus } = require('../src/index')
const OPENID = 'o_test_1'
// 往指定用户名下插一条纪念日
function seed(openid, id, personName, offsetDays, remindDays) {
const d = new Date(Date.now() + offsetDays * 86400000)
db.prepare(`
INSERT OR REPLACE INTO anniversaries
(id, openid, personId, personName, type, isLunar, solarYear, solarMonth, solarDay,
importance, remindEnabled, remindDays, createTime, updateTime)
VALUES (?, ?, 'p1', ?, 'birthday', 0, ?, ?, ?, 'high', 1, ?, 0, 0)
`).run(id, openid, personName, d.getFullYear(), d.getMonth() + 1, d.getDate(), remindDays)
}
seed(OPENID, 'seed1', '张三', 10, 3)
test('grant 累加并返回新余额', () => {
assert.deepStrictEqual(grantQuota(OPENID, { count: 1 }), { success: true, balance: 1 })
assert.deepStrictEqual(grantQuota(OPENID, { count: 1 }), { success: true, balance: 2 })
})
test('grant 的 data 缺省时按 1 计', () => {
const before = getQuotaStatus(OPENID).balance
grantQuota(OPENID, undefined)
assert.strictEqual(getQuotaStatus(OPENID).balance, before + 1)
})
test('get 返回余额与风险信息', () => {
const res = getQuotaStatus(OPENID)
assert.strictEqual(res.success, true)
assert.strictEqual(typeof res.balance, 'number')
assert.ok(Array.isArray(res.atRiskNames))
})
test('无纪念日时没有风险', () => {
const res = getQuotaStatus('o_empty')
assert.strictEqual(res.atRiskCount, 0)
assert.deepStrictEqual(res.atRiskNames, [])
})
test('纪念日多于余额时报出风险人名', () => {
const OP = 'o_test_2'
// 造两条近期纪念日,共 4 个提醒事件,但只给 1 点额度
seed(OP, 'x1', '甲', 10, 3)
seed(OP, 'x2', '乙', 20, 3)
require('../src/quota').grant(OP, 1)
const res = getQuotaStatus(OP)
assert.ok(res.atRiskCount >= 1, '应当报出风险')
assert.ok(res.atRiskNames.includes('乙'), '时间靠后的乙必然有风险')
})
+38
View File
@@ -0,0 +1,38 @@
const test = require('node:test')
const assert = require('node:assert')
const { useTempDb } = require('./helper')
// wx.js 本身不连库,但按项目约定统一在 require 业务模块前处理好临时库,
// 避免以后 wx.js 引入了连库逻辑时这份测试悄悄读写到真实数据库。
useTempDb()
const axios = require('axios')
const wx = require('../src/wx')
test('微信返回非 0 errcode 时,抛出的 Error 对象应带上原始 errcode', async () => {
const restoreGet = axios.get
const restorePost = axios.post
try {
// getAccessToken 走 axios.getsendSubscribeMessage 走 axios.post,两个都要 mock
axios.get = async () => ({ data: { access_token: 'fake_token', expires_in: 7200 } })
axios.post = async () => ({ data: { errcode: 43101, errmsg: 'user refuse to accept the msg' } })
await assert.rejects(
() => wx.sendSubscribeMessage({
touser: 'oTest',
page: 'pages/index/index',
templateId: 'tplTest',
data: {}
}),
(err) => {
assert.strictEqual(err.errcode, 43101, 'Error 对象应带上微信返回的原始 errcode,而不是靠字符串匹配')
return true
}
)
} finally {
// 断言先抛错也要恢复,避免污染同进程内后续用例
axios.get = restoreGet
axios.post = restorePost
}
})
+6 -1
View File
@@ -72,4 +72,9 @@ function person(action, data) {
return request({ url: '/api/person', data: { action, data } }) return request({ url: '/api/person', data: { action, data } })
} }
module.exports = { request, login, anniversary, person, BASE_URL } // 订阅额度:grant 上报授权,get 查询余额与风险
function subscribe(action, data) {
return request({ url: '/api/subscribe', data: { action, data } })
}
module.exports = { request, login, anniversary, person, subscribe, BASE_URL }
+68
View File
@@ -0,0 +1,68 @@
/**
* 订阅消息统一入口
*
* ⚠️ 最重要的约束:wx.requestSubscribeMessage 和 wx.openSetting 必须由真实点击手势
* 直接触发(基础库 2.8.2+),且调用前不能有 await 等异步操作,否则手势上下文丢失、
* 调用必然失败(报 fail can only be invoked by user TAP gesture)。
* 所以本模块的 requestAndReport 必须在 bindtap 处理函数里同步调用,
* 用户状态要提前用 getStatus 查好缓存起来,不能现查现用。
*/
const sync = require('./sync')
const TEMPLATE_ID = '6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw'
/**
* 查询用户当前的订阅设置状态
* itemSettings 只包含用户勾选过「总是保持以上选择」的模板,据此可判断调用会不会弹窗。
* @returns {Promise<string>}
* silent — 已长期同意,调用不弹窗,可在任意点击上搭车静默补额度
* willPrompt — 没勾过,调用会弹窗,只在用户主动点击时才调
* rejected — 已长期拒绝或被封禁,再调也没用,需引导去设置页
* mainSwitchOff — 通知总开关关闭,需引导去设置页
* unknown — 查询失败,按 willPrompt 保守处理
*/
function getStatus() {
return new Promise((resolve) => {
wx.getSetting({
withSubscriptions: true,
success: (res) => {
const s = res.subscriptionsSetting || {}
if (s.mainSwitch === false) return resolve('mainSwitchOff')
const item = s.itemSettings && s.itemSettings[TEMPLATE_ID]
if (item === 'accept') return resolve('silent')
if (item === 'reject' || item === 'ban') return resolve('rejected')
resolve('willPrompt')
},
fail: () => resolve('unknown')
})
})
}
/**
* 请求订阅并把新增额度上报后端
* 必须在 tap handler 中同步调用(本函数第一条语句就是 wx.requestSubscribeMessage
* @returns {Promise<boolean>} 用户是否同意
*/
function requestAndReport() {
return new Promise((resolve) => {
wx.requestSubscribeMessage({
tmplIds: [TEMPLATE_ID],
success: (res) => {
const accepted = res[TEMPLATE_ID] === 'accept'
if (!accepted) return resolve(false)
// 走同步队列上报:失败会自动入队,下次启动 flush。
// 不能静默吞掉——用户已经同意、微信侧额度确实 +1 了,我们漏记会导致余额低估,
// 进而白白跳过本可以发出去的提醒。
sync.syncOrEnqueue({ kind: 'subscribe', action: 'grant', data: { count: 1 } })
.then(() => resolve(true))
},
fail: (err) => {
console.warn('[subscribe] 订阅失败', err)
resolve(false)
}
})
})
}
module.exports = { TEMPLATE_ID, getStatus, requestAndReport }
+1
View File
@@ -24,6 +24,7 @@ function enqueue(item) {
async function dispatch(item) { async function dispatch(item) {
if (item.kind === 'person') return api.person(item.action, item.data) if (item.kind === 'person') return api.person(item.action, item.data)
if (item.kind === 'anniversary') return api.anniversary(item.action, item.data) if (item.kind === 'anniversary') return api.anniversary(item.action, item.data)
if (item.kind === 'subscribe') return api.subscribe(item.action, item.data)
throw new Error('unknown sync kind: ' + item.kind) throw new Error('unknown sync kind: ' + item.kind)
} }