Compare commits
24 Commits
e9c7330f31
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bf46f1114 | |||
| 6e89ec8ea7 | |||
| 23278fee06 | |||
| 7bf84bff61 | |||
| e47a399ad5 | |||
| 30a40252c6 | |||
| 12b146c2c9 | |||
| 981e802532 | |||
| 233a872c89 | |||
| 7f420762ef | |||
| bc4fb7445d | |||
| 1142346e24 | |||
| d8573359a2 | |||
| c7bdd3770a | |||
| 57470ade4f | |||
| 6548577a98 | |||
| b1949ac73e | |||
| 3a61b7952b | |||
| 07ee98f838 | |||
| 8d63b47577 | |||
| 68acbb3809 | |||
| c92df874d1 | |||
| 955c80e221 | |||
| 6d0119103f |
@@ -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)
|
||||||
@@ -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
@@ -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:流程合并,姓名不存在自动建人)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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:给存量用户补订阅额度初始值
|
||||||
|
*
|
||||||
|
* Why:subscribe_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
|
||||||
|
|||||||
@@ -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 }
|
||||||
+49
-5
@@ -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 }
|
||||||
|
|
||||||
// ---- 启动 ----
|
// ---- 启动 ----
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000
|
// 只在直接执行本文件(node src/index.js / npm start)时才真正监听端口、注册定时任务。
|
||||||
app.listen(PORT, () => {
|
// 测试用 require('../src/index') 只是拿函数引用,不需要(也不能)真的起服务——
|
||||||
console.log(`生日提醒后端已启动,监听 ${PORT}`)
|
// 否则会占住端口且 cron 定时器让进程一直不退出,测试永远卡住。
|
||||||
reminder.start()
|
if (require.main === module) {
|
||||||
})
|
const PORT = process.env.PORT || 3000
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`生日提醒后端已启动,监听 ${PORT}`)
|
||||||
|
reminder.start()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
+137
-77
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记账余额为 0 且尚未证实微信侧有额度时,只允许发一条探针
|
||||||
|
let isProbe = false
|
||||||
|
if (balance <= 0 && !quotaProven) {
|
||||||
|
if (probeUsed) {
|
||||||
|
logSkip(item); skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
isProbe = true
|
||||||
|
probeUsed = true
|
||||||
|
}
|
||||||
|
|
||||||
const typeName = getTypeName(anniv.type, anniv.customTypeName)
|
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) {
|
||||||
fail++
|
if (isQuotaError(err)) {
|
||||||
console.error(`[reminder] 发送失败: ${anniv.personName}`, err.message)
|
// 微信侧实际已无额度(或用户关了通知总开关),归零并停止骚扰
|
||||||
insertLog.run({
|
quota.reset(openid)
|
||||||
anniversaryId: anniv.id,
|
balance = 0
|
||||||
personName: anniv.personName,
|
halted = true
|
||||||
typeName: null,
|
logSkip(item, err.message); skipped++
|
||||||
daysUntil: null,
|
console.warn(`[reminder] ${openid} 额度已耗尽,本轮剩余全部跳过: ${err.message}`)
|
||||||
sendDate: Date.now(),
|
} else {
|
||||||
status: 'failed',
|
insertLog.run({
|
||||||
error: err.message
|
anniversaryId: anniv.id, personName: anniv.personName, typeName: null,
|
||||||
})
|
daysUntil: null, sendDate: Date.now(), status: 'failed', error: err.message
|
||||||
|
})
|
||||||
|
fail++
|
||||||
|
console.error(`[reminder] 发送失败: ${anniv.personName}`, 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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ['丁'])
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
@@ -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, '没有纪念日就没有要补的用户')
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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('乙'), '时间靠后的乙必然有风险')
|
||||||
|
})
|
||||||
@@ -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.get,sendSubscribeMessage 走 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
@@ -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 }
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user