Files
wxserver/docs/superpowers/plans/2026-08-15-订阅消息额度.md
T
2026-08-16 10:28:29 +08:00

51 KiB
Raw Blame History

订阅消息额度治理 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.requestSubscribeMessagewx.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.jsonscripts,加一行:

"test": "node --test test/"
  • Step 2: 写测试辅助

创建 server/test/helper.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

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

/**
 * 纪念日日期计算(纯函数,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: PASS7 个测试全绿

  • Step 7: 让 reminder.js 改用新模块

server/src/reminder.js 顶部加 const occurrence = require('./occurrence'),删除原有的 getThisYearDatedaysBetween 两个函数定义(原第 30-62 行),把 runOnce 里的调用改为:

      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: 提交
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

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: FAILCannot find module '../src/quota'

  • Step 3: 建表

server/src/db.jsdb.exec(...) 模板字符串末尾(remind_logs 索引之后)追加:

  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

/**
 * 订阅消息额度记账
 *
 * ⚠️ 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: 提交
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

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: FAILCannot find module '../src/atRisk'

  • Step 3: 实现模块

创建 server/src/atRisk.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: 提交
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

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: FAILgrantQuota is not a functionindex.js 尚未导出)

  • Step 3: 实现接口

server/src/index.js 顶部 require 区加:

const quota = require('./quota')
const atRisk = require('./atRisk')

在人员 CRUD 之后、/api/reminder/run 之前,加入处理函数与路由:

// ---- 订阅额度 ----

// 前端授权成功后上报。纯累加、不去重:微信侧确实每次授权都 +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(...) 之前加导出,供测试直接调用:

module.exports = { grantQuota, getQuotaStatus }
  • Step 4: 运行测试确认通过

Run: cd server && npm test Expected: PASS

  • Step 5: 手工验证接口连通
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},第二条返回含 balanceatRiskCountatRiskNames 的对象

  • Step 6: 提交
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.jsrunOnce 重写)
  • 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.jssendSubscribeMessage 的抛错分支:

  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

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: FAILreminder.runForUser is not a function

  • Step 4: 重写 reminder.js 的发送逻辑

server/src/reminder.js 顶部加 const quota = require('./quota')

新增重要程度排序表与判断函数(放在 insertLog 定义之后):

// 重要程度排序权重,未知值排最后
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 整个替换为:

/**
 * 处理单个用户的当日提醒
 *
 * 按「当天 > 提前」、同级「重要程度降序」排序后依次发送,余额耗尽即停,
 * 不在明知没额度时继续打无谓请求。
 */
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 }
}

把文件末尾导出改为:

module.exports = { start, runOnce, runForUser }
  • Step 5: 运行测试确认通过

Run: cd server && npm test Expected: PASS,全部 6 个 reminder 用例通过

  • Step 6: 提交
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-28dispatch 支持 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.jsperson 函数之后加:

// 订阅额度:grant 上报授权,get 查询余额与风险
function subscribe(action, data) {
  return request({ url: '/api/subscribe', data: { action, data } })
}

并把 module.exports 改为:

module.exports = { request, login, anniversary, person, subscribe, BASE_URL }
  • Step 2: 让同步队列支持 subscribe 类型

授权上报失败必须能重试:用户已经点了同意、微信侧额度确实 +1 了,我们没记上会导致余额低估,白白跳过本可以发出去的提醒。

修改 utils/sync.jsdispatch,在 anniversary 分支后加一行:

  if (item.kind === 'subscribe')   return api.subscribe(item.action, item.data)
  • Step 3: 写订阅模块

创建 utils/subscribe.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 区加:

const subscribe = require('../../utils/subscribe')

把第 376 行的 tmplIds: ['6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw'], 改为:

        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: 提交
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.jsonShow 拉取、状态缓存、文案拼装)
  • 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 区加:

const api = require('../../utils/api')
const subscribe = require('../../utils/subscribe')

data 中加一项:

    atRiskText: '',

onShow 改为:

  onShow() {
    this.loadPersons()
    this.refreshQuota()
    // 提前查好订阅状态缓存到实例上:点击处理函数里不能再 await,否则手势上下文会丢
    subscribe.getStatus().then(status => { this.subscribeStatus = status })
  },

