# 订阅消息额度治理 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 点定时任务触发前**执行完毕。