e47a399ad5
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>
217 lines
8.7 KiB
JavaScript
217 lines
8.7 KiB
JavaScript
const cron = require('node-cron')
|
|
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'
|
|
|
|
const TYPE_NAMES = {
|
|
birthday: '生日',
|
|
// 老数据 type=lunar_birthday 兼容回显(公历/农历由 isLunar 决定)
|
|
lunar_birthday: '生日',
|
|
wedding: '结婚纪念日',
|
|
engagement: '订婚纪念日',
|
|
other: '其他纪念日'
|
|
}
|
|
|
|
function getTypeName(type, customName) {
|
|
if (type === 'other' && customName) return customName
|
|
return TYPE_NAMES[type] || '纪念日'
|
|
}
|
|
|
|
function formatDate(date) {
|
|
const y = date.getFullYear()
|
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
|
const d = String(date.getDate()).padStart(2, '0')
|
|
return `${y}年${m}月${d}日`
|
|
}
|
|
|
|
// 检查今天是否已经给这条纪念日发过提醒
|
|
function alreadySentToday(anniversaryId) {
|
|
const start = new Date()
|
|
start.setHours(0, 0, 0, 0)
|
|
const row = db.prepare(
|
|
'SELECT COUNT(*) AS n FROM remind_logs WHERE anniversaryId = ? AND sendDate >= ? AND status = ?'
|
|
).get(anniversaryId, start.getTime(), 'success')
|
|
return row.n > 0
|
|
}
|
|
|
|
const insertLog = db.prepare(`
|
|
INSERT INTO remind_logs (anniversaryId, personName, typeName, daysUntil, sendDate, status, error)
|
|
VALUES (@anniversaryId, @personName, @typeName, @daysUntil, @sendDate, @status, @error)
|
|
`)
|
|
|
|
// 43101 = 用户拒收或下发次数不足,是我们与微信侧对账的唯一信号
|
|
function isQuotaError(err) {
|
|
return err && err.errcode === 43101
|
|
}
|
|
|
|
/**
|
|
* 处理单个用户的当日提醒
|
|
*
|
|
* 按「当天 > 提前」、同级「重要程度降序」排序后依次发送。
|
|
* 记账余额耗尽时不会直接全部放弃,而是发一条「探针」向微信求证(见下方 probe 注释)。
|
|
*/
|
|
async function runForUser(openid, items, today = new Date()) {
|
|
const due = []
|
|
for (const anniv of items) {
|
|
const target = occurrence.getNextOccurrence(anniv, today)
|
|
const daysUntil = occurrence.daysBetween(target, today)
|
|
const remindDays = anniv.remindDays || 0
|
|
|
|
const isOnDay = daysUntil === 0
|
|
const isAhead = remindDays > 0 && daysUntil === remindDays
|
|
if (!isOnDay && !isAhead) continue
|
|
if (alreadySentToday(anniv.id)) continue
|
|
|
|
due.push({ anniv, target, daysUntil, kind: isOnDay ? 'onDay' : 'ahead' })
|
|
}
|
|
|
|
due.sort((x, y) => {
|
|
if (x.kind !== y.kind) return x.kind === 'onDay' ? -1 : 1
|
|
return importanceRank(x.anniv.importance) - importanceRank(y.anniv.importance)
|
|
})
|
|
|
|
let balance = quota.getBalance(openid)
|
|
let halted = false
|
|
let ok = 0, fail = 0, skipped = 0
|
|
|
|
// ---- 探针机制 ----
|
|
// 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({
|
|
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: errorMsg
|
|
})
|
|
|
|
for (const item of due) {
|
|
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 {
|
|
await wx.sendSubscribeMessage({
|
|
touser: openid,
|
|
page: 'pages/index/index',
|
|
templateId: TEMPLATE_ID,
|
|
miniprogramState: MINIPROGRAM_STATE,
|
|
data: {
|
|
name1: { value: anniv.personName },
|
|
thing2: { value: item.daysUntil === 0 ? '今天' : `还有${item.daysUntil}天` },
|
|
thing6: { value: formatDate(item.target) },
|
|
thing5: { value: anniv.remark || '别忘了准备一份礼物哦!' }
|
|
}
|
|
})
|
|
// 探针成功时余额怎么记:照常 consume。
|
|
// consume 内部是 MAX(0, balance - 1),余额本来就是 0,扣不出负数,
|
|
// 净效果是「balance 保持 0、sentTotal +1」。
|
|
// 之所以不趁机把 balance 调高:微信不提供余额查询,探针只证明「至少还有 1 条」,
|
|
// 凭空补一个猜出来的数字会让首页的风险预告变成乐观的假话。宁可让 balance 保持
|
|
// 保守的 0,靠每轮的探针去发现真实额度——账面继续低估是安全的,因为低估不再等于停发。
|
|
quota.consume(openid, 1)
|
|
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
|
|
})
|
|
ok++
|
|
console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${item.daysUntil}天)`)
|
|
} catch (err) {
|
|
if (isQuotaError(err)) {
|
|
// 微信侧实际已无额度(或用户关了通知总开关),归零并停止骚扰
|
|
quota.reset(openid)
|
|
balance = 0
|
|
halted = true
|
|
logSkip(item, err.message); skipped++
|
|
console.warn(`[reminder] ${openid} 额度已耗尽,本轮剩余全部跳过: ${err.message}`)
|
|
} else {
|
|
insertLog.run({
|
|
anniversaryId: anniv.id, personName: anniv.personName, typeName: null,
|
|
daysUntil: null, sendDate: Date.now(), status: 'failed', error: err.message
|
|
})
|
|
fail++
|
|
console.error(`[reminder] 发送失败: ${anniv.personName}`, err.message)
|
|
}
|
|
}
|
|
}
|
|
|
|
return { ok, fail, skipped }
|
|
}
|
|
|
|
async function runOnce() {
|
|
console.log('[reminder] 开始扫描纪念日...')
|
|
// 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} 条`)
|
|
|
|
// 额度是按用户算的,所以必须分组处理
|
|
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() {
|
|
const expr = process.env.REMINDER_CRON || '0 9 * * *'
|
|
if (!cron.validate(expr)) {
|
|
console.error(`[reminder] 无效的 cron 表达式: ${expr},定时任务未启动`)
|
|
return
|
|
}
|
|
cron.schedule(expr, runOnce, { timezone: 'Asia/Shanghai' })
|
|
console.log(`[reminder] 定时任务已注册: ${expr} (Asia/Shanghai)`)
|
|
}
|
|
|
|
module.exports = { start, runOnce, runForUser }
|