const cron = require('node-cron') const db = require('./db') const wx = require('./wx') const lunar = require('./lunar') 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}日` } // 算出"距离今年(或明年)这个纪念日还有多少天" // 农历纪念日按 lunarMonth/lunarDay 计算每年对应的公历日期,否则按 solarMonth/solarDay function getThisYearDate(anniv) { const today = new Date() today.setHours(0, 0, 0, 0) if (anniv.isLunar && anniv.lunarMonth && anniv.lunarDay) { // 闰月生日按 A 方案处理:若当年无对应闰月,lunarToSolar 内部已自动按普通月份算 const todayLunar = lunar.solarToLunar(today) const wantLeap = !!anniv.isLeapMonth let target = lunar.lunarToSolar(todayLunar.year, anniv.lunarMonth, anniv.lunarDay, wantLeap) target.setHours(0, 0, 0, 0) if (target < today) { target = lunar.lunarToSolar(todayLunar.year + 1, anniv.lunarMonth, anniv.lunarDay, wantLeap) target.setHours(0, 0, 0, 0) } return target } let target = new Date(today.getFullYear(), anniv.solarMonth - 1, anniv.solarDay) target.setHours(0, 0, 0, 0) if (target < today) { target = new Date(today.getFullYear() + 1, anniv.solarMonth - 1, anniv.solarDay) target.setHours(0, 0, 0, 0) } return target } function daysBetween(target) { const today = new Date() today.setHours(0, 0, 0, 0) return Math.round((target - today) / 86400000) } // 检查今天是否已经给这条纪念日发过提醒 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) `) async function runOnce() { console.log('[reminder] 开始扫描纪念日...') const list = db.prepare('SELECT * FROM anniversaries WHERE remindEnabled = 1').all() console.log(`[reminder] 启用提醒的纪念日 ${list.length} 条`) let ok = 0 let fail = 0 for (const anniv of list) { try { const target = getThisYearDate(anniv) const daysUntil = 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, page: 'pages/index/index', templateId: TEMPLATE_ID, miniprogramState: MINIPROGRAM_STATE, data: { name1: { value: anniv.personName }, thing2: { value: daysUntil === 0 ? '今天' : `还有${daysUntil}天` }, thing6: { value: formatDate(target) }, thing5: { value: anniv.remark || '别忘了准备一份礼物哦!' } } }) insertLog.run({ anniversaryId: anniv.id, personName: anniv.personName, typeName, daysUntil, sendDate: Date.now(), status: 'success', error: null }) ok++ console.log(`[reminder] 发送成功: ${anniv.personName} (${typeName}, ${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 }) } } console.log(`[reminder] 完成: 成功 ${ok}, 失败 ${fail}`) return { total: list.length, ok, fail } } 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 }