定时任务改为按用户分组、按优先级发送,额度耗尽即停
This commit is contained in:
+97
-40
@@ -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) {
|
||||
try {
|
||||
const target = occurrence.getNextOccurrence(anniv)
|
||||
const daysUntil = occurrence.daysBetween(target)
|
||||
const isOnDay = daysUntil === 0
|
||||
const isAhead = remindDays > 0 && daysUntil === remindDays
|
||||
if (!isOnDay && !isAhead) continue
|
||||
if (alreadySentToday(anniv.id)) continue
|
||||
|
||||
const shouldRemind = (daysUntil === 0) || (daysUntil === (anniv.remindDays || 0))
|
||||
if (!shouldRemind) continue
|
||||
due.push({ anniv, target, daysUntil, kind: isOnDay ? 'onDay' : 'ahead' })
|
||||
}
|
||||
|
||||
if (alreadySentToday(anniv.id)) {
|
||||
console.log(`[reminder] 今天已发过,跳过: ${anniv.personName}`)
|
||||
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 {
|
||||
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) {
|
||||
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)
|
||||
insertLog.run({
|
||||
anniversaryId: anniv.id,
|
||||
personName: anniv.personName,
|
||||
typeName: null,
|
||||
daysUntil: null,
|
||||
sendDate: Date.now(),
|
||||
status: 'failed',
|
||||
error: 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 }
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user