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 }