From e47a399ad5090c2c6282878d02adc17acd12db67 Mon Sep 17 00:00:00 2001 From: yuming Date: Sun, 16 Aug 2026 10:23:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BD=99=E9=A2=9D=E4=B8=BA=200=20=E6=97=B6?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8F=91=E6=8E=A2=E9=92=88=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=97=A0=E6=9D=A1=E4=BB=B6=E5=85=A8=E9=83=A8=E8=B7=B3?= =?UTF-8?q?=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 + } })