From c92df874d14a76f49e704e9ccd47403d10836ee8 Mon Sep 17 00:00:00 2001 From: yuming Date: Sat, 15 Aug 2026 17:27:37 +0800 Subject: [PATCH 01/20] =?UTF-8?q?=E5=BF=BD=E7=95=A5=E5=AD=90=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E6=89=A7=E8=A1=8C=E8=BF=87=E7=A8=8B=E7=9A=84=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E4=BA=A7=E7=89=A9=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6aaa899..21ab8f0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ node_modules/ server/.env server/data/ server/node_modules/ + +# 子代理执行过程的临时产物(ledger、任务简报、评审包) +.superpowers/ From 68acbb38096891b3f4e7bb3ed739a3ddc8b5622c Mon Sep 17 00:00:00 2001 From: yuming Date: Sat, 15 Aug 2026 17:30:42 +0800 Subject: [PATCH 02/20] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E7=BA=AF=E5=87=BD=E6=95=B0=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=90=AD=E5=BB=BA=E5=90=8E=E7=AB=AF=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=9F=BA=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 server/src/occurrence.js:startOfDay/getNextOccurrence/daysBetween,today 可注入 - reminder.js 改为调用新模块,删除内联的 getThisYearDate/daysBetween - 引入 node:test 作为测试框架,加 npm test 脚本 - 新增 test/helper.js(临时库辅助,供后续任务用)和 test/occurrence.test.js(7 个用例,覆盖农历跨年等边界) --- server/package.json | 3 ++- server/src/occurrence.js | 48 ++++++++++++++++++++++++++++++++++ server/src/reminder.js | 39 +++------------------------ server/test/helper.js | 13 +++++++++ server/test/occurrence.test.js | 45 +++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 37 deletions(-) create mode 100644 server/src/occurrence.js create mode 100644 server/test/helper.js create mode 100644 server/test/occurrence.test.js diff --git a/server/package.json b/server/package.json index dd133d6..5cc202f 100644 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,8 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "dev": "node --watch src/index.js" + "dev": "node --watch src/index.js", + "test": "node --test test/" }, "dependencies": { "axios": "^1.7.7", diff --git a/server/src/occurrence.js b/server/src/occurrence.js new file mode 100644 index 0000000..08a0fcc --- /dev/null +++ b/server/src/occurrence.js @@ -0,0 +1,48 @@ +/** + * 纪念日日期计算(纯函数,today 可注入以便测试) + * + * 从 reminder.js 抽出,供定时发送与「风险预警」计算共用,避免两处各写一套算错。 + */ + +const lunar = require('./lunar') + +// 把日期归到当天 00:00:00,消除时分秒对比较和天数差的干扰 +function startOfDay(date) { + const d = new Date(date) + d.setHours(0, 0, 0, 0) + return d +} + +/** + * 算出某条纪念日的下一次发生日(今天或以后最近的一次) + * + * 农历分支以「农历年」为基准递推:公历年和农历年不对齐,农历腊月通常落在下一个公历年, + * 直接拿公历年当农历年传会整整错一年。 + * isLunar 为真但缺 lunarMonth/lunarDay 的老数据,安全降级按公历算。 + */ +function getNextOccurrence(anniv, today = new Date()) { + const base = startOfDay(today) + + if (anniv.isLunar && anniv.lunarMonth && anniv.lunarDay) { + const wantLeap = !!anniv.isLeapMonth + const todayLunar = lunar.solarToLunar(base) + let target = startOfDay(lunar.lunarToSolar(todayLunar.year, anniv.lunarMonth, anniv.lunarDay, wantLeap)) + if (target < base) { + target = startOfDay(lunar.lunarToSolar(todayLunar.year + 1, anniv.lunarMonth, anniv.lunarDay, wantLeap)) + } + return target + } + + let target = startOfDay(new Date(base.getFullYear(), anniv.solarMonth - 1, anniv.solarDay)) + if (target < base) { + target = startOfDay(new Date(base.getFullYear() + 1, anniv.solarMonth - 1, anniv.solarDay)) + } + return target +} + +// 距离目标日还有多少天(负数表示已过) +function daysBetween(target, today = new Date()) { + return Math.round((startOfDay(target) - startOfDay(today)) / 86400000) +} + +module.exports = { startOfDay, getNextOccurrence, daysBetween } diff --git a/server/src/reminder.js b/server/src/reminder.js index b322daa..40e580c 100644 --- a/server/src/reminder.js +++ b/server/src/reminder.js @@ -2,6 +2,7 @@ const cron = require('node-cron') const db = require('./db') const wx = require('./wx') const lunar = require('./lunar') +const occurrence = require('./occurrence') const TEMPLATE_ID = process.env.WX_TEMPLATE_ID const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal' @@ -27,40 +28,6 @@ function formatDate(date) { return `${y}年${m}月${d}日` } -// 算出"距离今年(或明年)这个纪念日还有多少天" -// 农历纪念日按 lunarMonth/lunarDay 计算每年对应的公历日期,否则按 solarMonth/solarDay -function getThisYearDate(anniv) { - const today = new Date() - today.setHours(0, 0, 0, 0) - - if (anniv.isLunar && anniv.lunarMonth && anniv.lunarDay) { - // 闰月生日按 A 方案处理:若当年无对应闰月,lunarToSolar 内部已自动按普通月份算 - const todayLunar = lunar.solarToLunar(today) - const wantLeap = !!anniv.isLeapMonth - let target = lunar.lunarToSolar(todayLunar.year, anniv.lunarMonth, anniv.lunarDay, wantLeap) - target.setHours(0, 0, 0, 0) - if (target < today) { - target = lunar.lunarToSolar(todayLunar.year + 1, anniv.lunarMonth, anniv.lunarDay, wantLeap) - target.setHours(0, 0, 0, 0) - } - return target - } - - let target = new Date(today.getFullYear(), anniv.solarMonth - 1, anniv.solarDay) - target.setHours(0, 0, 0, 0) - if (target < today) { - target = new Date(today.getFullYear() + 1, anniv.solarMonth - 1, anniv.solarDay) - target.setHours(0, 0, 0, 0) - } - return target -} - -function daysBetween(target) { - const today = new Date() - today.setHours(0, 0, 0, 0) - return Math.round((target - today) / 86400000) -} - // 检查今天是否已经给这条纪念日发过提醒 function alreadySentToday(anniversaryId) { const start = new Date() @@ -87,8 +54,8 @@ async function runOnce() { for (const anniv of list) { try { - const target = getThisYearDate(anniv) - const daysUntil = daysBetween(target) + const target = occurrence.getNextOccurrence(anniv) + const daysUntil = occurrence.daysBetween(target) const shouldRemind = (daysUntil === 0) || (daysUntil === (anniv.remindDays || 0)) if (!shouldRemind) continue diff --git a/server/test/helper.js b/server/test/helper.js new file mode 100644 index 0000000..a1f43f0 --- /dev/null +++ b/server/test/helper.js @@ -0,0 +1,13 @@ +const fs = require('fs') +const os = require('os') +const path = require('path') + +// 把 DB_PATH 指向临时目录,避免测试碰到真实库。 +// 必须在 require('../src/db') 之前调用,因为 db.js 在 require 时就读取 DB_PATH。 +function useTempDb() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bday-test-')) + process.env.DB_PATH = path.join(dir, 'test.db') + return dir +} + +module.exports = { useTempDb } diff --git a/server/test/occurrence.test.js b/server/test/occurrence.test.js new file mode 100644 index 0000000..d02618e --- /dev/null +++ b/server/test/occurrence.test.js @@ -0,0 +1,45 @@ +const test = require('node:test') +const assert = require('node:assert') +const { getNextOccurrence, daysBetween, startOfDay } = require('../src/occurrence') + +const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + +test('公历纪念日:今年还没到就取今年', () => { + const anniv = { isLunar: false, solarMonth: 12, solarDay: 25 } + assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2026-12-25') +}) + +test('公历纪念日:今年已过就取明年', () => { + const anniv = { isLunar: false, solarMonth: 5, solarDay: 20 } + assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2027-05-20') +}) + +test('农历腊月十五:跨公历年不会算错一年', () => { + const anniv = { isLunar: true, lunarMonth: 12, lunarDay: 15, isLeapMonth: false } + // 2027-01-20 时,下一次应是 2 天后的 2027-01-22,而不是 2028 年 + assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2027, 0, 20))), '2027-01-22') + // 过了之后应跳到下一个农历年 + assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2027, 0, 23))), '2028-01-11') +}) + +test('农历四月十七', () => { + const anniv = { isLunar: true, lunarMonth: 4, lunarDay: 17, isLeapMonth: false } + assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2027-05-22') +}) + +test('isLunar 但缺农历字段时降级按公历算,不抛错', () => { + const anniv = { isLunar: true, lunarMonth: null, lunarDay: null, solarMonth: 6, solarDay: 5 } + assert.strictEqual(fmt(getNextOccurrence(anniv, new Date(2026, 7, 15))), '2027-06-05') +}) + +test('daysBetween 忽略时分秒', () => { + const today = new Date(2026, 7, 15, 23, 59, 0) + const target = new Date(2026, 7, 17, 0, 1, 0) + assert.strictEqual(daysBetween(target, today), 2) +}) + +test('startOfDay 归零时分秒', () => { + const d = startOfDay(new Date(2026, 7, 15, 13, 45, 30)) + assert.strictEqual(d.getHours(), 0) + assert.strictEqual(d.getMinutes(), 0) +}) From 8d63b47577ddbb08dcbee2b1b47eeffcd45a804a Mon Sep 17 00:00:00 2001 From: yuming Date: Sat, 15 Aug 2026 17:34:46 +0800 Subject: [PATCH 03/20] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=A2=9D=E5=BA=A6=E8=AE=B0=E8=B4=A6=E8=A1=A8?= =?UTF-8?q?=E4=B8=8E=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/db.js | 8 ++++++ server/src/quota.js | 60 +++++++++++++++++++++++++++++++++++++++ server/test/quota.test.js | 51 +++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 server/src/quota.js create mode 100644 server/test/quota.test.js diff --git a/server/src/db.js b/server/src/db.js index 2bf4f0c..1f1228e 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -62,6 +62,14 @@ db.exec(` ); CREATE INDEX IF NOT EXISTS idx_log_anniv ON remind_logs(anniversaryId); CREATE INDEX IF NOT EXISTS idx_log_date ON remind_logs(sendDate); + + CREATE TABLE IF NOT EXISTS subscribe_quota ( + openid TEXT PRIMARY KEY, + balance INTEGER NOT NULL DEFAULT 0, + grantedTotal INTEGER NOT NULL DEFAULT 0, + sentTotal INTEGER NOT NULL DEFAULT 0, + updateTime INTEGER + ); `) // 旧库迁移:CREATE TABLE IF NOT EXISTS 不会给已存在的表加列,需要手动 ALTER diff --git a/server/src/quota.js b/server/src/quota.js new file mode 100644 index 0000000..cf5ca42 --- /dev/null +++ b/server/src/quota.js @@ -0,0 +1,60 @@ +/** + * 订阅消息额度记账 + * + * ⚠️ 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 } diff --git a/server/test/quota.test.js b/server/test/quota.test.js new file mode 100644 index 0000000..8ab39a1 --- /dev/null +++ b/server/test/quota.test.js @@ -0,0 +1,51 @@ +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) +}) From 07ee98f83858ffdc93e44283f597bcacaaad9739 Mon Sep 17 00:00:00 2001 From: yuming Date: Sat, 15 Aug 2026 17:40:31 +0800 Subject: [PATCH 04/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20consume=20=E7=BC=BA?= =?UTF-8?q?=E8=A1=8C=E4=B8=8D=E5=BB=BA=E8=A1=8C=E5=AF=BC=E8=87=B4=20sentTo?= =?UTF-8?q?tal=20=E4=B8=A2=E5=A4=B1=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/quota.js | 11 ++++++++--- server/test/quota.test.js | 12 ++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/server/src/quota.js b/server/src/quota.js index cf5ca42..5e30778 100644 --- a/server/src/quota.js +++ b/server/src/quota.js @@ -38,12 +38,17 @@ function grant(openid, count = 1) { return getBalance(openid) } +// 消费:即便该 openid 从未 grant 过也要建行,否则 sentTotal 会静默丢失 +// (新建行 balance 直接落 0,等价于「从 0 扣减再取 MAX(0, ...)」) 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 + INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime) + VALUES (@openid, 0, 0, @n, @now) + ON CONFLICT(openid) DO UPDATE SET + balance = MAX(0, balance - @n), + sentTotal = sentTotal + @n, + updateTime = @now `).run({ openid, n, now: Date.now() }) return getBalance(openid) } diff --git a/server/test/quota.test.js b/server/test/quota.test.js index 8ab39a1..9a78d55 100644 --- a/server/test/quota.test.js +++ b/server/test/quota.test.js @@ -4,6 +4,7 @@ const { useTempDb } = require('./helper') useTempDb() // 必须在 require quota/db 之前 const quota = require('../src/quota') +const db = require('../src/db') test('未记录过的用户余额为 0', () => { assert.strictEqual(quota.getBalance('u_new'), 0) @@ -49,3 +50,14 @@ test('各用户额度互不影响', () => { assert.strictEqual(quota.getBalance('a'), 3) assert.strictEqual(quota.getBalance('b'), 1) }) + +test('consume 对从未 grant 过的 openid 也要建行,不丢失 sentTotal', () => { + quota.consume('u6', 3) + // 仅断言 getBalance 为 0 不足以证明建行了(缺行时本来就返回 0), + // 必须直接查表,确认行确实被创建且 sentTotal 记录了这次消费 + assert.strictEqual(quota.getBalance('u6'), 0) + const row = db.prepare('SELECT * FROM subscribe_quota WHERE openid = ?').get('u6') + assert.ok(row, 'u6 对应的记录行应当被创建') + assert.strictEqual(row.balance, 0) + assert.strictEqual(row.sentTotal, 3) +}) From 3a61b7952bb70cf243d3ba6640fdf87e59621544 Mon Sep 17 00:00:00 2001 From: yuming Date: Sat, 15 Aug 2026 17:43:19 +0800 Subject: [PATCH 05/20] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E9=A2=9D=E5=BA=A6=E9=A3=8E=E9=99=A9=E9=A2=84=E8=AD=A6=E8=AE=A1?= =?UTF-8?q?=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/atRisk.js | 66 ++++++++++++++++++++++++++++++++ server/test/atRisk.test.js | 78 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 server/src/atRisk.js create mode 100644 server/test/atRisk.test.js diff --git a/server/src/atRisk.js b/server/src/atRisk.js new file mode 100644 index 0000000..3a87040 --- /dev/null +++ b/server/src/atRisk.js @@ -0,0 +1,66 @@ +/** + * 风险预警计算:算出未来一段时间内,有哪些提醒会因额度不足发不出去 + * + * 首页拿这个结果给用户看「谁的提醒有风险」,而不是「还剩几次额度」—— + * 用户理解不了额度这种抽象概念,但看得懂人名。 + */ + +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 } diff --git a/server/test/atRisk.test.js b/server/test/atRisk.test.js new file mode 100644 index 0000000..5f38309 --- /dev/null +++ b/server/test/atRisk.test.js @@ -0,0 +1,78 @@ +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) +}) From b1949ac73e0059f35de9306bb024e46b5f1d7e56 Mon Sep 17 00:00:00 2001 From: yuming Date: Sat, 15 Aug 2026 17:55:40 +0800 Subject: [PATCH 06/20] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20/api/subscribe=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=EF=BC=9A=E6=8E=88=E6=9D=83=E4=B8=8A=E6=8A=A5?= =?UTF-8?q?=E4=B8=8E=E9=A3=8E=E9=99=A9=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - grantQuota/getQuotaStatus 从 index.js 导出供测试直接调用 - app.listen 加 require.main 守卫,避免测试 require 本文件时 真的起服务占用端口、cron 定时器让进程无法退出 --- server/src/index.js | 52 +++++++++++++++++++++++++--- server/test/subscribeApi.test.js | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 server/test/subscribeApi.test.js diff --git a/server/src/index.js b/server/src/index.js index fda56d1..b802fbf 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -4,6 +4,8 @@ const express = require('express') const db = require('./db') const wx = require('./wx') const reminder = require('./reminder') +const quota = require('./quota') +const atRisk = require('./atRisk') const app = express() app.use(express.json({ limit: '1mb' })) @@ -221,6 +223,39 @@ function getPersons(openid) { return { success: true, data: rows } } +// ---- 订阅额度 ---- + +// 前端授权成功后上报。纯累加、不去重:微信侧确实每次授权都 +1,如实记录即可。 +function grantQuota(openid, data) { + const count = (data && data.count) || 1 + return { success: true, balance: quota.grant(openid, count) } +} + +// 首页查询:返回余额和「哪几个人的提醒有风险」 +function getQuotaStatus(openid) { + const balance = quota.getBalance(openid) + 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.post('/api/reminder/run', async (req, res) => { try { @@ -231,10 +266,17 @@ app.post('/api/reminder/run', async (req, res) => { } }) +module.exports = { grantQuota, getQuotaStatus } + // ---- 启动 ---- -const PORT = process.env.PORT || 3000 -app.listen(PORT, () => { - console.log(`生日提醒后端已启动,监听 ${PORT}`) - reminder.start() -}) +// 只在直接执行本文件(node src/index.js / npm start)时才真正监听端口、注册定时任务。 +// 测试用 require('../src/index') 只是拿函数引用,不需要(也不能)真的起服务—— +// 否则会占住端口且 cron 定时器让进程一直不退出,测试永远卡住。 +if (require.main === module) { + const PORT = process.env.PORT || 3000 + app.listen(PORT, () => { + console.log(`生日提醒后端已启动,监听 ${PORT}`) + reminder.start() + }) +} diff --git a/server/test/subscribeApi.test.js b/server/test/subscribeApi.test.js new file mode 100644 index 0000000..1133a5c --- /dev/null +++ b/server/test/subscribeApi.test.js @@ -0,0 +1,58 @@ +const test = require('node:test') +const assert = require('node:assert') +const { useTempDb } = require('./helper') + +useTempDb() +const db = require('../src/db') +const { grantQuota, getQuotaStatus } = require('../src/index') + +const OPENID = 'o_test_1' + +// 往指定用户名下插一条纪念日 +function seed(openid, id, personName, offsetDays, remindDays) { + const d = new Date(Date.now() + offsetDays * 86400000) + db.prepare(` + INSERT OR REPLACE INTO anniversaries + (id, openid, personId, personName, type, isLunar, solarYear, solarMonth, solarDay, + importance, remindEnabled, remindDays, createTime, updateTime) + VALUES (?, ?, 'p1', ?, 'birthday', 0, ?, ?, ?, 'high', 1, ?, 0, 0) + `).run(id, openid, personName, d.getFullYear(), d.getMonth() + 1, d.getDate(), remindDays) +} + +seed(OPENID, 'seed1', '张三', 10, 3) + +test('grant 累加并返回新余额', () => { + assert.deepStrictEqual(grantQuota(OPENID, { count: 1 }), { success: true, balance: 1 }) + assert.deepStrictEqual(grantQuota(OPENID, { count: 1 }), { success: true, balance: 2 }) +}) + +test('grant 的 data 缺省时按 1 计', () => { + const before = getQuotaStatus(OPENID).balance + grantQuota(OPENID, undefined) + assert.strictEqual(getQuotaStatus(OPENID).balance, before + 1) +}) + +test('get 返回余额与风险信息', () => { + const res = getQuotaStatus(OPENID) + assert.strictEqual(res.success, true) + assert.strictEqual(typeof res.balance, 'number') + assert.ok(Array.isArray(res.atRiskNames)) +}) + +test('无纪念日时没有风险', () => { + const res = getQuotaStatus('o_empty') + assert.strictEqual(res.atRiskCount, 0) + assert.deepStrictEqual(res.atRiskNames, []) +}) + +test('纪念日多于余额时报出风险人名', () => { + const OP = 'o_test_2' + // 造两条近期纪念日,共 4 个提醒事件,但只给 1 点额度 + seed(OP, 'x1', '甲', 10, 3) + seed(OP, 'x2', '乙', 20, 3) + require('../src/quota').grant(OP, 1) + + const res = getQuotaStatus(OP) + assert.ok(res.atRiskCount >= 1, '应当报出风险') + assert.ok(res.atRiskNames.includes('乙'), '时间靠后的乙必然有风险') +}) From 6548577a984fa3280c08d062286e958d9685f2f3 Mon Sep 17 00:00:00 2001 From: yuming Date: Sat, 15 Aug 2026 18:04:12 +0800 Subject: [PATCH 07/20] =?UTF-8?q?=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E6=8C=89=E7=94=A8=E6=88=B7=E5=88=86=E7=BB=84?= =?UTF-8?q?=E3=80=81=E6=8C=89=E4=BC=98=E5=85=88=E7=BA=A7=E5=8F=91=E9=80=81?= =?UTF-8?q?=EF=BC=8C=E9=A2=9D=E5=BA=A6=E8=80=97=E5=B0=BD=E5=8D=B3=E5=81=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/reminder.js | 151 ++++++++++++++++++++++++----------- server/src/wx.js | 4 +- server/test/reminder.test.js | 131 ++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 48 deletions(-) create mode 100644 server/test/reminder.test.js diff --git a/server/src/reminder.js b/server/src/reminder.js index 40e580c..c3f64f4 100644 --- a/server/src/reminder.js +++ b/server/src/reminder.js @@ -1,8 +1,8 @@ const cron = require('node-cron') const db = require('./db') const wx = require('./wx') -const lunar = require('./lunar') const occurrence = require('./occurrence') +const quota = require('./quota') const TEMPLATE_ID = process.env.WX_TEMPLATE_ID const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal' @@ -43,71 +43,128 @@ const insertLog = db.prepare(` VALUES (@anniversaryId, @personName, @typeName, @daysUntil, @sendDate, @status, @error) `) -async function runOnce() { - console.log('[reminder] 开始扫描纪念日...') +// 重要程度排序权重,未知值排最后 +const IMPORTANCE_RANK = { high: 0, medium: 1, low: 2 } +function importanceRank(v) { + return IMPORTANCE_RANK[v] === undefined ? 3 : IMPORTANCE_RANK[v] +} - const list = db.prepare('SELECT * FROM anniversaries WHERE remindEnabled = 1').all() - console.log(`[reminder] 启用提醒的纪念日 ${list.length} 条`) +// 43101 = 用户拒收或下发次数不足,是我们与微信侧对账的唯一信号 +function isQuotaError(err) { + return err && err.errcode === 43101 +} - let ok = 0 - let fail = 0 +/** + * 处理单个用户的当日提醒 + * + * 按「当天 > 提前」、同级「重要程度降序」排序后依次发送,余额耗尽即停, + * 不在明知没额度时继续打无谓请求。 + */ +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 - for (const anniv of list) { + 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 { - const target = occurrence.getNextOccurrence(anniv) - const daysUntil = occurrence.daysBetween(target) - - const shouldRemind = (daysUntil === 0) || (daysUntil === (anniv.remindDays || 0)) - if (!shouldRemind) continue - - if (alreadySentToday(anniv.id)) { - console.log(`[reminder] 今天已发过,跳过: ${anniv.personName}`) - continue - } - - const typeName = getTypeName(anniv.type, anniv.customTypeName) - await wx.sendSubscribeMessage({ - touser: anniv.openid, + touser: openid, page: 'pages/index/index', templateId: TEMPLATE_ID, miniprogramState: MINIPROGRAM_STATE, data: { name1: { value: anniv.personName }, - thing2: { value: daysUntil === 0 ? '今天' : `还有${daysUntil}天` }, - thing6: { value: formatDate(target) }, + 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, - sendDate: Date.now(), - status: 'success', - error: null + anniversaryId: anniv.id, personName: anniv.personName, typeName, + daysUntil: item.daysUntil, sendDate: Date.now(), status: 'success', error: null }) ok++ - console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${daysUntil}天)`) + console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${item.daysUntil}天)`) } catch (err) { - fail++ - console.error(`[reminder] 发送失败: ${anniv.personName}`, err.message) - insertLog.run({ - anniversaryId: anniv.id, - personName: anniv.personName, - typeName: null, - daysUntil: null, - sendDate: Date.now(), - status: 'failed', - error: err.message - }) + 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) + } } } - console.log(`[reminder] 完成: 成功 ${ok}, 失败 ${fail}`) - return { total: list.length, ok, fail } + 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 } } function start() { @@ -120,4 +177,4 @@ function start() { console.log(`[reminder] 定时任务已注册: ${expr} (Asia/Shanghai)`) } -module.exports = { start, runOnce } +module.exports = { start, runOnce, runForUser } diff --git a/server/src/wx.js b/server/src/wx.js index fb77a4f..4ec80be 100644 --- a/server/src/wx.js +++ b/server/src/wx.js @@ -49,7 +49,9 @@ async function sendSubscribeMessage({ touser, page, data, templateId, miniprogra } const { data: res } = await axios.post(url, body, { timeout: 8000 }) if (res.errcode !== 0) { - throw new Error(`发送订阅消息失败: ${JSON.stringify(res)}`) + const err = new Error(`发送订阅消息失败: ${JSON.stringify(res)}`) + err.errcode = res.errcode // 让调用方能精确判断 43101,而不是靠字符串匹配 + throw err } return res } diff --git a/server/test/reminder.test.js b/server/test/reminder.test.js new file mode 100644 index 0000000..f089f44 --- /dev/null +++ b/server/test/reminder.test.js @@ -0,0 +1,131 @@ +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') +}) From 57470ade4fb624d2dc39e8fb4c5e0e17baf2e4ad Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 09:07:46 +0800 Subject: [PATCH 08/20] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=8F=91=E9=80=81?= =?UTF-8?q?=E4=BE=A7=E5=88=86=E7=BB=84=E4=B8=8E=E9=94=99=E8=AF=AF=E5=88=86?= =?UTF-8?q?=E6=94=AF=E7=9A=84=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=EF=BC=8C?= =?UTF-8?q?=E4=BF=9D=E7=95=9943101=E5=8E=9F=E5=A7=8B=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E7=9A=84=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reminder.js:43101 跳过日志改为记录微信原始错误信息,便于和「额度耗尽」的 兜底文案区分排查(前一实施者已验证,此次原样带入提交) - reminder.test.js:新增 runOnce 按 openid 分组结算用例,验证多用户额度互不 串号;将「非额度类错误」用例扩到 2 条数据,证明该分支不会误中止后续发送 - 新建 wx.test.js:验证 sendSubscribeMessage 在微信返回非 0 errcode 时, 抛出的 Error 对象确实带有正确的 errcode 属性 Co-Authored-By: Claude Opus 5 (1M context) --- server/src/reminder.js | 10 ++++--- server/test/reminder.test.js | 53 ++++++++++++++++++++++++++++++++++-- server/test/wx.test.js | 35 ++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 server/test/wx.test.js diff --git a/server/src/reminder.js b/server/src/reminder.js index c3f64f4..ef387f0 100644 --- a/server/src/reminder.js +++ b/server/src/reminder.js @@ -84,14 +84,16 @@ async function runForUser(openid, items, today = new Date()) { let halted = false let ok = 0, fail = 0, skipped = 0 - const logSkip = (item) => insertLog.run({ + // errorMsg 默认是「根本没发请求」的兜底文案;真正打了请求并被 43101 拒绝的那一条 + // 会传入微信返回的原始错误信息,两者都记为 skipped,但 error 字段要能区分开 + const logSkip = (item, errorMsg = 'quota_exhausted') => insertLog.run({ anniversaryId: item.anniv.id, personName: item.anniv.personName, typeName: getTypeName(item.anniv.type, item.anniv.customTypeName), daysUntil: item.daysUntil, sendDate: Date.now(), status: 'skipped', - error: 'quota_exhausted' + error: errorMsg }) for (const item of due) { @@ -129,8 +131,8 @@ async function runForUser(openid, items, today = new Date()) { quota.reset(openid) balance = 0 halted = true - logSkip(item); skipped++ - console.warn(`[reminder] ${openid} 额度已耗尽,本轮剩余全部跳过`) + logSkip(item, err.message); skipped++ + console.warn(`[reminder] ${openid} 额度已耗尽,本轮剩余全部跳过: ${err.message}`) } else { insertLog.run({ anniversaryId: anniv.id, personName: anniv.personName, typeName: null, diff --git a/server/test/reminder.test.js b/server/test/reminder.test.js index f089f44..8b99163 100644 --- a/server/test/reminder.test.js +++ b/server/test/reminder.test.js @@ -103,21 +103,29 @@ test('遇到 43101 立即归零并中止本用户剩余发送', async () => { assert.strictEqual(r.skipped, 3, '含失败那条在内全部计为 skipped') }) -test('非额度类错误只记 failed,不动余额', async () => { +test('非额度类错误只记 failed,不中止后续发送,也不动余额', async () => { resetLogs() quota.grant('uE', 4) + let called = 0 const restore = wx.sendSubscribeMessage wx.sendSubscribeMessage = async () => { + called++ const e = new Error('网络炸了') e.errcode = 40003 throw e } - const items = [makeAnniv('E1', 'uE', '甲', 'high', 0, 3)] + // 至少 2 条数据:如果有人误把 halted = true 加进非额度错误分支, + // 第二条就不会被尝试,called 会停在 1,这条断言就能抓到 + const items = [ + makeAnniv('E1', 'uE', '甲', 'high', 0, 3), + makeAnniv('E2', 'uE', '乙', 'high', 0, 3) + ] const r = await reminder.runForUser('uE', items, TODAY) wx.sendSubscribeMessage = restore - assert.strictEqual(r.fail, 1) + assert.strictEqual(called, 2, '第二条也应被尝试,证明非额度错误不会误中止后续发送') + assert.strictEqual(r.fail, 2) assert.strictEqual(quota.getBalance('uE'), 4, '配置/网络错误不该动余额') }) @@ -129,3 +137,42 @@ test('skipped 会写入 remind_logs 便于排查', async () => { assert.ok(row, '应写入 skipped 日志') assert.strictEqual(row.error, 'quota_exhausted') }) + +// runOnce 是从数据库读数据的(SELECT * FROM anniversaries WHERE remindEnabled = 1), +// 不能像 runForUser 那样直接传数组,必须真的往临时库插行 +function insertAnniv(id, openid, personName, offsetDays, remindDays) { + const d = inDays(offsetDays) + db.prepare(` + INSERT INTO anniversaries + (id, openid, personId, personName, type, isLunar, solarYear, solarMonth, solarDay, + importance, remindEnabled, remindDays, createTime, updateTime) + VALUES (?, ?, 'p', ?, 'birthday', 0, ?, ?, ?, 'high', 1, ?, 0, 0) + `).run(id, openid, personName, d.getFullYear(), d.getMonth() + 1, d.getDate(), remindDays) +} + +test('runOnce 按 openid 分组结算,各用户额度互不影响', async () => { + resetLogs() + // 会读到库里所有 remindEnabled=1 的行,先清空避免受其它用例残留数据干扰 + db.prepare('DELETE FROM anniversaries').run() + + quota.grant('uG', 5) // uG 有额度 + // uH 不 grant,余额保持 0 + + insertAnniv('G1', 'uG', '甲', 0, 3) // 今天到期 + insertAnniv('H1', 'uH', '乙', 0, 3) // 今天到期 + + let called = 0 + const restore = wx.sendSubscribeMessage + wx.sendSubscribeMessage = async () => { called++; return { errcode: 0 } } + + const r = await reminder.runOnce() + wx.sendSubscribeMessage = restore + + assert.strictEqual(r.total, 2) + assert.strictEqual(r.ok, 1, '有额度的 uG 应正常发出') + assert.strictEqual(r.skipped, 1, '没额度的 uH 应被跳过') + assert.strictEqual(r.fail, 0) + assert.strictEqual(called, 1, '没额度的用户不该真的发起请求,两用户不能互相借额度') + assert.strictEqual(quota.getBalance('uG'), 4, 'uG 消费后余额正确减少') + assert.strictEqual(quota.getBalance('uH'), 0, 'uH 的余额不受 uG 影响') +}) diff --git a/server/test/wx.test.js b/server/test/wx.test.js new file mode 100644 index 0000000..24b39a7 --- /dev/null +++ b/server/test/wx.test.js @@ -0,0 +1,35 @@ +const test = require('node:test') +const assert = require('node:assert') +const { useTempDb } = require('./helper') + +// wx.js 本身不连库,但按项目约定统一在 require 业务模块前处理好临时库, +// 避免以后 wx.js 引入了连库逻辑时这份测试悄悄读写到真实数据库。 +useTempDb() + +const axios = require('axios') +const wx = require('../src/wx') + +test('微信返回非 0 errcode 时,抛出的 Error 对象应带上原始 errcode', async () => { + const restoreGet = axios.get + const restorePost = axios.post + + // getAccessToken 走 axios.get,sendSubscribeMessage 走 axios.post,两个都要 mock + axios.get = async () => ({ data: { access_token: 'fake_token', expires_in: 7200 } }) + axios.post = async () => ({ data: { errcode: 43101, errmsg: 'user refuse to accept the msg' } }) + + await assert.rejects( + () => wx.sendSubscribeMessage({ + touser: 'oTest', + page: 'pages/index/index', + templateId: 'tplTest', + data: {} + }), + (err) => { + assert.strictEqual(err.errcode, 43101, 'Error 对象应带上微信返回的原始 errcode,而不是靠字符串匹配') + return true + } + ) + + axios.get = restoreGet + axios.post = restorePost +}) From c7bdd3770aa42a418fd5195740c5eda2f492f798 Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 09:14:50 +0800 Subject: [PATCH 09/20] =?UTF-8?q?=E8=A1=A5=E4=B8=8A43101=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=8E=9F=E5=A7=8B=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF=E7=9A=84?= =?UTF-8?q?=E5=8C=BA=E5=88=86=E6=80=A7=E6=96=AD=E8=A8=80=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8Dwx=E6=B5=8B=E8=AF=95=E7=9A=84=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=97=B6=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reminder.test.js:43101用例新增对 remind_logs.error 的断言,区分 「真发送失败」(应为微信原始错误信息)与「因halted被跳过」(应为 quota_exhausted兜底文案),此前该区别完全没有测试覆盖 - wx.test.js:把恢复 axios.get/post 的语句移入 try/finally,避免断言 先抛错时污染同进程内后续用例 Co-Authored-By: Claude Opus 5 (1M context) --- server/test/reminder.test.js | 16 ++++++++++++++- server/test/wx.test.js | 39 +++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/server/test/reminder.test.js b/server/test/reminder.test.js index 8b99163..85cbb70 100644 --- a/server/test/reminder.test.js +++ b/server/test/reminder.test.js @@ -82,10 +82,11 @@ test('遇到 43101 立即归零并中止本用户剩余发送', async () => { resetLogs() quota.grant('uD', 9) let called = 0 + const WX_ERROR_MESSAGE = '发送订阅消息失败' const restore = wx.sendSubscribeMessage wx.sendSubscribeMessage = async () => { called++ - const e = new Error('发送订阅消息失败') + const e = new Error(WX_ERROR_MESSAGE) e.errcode = 43101 throw e } @@ -101,6 +102,19 @@ test('遇到 43101 立即归零并中止本用户剩余发送', async () => { assert.strictEqual(called, 1, '失败一次就该停,不该继续打请求') assert.strictEqual(quota.getBalance('uD'), 0, '余额应被归零校正') assert.strictEqual(r.skipped, 3, '含失败那条在内全部计为 skipped') + + // D1 是真正打了请求、被微信 43101 拒绝的那一条:error 字段应保留微信原始错误信息, + // 而不是「根本没发请求」时用的兜底文案 'quota_exhausted'。 + const d1Log = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'D1'").get() + assert.ok(d1Log, 'D1 应写入 remind_logs') + assert.strictEqual(d1Log.status, 'skipped') + assert.strictEqual(d1Log.error, WX_ERROR_MESSAGE, 'D1 应记录微信原始错误信息,而不是 quota_exhausted 兜底文案') + + // D2、D3 是因为 halted 而被跳过、根本没发起请求的,error 字段应保持默认兜底文案。 + const d2Log = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'D2'").get() + const d3Log = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'D3'").get() + assert.strictEqual(d2Log.error, 'quota_exhausted', 'D2 从未真正发起请求,应为兜底文案') + assert.strictEqual(d3Log.error, 'quota_exhausted', 'D3 从未真正发起请求,应为兜底文案') }) test('非额度类错误只记 failed,不中止后续发送,也不动余额', async () => { diff --git a/server/test/wx.test.js b/server/test/wx.test.js index 24b39a7..d20ac39 100644 --- a/server/test/wx.test.js +++ b/server/test/wx.test.js @@ -13,23 +13,26 @@ test('微信返回非 0 errcode 时,抛出的 Error 对象应带上原始 errc const restoreGet = axios.get const restorePost = axios.post - // getAccessToken 走 axios.get,sendSubscribeMessage 走 axios.post,两个都要 mock - axios.get = async () => ({ data: { access_token: 'fake_token', expires_in: 7200 } }) - axios.post = async () => ({ data: { errcode: 43101, errmsg: 'user refuse to accept the msg' } }) + try { + // getAccessToken 走 axios.get,sendSubscribeMessage 走 axios.post,两个都要 mock + axios.get = async () => ({ data: { access_token: 'fake_token', expires_in: 7200 } }) + axios.post = async () => ({ data: { errcode: 43101, errmsg: 'user refuse to accept the msg' } }) - await assert.rejects( - () => wx.sendSubscribeMessage({ - touser: 'oTest', - page: 'pages/index/index', - templateId: 'tplTest', - data: {} - }), - (err) => { - assert.strictEqual(err.errcode, 43101, 'Error 对象应带上微信返回的原始 errcode,而不是靠字符串匹配') - return true - } - ) - - axios.get = restoreGet - axios.post = restorePost + await assert.rejects( + () => wx.sendSubscribeMessage({ + touser: 'oTest', + page: 'pages/index/index', + templateId: 'tplTest', + data: {} + }), + (err) => { + assert.strictEqual(err.errcode, 43101, 'Error 对象应带上微信返回的原始 errcode,而不是靠字符串匹配') + return true + } + ) + } finally { + // 断言先抛错也要恢复,避免污染同进程内后续用例 + axios.get = restoreGet + axios.post = restorePost + } }) From d8573359a2c7384f76d0f4b9abf6b9a2cebacbf6 Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 09:21:09 +0800 Subject: [PATCH 10/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E9=A3=8E=E9=99=A9=E9=A2=84=E5=91=8A=E4=B8=8E=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E5=8F=91=E9=80=81=E7=9A=84=E6=8E=92=E5=BA=8F=E4=B8=8D=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit atRisk.js 之前只按 fireInDays 排序,同一天内多条提醒的先后完全未定义, 和 reminder.js 实际发送时「当天>提前、重要程度降序」的规则对不上,导致 额度卡在中间时首页提示的风险人名和定时任务实际跳过的人不一致。 - 把 importanceRank 从 reminder.js 抽到共享的 src/importance.js,两处复用 - atRisk.js 的 computeAtRisk 排序改为三段式:fireInDays 升序 → 同天内 kind(当天优先于提前)→ 同 kind 内重要程度降序 - 补充 atRisk.test.js 用例覆盖「同一天多条事件、额度只够一部分」场景 --- server/src/atRisk.js | 14 +++++++++--- server/src/importance.js | 14 ++++++++++++ server/src/reminder.js | 7 +----- server/test/atRisk.test.js | 47 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 server/src/importance.js diff --git a/server/src/atRisk.js b/server/src/atRisk.js index 3a87040..f67c21c 100644 --- a/server/src/atRisk.js +++ b/server/src/atRisk.js @@ -6,6 +6,7 @@ */ const { getNextOccurrence, daysBetween } = require('./occurrence') +const { importanceRank } = require('./importance') // 预警窗口。太短则提示不及时,太长会拿几个月后的事惊扰用户,两个月是折中。 const LOOKAHEAD_DAYS = 60 @@ -43,14 +44,21 @@ function expandEvents(anniv, today = new Date()) { /** * 算出有风险的提醒 - * 排序用「时间先后」而非重要程度——跨天的额度就是先到先消耗。 - * 重要程度只在同一天内比较(那部分逻辑在 reminder.js)。 + * + * 排序必须与 reminder.js 的 runForUser 完全一致,否则首页提示的人名会跟 + * 定时任务实际跳过的人对不上:条数一样、人名却错了,用户会去补错人的额度。 + * 排序规则:先按 fireInDays 升序(跨天的额度是先到先消耗), + * 同一天内再按「当天 > 提前」,然后按重要程度降序(重要程度权重见 importance.js)。 */ function computeAtRisk(anniversaries, balance, today = new Date()) { const events = (anniversaries || []) .filter(a => a && a.remindEnabled) .flatMap(a => expandEvents(a, today)) - .sort((x, y) => x.fireInDays - y.fireInDays) + .sort((x, y) => { + if (x.fireInDays !== y.fireInDays) return x.fireInDays - y.fireInDays + if (x.kind !== y.kind) return x.kind === 'onDay' ? -1 : 1 + return importanceRank(x.importance) - importanceRank(y.importance) + }) const safeCount = Math.max(0, balance) const atRisk = events.slice(safeCount) diff --git a/server/src/importance.js b/server/src/importance.js new file mode 100644 index 0000000..423801b --- /dev/null +++ b/server/src/importance.js @@ -0,0 +1,14 @@ +/** + * 重要程度排序权重(纯函数) + * + * 从 reminder.js 抽出,供「定时发送」(reminder.js)与「风险预警」(atRisk.js) + * 共用同一套排序权重,避免两处各写一套、后续改一处忘了改另一处导致排序不一致。 + */ + +// 重要程度排序权重,未知值排最后 +const IMPORTANCE_RANK = { high: 0, medium: 1, low: 2 } +function importanceRank(v) { + return IMPORTANCE_RANK[v] === undefined ? 3 : IMPORTANCE_RANK[v] +} + +module.exports = { importanceRank } diff --git a/server/src/reminder.js b/server/src/reminder.js index ef387f0..b72d219 100644 --- a/server/src/reminder.js +++ b/server/src/reminder.js @@ -3,6 +3,7 @@ const db = require('./db') const wx = require('./wx') const occurrence = require('./occurrence') const quota = require('./quota') +const { importanceRank } = require('./importance') const TEMPLATE_ID = process.env.WX_TEMPLATE_ID const MINIPROGRAM_STATE = process.env.WX_MINIPROGRAM_STATE || 'formal' @@ -43,12 +44,6 @@ const insertLog = db.prepare(` VALUES (@anniversaryId, @personName, @typeName, @daysUntil, @sendDate, @status, @error) `) -// 重要程度排序权重,未知值排最后 -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 diff --git a/server/test/atRisk.test.js b/server/test/atRisk.test.js index 5f38309..d0938b7 100644 --- a/server/test/atRisk.test.js +++ b/server/test/atRisk.test.js @@ -76,3 +76,50 @@ test('未开启提醒的纪念日不参与计算', () => { const r = computeAtRisk([{ ...near, remindEnabled: 0 }], 0, TODAY) assert.strictEqual(r.atRiskCount, 0) }) + +// ------ 同一天内排序需与 reminder.js 的 runForUser 完全对齐 ------ +// 四条纪念日都恰好在第 5 天产生一个事件: +// 甲(当天/high) 乙(当天/low) 丙(提前/high) 丁(提前/medium) +// 丙、丁的「当天」事件被安排在第 65 天(超出 60 天窗口),不会进入计算, +// 这样每人恰好只贡献一个事件,排序结果可以精确断言。 +const onDayHigh = { + id: 'e1', personName: '甲', remindEnabled: 1, importance: 'high', + isLunar: false, solarMonth: 8, solarDay: 20, remindDays: 0 +} +const onDayLow = { + id: 'e2', personName: '乙', remindEnabled: 1, importance: 'low', + isLunar: false, solarMonth: 8, solarDay: 20, remindDays: 0 +} +const aheadHigh = { + id: 'e3', personName: '丙', remindEnabled: 1, importance: 'high', + isLunar: false, solarMonth: 10, solarDay: 19, remindDays: 60 +} +const aheadMedium = { + id: 'e4', personName: '丁', remindEnabled: 1, importance: 'medium', + isLunar: false, solarMonth: 10, solarDay: 19, remindDays: 60 +} +// 刻意打乱输入顺序(不按「当天>提前、重要程度降序」排列), +// 这样如果排序逻辑退化成只按 fireInDays 排(Array.sort 是稳定排序, +// 同值会保留输入顺序),测试才能真正暴露出排序规则失效,而不是被输入顺序碰巧掩盖。 +const sameDayGroup = [aheadMedium, aheadHigh, onDayLow, onDayHigh] + +test('同一天内,当天事件排在提前事件之前(与 reminder.js 一致)', () => { + // 额度只够 2 条:应先消耗「当天」的甲、乙,风险留给「提前」的丙、丁 + const r = computeAtRisk(sameDayGroup, 2, TODAY) + assert.strictEqual(r.atRiskCount, 2) + assert.deepStrictEqual(r.atRiskNames, ['丙', '丁']) +}) + +test('同一天内,当天事件按重要程度降序(high 先于 low)', () => { + // 额度只够 1 条:当天里 high 的甲应排在 low 的乙前面,先被消耗 + const r = computeAtRisk(sameDayGroup, 1, TODAY) + assert.strictEqual(r.atRiskCount, 3) + assert.deepStrictEqual(r.atRiskNames, ['乙', '丙', '丁']) +}) + +test('同一天内,提前事件按重要程度降序(high 先于 medium)', () => { + // 额度够 3 条:甲、乙(当天)先消耗,提前事件里 high 的丙应先于 medium 的丁被消耗 + const r = computeAtRisk(sameDayGroup, 3, TODAY) + assert.strictEqual(r.atRiskCount, 1) + assert.deepStrictEqual(r.atRiskNames, ['丁']) +}) From 1142346e240fe96c255f9fb211c9768095f30d1f Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 09:25:12 +0800 Subject: [PATCH 11/20] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E6=A8=A1=E5=9D=97=EF=BC=8C=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=20ID=20=E5=88=B0=E5=94=AF=E4=B8=80=E6=9D=A5?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- pages/add-anniversary/add-anniversary.js | 3 +- utils/api.js | 7 ++- utils/subscribe.js | 68 ++++++++++++++++++++++++ utils/sync.js | 1 + 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 utils/subscribe.js diff --git a/pages/add-anniversary/add-anniversary.js b/pages/add-anniversary/add-anniversary.js index d6f7c34..ba8ce05 100644 --- a/pages/add-anniversary/add-anniversary.js +++ b/pages/add-anniversary/add-anniversary.js @@ -3,6 +3,7 @@ const storage = require('../../utils/storage') const dateUtils = require('../../utils/date') const sync = require('../../utils/sync') const lunar = require('../../utils/lunar') +const subscribe = require('../../utils/subscribe') Page({ data: { @@ -373,7 +374,7 @@ Page({ requestSubscribe() { return new Promise((resolve, reject) => { wx.requestSubscribeMessage({ - tmplIds: ['6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw'], + tmplIds: [subscribe.TEMPLATE_ID], success: (res) => { console.log('订阅消息授权结果:', res) resolve(res) diff --git a/utils/api.js b/utils/api.js index 72dacbc..6615afa 100644 --- a/utils/api.js +++ b/utils/api.js @@ -72,4 +72,9 @@ function person(action, data) { return request({ url: '/api/person', data: { action, data } }) } -module.exports = { request, login, anniversary, person, BASE_URL } +// 订阅额度:grant 上报授权,get 查询余额与风险 +function subscribe(action, data) { + return request({ url: '/api/subscribe', data: { action, data } }) +} + +module.exports = { request, login, anniversary, person, subscribe, BASE_URL } diff --git a/utils/subscribe.js b/utils/subscribe.js new file mode 100644 index 0000000..ef25a27 --- /dev/null +++ b/utils/subscribe.js @@ -0,0 +1,68 @@ +/** + * 订阅消息统一入口 + * + * ⚠️ 最重要的约束:wx.requestSubscribeMessage 和 wx.openSetting 必须由真实点击手势 + * 直接触发(基础库 2.8.2+),且调用前不能有 await 等异步操作,否则手势上下文丢失、 + * 调用必然失败(报 fail can only be invoked by user TAP gesture)。 + * 所以本模块的 requestAndReport 必须在 bindtap 处理函数里同步调用, + * 用户状态要提前用 getStatus 查好缓存起来,不能现查现用。 + */ + +const sync = require('./sync') + +const TEMPLATE_ID = '6J7Stt-lu7DKU6jblJ0nZGq_D81z5glnksf7qWfy5Yw' + +/** + * 查询用户当前的订阅设置状态 + * itemSettings 只包含用户勾选过「总是保持以上选择」的模板,据此可判断调用会不会弹窗。 + * @returns {Promise} + * 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 } diff --git a/utils/sync.js b/utils/sync.js index 03cd601..c8b1e5d 100644 --- a/utils/sync.js +++ b/utils/sync.js @@ -24,6 +24,7 @@ function enqueue(item) { async function dispatch(item) { if (item.kind === 'person') return api.person(item.action, item.data) if (item.kind === 'anniversary') return api.anniversary(item.action, item.data) + if (item.kind === 'subscribe') return api.subscribe(item.action, item.data) throw new Error('unknown sync kind: ' + item.kind) } From bc4fb7445d9edc8ccffc531508759511f05e5a31 Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 09:31:03 +0800 Subject: [PATCH 12/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E7=BA=AA=E5=BF=B5=E6=97=A5=E6=97=B6=E6=8E=88=E6=9D=83=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E6=B6=88=E6=81=AF=E6=9C=AA=E4=B8=8A=E6=8A=A5=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E7=9A=84=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestSubscribe() 改为委托给 utils/subscribe.js 的 requestAndReport(),用户同意授权后会把新增额度同步上报后端, 避免最主要的授权入口被漏记导致余额系统性低估、定时任务拒发。 onSubmit 里原来依赖 reject 的 try/catch 改为直接判断布尔返回值, 消除死代码分支,"拒绝不阻塞保存"的行为保持不变。 Co-Authored-By: Claude Opus 5 (1M context) --- pages/add-anniversary/add-anniversary.js | 30 ++++++++++-------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/pages/add-anniversary/add-anniversary.js b/pages/add-anniversary/add-anniversary.js index ba8ce05..4a8a01d 100644 --- a/pages/add-anniversary/add-anniversary.js +++ b/pages/add-anniversary/add-anniversary.js @@ -301,11 +301,13 @@ Page({ } // 如果开启了提醒,请求订阅消息授权 + // 注:requestSubscribe 委托给 utils/subscribe.js 的 requestAndReport, + // 该函数只会 resolve(true=同意/false=拒绝),不会 reject; + // 无论用户是否同意,都不阻塞后续保存流程 if (formData.remindEnabled) { - try { - await this.requestSubscribe() - } catch (err) { - console.log('用户拒绝订阅消息') + const accepted = await this.requestSubscribe() + if (!accepted) { + console.log('用户未同意订阅消息,本次不上报额度') } } @@ -370,21 +372,15 @@ Page({ /** * 请求订阅消息授权 + * 委托给 utils/subscribe.js 的 requestAndReport:同意后会自动把新增额度 + * 上报给后端(走同步队列,失败自动入队)。 + * 注意:本方法不能加 async,且内部不能在调用 requestAndReport 之前插入 + * 任何 await —— wx.requestSubscribeMessage 必须由用户点击手势同步触发, + * 一旦中间出现异步跳跃,手势上下文丢失会导致调用必然失败。 + * @returns {Promise} 用户是否同意 */ requestSubscribe() { - return new Promise((resolve, reject) => { - wx.requestSubscribeMessage({ - tmplIds: [subscribe.TEMPLATE_ID], - success: (res) => { - console.log('订阅消息授权结果:', res) - resolve(res) - }, - fail: (err) => { - console.error('订阅消息授权失败:', err) - reject(err) - } - }) - }) + return subscribe.requestAndReport() }, /** From 7f420762ef23d46056dd75b64a139b141fb4676a Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 09:34:55 +0800 Subject: [PATCH 13/20] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=A2=9D=E5=BA=A6=E9=A3=8E=E9=99=A9=E6=8F=90=E7=A4=BA=E5=8D=A1?= =?UTF-8?q?=E7=89=87=EF=BC=8C=E4=BD=9C=E4=B8=BA=E8=A1=A5=E9=A2=9D=E5=BA=A6?= =?UTF-8?q?=E4=B8=BB=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/index/index.js | 51 +++++++++++++++++++++++++++++++++++++++++- pages/index/index.wxml | 6 +++++ pages/index/index.wxss | 24 ++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/pages/index/index.js b/pages/index/index.js index 3000807..3cb0b50 100644 --- a/pages/index/index.js +++ b/pages/index/index.js @@ -2,6 +2,8 @@ const storage = require('../../utils/storage') const dateUtils = require('../../utils/date') const fmt = require('../../utils/format') +const api = require('../../utils/api') +const subscribe = require('../../utils/subscribe') Page({ data: { @@ -11,7 +13,8 @@ Page({ currentFilter: 'all', totalCount: 0, upcomingCount: 0, - todayText: '' + todayText: '', + atRiskText: '' }, onLoad() { @@ -20,6 +23,9 @@ Page({ onShow() { this.loadPersons() + this.refreshQuota() + // 提前查好订阅状态缓存到实例上:点击处理函数里不能再 await,否则手势上下文会丢 + subscribe.getStatus().then(status => { this.subscribeStatus = status }) }, /** @@ -159,6 +165,49 @@ Page({ }) }, + /** + * 拉取额度风险,拼成给用户看的文案 + * 说「谁的提醒有风险」而不是「还剩几次额度」——用户理解不了额度这种抽象概念 + */ + 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' }) + } + }) + }, + /** * 点击添加按钮:直接进添加纪念日(方案 B:流程合并,姓名不存在自动建人) */ diff --git a/pages/index/index.wxml b/pages/index/index.wxml index 48f90a6..6a9a88a 100644 --- a/pages/index/index.wxml +++ b/pages/index/index.wxml @@ -18,6 +18,12 @@ + + + ⚠️ + {{atRiskText}}的生日提醒可能发不出去,点这里补上 + + diff --git a/pages/index/index.wxss b/pages/index/index.wxss index 3d4bf39..cae96ae 100644 --- a/pages/index/index.wxss +++ b/pages/index/index.wxss @@ -391,3 +391,27 @@ page { line-height: 1; margin-top: -4rpx; } + +/* 额度风险提示卡片:仅在有提醒可能发不出去时出现 */ +.quota-warn { + display: flex; + align-items: center; + margin: 0 32rpx 24rpx; + padding: 24rpx 28rpx; + background: #FBF3E4; + border: 2rpx solid #E0C89A; + border-radius: 12rpx; +} + +.quota-warn-icon { + flex-shrink: 0; + margin-right: 16rpx; + font-size: 32rpx; +} + +.quota-warn-text { + flex: 1; + font-size: 26rpx; + line-height: 1.5; + color: #8A5A1F; +} From 233a872c89d492d35641b6c4a539901854a5f486 Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 09:41:52 +0800 Subject: [PATCH 14/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A1=A5=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6=E5=8F=8D=E9=A6=88=E8=87=AA=E7=9B=B8=E7=9F=9B=E7=9B=BE?= =?UTF-8?q?=E5=8F=8A=E8=AE=A2=E9=98=85=E7=8A=B6=E6=80=81=E6=9C=AA=E5=B0=B1?= =?UTF-8?q?=E7=BB=AA=E5=85=9C=E5=BA=95=E6=98=BE=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/index/index.js | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/pages/index/index.js b/pages/index/index.js index 3cb0b50..eac3296 100644 --- a/pages/index/index.js +++ b/pages/index/index.js @@ -168,11 +168,14 @@ Page({ /** * 拉取额度风险,拼成给用户看的文案 * 说「谁的提醒有风险」而不是「还剩几次额度」——用户理解不了额度这种抽象概念 + * @returns {Promise} 本次刷新是否成功拿到了最新数据。 + * 失败时不清空/覆盖 atRiskText——网络抖动时清空反而会掩盖真实存在的风险, + * 比"文案没更新"更糟;调用方需要这个返回值来判断该给用户什么反馈(见 onTopUp)。 */ async refreshQuota() { try { const res = await api.subscribe('get') - if (!res || !res.success) return + if (!res || !res.success) return false const names = res.atRiskNames || [] let text = '' if (names.length > 0 && names.length <= 3) { @@ -181,9 +184,11 @@ Page({ text = names.slice(0, 3).join('、') + ' 等 ' + names.length + ' 人' } this.setData({ atRiskText: text }) + return true } catch (e) { - // 查询失败就不显示提示,静默处理,不打扰用户 + // 查询失败就不显示提示,静默处理,不打扰用户;atRiskText 保留旧值不清空(理由见上) console.warn('[index] 额度查询失败', e) + return false } }, @@ -198,13 +203,38 @@ Page({ wx.openSetting({ withSubscriptions: true }) return } + + // 显式处理 status === undefined,不让它靠"没匹配上第一个 if"隐式落地: + // undefined 出现在 onShow 里 subscribe.getStatus() 还没返回、用户就点了卡片的情况; + // 这里没法现查状态兜底——getStatus() 是异步的,哪怕 await 一下也会丢失手势上下文, + // 所以有意让 undefined 和 unknown(查询失败)一样,都保守地按 willPrompt 处理, + // 与 utils/subscribe.js 里「unknown 按 willPrompt 保守处理」的取向一致。 + // 代价:如果用户真实状态其实是 mainSwitchOff(通知总开关关了),这里不会拦截去设置页, + // 只会静默调用 wx.requestSubscribeMessage,微信不弹窗、直接判定未同意, + // 用户只会看到下面「未开启提醒」的兜底提示,不会被引导去设置页—— + // 这是已知且可接受的体验降级,不在本次修复范围内。 + const isConservativeFallback = status === undefined || status === 'unknown' + if (isConservativeFallback) { + // 不阻断流程,只留一条日志方便排查——命中这条分支时用户体验会比正常 + // 情况弱一点(见上面的注释),出问题时能在控制台里定位到具体是哪种取舍 + console.warn('[index] onTopUp 命中保守兜底分支,status =', status) + } + subscribe.requestAndReport().then(accepted => { - if (accepted) { - this.refreshQuota() - wx.showToast({ title: '已补充提醒次数', icon: 'success' }) - } else { + if (!accepted) { wx.showToast({ title: '未开启提醒', icon: 'none' }) + return } + // 补额度这步已经成功(微信侧确实同意了),但下面刷新风险文案是另一次独立请求, + // 可能失败;两者不能混为一谈,否则会出现"已补充"toast 和纹丝不动的风险卡片 + // 同时出现的自相矛盾画面。用刷新结果决定 toast 措辞,不对刷新结果做绝对断言。 + this.refreshQuota().then(refreshed => { + if (refreshed) { + wx.showToast({ title: '已补充提醒次数', icon: 'success' }) + } else { + wx.showToast({ title: '已补充,稍后自动更新', icon: 'none' }) + } + }) }) }, From 981e8025322a9430d1532f65cb84da890c14f19c Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 09:45:27 +0800 Subject: [PATCH 15/20] =?UTF-8?q?=E5=B7=B2=E9=95=BF=E6=9C=9F=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E7=9A=84=E7=94=A8=E6=88=B7=E5=9C=A8=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E4=BA=BA=E5=91=98=E6=97=B6=E9=9D=99=E9=BB=98=E8=A1=A5=E9=A2=9D?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/index/index.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pages/index/index.js b/pages/index/index.js index eac3296..f129870 100644 --- a/pages/index/index.js +++ b/pages/index/index.js @@ -156,9 +156,13 @@ Page({ }, /** - * 点击人员 + * 点击人员进详情 + * 顺带搭车补额度:仅对已勾选「总是保持以上选择」的用户,此调用不弹窗、完全无感。 + * 必须同步调用且放在 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}` From 12b146c2c9b4e420e04220a7e4acb54730709005 Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 10:19:47 +0800 Subject: [PATCH 16/20] =?UTF-8?q?=E4=B8=A4=E5=A4=84=E6=8F=90=E9=86=92?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E8=A1=A5=20ORDER=20BY=20id=EF=BC=8C=E4=BF=9D?= =?UTF-8?q?=E8=AF=81=E5=B9=B3=E5=B1=80=E5=8F=96=E8=88=8D=E5=8F=AF=E5=A4=8D?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reminder.js 扫全表和 index.js 的 getQuotaStatus 都没有 ORDER BY, 两边的 sort 又都是稳定排序,同一天、同 kind、同 importance 的条目 先后完全由 SQLite 返回顺序决定。一个走全表扫、一个可能走索引, 顺序一致纯属巧合——一旦不一致,首页预告的人名就和实际被跳过的人对不上。 Co-Authored-By: Claude Opus 5 (1M context) --- server/src/index.js | 4 +++- server/src/reminder.js | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/server/src/index.js b/server/src/index.js index b802fbf..ea81541 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -234,7 +234,9 @@ function grantQuota(openid, data) { // 首页查询:返回余额和「哪几个人的提醒有风险」 function getQuotaStatus(openid) { const balance = quota.getBalance(openid) - const rows = db.prepare('SELECT * FROM anniversaries WHERE openid = ? AND remindEnabled = 1').all(openid) + // ORDER BY id 必须与 reminder.js 的取数保持一致:平局(同日、同 kind、同 importance)时 + // 先后顺序完全由这里决定,两边不一致就会出现「预告的人名不是真正被跳过的人」 + const rows = db.prepare('SELECT * FROM anniversaries WHERE openid = ? AND remindEnabled = 1 ORDER BY id').all(openid) const { atRiskCount, atRiskNames } = atRisk.computeAtRisk(rows.map(normalize), balance) return { success: true, balance, atRiskCount, atRiskNames } } diff --git a/server/src/reminder.js b/server/src/reminder.js index b72d219..57e2570 100644 --- a/server/src/reminder.js +++ b/server/src/reminder.js @@ -144,7 +144,11 @@ async function runForUser(openid, items, today = new Date()) { async function runOnce() { console.log('[reminder] 开始扫描纪念日...') - const list = db.prepare('SELECT * FROM anniversaries WHERE remindEnabled = 1').all() + // ORDER BY id 不是为了排序好看,而是为了「平局可复现」: + // 同一天、同 kind、同 importance 的条目在 sort 里比不出先后(JS sort 是稳定的), + // 最终取舍就取决于 SQLite 的返回顺序。这里和 index.js 的 getQuotaStatus 必须用同一个 + // 兜底顺序,否则首页预告「谁有风险」的人名会和实际被跳过的人对不上。 + const list = db.prepare('SELECT * FROM anniversaries WHERE remindEnabled = 1 ORDER BY id').all() console.log(`[reminder] 启用提醒的纪念日 ${list.length} 条`) // 额度是按用户算的,所以必须分组处理 From 30a40252c615ae5cd3cecea5a95f687093b879c6 Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 10:19:47 +0800 Subject: [PATCH 17/20] =?UTF-8?q?=E5=AD=98=E9=87=8F=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E9=A2=9D=E5=BA=A6=E6=94=B9=E4=B8=BA=20db.js?= =?UTF-8?q?=20=E9=87=8C=E7=9A=84=E8=87=AA=E5=8A=A8=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原计划是部署后上服务器手工执行一条 SQL 补额度,漏执行的后果是所有 存量用户的提醒立刻大面积降级。改成 PRAGMA user_version 驱动的一次性 自动迁移,部署即执行、不可能漏。 幂等性两层保证:user_version 版本号 + INSERT OR IGNORE 只补尚无记录的 openid,已有记录一律不覆盖。整段包在事务里。 Co-Authored-By: Claude Opus 5 (1M context) --- server/src/db.js | 46 ++++++++++++++ server/test/migration.test.js | 112 ++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 server/test/migration.test.js diff --git a/server/src/db.js b/server/src/db.js index 1f1228e..760f604 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -86,4 +86,50 @@ tryAddColumn('anniversaries', 'lunarMonth', 'INTEGER') tryAddColumn('anniversaries', 'lunarDay', 'INTEGER') tryAddColumn('anniversaries', 'isLeapMonth', 'INTEGER DEFAULT 0') +// ---- 版本化数据迁移 ---- +// +// 用 SQLite 的 PRAGMA user_version 记录「这个库已经跑到第几版迁移」。 +// 它是存在数据库文件头里的一个整数,读写都随事务提交/回滚,天生适合做迁移标记 +// (见 MAINTENANCE.md 场景 11)。新增迁移的做法:写一个 migrateVN(), +// 在 runMigrations 里加一行 `if (current < N) migrateVN()`,并把 SCHEMA_VERSION 改成 N。 +const SCHEMA_VERSION = 1 + +/** + * v1:给存量用户补订阅额度初始值 + * + * Why:subscribe_quota 是随「订阅消息额度治理」新建的表,所有存量用户余额都是 0。 + * 而 reminder.js 在余额为 0 时只会发一条探针,其余按优先级取舍——不补的话, + * 后端一上线,老用户的提醒会立刻大面积降级,而他们手机上还是旧版小程序, + * 根本没有补额度的入口。 + * 存量用户过去每存一条纪念日就授权过一次订阅,所以按「该用户的纪念日条数」 + * 估算初始余额是合理的(这也是原计划文档里那条手工 SQL 的口径)。 + * 做成代码里的自动迁移是为了「部署即执行」,不依赖人记得上服务器敲命令。 + * + * 幂等性有两层保证:外层的 user_version 版本号(跑过就不再进来), + * 以及 INSERT OR IGNORE(只给 subscribe_quota 里尚无记录的 openid 补,绝不覆盖已有记录)。 + */ +function migrateV1() { + db.prepare(` + INSERT OR IGNORE INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime) + SELECT openid, COUNT(*), COUNT(*), 0, ? + FROM anniversaries + WHERE openid IS NOT NULL + GROUP BY openid + `).run(Date.now()) +} + +function runMigrations() { + const current = db.pragma('user_version', { simple: true }) + if (current >= SCHEMA_VERSION) return + + // 整段包在事务里:迁移和版本号必须同生共死,中途崩了要能整体回滚重来 + db.transaction(() => { + if (current < 1) migrateV1() + db.pragma(`user_version = ${SCHEMA_VERSION}`) + })() + + console.log(`[db] 数据迁移完成: user_version ${current} -> ${SCHEMA_VERSION}`) +} +runMigrations() + module.exports = db diff --git a/server/test/migration.test.js b/server/test/migration.test.js new file mode 100644 index 0000000..f585b22 --- /dev/null +++ b/server/test/migration.test.js @@ -0,0 +1,112 @@ +const test = require('node:test') +const assert = require('node:assert') +const { execFileSync } = require('node:child_process') +const Database = require('better-sqlite3') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') + +// db.js 的迁移是在 require 时执行一次的(模块单例),所以没法在同一个进程里反复触发。 +// 这里用子进程来「启动一次后端的数据层」,父进程只用 better-sqlite3 直接开同一个文件做 +// 前置造数和结果断言。全程使用系统临时目录,绝不碰 server/data/birthday.db。 +const DB_JS = path.join(__dirname, '..', 'src', 'db.js') + +function bootDb(dbPath) { + execFileSync(process.execPath, ['-e', 'require(process.env.MIGRATION_ENTRY)'], { + env: { ...process.env, MIGRATION_ENTRY: DB_JS, DB_PATH: dbPath }, + stdio: 'ignore' + }) +} + +function tempDbPath() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bday-mig-')) + return path.join(dir, 'mig.db') +} + +// 造一个「老库」:表结构齐全、有纪念日数据,但 subscribe_quota 为空、user_version 还是 0。 +// 这正是本次改造上线时线上生产库的样子。 +function makeLegacyDb(rows) { + const dbPath = tempDbPath() + bootDb(dbPath) // 先借 db.js 建出完整表结构 + const raw = new Database(dbPath) + const insert = raw.prepare(` + INSERT INTO anniversaries (id, openid, personName, type, solarMonth, solarDay, remindEnabled, remindDays) + VALUES (?, ?, ?, 'birthday', 1, 1, 1, 3) + `) + for (const [id, openid] of rows) insert.run(id, openid, '某人') + raw.prepare('DELETE FROM subscribe_quota').run() + raw.pragma('user_version = 0') + raw.close() + return dbPath +} + +function readQuota(dbPath) { + const raw = new Database(dbPath, { readonly: true }) + const list = raw.prepare('SELECT * FROM subscribe_quota ORDER BY openid').all() + const version = raw.pragma('user_version', { simple: true }) + raw.close() + return { list, version } +} + +test('迁移 v1:按纪念日条数给存量用户补初始余额', () => { + const dbPath = makeLegacyDb([ + ['a1', 'uOld1'], ['a2', 'uOld1'], ['a3', 'uOld1'], // 3 条 + ['b1', 'uOld2'] // 1 条 + ]) + + bootDb(dbPath) + + const { list, version } = readQuota(dbPath) + assert.strictEqual(version, 1, 'user_version 应被推进到 1') + assert.deepStrictEqual( + list.map(r => [r.openid, r.balance, r.grantedTotal, r.sentTotal]), + [['uOld1', 3, 3, 0], ['uOld2', 1, 1, 0]], + '每个存量用户的初始余额应等于其纪念日条数' + ) +}) + +test('迁移 v1:重复启动不会重复累加(幂等)', () => { + const dbPath = makeLegacyDb([['a1', 'uIdem'], ['a2', 'uIdem']]) + + bootDb(dbPath) // 第一次:补 2 + bootDb(dbPath) // 第二次:user_version 已是 1,应直接跳过 + bootDb(dbPath) // 第三次:同上 + + const { list } = readQuota(dbPath) + assert.strictEqual(list.length, 1) + assert.strictEqual(list[0].balance, 2, '反复启动后余额仍应是 2,不能累加成 4 或 6') + assert.strictEqual(list[0].grantedTotal, 2) +}) + +test('迁移 v1:已有额度记录的用户不被覆盖', () => { + const dbPath = makeLegacyDb([['a1', 'uHas'], ['a2', 'uHas'], ['c1', 'uNone']]) + + // 模拟「已经用新版小程序补过额度」的用户:subscribe_quota 里已有一行 + const raw = new Database(dbPath) + raw.prepare(` + INSERT INTO subscribe_quota (openid, balance, grantedTotal, sentTotal, updateTime) + VALUES ('uHas', 7, 9, 2, 0) + `).run() + raw.close() + + bootDb(dbPath) + + const { list } = readQuota(dbPath) + const has = list.find(r => r.openid === 'uHas') + const none = list.find(r => r.openid === 'uNone') + assert.deepStrictEqual( + [has.balance, has.grantedTotal, has.sentTotal], [7, 9, 2], + '已有记录必须原样保留,不能被按纪念日条数估算出来的值覆盖' + ) + assert.strictEqual(none.balance, 1, '尚无记录的用户照常补') +}) + +test('迁移 v1:全新空库也能正常推进版本号', () => { + const dbPath = tempDbPath() + + bootDb(dbPath) + + const { list, version } = readQuota(dbPath) + assert.strictEqual(version, 1) + assert.strictEqual(list.length, 0, '没有纪念日就没有要补的用户') +}) From e47a399ad5090c2c6282878d02adc17acd12db67 Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 10:23:11 +0800 Subject: [PATCH 18/20] =?UTF-8?q?=E4=BD=99=E9=A2=9D=E4=B8=BA=200=20?= =?UTF-8?q?=E6=97=B6=E6=94=B9=E4=B8=BA=E5=8F=91=E6=8E=A2=E9=92=88=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=97=A0=E6=9D=A1=E4=BB=B6=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E8=B7=B3=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit balance 只有前端上报这一条上升通道,没升级小程序的老用户在微信侧仍在 真实累积额度却永远不上报,记账会系统性低估。低估到 0 就一条不发,比 改造前更糟——改造前至少还会试。 改为:余额为 0 时仍发出优先级最高的那一条当探针。成功则证实微信侧有 额度,本轮继续发;43101 则立即中止(与原行为一致);非额度错误记 failed 且不再重复探。每用户每轮最多探一次。 探针成功仍走 quota.consume:内部 MAX(0, balance-1) 扣不出负数,净效果是 balance 保持 0、sentTotal +1。不趁机把 balance 调高——探针只证明「至少 还有 1 条」,凭空补猜出来的数字会让首页风险预告变成乐观的假话。 注:三个原有用例断言的是「余额 0 就一个请求都不发」这一被本次刻意推翻的 旧契约,已按新行为改写;改写后仍用变异测试确认能抓到探针被关掉。 Co-Authored-By: Claude Opus 5 (1M context) --- server/src/reminder.js | 43 +++++++- server/test/reminder.test.js | 191 ++++++++++++++++++++++++++++------- 2 files changed, 196 insertions(+), 38 deletions(-) diff --git a/server/src/reminder.js b/server/src/reminder.js index 57e2570..425ce55 100644 --- a/server/src/reminder.js +++ b/server/src/reminder.js @@ -52,8 +52,8 @@ function isQuotaError(err) { /** * 处理单个用户的当日提醒 * - * 按「当天 > 提前」、同级「重要程度降序」排序后依次发送,余额耗尽即停, - * 不在明知没额度时继续打无谓请求。 + * 按「当天 > 提前」、同级「重要程度降序」排序后依次发送。 + * 记账余额耗尽时不会直接全部放弃,而是发一条「探针」向微信求证(见下方 probe 注释)。 */ async function runForUser(openid, items, today = new Date()) { const due = [] @@ -79,6 +79,20 @@ async function runForUser(openid, items, today = new Date()) { let halted = false let ok = 0, fail = 0, skipped = 0 + // ---- 探针机制 ---- + // balance 只有「前端授权后上报 +1」这一条上升通道。没升级小程序的老用户在微信侧 + // 仍在真实累积额度(每存一条纪念日就授权一次)却永远不会上报,于是我们的记账会 + // 系统性低估。如果低估到 0 就一条都不发,反而比改造前更糟——改造前至少还会试一下。 + // + // 所以余额为 0 时仍然把「优先级最高的那一条」发出去,拿它当探针问微信要个答案: + // 探针成功 → 证实微信侧确实还有额度、是我们低估了,本轮继续往下发, + // 直到遇到 43101 或发完 + // 探针返回 43101 → 证实确实没额度,立即中止,剩余全部记 skipped(与原行为一致) + // 探针遇非额度错误 → 什么都没证明(网络/配置问题),记 failed、不动余额、不中止 + // 每个用户每轮最多探一次:在「有额度」这个结论被证实之前,不值得再赌第二个请求。 + let probeUsed = false // 本轮已经用掉探针机会 + let quotaProven = false // 探针已证实微信侧有额度,后续不必再受记账余额约束 + // errorMsg 默认是「根本没发请求」的兜底文案;真正打了请求并被 43101 拒绝的那一条 // 会传入微信返回的原始错误信息,两者都记为 skipped,但 error 字段要能区分开 const logSkip = (item, errorMsg = 'quota_exhausted') => insertLog.run({ @@ -92,11 +106,22 @@ async function runForUser(openid, items, today = new Date()) { }) for (const item of due) { - if (halted || balance <= 0) { + if (halted) { logSkip(item); skipped++ continue } + // 记账余额为 0 且尚未证实微信侧有额度时,只允许发一条探针 + let isProbe = false + if (balance <= 0 && !quotaProven) { + if (probeUsed) { + logSkip(item); skipped++ + continue + } + isProbe = true + probeUsed = true + } + const anniv = item.anniv const typeName = getTypeName(anniv.type, anniv.customTypeName) try { @@ -112,8 +137,18 @@ async function runForUser(openid, items, today = new Date()) { thing5: { value: anniv.remark || '别忘了准备一份礼物哦!' } } }) + // 探针成功时余额怎么记:照常 consume。 + // consume 内部是 MAX(0, balance - 1),余额本来就是 0,扣不出负数, + // 净效果是「balance 保持 0、sentTotal +1」。 + // 之所以不趁机把 balance 调高:微信不提供余额查询,探针只证明「至少还有 1 条」, + // 凭空补一个猜出来的数字会让首页的风险预告变成乐观的假话。宁可让 balance 保持 + // 保守的 0,靠每轮的探针去发现真实额度——账面继续低估是安全的,因为低估不再等于停发。 quota.consume(openid, 1) - balance-- + balance = Math.max(0, balance - 1) + if (isProbe) { + quotaProven = true + console.log(`[reminder] ${openid} 探针发送成功,微信侧仍有额度,记账偏低,本轮继续发送`) + } insertLog.run({ anniversaryId: anniv.id, personName: anniv.personName, typeName, daysUntil: item.daysUntil, sendDate: Date.now(), status: 'success', error: null diff --git a/server/test/reminder.test.js b/server/test/reminder.test.js index 85cbb70..a42934c 100644 --- a/server/test/reminder.test.js +++ b/server/test/reminder.test.js @@ -47,35 +47,132 @@ test('额度够时全部发出', async () => { test('额度不足时当天优先、同级按重要程度', async () => { resetLogs() quota.grant('uB', 1) + const attempts = [] // 所有真正打出去的请求 + const sent = [] // 其中发送成功的 + const restore = wx.sendSubscribeMessage + // 第一条放行、之后一律 43101。 + // Why 要让微信亲口说 43101:记账余额只有 1,但第二条会被当作「探针」发出去(见探针机制), + // 只有微信侧确认没额度,「额度不足时的取舍」才真正成立。 + wx.sendSubscribeMessage = async (p) => { + attempts.push(p.data.name1.value) + if (attempts.length > 1) { + const e = new Error('发送订阅消息失败') + e.errcode = 43101 + throw e + } + sent.push(p.data.name1.value) + return { errcode: 0 } + } + + try { + const items = [ + makeAnniv('B1', 'uB', '提前的', 'high', 3, 3), // 提前事件(daysUntil=3=remindDays) + makeAnniv('B2', 'uB', '当天低', 'low', 0, 3), // 当天事件 + makeAnniv('B3', 'uB', '当天高', 'high', 0, 3) // 当天事件,重要程度更高 + ] + const r = await reminder.runForUser('uB', items, TODAY) + + assert.strictEqual(r.ok, 1) + assert.strictEqual(r.skipped, 2) + assert.deepStrictEqual(sent, ['当天高'], '应当只发出当天且最重要的那条') + assert.deepStrictEqual(attempts, ['当天高', '当天低'], '探针也要按优先级挑下一条,且被拒后不再打第三个请求') + } finally { + wx.sendSubscribeMessage = restore + } +}) + +test('记账余额为 0 但微信侧仍有额度:探针成功后本轮继续发送', async () => { + resetLogs() + // uP1 从未 grant 过,记账余额为 0——模拟「没升级小程序、授权从不上报」的老用户 + assert.strictEqual(quota.getBalance('uP1'), 0) const sent = [] const restore = wx.sendSubscribeMessage wx.sendSubscribeMessage = async (p) => { sent.push(p.data.name1.value); return { errcode: 0 } } - 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 + try { + const items = [ + makeAnniv('P1', 'uP1', '甲', 'high', 0, 3), + makeAnniv('P2', 'uP1', '乙', 'medium', 0, 3), + makeAnniv('P3', 'uP1', '丙', 'low', 0, 3) + ] + const r = await reminder.runForUser('uP1', items, TODAY) - assert.strictEqual(r.ok, 1) - assert.strictEqual(r.skipped, 2) - assert.deepStrictEqual(sent, ['当天高'], '应当只发出当天且最重要的那条') + assert.strictEqual(r.ok, 3, '探针证实微信侧有额度后,剩下两条也应照常发出') + assert.strictEqual(r.skipped, 0) + assert.deepStrictEqual(sent, ['甲', '乙', '丙']) + assert.strictEqual(quota.getBalance('uP1'), 0, '探针成功不凭空补余额,账面保持保守的 0') + const row = db.prepare('SELECT sentTotal FROM subscribe_quota WHERE openid = ?').get('uP1') + assert.strictEqual(row.sentTotal, 3, 'sentTotal 要如实记录每一次成功发送,便于排查') + } finally { + wx.sendSubscribeMessage = restore + } }) -test('余额为 0 时不发任何请求', async () => { +test('记账余额为 0 且微信侧确实没额度:探针被 43101 拒绝后立即中止', async () => { + resetLogs() + const WX_ERROR_MESSAGE = '发送订阅消息失败: {"errcode":43101}' + let called = 0 + const restore = wx.sendSubscribeMessage + wx.sendSubscribeMessage = async () => { + called++ + const e = new Error(WX_ERROR_MESSAGE) + e.errcode = 43101 + throw e + } + + try { + const items = [ + makeAnniv('Q1', 'uP2', '甲', 'high', 0, 3), + makeAnniv('Q2', 'uP2', '乙', 'high', 0, 3), + makeAnniv('Q3', 'uP2', '丙', 'high', 0, 3) + ] + const r = await reminder.runForUser('uP2', items, TODAY) + + assert.strictEqual(called, 1, '只探一次,被拒后不该继续打请求') + assert.strictEqual(r.ok, 0) + assert.strictEqual(r.skipped, 3, '含探针那条在内全部计为 skipped') + assert.strictEqual(quota.getBalance('uP2'), 0) + + const q1 = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'Q1'").get() + const q2 = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'Q2'").get() + assert.strictEqual(q1.error, WX_ERROR_MESSAGE, '探针那条真打了请求,应保留微信原始错误信息') + assert.strictEqual(q2.error, 'quota_exhausted', '后续条目根本没发请求,记兜底文案') + } finally { + wx.sendSubscribeMessage = restore + } +}) + +test('探针遇非额度错误:记 failed、不动余额、本轮不再重复探', async () => { resetLogs() let called = 0 const restore = wx.sendSubscribeMessage - wx.sendSubscribeMessage = async () => { called++; return { errcode: 0 } } + wx.sendSubscribeMessage = async () => { + called++ + const e = new Error('网络炸了') + e.errcode = 40003 + throw e + } - const items = [makeAnniv('C1', 'uC', '丙', 'high', 0, 3)] - const r = await reminder.runForUser('uC', items, TODAY) - wx.sendSubscribeMessage = restore + try { + const items = [ + makeAnniv('R1', 'uP3', '甲', 'high', 0, 3), + makeAnniv('R2', 'uP3', '乙', 'high', 0, 3), + makeAnniv('R3', 'uP3', '丙', 'high', 0, 3) + ] + const r = await reminder.runForUser('uP3', items, TODAY) - assert.strictEqual(called, 0, '没额度就不该打请求') - assert.strictEqual(r.skipped, 1) + // 网络/配置错误什么都没证明,既不能当作「有额度」继续发,也不该反复赌请求 + assert.strictEqual(called, 1, '探针机会用掉就没了,不该对同一个用户反复重试') + assert.strictEqual(r.fail, 1, '探针那条记 failed,不是 skipped') + assert.strictEqual(r.skipped, 2) + assert.strictEqual(quota.getBalance('uP3'), 0, '非额度错误不动余额') + + const r1 = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'R1'").get() + assert.strictEqual(r1.status, 'failed') + assert.strictEqual(r1.error, '网络炸了') + } finally { + wx.sendSubscribeMessage = restore + } }) test('遇到 43101 立即归零并中止本用户剩余发送', async () => { @@ -145,11 +242,26 @@ test('非额度类错误只记 failed,不中止后续发送,也不动余额' 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') + // 余额为 0 时第一条会被当探针发出去,所以要放两条,第二条才是「压根没发请求」的 skipped + const restore = wx.sendSubscribeMessage + wx.sendSubscribeMessage = async () => { + const e = new Error('发送订阅消息失败') + e.errcode = 43101 + throw e + } + try { + const items = [ + makeAnniv('F1', 'uF', '甲', 'high', 0, 3), + makeAnniv('F2', 'uF', '乙', 'high', 0, 3) + ] + await reminder.runForUser('uF', items, TODAY) + const row = db.prepare("SELECT * FROM remind_logs WHERE anniversaryId = 'F2'").get() + assert.ok(row, '应写入 skipped 日志') + assert.strictEqual(row.status, 'skipped') + assert.strictEqual(row.error, 'quota_exhausted') + } finally { + wx.sendSubscribeMessage = restore + } }) // runOnce 是从数据库读数据的(SELECT * FROM anniversaries WHERE remindEnabled = 1), @@ -170,23 +282,34 @@ test('runOnce 按 openid 分组结算,各用户额度互不影响', async () = db.prepare('DELETE FROM anniversaries').run() quota.grant('uG', 5) // uG 有额度 - // uH 不 grant,余额保持 0 + // uH 不 grant,余额保持 0,且微信侧也确实没额度(下面 mock 里对 uH 回 43101) insertAnniv('G1', 'uG', '甲', 0, 3) // 今天到期 insertAnniv('H1', 'uH', '乙', 0, 3) // 今天到期 - let called = 0 + const attempts = [] const restore = wx.sendSubscribeMessage - wx.sendSubscribeMessage = async () => { called++; return { errcode: 0 } } + wx.sendSubscribeMessage = async (p) => { + attempts.push(p.touser) + if (p.touser === 'uH') { + const e = new Error('发送订阅消息失败') + e.errcode = 43101 + throw e + } + return { errcode: 0 } + } - const r = await reminder.runOnce() - wx.sendSubscribeMessage = restore + try { + const r = await reminder.runOnce() - assert.strictEqual(r.total, 2) - assert.strictEqual(r.ok, 1, '有额度的 uG 应正常发出') - assert.strictEqual(r.skipped, 1, '没额度的 uH 应被跳过') - assert.strictEqual(r.fail, 0) - assert.strictEqual(called, 1, '没额度的用户不该真的发起请求,两用户不能互相借额度') - assert.strictEqual(quota.getBalance('uG'), 4, 'uG 消费后余额正确减少') - assert.strictEqual(quota.getBalance('uH'), 0, 'uH 的余额不受 uG 影响') + assert.strictEqual(r.total, 2) + assert.strictEqual(r.ok, 1, '有额度的 uG 应正常发出') + assert.strictEqual(r.skipped, 1, '微信侧确认没额度的 uH 应被跳过') + assert.strictEqual(r.fail, 0) + assert.deepStrictEqual(attempts, ['uG', 'uH'], 'uH 只有一次探针请求,不能借用 uG 的额度') + assert.strictEqual(quota.getBalance('uG'), 4, 'uG 消费后余额正确减少') + assert.strictEqual(quota.getBalance('uH'), 0, 'uH 的余额不受 uG 影响') + } finally { + wx.sendSubscribeMessage = restore + } }) From 7bf84bff617b37f351d3c8dd18f1bea14f7da5ac Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 10:25:05 +0800 Subject: [PATCH 19/20] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E7=BA=AA=E5=BF=B5?= =?UTF-8?q?=E6=97=A5=E5=8E=BB=E6=8E=89=20await=20=E4=B8=8A=E6=8A=A5?= =?UTF-8?q?=E5=B9=B6=E5=8A=A0=E9=98=B2=E9=87=8D=E5=85=A5=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E5=BC=B1=E7=BD=91=E4=B8=8B=E5=AD=98=E5=87=BA=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onSubmit 原本 await requestSubscribe(),而它内部的 grant 上报走 api.request、 超时 10 秒。对已勾「总是保持以上选择」的用户连订阅弹窗都不会出现,点「保存」后 界面最长 10 秒毫无反馈;期间又没有任何提交中标志,重复点击会走两遍 storage.addAnniversary(),generateId() 每次生成新 id,直接存出两条重复纪念日。 改为:订阅授权 fire-and-forget(上报失败本就有 pending_sync_queue 兜底), onSubmit 去掉 async、加实例级 submitting 标志防重入,本地写失败时放开标志并提示。 手势铁律未破:onSubmit 由 bindtap 直接绑定,方法体去掉了 async,从入口到 requestSubscribe 之间只有 if 判断、解构和三个同步校验,无任何 await 或 then。 Co-Authored-By: Claude Opus 5 (1M context) --- pages/add-anniversary/add-anniversary.js | 52 +++++++++++++++++++----- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/pages/add-anniversary/add-anniversary.js b/pages/add-anniversary/add-anniversary.js index 4a8a01d..81b5a0a 100644 --- a/pages/add-anniversary/add-anniversary.js +++ b/pages/add-anniversary/add-anniversary.js @@ -6,6 +6,10 @@ const lunar = require('../../utils/lunar') const subscribe = require('../../utils/subscribe') Page({ + // 保存中标志。放在实例上而不是 data 里:它只用于防重入,不参与渲染, + // 走 setData 反而会多一次没必要的视图层通信。 + submitting: false, + data: { anniversaryId: null, personId: null, @@ -276,8 +280,21 @@ Page({ /** * 提交 + * + * ⚠️ 本方法绝对不能加 async,方法体内在调用 requestSubscribe 之前也不能出现任何 await + * 或其他异步跳跃(Promise.then、wx 异步 API 的回调里再调等): + * wx.requestSubscribeMessage 必须由用户的真实点击手势同步触发,一旦中间断开, + * 手势上下文丢失,真机上必然报 "fail can only be invoked by user TAP gesture"。 + * 从 bindtap="onSubmit" 进来到 requestSubscribe 之间,只有 setData 读取和几个同步校验。 */ - async onSubmit() { + onSubmit() { + // 防重入。 + // Why:保存链路本身是同步的,但保存成功后要等 1.5 秒的 toast 才 navigateBack, + // 这段时间用户完全可能再点一次「保存」;而 storage.addAnniversary 每次都会 + // generateId() 生成一个新 id,重复点击的结果就是存出两条一模一样的纪念日。 + // 注:这一句是同步的 if 判断,不影响下面订阅调用的手势上下文。 + if (this.submitting) return + const { formData, anniversaryId, inputName } = this.data let { personId } = this.data @@ -300,21 +317,26 @@ Page({ return } - // 如果开启了提醒,请求订阅消息授权 - // 注:requestSubscribe 委托给 utils/subscribe.js 的 requestAndReport, - // 该函数只会 resolve(true=同意/false=拒绝),不会 reject; - // 无论用户是否同意,都不阻塞后续保存流程 + // 如果开启了提醒,请求订阅消息授权。 + // 这里是 fire-and-forget,故意不 await: + // 1) await 会切断手势上下文,订阅调用必然失败(见方法头注释); + // 2) requestAndReport 内部的 grant 上报走 api.request,超时长达 10 秒。对已勾选 + // 「总是保持以上选择」的用户连订阅弹窗都不会出现,await 会让用户点完「保存」后 + // 最长干等 10 秒、界面毫无反馈; + // 3) 上报失败本来就有 pending_sync_queue 兜底,下次启动会自动 flush,等它没有意义。 + // requestAndReport 只 resolve、不 reject,所以这里不挂 catch 也不会产生未处理拒绝。 if (formData.remindEnabled) { - const accepted = await this.requestSubscribe() - if (!accepted) { - console.log('用户未同意订阅消息,本次不上报额度') - } + this.requestSubscribe() } + // 订阅调用已经发出,后面就没有手势上下文的顾虑了,可以安全地置位防重入标志 + this.submitting = true + // 新增模式且没绑定 personId → 按姓名查找或自动创建 if (!anniversaryId && !personId) { const person = storage.ensurePerson(name) if (!person) { + this.submitting = false wx.showToast({ title: '保存失败', icon: 'none' }) return } @@ -348,9 +370,13 @@ Page({ personName, ...formData }, 'update') - + wx.showToast({ title: '保存成功', icon: 'success' }) setTimeout(() => wx.navigateBack(), 1500) + } else { + // 写本地失败:必须放开防重入标志,否则用户连重试的机会都没有 + this.submitting = false + wx.showToast({ title: '保存失败', icon: 'none' }) } } else { // 新增模式 @@ -363,9 +389,13 @@ Page({ if (newAnniversary) { // 同步到云端 this.syncToCloud(newAnniversary.id, newAnniversary, 'add') - + wx.showToast({ title: '添加成功', icon: 'success' }) setTimeout(() => wx.navigateBack(), 1500) + } else { + // 同上:写本地失败要放开标志,让用户能重试 + this.submitting = false + wx.showToast({ title: '保存失败', icon: 'none' }) } } }, From 23278fee06245439e3985588cee51e55b4d291db Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 10:28:29 +0800 Subject: [PATCH 20/20] =?UTF-8?q?=E8=A1=A5=E6=9C=80=E7=BB=88=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=8A=A5=E5=91=8A=EF=BC=8C=E5=B9=B6=E6=A0=87=E6=B3=A8?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E6=96=87=E6=A1=A3=E9=87=8C=E5=B7=B2=E8=BF=87?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E6=89=8B=E5=B7=A5=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-15-订阅消息额度.md | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/plans/2026-08-15-订阅消息额度.md b/docs/superpowers/plans/2026-08-15-订阅消息额度.md index c4938dd..1164d62 100644 --- a/docs/superpowers/plans/2026-08-15-订阅消息额度.md +++ b/docs/superpowers/plans/2026-08-15-订阅消息额度.md @@ -1524,24 +1524,30 @@ Expected: 后端测试全绿,全部文件语法通过 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——**这是本次上线最大的风险点**,见下方说明 +3. 验证后端对**线上旧版**小程序仍兼容:旧版不会调 `/api/subscribe`,老用户 `balance = 0`——见下方说明(合并前评审已修,风险大幅下降) 4. 确认无误后再上传小程序 → 体验版自测 → 提交审核 → 发布 -### ⚠️ 上线首日的额度真空问题 +### ⚠️ 上线首日的额度真空问题(已在合并前评审中修掉,此处保留背景) -新版 `reminder.js` 在 `balance <= 0` 时**不发任何请求**。而 `subscribe_quota` 表是新建的,所有存量用户的初始 `balance` 都是 0——这意味着**后端一上线,所有存量用户的提醒会立刻全部停发**,而新版小程序还在审核中,用户没有任何途径补额度。 +**原风险**:`reminder.js` 在 `balance <= 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(仅作为迁移口径的备查,**不要执行**): ```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; -" +# 已由 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; ``` -这一步**必须在后端部署完成后、当天 9 点定时任务触发前**执行完毕。 +按 `MAINTENANCE.md` 的惯例,部署前依然建议先备份生产库: + +```bash +cp /volume1/docker/apps/birthday-server/data/birthday.db{,.bak-$(date +%F)} +```