From 955c80e221fcef9e81b6fbf73cdbf0eaca989ebe Mon Sep 17 00:00:00 2001 From: yuming Date: Sat, 15 Aug 2026 16:55:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=AE=A1=E5=88=92=EF=BC=9A?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E6=B6=88=E6=81=AF=E9=A2=9D=E5=BA=A6=E6=B2=BB?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../plans/2026-08-15-订阅消息额度.md | 1547 +++++++++++++++++ 1 file changed, 1547 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-15-订阅消息额度.md diff --git a/docs/superpowers/plans/2026-08-15-订阅消息额度.md b/docs/superpowers/plans/2026-08-15-订阅消息额度.md new file mode 100644 index 0000000..c4938dd --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-订阅消息额度.md @@ -0,0 +1,1547 @@ +# 订阅消息额度治理 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` — **必须在 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} + * 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} 用户是否同意 + */ +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 + + ⚠️ + {{atRiskText}}的生日提醒可能发不出去,点这里补上 + +``` + +- [ ] **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 点定时任务触发前**执行完毕。