onAddTap 之前加入两个方法:

  /**
   * 拉取额度风险,拼成给用户看的文案
   * 说「谁的提醒有风险」而不是「还剩几次额度」——用户理解不了额度这种抽象概念
   */
  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 中,把提示卡片插入到人员列表之前(紧跟顶部统计区域之后,让用户一眼看到):

  <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 末尾追加(沿用项目现有的纸感编辑风配色):

/* 额度风险提示卡片:仅在有提醒可能发不出去时出现 */
.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: 提交
git add pages/index/index.js pages/index/index.wxml pages/index/index.wxss
git commit -m "首页新增额度风险提示卡片,作为补额度主入口"

Task 8: 日常点击搭车补额度

只对已勾选「总是保持以上选择」的用户生效——这类用户调用不弹窗,完全无感。没勾过的用户绝不在这里调,否则点开个人详情就弹窗,体验很差。

Files:

  • Modify: pages/index/index.jsonPersonTap

Interfaces:

  • Consumes: this.subscribeStatusTask 7 中在 onShow 缓存)、subscribe.requestAndReport()

  • Step 1: 在人员点击处搭车

pages/index/index.jsonPersonTap 改为:

  /**
   * 点击人员进详情
   * 顺带搭车补额度:仅对已勾选「总是保持以上选择」的用户,此调用不弹窗、完全无感。
   * 必须同步调用且放在 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: 提交
git add pages/index/index.js
git commit -m "已长期订阅的用户在点击人员时静默补额度"

Task 9: 端到端验证

在模拟器 + 本地临时库上跑通完整链路,并留下可复用的验证脚本。

Files:

  • Create: /tmp/wxverify/quota_e2e.js(临时脚本,不入库)

Interfaces:

  • Consumes: 前八个任务的全部产出

  • Step 1: 起本地后端(临时库,关掉定时任务)

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: 让开发者工具重新编译并开自动化端口
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: 输出 ✔ auto9420 端口处于 LISTEN

  • Step 3: 写端到端脚本

创建 /tmp/wxverify/quota_e2e.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: 核对服务端记账
sqlite3 /tmp/wxverify/quota.db "select openid, balance, grantedTotal from subscribe_quota;"

Expected: 出现一条记录,grantedTotal >= 1,说明前端授权确实上报成功

  • Step 6: 清理测试数据并收尾
pkill -f "node src/index.js"
ls -l server/data/birthday.db

在模拟器中移除 E2E_1 这条测试纪念日:

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 自动部署后端(38 分钟)
  2. 验证:curl https://wxserver.ymxixi.space/api/health
  3. 验证后端对线上旧版小程序仍兼容:旧版不会调 /api/subscribe,老用户 balance = 0——见下方说明(合并前评审已修,风险大幅下降)
  4. 确认无误后再上传小程序 → 体验版自测 → 提交审核 → 发布

⚠️ 上线首日的额度真空问题(已在合并前评审中修掉,此处保留背景)

原风险reminder.jsbalance <= 0 时不发任何请求,而 subscribe_quota 是新建表,所有存量用户初始 balance 都是 0——后端一上线,存量用户的提醒会立刻全部停发,而新版小程序还在审核中,用户没有任何途径补额度。

现状:不需要再手工执行任何 SQL。 合并前评审拍板了两条修复,均已落地:

  1. 自动迁移server/src/db.js):下面这条 SQL 已经做成 PRAGMA user_version 驱动的一次性迁移,容器启动即执行,幂等(user_version 版本号 + INSERT OR IGNORE),只补 subscribe_quota 里尚无记录的 openid。再手工敲一遍无害但完全多余。
  2. 探针机制server/src/reminder.js):即使余额为 0,每个用户每轮仍会把优先级最高的那条发出去探路;探针成功就继续正常发送。所以「记账低估 = 停发」这个前提本身已经不成立了。

原手工 SQL(仅作为迁移口径的备查,不要执行):

# 已由 db.js 的 migrateV1() 自动完成,保留在此仅供理解口径
# 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;

MAINTENANCE.md 的惯例,部署前依然建议先备份生产库:

cp /volume1/docker/apps/birthday-server/data/birthday.db{,.bak-$(date +%F)}