余额为 0 时改为发探针,不再无条件全部跳过

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) <noreply@anthropic.com>
This commit is contained in:
yuming
2026-08-16 10:23:11 +08:00
parent 30a40252c6
commit e47a399ad5
2 changed files with 196 additions and 38 deletions
+39 -4
View File
@@ -52,8 +52,8 @@ function isQuotaError(err) {
/** /**
* 处理单个用户的当日提醒 * 处理单个用户的当日提醒
* *
* 按「当天 > 提前」、同级「重要程度降序」排序后依次发送,余额耗尽即停, * 按「当天 > 提前」、同级「重要程度降序」排序后依次发送
* 不在明知没额度时继续打无谓请求 * 记账余额耗尽时不会直接全部放弃,而是发一条「探针」向微信求证(见下方 probe 注释)
*/ */
async function runForUser(openid, items, today = new Date()) { async function runForUser(openid, items, today = new Date()) {
const due = [] const due = []
@@ -79,6 +79,20 @@ async function runForUser(openid, items, today = new Date()) {
let halted = false let halted = false
let ok = 0, fail = 0, skipped = 0 let ok = 0, fail = 0, skipped = 0
// ---- 探针机制 ----
// balance 只有「前端授权后上报 +1」这一条上升通道。没升级小程序的老用户在微信侧
// 仍在真实累积额度(每存一条纪念日就授权一次)却永远不会上报,于是我们的记账会
// 系统性低估。如果低估到 0 就一条都不发,反而比改造前更糟——改造前至少还会试一下。
//
// 所以余额为 0 时仍然把「优先级最高的那一条」发出去,拿它当探针问微信要个答案:
// 探针成功 → 证实微信侧确实还有额度、是我们低估了,本轮继续往下发,
// 直到遇到 43101 或发完
// 探针返回 43101 → 证实确实没额度,立即中止,剩余全部记 skipped(与原行为一致)
// 探针遇非额度错误 → 什么都没证明(网络/配置问题),记 failed、不动余额、不中止
// 每个用户每轮最多探一次:在「有额度」这个结论被证实之前,不值得再赌第二个请求。
let probeUsed = false // 本轮已经用掉探针机会
let quotaProven = false // 探针已证实微信侧有额度,后续不必再受记账余额约束
// errorMsg 默认是「根本没发请求」的兜底文案;真正打了请求并被 43101 拒绝的那一条 // errorMsg 默认是「根本没发请求」的兜底文案;真正打了请求并被 43101 拒绝的那一条
// 会传入微信返回的原始错误信息,两者都记为 skipped,但 error 字段要能区分开 // 会传入微信返回的原始错误信息,两者都记为 skipped,但 error 字段要能区分开
const logSkip = (item, errorMsg = 'quota_exhausted') => insertLog.run({ 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) { for (const item of due) {
if (halted || balance <= 0) { if (halted) {
logSkip(item); skipped++ logSkip(item); skipped++
continue continue
} }
// 记账余额为 0 且尚未证实微信侧有额度时,只允许发一条探针
let isProbe = false
if (balance <= 0 && !quotaProven) {
if (probeUsed) {
logSkip(item); skipped++
continue
}
isProbe = true
probeUsed = true
}
const anniv = item.anniv const anniv = item.anniv
const typeName = getTypeName(anniv.type, anniv.customTypeName) const typeName = getTypeName(anniv.type, anniv.customTypeName)
try { try {
@@ -112,8 +137,18 @@ async function runForUser(openid, items, today = new Date()) {
thing5: { value: anniv.remark || '别忘了准备一份礼物哦!' } thing5: { value: anniv.remark || '别忘了准备一份礼物哦!' }
} }
}) })
// 探针成功时余额怎么记:照常 consume。
// consume 内部是 MAX(0, balance - 1),余额本来就是 0,扣不出负数,
// 净效果是「balance 保持 0、sentTotal +1」。
// 之所以不趁机把 balance 调高:微信不提供余额查询,探针只证明「至少还有 1 条」,
// 凭空补一个猜出来的数字会让首页的风险预告变成乐观的假话。宁可让 balance 保持
// 保守的 0,靠每轮的探针去发现真实额度——账面继续低估是安全的,因为低估不再等于停发。
quota.consume(openid, 1) quota.consume(openid, 1)
balance-- balance = Math.max(0, balance - 1)
if (isProbe) {
quotaProven = true
console.log(`[reminder] ${openid} 探针发送成功,微信侧仍有额度,记账偏低,本轮继续发送`)
}
insertLog.run({ insertLog.run({
anniversaryId: anniv.id, personName: anniv.personName, typeName, anniversaryId: anniv.id, personName: anniv.personName, typeName,
daysUntil: item.daysUntil, sendDate: Date.now(), status: 'success', error: null daysUntil: item.daysUntil, sendDate: Date.now(), status: 'success', error: null
+157 -34
View File
@@ -47,35 +47,132 @@ test('额度够时全部发出', async () => {
test('额度不足时当天优先、同级按重要程度', async () => { test('额度不足时当天优先、同级按重要程度', async () => {
resetLogs() resetLogs()
quota.grant('uB', 1) 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 sent = []
const restore = wx.sendSubscribeMessage const restore = wx.sendSubscribeMessage
wx.sendSubscribeMessage = async (p) => { sent.push(p.data.name1.value); return { errcode: 0 } } wx.sendSubscribeMessage = async (p) => { sent.push(p.data.name1.value); return { errcode: 0 } }
const items = [ try {
makeAnniv('B1', 'uB', '提前的', 'high', 3, 3), // 提前事件(daysUntil=3=remindDays const items = [
makeAnniv('B2', 'uB', '当天低', 'low', 0, 3), // 当天事件 makeAnniv('P1', 'uP1', '', 'high', 0, 3),
makeAnniv('B3', 'uB', '当天高', 'high', 0, 3) // 当天事件,重要程度更高 makeAnniv('P2', 'uP1', '', 'medium', 0, 3),
] makeAnniv('P3', 'uP1', '丙', 'low', 0, 3)
const r = await reminder.runForUser('uB', items, TODAY) ]
wx.sendSubscribeMessage = restore const r = await reminder.runForUser('uP1', items, TODAY)
assert.strictEqual(r.ok, 1) assert.strictEqual(r.ok, 3, '探针证实微信侧有额度后,剩下两条也应照常发出')
assert.strictEqual(r.skipped, 2) assert.strictEqual(r.skipped, 0)
assert.deepStrictEqual(sent, ['当天高'], '应当只发出当天且最重要的那条') 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() resetLogs()
let called = 0 let called = 0
const restore = wx.sendSubscribeMessage 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)] try {
const r = await reminder.runForUser('uC', items, TODAY) const items = [
wx.sendSubscribeMessage = restore 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 () => { test('遇到 43101 立即归零并中止本用户剩余发送', async () => {
@@ -145,11 +242,26 @@ test('非额度类错误只记 failed,不中止后续发送,也不动余额'
test('skipped 会写入 remind_logs 便于排查', async () => { test('skipped 会写入 remind_logs 便于排查', async () => {
resetLogs() resetLogs()
const items = [makeAnniv('F1', 'uF', '甲', 'high', 0, 3)] // 余额为 0 时第一条会被当探针发出去,所以要放两条,第二条才是「压根没发请求」的 skipped
await reminder.runForUser('uF', items, TODAY) const restore = wx.sendSubscribeMessage
const row = db.prepare("SELECT * FROM remind_logs WHERE status = 'skipped'").get() wx.sendSubscribeMessage = async () => {
assert.ok(row, '应写入 skipped 日志') const e = new Error('发送订阅消息失败')
assert.strictEqual(row.error, 'quota_exhausted') 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), // runOnce 是从数据库读数据的(SELECT * FROM anniversaries WHERE remindEnabled = 1),
@@ -170,23 +282,34 @@ test('runOnce 按 openid 分组结算,各用户额度互不影响', async () =
db.prepare('DELETE FROM anniversaries').run() db.prepare('DELETE FROM anniversaries').run()
quota.grant('uG', 5) // uG 有额度 quota.grant('uG', 5) // uG 有额度
// uH 不 grant,余额保持 0 // uH 不 grant,余额保持 0,且微信侧也确实没额度(下面 mock 里对 uH 回 43101
insertAnniv('G1', 'uG', '甲', 0, 3) // 今天到期 insertAnniv('G1', 'uG', '甲', 0, 3) // 今天到期
insertAnniv('H1', 'uH', '乙', 0, 3) // 今天到期 insertAnniv('H1', 'uH', '乙', 0, 3) // 今天到期
let called = 0 const attempts = []
const restore = wx.sendSubscribeMessage 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() try {
wx.sendSubscribeMessage = restore const r = await reminder.runOnce()
assert.strictEqual(r.total, 2) assert.strictEqual(r.total, 2)
assert.strictEqual(r.ok, 1, '有额度的 uG 应正常发出') assert.strictEqual(r.ok, 1, '有额度的 uG 应正常发出')
assert.strictEqual(r.skipped, 1, '没额度的 uH 应被跳过') assert.strictEqual(r.skipped, 1, '微信侧确认没额度的 uH 应被跳过')
assert.strictEqual(r.fail, 0) assert.strictEqual(r.fail, 0)
assert.strictEqual(called, 1, '没额度的用户不该真的发起请求,两用户不能互相借额度') assert.deepStrictEqual(attempts, ['uG', 'uH'], 'uH 只有一次探针请求,不能借用 uG 的额度')
assert.strictEqual(quota.getBalance('uG'), 4, 'uG 消费后余额正确减少') assert.strictEqual(quota.getBalance('uG'), 4, 'uG 消费后余额正确减少')
assert.strictEqual(quota.getBalance('uH'), 0, 'uH 的余额不受 uG 影响') assert.strictEqual(quota.getBalance('uH'), 0, 'uH 的余额不受 uG 影响')
} finally {
wx.sendSubscribeMessage = restore
}
}) })