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>
1548 lines
50 KiB
Markdown
1548 lines
50 KiB
Markdown
# 订阅消息额度治理 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 把「订阅消息额度不足导致提醒静默失败」变成「服务端按用户记账、按优先级取舍、首页提前预警并一键补救」。
|
||
|
||
**Architecture:** 后端新增 `subscribe_quota` 表按 openid 记账,新增 `POST /api/subscribe` 供前端上报授权与查询风险;`reminder.js` 从「扫全表逐条发」改为「按 openid 分组、组内按优先级发、余额耗尽即停」。前端新增 `utils/subscribe.js` 统一管理订阅调用,首页在有风险时展示提示卡片作为补额度主入口。
|
||
|
||
**Tech Stack:** Node 18 + Express + better-sqlite3 + node-cron(后端);微信原生小程序(前端);`node:test` 内置测试运行器;miniprogram-automator(前端端到端)。
|
||
|
||
## Global Constraints
|
||
|
||
- 所有界面提示、代码注释、提交信息一律简体中文。
|
||
- `wx.requestSubscribeMessage` 与 `wx.openSetting` **必须在 tap handler 中同步调用**,调用前不得有 `await` 或任何异步操作,否则手势上下文丢失、调用必然失败。
|
||
- 模板 ID 常量值:`6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw`
|
||
- `LOOKAHEAD_DAYS = 60`
|
||
- importance 优先级顺序:`high` > `medium` > `low`;未知值排最后。
|
||
- `balance` 是**估算值**,微信端才是真值;所有相关代码注释必须写明这一点。
|
||
- 后端改动必须向后兼容:不删字段、不加必填项,线上旧版小程序不能因此报错。
|
||
- 测试一律使用临时数据库,**禁止**触碰 `server/data/birthday.db`。
|
||
|
||
---
|
||
|
||
### Task 1: 抽出日期计算纯函数 + 搭建后端测试基建
|
||
|
||
把 `reminder.js` 内联的日期计算抽成可注入 `today` 的纯函数模块,后续 `atRisk` 计算与发送逻辑共用,也让核心逻辑第一次变得可单测。
|
||
|
||
**Files:**
|
||
- Create: `server/src/occurrence.js`
|
||
- Create: `server/test/helper.js`
|
||
- Create: `server/test/occurrence.test.js`
|
||
- Modify: `server/package.json`(加 `test` 脚本)
|
||
- Modify: `server/src/reminder.js:30-62`(改为调用新模块)
|
||
|
||
**Interfaces:**
|
||
- Consumes: 无
|
||
- Produces:
|
||
- `startOfDay(date: Date): Date`
|
||
- `getNextOccurrence(anniv: object, today?: Date): Date` — anniv 需含 `isLunar/lunarMonth/lunarDay/isLeapMonth/solarMonth/solarDay`
|
||
- `daysBetween(target: Date, today?: Date): number`
|
||
- `helper.useTempDb(): string` — 设置 `process.env.DB_PATH` 指向临时库,返回目录路径,**必须在 require `../src/db` 之前调用**
|
||
|
||
- [ ] **Step 1: 加测试脚本**
|
||
|
||
修改 `server/package.json` 的 `scripts`,加一行:
|
||
|
||
```json
|
||
"test": "node --test test/"
|
||
```
|
||
|
||
- [ ] **Step 2: 写测试辅助**
|
||
|
||
创建 `server/test/helper.js`:
|
||
|
||
```js
|
||
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 }
|
||
```
|
||
|
||
- [ ] **Step 3: 写失败的测试**
|
||
|
||
创建 `server/test/occurrence.test.js`:
|
||
|
||
```js
|
||
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)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试确认失败**
|
||
|
||
Run: `cd server && npm test`
|
||
Expected: FAIL,报错 `Cannot find module '../src/occurrence'`
|
||
|
||
- [ ] **Step 5: 实现模块**
|
||
|
||
创建 `server/src/occurrence.js`:
|
||
|
||
```js
|
||
/**
|
||
* 纪念日日期计算(纯函数,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 }
|
||
```
|
||
|
||
- [ ] **Step 6: 运行测试确认通过**
|
||
|
||
Run: `cd server && npm test`
|
||
Expected: PASS,7 个测试全绿
|
||
|
||
- [ ] **Step 7: 让 reminder.js 改用新模块**
|
||
|
||
在 `server/src/reminder.js` 顶部加 `const occurrence = require('./occurrence')`,删除原有的 `getThisYearDate` 与 `daysBetween` 两个函数定义(原第 30-62 行),把 `runOnce` 里的调用改为:
|
||
|
||
```js
|
||
const target = occurrence.getNextOccurrence(anniv)
|
||
const daysUntil = occurrence.daysBetween(target)
|
||
```
|
||
|
||
- [ ] **Step 8: 确认语法与行为未变**
|
||
|
||
Run: `cd server && node --check src/reminder.js && npm test`
|
||
Expected: 语法通过,测试仍全绿
|
||
|
||
- [ ] **Step 9: 提交**
|
||
|
||
```bash
|
||
git add server/src/occurrence.js server/test/ server/package.json server/src/reminder.js
|
||
git commit -m "抽出日期计算纯函数模块,并搭建后端测试基建"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: 额度记账表与模块
|
||
|
||
**Files:**
|
||
- Modify: `server/src/db.js`(建表)
|
||
- Create: `server/src/quota.js`
|
||
- Create: `server/test/quota.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `helper.useTempDb()`
|
||
- Produces:
|
||
- `getBalance(openid: string): number`
|
||
- `grant(openid: string, count?: number): number` — 返回新余额
|
||
- `consume(openid: string, count?: number): number` — 返回新余额,不会低于 0
|
||
- `reset(openid: string): void` — 余额归零(43101 自愈用)
|
||
|
||
- [ ] **Step 1: 写失败的测试**
|
||
|
||
创建 `server/test/quota.test.js`:
|
||
|
||
```js
|
||
const test = require('node:test')
|
||
const assert = require('node:assert')
|
||
const { useTempDb } = require('./helper')
|
||
|
||
useTempDb() // 必须在 require quota/db 之前
|
||
const quota = require('../src/quota')
|
||
|
||
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)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cd server && npm test`
|
||
Expected: FAIL,`Cannot find module '../src/quota'`
|
||
|
||
- [ ] **Step 3: 建表**
|
||
|
||
在 `server/src/db.js` 的 `db.exec(...)` 模板字符串末尾(`remind_logs` 索引之后)追加:
|
||
|
||
```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
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 4: 实现模块**
|
||
|
||
创建 `server/src/quota.js`:
|
||
|
||
```js
|
||
/**
|
||
* 订阅消息额度记账
|
||
*
|
||
* ⚠️ 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)
|
||
}
|
||
|
||
function consume(openid, count = 1) {
|
||
const n = _normalize(count)
|
||
db.prepare(`
|
||
UPDATE subscribe_quota
|
||
SET balance = MAX(0, balance - @n), sentTotal = sentTotal + @n, updateTime = @now
|
||
WHERE openid = @openid
|
||
`).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 }
|
||
```
|
||
|
||
- [ ] **Step 5: 运行测试确认通过**
|
||
|
||
Run: `cd server && npm test`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
git add server/src/db.js server/src/quota.js server/test/quota.test.js
|
||
git commit -m "新增订阅消息额度记账表与模块"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 风险预警计算
|
||
|
||
**Files:**
|
||
- Create: `server/src/atRisk.js`
|
||
- Create: `server/test/atRisk.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `occurrence.getNextOccurrence`, `occurrence.daysBetween`
|
||
- Produces:
|
||
- `LOOKAHEAD_DAYS: number`(值为 60)
|
||
- `expandEvents(anniv: object, today?: Date): Array<{anniversaryId, personName, kind: 'onDay'|'ahead', fireInDays: number, importance: string}>`
|
||
- `computeAtRisk(anniversaries: Array, balance: number, today?: Date): {atRiskCount: number, atRiskNames: string[]}`
|
||
|
||
- [ ] **Step 1: 写失败的测试**
|
||
|
||
创建 `server/test/atRisk.test.js`:
|
||
|
||
```js
|
||
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)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cd server && npm test`
|
||
Expected: FAIL,`Cannot find module '../src/atRisk'`
|
||
|
||
- [ ] **Step 3: 实现模块**
|
||
|
||
创建 `server/src/atRisk.js`:
|
||
|
||
```js
|
||
/**
|
||
* 风险预警计算:算出未来一段时间内,有哪些提醒会因额度不足发不出去
|
||
*
|
||
* 首页拿这个结果给用户看「谁的提醒有风险」,而不是「还剩几次额度」——
|
||
* 用户理解不了额度这种抽象概念,但看得懂人名。
|
||
*/
|
||
|
||
const { getNextOccurrence, daysBetween } = require('./occurrence')
|
||
|
||
// 预警窗口。太短则提示不及时,太长会拿几个月后的事惊扰用户,两个月是折中。
|
||
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)。
|
||
*/
|
||
function computeAtRisk(anniversaries, balance, today = new Date()) {
|
||
const events = (anniversaries || [])
|
||
.filter(a => a && a.remindEnabled)
|
||
.flatMap(a => expandEvents(a, today))
|
||
.sort((x, y) => x.fireInDays - y.fireInDays)
|
||
|
||
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 }
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试确认通过**
|
||
|
||
Run: `cd server && npm test`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add server/src/atRisk.js server/test/atRisk.test.js
|
||
git commit -m "新增订阅额度风险预警计算"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `/api/subscribe` 接口
|
||
|
||
**Files:**
|
||
- Modify: `server/src/index.js`(新增路由与两个处理函数)
|
||
- Create: `server/test/subscribeApi.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `quota.getBalance/grant`, `atRisk.computeAtRisk`
|
||
- Produces:
|
||
- `grantQuota(openid: string, data: object): {success: boolean, balance: number}`
|
||
- `getQuotaStatus(openid: string): {success: boolean, balance: number, atRiskCount: number, atRiskNames: string[]}`
|
||
- 两者均从 `server/src/index.js` 导出,供测试直接调用
|
||
|
||
- [ ] **Step 1: 写失败的测试**
|
||
|
||
创建 `server/test/subscribeApi.test.js`:
|
||
|
||
```js
|
||
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('乙'), '时间靠后的乙必然有风险')
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run: `cd server && npm test`
|
||
Expected: FAIL,`grantQuota is not a function`(index.js 尚未导出)
|
||
|
||
- [ ] **Step 3: 实现接口**
|
||
|
||
在 `server/src/index.js` 顶部 require 区加:
|
||
|
||
```js
|
||
const quota = require('./quota')
|
||
const atRisk = require('./atRisk')
|
||
```
|
||
|
||
在人员 CRUD 之后、`/api/reminder/run` 之前,加入处理函数与路由:
|
||
|
||
```js
|
||
// ---- 订阅额度 ----
|
||
|
||
// 前端授权成功后上报。纯累加、不去重:微信侧确实每次授权都 +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)
|
||
const rows = db.prepare('SELECT * FROM anniversaries WHERE openid = ? AND remindEnabled = 1').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.listen(...)` **之前**加导出,供测试直接调用:
|
||
|
||
```js
|
||
module.exports = { grantQuota, getQuotaStatus }
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试确认通过**
|
||
|
||
Run: `cd server && npm test`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: 手工验证接口连通**
|
||
|
||
```bash
|
||
cd server
|
||
DB_PATH=/tmp/quota-check.db REMINDER_CRON="0 4 1 1 *" npm start &
|
||
sleep 3
|
||
curl -s -X POST http://localhost:3000/api/subscribe -H 'Content-Type: application/json' -H 'x-openid: probe' -d '{"action":"grant","data":{"count":1}}'
|
||
curl -s -X POST http://localhost:3000/api/subscribe -H 'Content-Type: application/json' -H 'x-openid: probe' -d '{"action":"get"}'
|
||
pkill -f "node src/index.js"
|
||
```
|
||
|
||
Expected: 第一条返回 `{"success":true,"balance":1}`,第二条返回含 `balance`、`atRiskCount`、`atRiskNames` 的对象
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
git add server/src/index.js server/test/subscribeApi.test.js
|
||
git commit -m "新增 /api/subscribe 接口:授权上报与风险查询"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: 发送侧按用户分组与优先级取舍
|
||
|
||
**Files:**
|
||
- Modify: `server/src/wx.js:51-53`(错误对象带上 errcode)
|
||
- Modify: `server/src/reminder.js`(`runOnce` 重写)
|
||
- Create: `server/test/reminder.test.js`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `quota.*`, `occurrence.*`
|
||
- Produces:
|
||
- `runOnce(): Promise<{total, ok, fail, skipped}>`
|
||
- `runForUser(openid: string, items: Array, today?: Date): Promise<{ok, fail, skipped}>`(导出以便测试)
|
||
|
||
- [ ] **Step 1: 让 wx.js 的错误带上 errcode**
|
||
|
||
修改 `server/src/wx.js` 中 `sendSubscribeMessage` 的抛错分支:
|
||
|
||
```js
|
||
if (res.errcode !== 0) {
|
||
const err = new Error(`发送订阅消息失败: ${JSON.stringify(res)}`)
|
||
err.errcode = res.errcode // 让调用方能精确判断 43101,而不是靠字符串匹配
|
||
throw err
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 写失败的测试**
|
||
|
||
创建 `server/test/reminder.test.js`:
|
||
|
||
```js
|
||
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 sent = []
|
||
const restore = wx.sendSubscribeMessage
|
||
wx.sendSubscribeMessage = async (p) => { sent.push(p.data.name1.value); return { errcode: 0 } }
|
||
|
||
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)
|
||
wx.sendSubscribeMessage = restore
|
||
|
||
assert.strictEqual(r.ok, 1)
|
||
assert.strictEqual(r.skipped, 2)
|
||
assert.deepStrictEqual(sent, ['当天高'], '应当只发出当天且最重要的那条')
|
||
})
|
||
|
||
test('余额为 0 时不发任何请求', async () => {
|
||
resetLogs()
|
||
let called = 0
|
||
const restore = wx.sendSubscribeMessage
|
||
wx.sendSubscribeMessage = async () => { called++; return { errcode: 0 } }
|
||
|
||
const items = [makeAnniv('C1', 'uC', '丙', 'high', 0, 3)]
|
||
const r = await reminder.runForUser('uC', items, TODAY)
|
||
wx.sendSubscribeMessage = restore
|
||
|
||
assert.strictEqual(called, 0, '没额度就不该打请求')
|
||
assert.strictEqual(r.skipped, 1)
|
||
})
|
||
|
||
test('遇到 43101 立即归零并中止本用户剩余发送', async () => {
|
||
resetLogs()
|
||
quota.grant('uD', 9)
|
||
let called = 0
|
||
const restore = wx.sendSubscribeMessage
|
||
wx.sendSubscribeMessage = async () => {
|
||
called++
|
||
const e = new Error('发送订阅消息失败')
|
||
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')
|
||
})
|
||
|
||
test('非额度类错误只记 failed,不动余额', async () => {
|
||
resetLogs()
|
||
quota.grant('uE', 4)
|
||
const restore = wx.sendSubscribeMessage
|
||
wx.sendSubscribeMessage = async () => {
|
||
const e = new Error('网络炸了')
|
||
e.errcode = 40003
|
||
throw e
|
||
}
|
||
|
||
const items = [makeAnniv('E1', 'uE', '甲', 'high', 0, 3)]
|
||
const r = await reminder.runForUser('uE', items, TODAY)
|
||
wx.sendSubscribeMessage = restore
|
||
|
||
assert.strictEqual(r.fail, 1)
|
||
assert.strictEqual(quota.getBalance('uE'), 4, '配置/网络错误不该动余额')
|
||
})
|
||
|
||
test('skipped 会写入 remind_logs 便于排查', async () => {
|
||
resetLogs()
|
||
const items = [makeAnniv('F1', 'uF', '甲', 'high', 0, 3)]
|
||
await reminder.runForUser('uF', items, TODAY)
|
||
const row = db.prepare("SELECT * FROM remind_logs WHERE status = 'skipped'").get()
|
||
assert.ok(row, '应写入 skipped 日志')
|
||
assert.strictEqual(row.error, 'quota_exhausted')
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: 运行测试确认失败**
|
||
|
||
Run: `cd server && npm test`
|
||
Expected: FAIL,`reminder.runForUser is not a function`
|
||
|
||
- [ ] **Step 4: 重写 reminder.js 的发送逻辑**
|
||
|
||
在 `server/src/reminder.js` 顶部加 `const quota = require('./quota')`。
|
||
|
||
新增重要程度排序表与判断函数(放在 `insertLog` 定义之后):
|
||
|
||
```js
|
||
// 重要程度排序权重,未知值排最后
|
||
const IMPORTANCE_RANK = { high: 0, medium: 1, low: 2 }
|
||
function importanceRank(v) {
|
||
return IMPORTANCE_RANK[v] === undefined ? 3 : IMPORTANCE_RANK[v]
|
||
}
|
||
|
||
// 43101 = 用户拒收或下发次数不足,是我们与微信侧对账的唯一信号
|
||
function isQuotaError(err) {
|
||
return err && err.errcode === 43101
|
||
}
|
||
```
|
||
|
||
把原 `runOnce` 整个替换为:
|
||
|
||
```js
|
||
/**
|
||
* 处理单个用户的当日提醒
|
||
*
|
||
* 按「当天 > 提前」、同级「重要程度降序」排序后依次发送,余额耗尽即停,
|
||
* 不在明知没额度时继续打无谓请求。
|
||
*/
|
||
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
|
||
|
||
const isOnDay = daysUntil === 0
|
||
const isAhead = remindDays > 0 && daysUntil === remindDays
|
||
if (!isOnDay && !isAhead) continue
|
||
if (alreadySentToday(anniv.id)) continue
|
||
|
||
due.push({ anniv, target, daysUntil, kind: isOnDay ? 'onDay' : 'ahead' })
|
||
}
|
||
|
||
due.sort((x, y) => {
|
||
if (x.kind !== y.kind) return x.kind === 'onDay' ? -1 : 1
|
||
return importanceRank(x.anniv.importance) - importanceRank(y.anniv.importance)
|
||
})
|
||
|
||
let balance = quota.getBalance(openid)
|
||
let halted = false
|
||
let ok = 0, fail = 0, skipped = 0
|
||
|
||
const logSkip = (item) => 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: 'quota_exhausted'
|
||
})
|
||
|
||
for (const item of due) {
|
||
if (halted || balance <= 0) {
|
||
logSkip(item); skipped++
|
||
continue
|
||
}
|
||
|
||
const anniv = item.anniv
|
||
const typeName = getTypeName(anniv.type, anniv.customTypeName)
|
||
try {
|
||
await wx.sendSubscribeMessage({
|
||
touser: openid,
|
||
page: 'pages/index/index',
|
||
templateId: TEMPLATE_ID,
|
||
miniprogramState: MINIPROGRAM_STATE,
|
||
data: {
|
||
name1: { value: anniv.personName },
|
||
thing2: { value: item.daysUntil === 0 ? '今天' : `还有${item.daysUntil}天` },
|
||
thing6: { value: formatDate(item.target) },
|
||
thing5: { value: anniv.remark || '别忘了准备一份礼物哦!' }
|
||
}
|
||
})
|
||
quota.consume(openid, 1)
|
||
balance--
|
||
insertLog.run({
|
||
anniversaryId: anniv.id, personName: anniv.personName, typeName,
|
||
daysUntil: item.daysUntil, sendDate: Date.now(), status: 'success', error: null
|
||
})
|
||
ok++
|
||
console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${item.daysUntil}天)`)
|
||
} catch (err) {
|
||
if (isQuotaError(err)) {
|
||
// 微信侧实际已无额度(或用户关了通知总开关),归零并停止骚扰
|
||
quota.reset(openid)
|
||
balance = 0
|
||
halted = true
|
||
logSkip(item); skipped++
|
||
console.warn(`[reminder] ${openid} 额度已耗尽,本轮剩余全部跳过`)
|
||
} else {
|
||
insertLog.run({
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
return { ok, fail, skipped }
|
||
}
|
||
|
||
async function runOnce() {
|
||
console.log('[reminder] 开始扫描纪念日...')
|
||
const list = db.prepare('SELECT * FROM anniversaries WHERE remindEnabled = 1').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 }
|
||
}
|
||
```
|
||
|
||
把文件末尾导出改为:
|
||
|
||
```js
|
||
module.exports = { start, runOnce, runForUser }
|
||
```
|
||
|
||
- [ ] **Step 5: 运行测试确认通过**
|
||
|
||
Run: `cd server && npm test`
|
||
Expected: PASS,全部 6 个 reminder 用例通过
|
||
|
||
- [ ] **Step 6: 提交**
|
||
|
||
```bash
|
||
git add server/src/wx.js server/src/reminder.js server/test/reminder.test.js
|
||
git commit -m "定时任务改为按用户分组、按优先级发送,额度耗尽即停"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: 前端订阅模块与接口封装
|
||
|
||
**Files:**
|
||
- Create: `utils/subscribe.js`
|
||
- Modify: `utils/api.js`(新增 `subscribe`)
|
||
- Modify: `utils/sync.js:24-28`(`dispatch` 支持 `subscribe` 类型)
|
||
- Modify: `pages/add-anniversary/add-anniversary.js:376`(改用常量,消除硬编码重复)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `api.subscribe(action, data)`, `sync.syncOrEnqueue({kind: 'subscribe', action, data})`
|
||
- Produces:
|
||
- `TEMPLATE_ID: string`
|
||
- `getStatus(): Promise<'silent'|'willPrompt'|'rejected'|'mainSwitchOff'|'unknown'>`
|
||
- `requestAndReport(): Promise<boolean>` — **必须在 tap handler 中同步调用**,返回是否 accept
|
||
|
||
- [ ] **Step 1: 给 api.js 加接口**
|
||
|
||
在 `utils/api.js` 的 `person` 函数之后加:
|
||
|
||
```js
|
||
// 订阅额度:grant 上报授权,get 查询余额与风险
|
||
function subscribe(action, data) {
|
||
return request({ url: '/api/subscribe', data: { action, data } })
|
||
}
|
||
```
|
||
|
||
并把 `module.exports` 改为:
|
||
|
||
```js
|
||
module.exports = { request, login, anniversary, person, subscribe, BASE_URL }
|
||
```
|
||
|
||
- [ ] **Step 2: 让同步队列支持 subscribe 类型**
|
||
|
||
授权上报失败必须能重试:用户已经点了同意、微信侧额度确实 +1 了,我们没记上会导致余额低估,白白跳过本可以发出去的提醒。
|
||
|
||
修改 `utils/sync.js` 的 `dispatch`,在 anniversary 分支后加一行:
|
||
|
||
```js
|
||
if (item.kind === 'subscribe') return api.subscribe(item.action, item.data)
|
||
```
|
||
|
||
- [ ] **Step 3: 写订阅模块**
|
||
|
||
创建 `utils/subscribe.js`:
|
||
|
||
```js
|
||
/**
|
||
* 订阅消息统一入口
|
||
*
|
||
* ⚠️ 最重要的约束: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 }
|
||
```
|
||
|
||
- [ ] **Step 4: 消除硬编码的模板 ID**
|
||
|
||
在 `pages/add-anniversary/add-anniversary.js` 顶部 require 区加:
|
||
|
||
```js
|
||
const subscribe = require('../../utils/subscribe')
|
||
```
|
||
|
||
把第 376 行的 `tmplIds: ['6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw'],` 改为:
|
||
|
||
```js
|
||
tmplIds: [subscribe.TEMPLATE_ID],
|
||
```
|
||
|
||
- [ ] **Step 5: 语法检查**
|
||
|
||
Run: `node --check utils/subscribe.js && node --check utils/api.js && node --check utils/sync.js && node --check pages/add-anniversary/add-anniversary.js`
|
||
Expected: 全部通过,无输出
|
||
|
||
- [ ] **Step 6: 确认模板 ID 只剩一处定义**
|
||
|
||
Run: `grep -rn "6J7Stt" pages utils`
|
||
Expected: 只有 `utils/subscribe.js` 一处
|
||
|
||
- [ ] **Step 7: 提交**
|
||
|
||
```bash
|
||
git add utils/subscribe.js utils/api.js utils/sync.js pages/add-anniversary/add-anniversary.js
|
||
git commit -m "新增前端订阅模块,收敛模板 ID 到唯一来源"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: 首页风险提示卡片
|
||
|
||
**Files:**
|
||
- Modify: `pages/index/index.js`(`onShow` 拉取、状态缓存、文案拼装)
|
||
- Modify: `pages/index/index.wxml`(提示卡片)
|
||
- Modify: `pages/index/index.wxss`(卡片样式)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `subscribe.getStatus()`, `api.subscribe('get')`
|
||
- Produces: 页面 data 新增 `atRiskText: string`(空串表示无风险);页面实例属性 `subscribeStatus: string`(**不放 data**,避免无谓的 setData)
|
||
|
||
- [ ] **Step 1: 页面逻辑接上**
|
||
|
||
在 `pages/index/index.js` 顶部 require 区加:
|
||
|
||
```js
|
||
const api = require('../../utils/api')
|
||
const subscribe = require('../../utils/subscribe')
|
||
```
|
||
|
||
在 `data` 中加一项:
|
||
|
||
```js
|
||
atRiskText: '',
|
||
```
|
||
|
||
把 `onShow` 改为:
|
||
|
||
```js
|
||
onShow() {
|
||
this.loadPersons()
|
||
this.refreshQuota()
|
||
// 提前查好订阅状态缓存到实例上:点击处理函数里不能再 await,否则手势上下文会丢
|
||
subscribe.getStatus().then(status => { this.subscribeStatus = status })
|
||
},
|
||
```
|
||
|
||
在 `onAddTap` 之前加入两个方法:
|
||
|
||
```js
|
||
/**
|
||
* 拉取额度风险,拼成给用户看的文案
|
||
* 说「谁的提醒有风险」而不是「还剩几次额度」——用户理解不了额度这种抽象概念
|
||
*/
|
||
async refreshQuota() {
|
||
try {
|
||
const res = await api.subscribe('get')
|
||
if (!res || !res.success) return
|
||
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 })
|
||
} catch (e) {
|
||
// 查询失败就不显示提示,静默处理,不打扰用户
|
||
console.warn('[index] 额度查询失败', e)
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 点击风险提示卡片补额度
|
||
* 注意:wx.requestSubscribeMessage / wx.openSetting 必须在这里同步调用,
|
||
* 前面绝不能有 await,否则手势上下文丢失、调用必然失败
|
||
*/
|
||
onTopUp() {
|
||
const status = this.subscribeStatus
|
||
if (status === 'rejected' || status === 'mainSwitchOff') {
|
||
wx.openSetting({ withSubscriptions: true })
|
||
return
|
||
}
|
||
subscribe.requestAndReport().then(accepted => {
|
||
if (accepted) {
|
||
this.refreshQuota()
|
||
wx.showToast({ title: '已补充提醒次数', icon: 'success' })
|
||
} else {
|
||
wx.showToast({ title: '未开启提醒', icon: 'none' })
|
||
}
|
||
})
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 2: 加提示卡片**
|
||
|
||
在 `pages/index/index.wxml` 中,把提示卡片插入到人员列表**之前**(紧跟顶部统计区域之后,让用户一眼看到):
|
||
|
||
```xml
|
||
<view wx:if="{{atRiskText}}" class="quota-warn" bindtap="onTopUp">
|
||
<text class="quota-warn-icon">⚠️</text>
|
||
<text class="quota-warn-text">{{atRiskText}}的生日提醒可能发不出去,点这里补上</text>
|
||
</view>
|
||
```
|
||
|
||
- [ ] **Step 3: 加样式**
|
||
|
||
在 `pages/index/index.wxss` 末尾追加(沿用项目现有的纸感编辑风配色):
|
||
|
||
```css
|
||
/* 额度风险提示卡片:仅在有提醒可能发不出去时出现 */
|
||
.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;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 语法检查**
|
||
|
||
Run: `node --check pages/index/index.js`
|
||
Expected: 通过,无输出
|
||
|
||
- [ ] **Step 5: 提交**
|
||
|
||
```bash
|
||
git add pages/index/index.js pages/index/index.wxml pages/index/index.wxss
|
||
git commit -m "首页新增额度风险提示卡片,作为补额度主入口"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: 日常点击搭车补额度
|
||
|
||
只对已勾选「总是保持以上选择」的用户生效——这类用户调用不弹窗,完全无感。没勾过的用户绝不在这里调,否则点开个人详情就弹窗,体验很差。
|
||
|
||
**Files:**
|
||
- Modify: `pages/index/index.js`(`onPersonTap`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `this.subscribeStatus`(Task 7 中在 `onShow` 缓存)、`subscribe.requestAndReport()`
|
||
|
||
- [ ] **Step 1: 在人员点击处搭车**
|
||
|
||
把 `pages/index/index.js` 的 `onPersonTap` 改为:
|
||
|
||
```js
|
||
/**
|
||
* 点击人员进详情
|
||
* 顺带搭车补额度:仅对已勾选「总是保持以上选择」的用户,此调用不弹窗、完全无感。
|
||
* 必须同步调用且放在 navigateTo 之前,否则手势上下文丢失。
|
||
*/
|
||
onPersonTap(e) {
|
||
if (this.subscribeStatus === 'silent') subscribe.requestAndReport()
|
||
|
||
const id = e.currentTarget.dataset.id
|
||
wx.navigateTo({
|
||
url: `/pages/person-detail/person-detail?id=${id}`
|
||
})
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 2: 语法检查**
|
||
|
||
Run: `node --check pages/index/index.js`
|
||
Expected: 通过,无输出
|
||
|
||
- [ ] **Step 3: 提交**
|
||
|
||
```bash
|
||
git add pages/index/index.js
|
||
git commit -m "已长期订阅的用户在点击人员时静默补额度"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: 端到端验证
|
||
|
||
在模拟器 + 本地临时库上跑通完整链路,并留下可复用的验证脚本。
|
||
|
||
**Files:**
|
||
- Create: `/tmp/wxverify/quota_e2e.js`(临时脚本,不入库)
|
||
|
||
**Interfaces:**
|
||
- Consumes: 前八个任务的全部产出
|
||
|
||
- [ ] **Step 1: 起本地后端(临时库,关掉定时任务)**
|
||
|
||
```bash
|
||
cd server
|
||
DB_PATH=/tmp/wxverify/quota.db REMINDER_CRON="0 4 1 1 *" npm start > /tmp/wxverify/quota-server.log 2>&1 &
|
||
sleep 4
|
||
curl -s http://localhost:3000/api/health
|
||
```
|
||
|
||
Expected: 返回 `{"ok":true,...}`。真实库 `server/data/birthday.db` 时间戳不得变化。
|
||
|
||
- [ ] **Step 2: 让开发者工具重新编译并开自动化端口**
|
||
|
||
```bash
|
||
CLI=/Applications/wechatwebdevtools.app/Contents/MacOS/cli
|
||
P=/Users/gaotu/WebstormProjects/myself/生日提醒小程序
|
||
"$CLI" close --project "$P"; sleep 5
|
||
"$CLI" open --project "$P"; sleep 20
|
||
"$CLI" auto --project "$P" --auto-port 9420 &
|
||
sleep 20
|
||
```
|
||
|
||
Expected: 输出 `✔ auto`,9420 端口处于 LISTEN
|
||
|
||
- [ ] **Step 3: 写端到端脚本**
|
||
|
||
创建 `/tmp/wxverify/quota_e2e.js`:
|
||
|
||
```js
|
||
const automator = require('miniprogram-automator')
|
||
setTimeout(() => { console.error('!! 超时'); process.exit(2) }, 90000)
|
||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||
|
||
;(async () => {
|
||
const mp = await automator.connect({ wsEndpoint: 'ws://127.0.0.1:9420' })
|
||
|
||
// 造一条近期纪念日,保证会产生风险
|
||
await mp.evaluate(() => {
|
||
const a = wx.getStorageSync('anniversaries') || []
|
||
const p = wx.getStorageSync('persons') || []
|
||
const d = new Date(Date.now() + 10 * 86400000)
|
||
a.push({
|
||
id: 'E2E_1', personId: p[0] && p[0].id, personName: '额度测试',
|
||
type: 'birthday', isLunar: false,
|
||
solarYear: d.getFullYear(), solarMonth: d.getMonth() + 1, solarDay: d.getDate(),
|
||
importance: 'high', remindEnabled: true, remindDays: 3,
|
||
createTime: Date.now(), updateTime: Date.now()
|
||
})
|
||
wx.setStorageSync('anniversaries', a)
|
||
})
|
||
|
||
await mp.reLaunch('/pages/index/index')
|
||
await sleep(2500)
|
||
const page = await mp.currentPage()
|
||
const before = (await page.data()).atRiskText
|
||
console.log('提示文案:', JSON.stringify(before))
|
||
if (!before) { console.error('✗ 应当显示风险提示'); process.exit(1) }
|
||
|
||
// mock 掉订阅弹窗,模拟用户同意
|
||
await mp.mockWxMethod('requestSubscribeMessage', {
|
||
'6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw': 'accept'
|
||
})
|
||
const card = await page.$('.quota-warn')
|
||
if (!card) { console.error('✗ 找不到提示卡片'); process.exit(1) }
|
||
await card.tap()
|
||
await sleep(4000)
|
||
await mp.restoreWxMethod('requestSubscribeMessage')
|
||
|
||
console.log('✓ 点击补额度已执行')
|
||
process.exit(0)
|
||
})().catch(e => { console.error('ERR:', e.message); process.exit(1) })
|
||
```
|
||
|
||
- [ ] **Step 4: 跑脚本**
|
||
|
||
Run: `cd /tmp/wxverify && node quota_e2e.js`
|
||
Expected: 打印出非空的提示文案,且 `✓ 点击补额度已执行`
|
||
|
||
- [ ] **Step 5: 核对服务端记账**
|
||
|
||
```bash
|
||
sqlite3 /tmp/wxverify/quota.db "select openid, balance, grantedTotal from subscribe_quota;"
|
||
```
|
||
|
||
Expected: 出现一条记录,`grantedTotal >= 1`,说明前端授权确实上报成功
|
||
|
||
- [ ] **Step 6: 清理测试数据并收尾**
|
||
|
||
```bash
|
||
pkill -f "node src/index.js"
|
||
ls -l server/data/birthday.db
|
||
```
|
||
|
||
在模拟器中移除 `E2E_1` 这条测试纪念日:
|
||
|
||
```bash
|
||
cd /tmp/wxverify && node -e '
|
||
const automator = require("miniprogram-automator");
|
||
(async () => {
|
||
const mp = await automator.connect({ wsEndpoint: "ws://127.0.0.1:9420" });
|
||
await mp.evaluate(() => {
|
||
const a = (wx.getStorageSync("anniversaries") || []).filter(x => x.id !== "E2E_1");
|
||
wx.setStorageSync("anniversaries", a);
|
||
});
|
||
console.log("已清理测试数据");
|
||
process.exit(0);
|
||
})().catch(e => { console.error(e.message); process.exit(1) })'
|
||
```
|
||
|
||
Expected: 真实库时间戳仍为改动前的值;模拟器中不再有 `E2E_1`
|
||
|
||
- [ ] **Step 7: 全量回归**
|
||
|
||
Run: `cd server && npm test && cd .. && for f in app.js utils/*.js pages/*/*.js server/src/*.js; do node --check $f || echo "FAIL $f"; done`
|
||
Expected: 后端测试全绿,全部文件语法通过
|
||
|
||
---
|
||
|
||
## 部署顺序(务必遵守)
|
||
|
||
依据 `MAINTENANCE.md` 场景 3 的铁律,且本次后端**先于**前端上线:
|
||
|
||
1. `git push origin master` → Gitea Actions 自动部署后端(3–8 分钟)
|
||
2. 验证:`curl https://wxserver.ymxixi.space/api/health`
|
||
3. 验证后端对**线上旧版**小程序仍兼容:旧版不会调 `/api/subscribe`,且 `reminder.js` 对 `balance = 0` 的老用户会全部记 skipped——**这是本次上线最大的风险点**,见下方说明
|
||
4. 确认无误后再上传小程序 → 体验版自测 → 提交审核 → 发布
|
||
|
||
### ⚠️ 上线首日的额度真空问题
|
||
|
||
新版 `reminder.js` 在 `balance <= 0` 时**不发任何请求**。而 `subscribe_quota` 表是新建的,所有存量用户的初始 `balance` 都是 0——这意味着**后端一上线,所有存量用户的提醒会立刻全部停发**,而新版小程序还在审核中,用户没有任何途径补额度。
|
||
|
||
处理办法:部署后立刻给存量用户补一个初始余额,让他们至少维持现状不倒退。存量用户过去每存一条纪念日就授权过一次,所以按「该用户的纪念日条数」补是合理的估算:
|
||
|
||
```bash
|
||
# 在群晖上对生产库执行(先备份)
|
||
cp /volume1/docker/apps/birthday-server/data/birthday.db{,.bak-$(date +%F)}
|
||
sqlite3 /volume1/docker/apps/birthday-server/data/birthday.db "
|
||
INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime)
|
||
SELECT openid, COUNT(*), COUNT(*), 0, strftime('%s','now')*1000
|
||
FROM anniversaries GROUP BY openid
|
||
ON CONFLICT(openid) DO NOTHING;
|
||
"
|
||
```
|
||
|
||
这一步**必须在后端部署完成后、当天 9 点定时任务触发前**执行完毕。
|