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') +})