Files
wxserver/server/src/reminder.js
T
yuming d8573359a2 修复首页风险预告与实际发送的排序不一致问题
atRisk.js 之前只按 fireInDays 排序,同一天内多条提醒的先后完全未定义,
和 reminder.js 实际发送时「当天>提前、重要程度降序」的规则对不上,导致
额度卡在中间时首页提示的风险人名和定时任务实际跳过的人不一致。

- 把 importanceRank 从 reminder.js 抽到共享的 src/importance.js,两处复用
- atRisk.js 的 computeAtRisk 排序改为三段式:fireInDays 升序 →
  同天内 kind(当天优先于提前)→ 同 kind 内重要程度降序
- 补充 atRisk.test.js 用例覆盖「同一天多条事件、额度只够一部分」场景
2026-08-16 09:21:09 +08:00

178 lines
6.1 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
}
/**
* 处理单个用户的当日提醒
*
* 按「当天 > 提前」、同级「重要程度降序」排序后依次发送,余额耗尽即停,
* 不在明知没额度时继续打无谓请求。
*/
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
// 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 || balance <= 0) {
logSkip(item); skipped++
continue
}
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 || '别忘了准备一份礼物哦!' }
}
})
quota.consume(openid, 1)
balance--
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] 开始扫描纪念日...')
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() {
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 }