From 0bb7e7670deeb0da5513dec7aec00daf641d9557 Mon Sep 17 00:00:00 2001 From: wangwangit Date: Sun, 24 May 2026 18:11:47 +0800 Subject: [PATCH] =?UTF-8?q?refactor(scheduler):=20v3=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=20-=20TZ=20=E6=84=9F=E7=9F=A5=20+=20=E5=A4=9A=E8=A7=84?= =?UTF-8?q?=E5=88=99=20+=20=E7=BB=93=E6=9E=84=E5=8C=96=E6=97=A5=E5=BF=97?= =?UTF-8?q?=EF=BC=88=E4=BF=AE=E5=A4=8D=20#91/#52/#166=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/services/notify/reminder-engine.js(新): - 纯函数 shouldFire(rule, ctx) - 三种 rule type 显式分支(before_expiry / on_expiry / after_expiry) - 24 条表驱动单测覆盖各种边界 src/services/scheduler.js(重写): 1. 时区基准统一:通过 getNowInTimezone(config.TIMEZONE) 取用户 TZ 下 hourString,与 NOTIFICATION_HOURS 比对(修复 #91 / #52 根因—— v2 把 UTC 小时当作"用户本地小时") 2. 多提醒规则:从 reminders.repo 加载每订阅的规则数组,逐条调 reminder-engine.shouldFire;老订阅没有规则时现场用 legacyFieldToRule 转一条 3. 去重粒度:dedup key = notify_dedupe:{subId}:{ruleId}:{ymdh-local} 不再让一条订阅的多规则相互打架 4. 结构化日志: - 每次执行写 sched_log:{iso}(含命中/去重/发送/续订统计 + 候选明细) - 每条通知发送(成功/失败)写 notify_log(dispatch.js 自动落库) 5. 用户日历"剩余天数"基于 getDaysBetween(now, expiry, tz), 修复 #166 跨日界场景 测试 tests/services/scheduler.test.js 7 条覆盖: - 场景 1:UTC 0点 + 北京 8 点 + 配置 [08] → 发送 - 场景 2:同上配 [00] → 跳过 inWindow=false - 场景 3:4 条规则订阅(7/3/1/0),距 3 天 → 仅命中 value=3 - 场景 4:同 sub+rule+ymdh 重复调用 → 去重 dedupedCount=1 - 自动续订:cycle 模式补齐 + auto 支付记录 - 写 sched_log - 成功发送时写 notify_log 总计 150 条测试全绿;wrangler dry-run 415 KiB / gzip 86 KiB。 Refs Task 6 of refactor/v3-product-grade plan. --- src/services/notify/reminder-engine.js | 145 ++++++ src/services/scheduler.js | 488 +++++++++++------- tests/services/notify/reminder-engine.test.js | 148 ++++++ tests/services/scheduler.test.js | 277 ++++++++++ 4 files changed, 872 insertions(+), 186 deletions(-) create mode 100644 src/services/notify/reminder-engine.js create mode 100644 tests/services/notify/reminder-engine.test.js create mode 100644 tests/services/scheduler.test.js diff --git a/src/services/notify/reminder-engine.js b/src/services/notify/reminder-engine.js new file mode 100644 index 0000000..b26d9d9 --- /dev/null +++ b/src/services/notify/reminder-engine.js @@ -0,0 +1,145 @@ +// @ts-check +/** + * 提醒规则触发引擎 + * + * 给定一条规则 + 当前到期距离(天/小时)+ 上次触发时间, + * 判断"现在这一小时是否应该发出提醒"。 + * + * 设计成纯函数,与 KV / 网络解耦,便于单元测试覆盖所有边界。 + * + * 三种规则类型语义: + * - before_expiry: value 表示"提前 N 天/小时"。当 daysDiff/hoursDiff 落在 [0, value] 区间触发。 + * 特别地,value=0 等同于 on_expiry。 + * - on_expiry: 仅当到期日(daysDiff===0)触发。 + * - after_expiry: 已过期场景。每隔 repeatInterval 小时触发一次,直到达到终止条件 + * (renewed/acknowledged/never)。本引擎不关心终止判断(由 scheduler 在加载规则前过滤), + * 但会校验"距上次触发是否超过 repeatInterval"。 + * + * 维护人:v3 重构 (2026-05) + */ + +/** + * @typedef {import('../../data/reminders.repo.js').ReminderRule} ReminderRule + */ + +/** + * @typedef {Object} FireContext + * @property {number} daysDiff 距到期天数(基于用户 TZ 零点;可为负数 = 已过期天数) + * @property {number} hoursDiff 距到期小时数(可为负数 = 已过期小时数) + * @property {string} [lastFireAtIso] 同一规则上次触发的 ISO 时间(用于 after_expiry 重复间隔) + * @property {string} [nowIso] 当前 ISO 时间,默认 new Date().toISOString() + */ + +/** + * @typedef {Object} FireDecision + * @property {boolean} fire 是否应该触发 + * @property {string} [reason] 触发或拒绝的原因(便于日志诊断) + */ + +/** + * 判断规则是否应该在"本次调度"触发。 + * + * @param {ReminderRule} rule + * @param {FireContext} ctx + * @returns {FireDecision} + */ +export function shouldFire(rule, ctx) { + if (!rule || rule.isEnabled === false) { + return { fire: false, reason: 'rule_disabled' }; + } + + const { daysDiff, hoursDiff } = ctx; + if (!Number.isFinite(daysDiff) || !Number.isFinite(hoursDiff)) { + return { fire: false, reason: 'invalid_diff' }; + } + + switch (rule.type) { + case 'before_expiry': + return decideBeforeExpiry(rule, ctx); + case 'on_expiry': + return decideOnExpiry(rule, ctx); + case 'after_expiry': + return decideAfterExpiry(rule, ctx); + default: + return { fire: false, reason: 'unknown_rule_type' }; + } +} + +/** + * @param {ReminderRule} rule + * @param {FireContext} ctx + * @returns {FireDecision} + */ +function decideBeforeExpiry(rule, ctx) { + const { daysDiff, hoursDiff } = ctx; + + if (rule.unit === 'hours') { + // hours 模式:value=0 意味着"到期当小时内" + if (rule.value === 0) { + return hoursDiff >= 0 && hoursDiff < 1 + ? { fire: true, reason: 'within_hour' } + : { fire: false, reason: 'not_within_hour' }; + } + // 其余:剩余小时刚好等于规则 value(精确触发) + if (hoursDiff < 0) return { fire: false, reason: 'already_expired' }; + if (Math.round(hoursDiff) === rule.value) { + return { fire: true, reason: `hours_diff_eq_${rule.value}` }; + } + return { fire: false, reason: `hours_diff=${hoursDiff}_not_match_${rule.value}` }; + } + + // days 模式:value=0 等同 on_expiry + if (rule.value === 0) { + return daysDiff === 0 + ? { fire: true, reason: 'days_diff_zero' } + : { fire: false, reason: `days_diff=${daysDiff}_not_zero` }; + } + // 其余:精确匹配剩余天数 + if (daysDiff === rule.value) { + return { fire: true, reason: `days_diff_eq_${rule.value}` }; + } + return { fire: false, reason: `days_diff=${daysDiff}_not_match_${rule.value}` }; +} + +/** + * @param {ReminderRule} rule + * @param {FireContext} ctx + * @returns {FireDecision} + */ +function decideOnExpiry(rule, ctx) { + void rule; + return ctx.daysDiff === 0 + ? { fire: true, reason: 'on_expiry_day' } + : { fire: false, reason: `days_diff=${ctx.daysDiff}_not_today` }; +} + +/** + * @param {ReminderRule} rule + * @param {FireContext} ctx + * @returns {FireDecision} + */ +function decideAfterExpiry(rule, ctx) { + if (ctx.daysDiff >= 0) return { fire: false, reason: 'not_expired_yet' }; + + // 没设 repeatInterval → 仅在过期当天 / 当天后某一时点触发一次(这里取每天 1 次) + // 正常用法:repeatInterval > 0 + const interval = Number.isFinite(rule.repeatInterval) && rule.repeatInterval > 0 + ? rule.repeatInterval + : 24; + + if (!ctx.lastFireAtIso) { + return { fire: true, reason: 'after_expiry_first_fire' }; + } + + const last = new Date(ctx.lastFireAtIso).getTime(); + const now = ctx.nowIso ? new Date(ctx.nowIso).getTime() : Date.now(); + if (Number.isNaN(last) || Number.isNaN(now)) { + return { fire: true, reason: 'invalid_last_fire_assume_due' }; + } + + const elapsedHours = (now - last) / (3600 * 1000); + if (elapsedHours >= interval) { + return { fire: true, reason: `after_expiry_interval_${interval}h_elapsed` }; + } + return { fire: false, reason: `after_expiry_within_${interval}h_window` }; +} diff --git a/src/services/scheduler.js b/src/services/scheduler.js index 1ee4371..131ee09 100644 --- a/src/services/scheduler.js +++ b/src/services/scheduler.js @@ -1,230 +1,346 @@ +// 注:本文件暂不启用 // @ts-check,因 lunar 库返回类型分支较多,类型清理推迟到后续 Task。 +/** + * 定时任务调度器(v3 重写) + * + * ── 修复的核心问题(#91 / #52 / #166 根因)───────────────── + * v2 调度器把"当前 UTC 时刻的小时"当作"用户本地小时"来对比 NOTIFICATION_HOURS, + * 配合"通知时段语义不一致"的文档表述,造成大量"不响 / 错时响"。 + * + * v3 修复: + * 1. 统一时区基准:通过 getNowInTimezone(config.TIMEZONE) 取用户 TZ 下的 hourString + * 与 NOTIFICATION_HOURS(按用户 TZ 解释)比对,语义清晰。 + * 2. 多提醒规则:从 reminders.repo 加载每个订阅的规则数组,逐条调 + * reminder-engine.shouldFire 判断(不再单点 reminderUnit/reminderValue)。 + * 3. 去重粒度细化:dedup key 改为 (subId × ruleId × ymdh-local),避免一条订阅 + * 多规则相互打架。 + * 4. 结构化日志:每次执行写一条 sched_log;每条通知发送(成功/失败)写 notify_log。 + * + * 数据流: + * Cron tick → + * ensureMigrations → + * load config + subs + rules → + * check window → + * for each (sub, rule): + * - daysDiff/hoursDiff 用 getDaysBetween(按用户 TZ)算 + * - 自动续订(针对 sub 整体,仅算一次) + * - shouldFire? → dedupe → dispatch.send → notify_log + * → sched_log + * + * 维护人:v3 重构 (2026-05) + */ + import { getConfig } from '../data/config.js'; import { getAllSubscriptions } from '../data/subscriptions.js'; import * as subRepo from '../data/subscriptions.repo.js'; -import { getCurrentTimeInTimezone, MS_PER_HOUR, MS_PER_DAY, getTimezoneMidnightTimestamp, getTimezoneDateParts } from '../core/time.js'; -import { formatNotificationContent, shouldTriggerReminder } from './notify/reminder.js'; -import { sendNotificationToAllChannels } from './notify/index.js'; +import * as remindersRepo from '../data/reminders.repo.js'; +import * as schedulerLogsRepo from '../data/scheduler-logs.repo.js'; +import { + MS_PER_HOUR, + getNowInTimezone, + getDaysBetween +} from '../core/time.js'; +import { formatNotificationContent } from './notify/reminder.js'; +import { dispatch } from './notify/dispatch.js'; +import { shouldFire } from './notify/reminder-engine.js'; import { lunarCalendar, lunarBiz } from '../core/lunar.js'; -async function saveSchedulerStatus(env, status) { - try { - await env.SUBSCRIPTIONS_KV.put('scheduler_status', JSON.stringify(status)); +const DEDUPE_TTL_SEC = 60 * 60 * 48; // 48h - const historyLimit = 20; - const historyRaw = await env.SUBSCRIPTIONS_KV.get('scheduler_status_history'); - const history = historyRaw ? JSON.parse(historyRaw) : []; - const nextHistory = [status, ...(Array.isArray(history) ? history : [])].slice(0, historyLimit); - await env.SUBSCRIPTIONS_KV.put('scheduler_status_history', JSON.stringify(nextHistory)); - } catch (error) { - console.error('[定时任务] 写入执行状态失败:', error); - } -} - -async function dedupeNotifications(env, subscriptions, bucketKey) { - const deduped = []; - let skipped = 0; - - for (const subscription of subscriptions) { - const key = `notify_dedupe:${subscription.id}:${bucketKey}`; - const exists = await env.SUBSCRIPTIONS_KV.get(key); - if (exists) { - skipped += 1; - continue; - } - - await env.SUBSCRIPTIONS_KV.put(key, '1', { expirationTtl: 60 * 60 * 48 }); - deduped.push(subscription); - } - - return { deduped, skipped }; -} - -async function checkExpiringSubscriptions(env) { +/** + * 入口:被 Cron 触发的 scheduled() 调用。 + * + * @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env + * @returns {Promise} + */ +export async function checkExpiringSubscriptions(env) { + const startedAtIso = new Date().toISOString(); try { const config = await getConfig(env); const timezone = config.TIMEZONE || 'UTC'; - const currentTime = getCurrentTimeInTimezone(timezone); - const todayMidnight = getTimezoneMidnightTimestamp(currentTime, timezone); + const now = getNowInTimezone(timezone); + + const normalizedHours = Array.isArray(config.NOTIFICATION_HOURS) + ? config.NOTIFICATION_HOURS.map((h) => String(h).padStart(2, '0')) + : []; + const inWindow = + normalizedHours.length === 0 || + normalizedHours.includes('*') || + normalizedHours.includes('ALL') || + normalizedHours.includes(now.hourString); const subscriptions = await getAllSubscriptions(env); - const expiringSubscriptions = []; - const updatedSubscriptions = []; - let hasUpdates = false; + let activeCount = 0; + let matchedCount = 0; + let dedupedCount = 0; + let sentCount = 0; + let autoRenewedCount = 0; - const normalizedNotificationHours = Array.isArray(config.NOTIFICATION_HOURS) - ? config.NOTIFICATION_HOURS.map(h => String(h).padStart(2, '0')) - : []; - const currentHour = String(getTimezoneDateParts(currentTime, timezone).hour).padStart(2, '0'); - const shouldNotifyThisHour = - normalizedNotificationHours.includes('*') || - normalizedNotificationHours.includes('ALL') || - normalizedNotificationHours.includes(currentHour) || - normalizedNotificationHours.length === 0; + // 不在通知时段:不发送但仍跑自动续订(业务上希望续订总能发生) + /** @type {Array<{ sub: any, rule: any, daysDiff: number, hoursDiff: number }>} */ + const candidates = []; - const status = { - lastRunAt: new Date().toISOString(), - timezone, - currentHour, - configuredHours: normalizedNotificationHours, - shouldNotifyThisHour, - checkedSubscriptions: Array.isArray(subscriptions) ? subscriptions.length : 0, - activeSubscriptions: 0, - expiringMatched: 0, - dedupeSkipped: 0, - updatedSubscriptions: 0, - sent: false, - sendResult: null, - reason: '' - }; + /** @type {Array} */ + const updatedSubsToSave = []; for (const subscription of subscriptions) { if (!subscription.isActive) continue; - status.activeSubscriptions += 1; + activeCount++; - const reminderSetting = { unit: subscription.reminderUnit || 'day', value: subscription.reminderValue ?? 7 }; + // 计算到期天数(按用户 TZ) let expiryDate = new Date(subscription.expiryDate); - let daysDiff = Math.ceil((expiryDate.getTime() - todayMidnight) / MS_PER_DAY); - let diffMs = expiryDate.getTime() - currentTime.getTime(); - let diffHours = diffMs / MS_PER_HOUR; + let daysDiff = getDaysBetween(now.utc, expiryDate, timezone); + let hoursDiff = (expiryDate.getTime() - now.utc.getTime()) / MS_PER_HOUR; + // 自动续订:已过期 + autoRenew=true → 推进到期日并写支付记录 if (subscription.autoRenew && daysDiff < 0) { - const mode = subscription.subscriptionMode || 'cycle'; - let periodsAdded = 0; - - if (subscription.useLunar) { - let lunar = lunarCalendar.solar2lunar(expiryDate.getFullYear(), expiryDate.getMonth() + 1, expiryDate.getDate()); - while (expiryDate <= currentTime) { - lunar = lunarBiz.addLunarPeriod(lunar, subscription.periodValue, subscription.periodUnit); - const solar = lunarBiz.lunar2solar(lunar); - expiryDate = new Date(solar.year, solar.month - 1, solar.day); - periodsAdded++; - } - } else { - while (expiryDate <= currentTime) { - if (mode === 'reset') { - expiryDate = new Date(currentTime); - } - if (subscription.periodUnit === 'day') { - expiryDate.setDate(expiryDate.getDate() + subscription.periodValue); - } else if (subscription.periodUnit === 'month') { - expiryDate.setMonth(expiryDate.getMonth() + subscription.periodValue); - } else if (subscription.periodUnit === 'year') { - expiryDate.setFullYear(expiryDate.getFullYear() + subscription.periodValue); - } - periodsAdded++; - } + const renewed = autoRenew(subscription, now.utc, timezone, config); + if (renewed) { + updatedSubsToSave.push(renewed.next); + autoRenewedCount++; + // 续订后重算 diff + expiryDate = new Date(renewed.next.expiryDate); + daysDiff = getDaysBetween(now.utc, expiryDate, timezone); + hoursDiff = (expiryDate.getTime() - now.utc.getTime()) / MS_PER_HOUR; + // 用续订后的对象作后续判断 + subscription.expiryDate = renewed.next.expiryDate; + subscription.startDate = renewed.next.startDate; + subscription.lastPaymentDate = renewed.next.lastPaymentDate; + subscription.paymentHistory = renewed.next.paymentHistory; } + } - const newStartDate = mode === 'reset' ? new Date(currentTime) : new Date(subscription.expiryDate); - const newExpiryDate = expiryDate; - const paymentRecord = { - id: Date.now().toString(), - date: currentTime.toISOString(), - amount: subscription.amount || 0, - type: 'auto', - note: `自动续订 (${mode === 'reset' ? '重置模式' : '接续模式'}${periodsAdded > 1 ? ', 补齐' + periodsAdded + '周期' : ''})`, - periodStart: newStartDate.toISOString(), - periodEnd: newExpiryDate.toISOString() - }; + // 加载规则;老订阅没有规则时,用 legacyFieldToRule 现场转一条 + let rules = await remindersRepo.listForSubscription(env, subscription.id); + if (rules.length === 0) { + rules = [remindersRepo.legacyFieldToRule(subscription)]; + } - const paymentHistory = subscription.paymentHistory || []; - paymentHistory.push(paymentRecord); - const paymentHistoryLimit = Number(config.PAYMENT_HISTORY_LIMIT) || 100; - const trimmedPaymentHistory = paymentHistory.length > paymentHistoryLimit - ? paymentHistory.slice(-paymentHistoryLimit) - : paymentHistory; + for (const rule of rules) { + const decision = shouldFire(rule, { daysDiff, hoursDiff, nowIso: now.utc.toISOString() }); + if (!decision.fire) continue; + matchedCount++; + candidates.push({ sub: subscription, rule, daysDiff, hoursDiff }); + } + } - const updatedSubscription = { - ...subscription, - startDate: newStartDate.toISOString(), - expiryDate: newExpiryDate.toISOString(), - lastPaymentDate: currentTime.toISOString(), - paymentHistory: trimmedPaymentHistory - }; + // 持久化自动续订结果 + if (updatedSubsToSave.length > 0) { + await subRepo.saveMany(env, updatedSubsToSave); + console.log(`[定时任务] 已自动续订 ${updatedSubsToSave.length} 个订阅`); + } - updatedSubscriptions.push(updatedSubscription); - hasUpdates = true; + // 不在通知时段 → 写日志后返回 + if (!inWindow) { + const entry = await schedulerLogsRepo.writeLog(env, { + startedAt: startedAtIso, + finishedAt: new Date().toISOString(), + timezone, + currentHour: now.hourString, + configuredHours: normalizedHours, + inWindow: false, + checkedCount: activeCount, + matchedCount, + dedupedCount: 0, + sentCount: 0, + autoRenewedCount, + status: 'skipped', + reason: `当前用户 TZ 小时 ${now.hourString} 不在配置时段 [${normalizedHours.join(',') || '空'}] 内` + }); + return entry; + } - diffMs = newExpiryDate.getTime() - currentTime.getTime(); - diffHours = diffMs / MS_PER_HOUR; - daysDiff = Math.ceil((newExpiryDate.getTime() - todayMidnight) / MS_PER_DAY); - const shouldRemindAfterRenewal = shouldTriggerReminder(reminderSetting, daysDiff, diffHours); - if (shouldRemindAfterRenewal) { - expiringSubscriptions.push({ - ...updatedSubscription, - daysRemaining: daysDiff, - hoursRemaining: Math.round(diffHours) - }); - } + // 在时段:去重 + 发送 + /** @type {Array<{ sub: any, rule: any, daysDiff: number, hoursDiff: number }>} */ + const ready = []; + const ymdhLocal = `${now.parts.year}${String(now.parts.month).padStart(2, '0')}${String( + now.parts.day + ).padStart(2, '0')}${now.hourString}`; + for (const c of candidates) { + const dedupeKey = `notify_dedupe:${c.sub.id}:${c.rule.id}:${ymdhLocal}`; + const exists = await env.SUBSCRIPTIONS_KV.get(dedupeKey); + if (exists) { + dedupedCount++; continue; } - - const shouldRemind = shouldTriggerReminder(reminderSetting, daysDiff, diffHours); - if (daysDiff < 0 && subscription.autoRenew === false) { - expiringSubscriptions.push({ - ...subscription, - daysRemaining: daysDiff, - hoursRemaining: Math.round(diffHours) - }); - } else if (shouldRemind) { - expiringSubscriptions.push({ - ...subscription, - daysRemaining: daysDiff, - hoursRemaining: Math.round(diffHours) - }); - } + await env.SUBSCRIPTIONS_KV.put(dedupeKey, '1', { expirationTtl: DEDUPE_TTL_SEC }); + ready.push(c); } - if (hasUpdates) { - // v3:仅保存被更新的订阅,无需重写整个数组 - await subRepo.saveMany(env, updatedSubscriptions); - console.log(`[定时任务] 已更新 ${updatedSubscriptions.length} 个自动续费订阅`); + if (ready.length === 0) { + const entry = await schedulerLogsRepo.writeLog(env, { + startedAt: startedAtIso, + finishedAt: new Date().toISOString(), + timezone, + currentHour: now.hourString, + configuredHours: normalizedHours, + inWindow: true, + checkedCount: activeCount, + matchedCount, + dedupedCount, + sentCount: 0, + autoRenewedCount, + status: matchedCount > 0 ? 'skipped' : 'ok', + reason: + matchedCount > 0 + ? `命中 ${matchedCount} 条规则但全部在去重窗口内(跳过 ${dedupedCount})` + : '本次未命中任何提醒规则' + }); + return entry; } - status.updatedSubscriptions = updatedSubscriptions.length; - status.expiringMatched = expiringSubscriptions.length; + // 排序:按剩余天数升序,更紧迫的在前 + ready.sort((a, b) => a.daysDiff - b.daysDiff); - if (expiringSubscriptions.length > 0) { - if (!shouldNotifyThisHour) { - status.sent = false; - status.reason = `当前小时 ${currentHour} 未在通知时段内 (${normalizedNotificationHours.join(',') || '空'})`; - console.log(`[定时任务] ${status.reason},跳过发送`); - } else { - expiringSubscriptions.sort((a, b) => a.daysRemaining - b.daysRemaining); - const bucketKey = `${new Date().toISOString().slice(0, 13)}`; - const dedupeResult = await dedupeNotifications(env, expiringSubscriptions, bucketKey); - status.dedupeSkipped = dedupeResult.skipped; + // 当前 v3 仍按"一次性聚合所有订阅成一条通知"的方式发送(v2 行为兼容) + // notify_log 按 (subId, ruleId, channel) 维度落,仍可细粒度查询 + const enrichedSubs = ready.map((c) => ({ + ...c.sub, + daysRemaining: c.daysDiff, + hoursRemaining: Math.round(c.hoursDiff) + })); + const content = formatNotificationContent(enrichedSubs, config); + const title = '订阅到期/续费提醒'; - if (dedupeResult.deduped.length === 0) { - status.sent = false; - status.reason = `命中 ${expiringSubscriptions.length} 条,但全部在去重窗口内(跳过 ${dedupeResult.skipped} 条)`; - console.log(`[定时任务] ${status.reason}`); - } else { - console.log(`[定时任务] 发送 ${dedupeResult.deduped.length} 条提醒通知(去重跳过 ${dedupeResult.skipped} 条)`); - const commonContent = formatNotificationContent(dedupeResult.deduped, config); - const sendResult = await sendNotificationToAllChannels('订阅到期/续费提醒', commonContent, config, '[定时任务]'); - status.sent = true; - status.sendResult = sendResult; - status.reason = sendResult && sendResult.attempted > 0 - ? `已尝试发送到 ${sendResult.attempted} 个渠道,成功 ${sendResult.successCount} 个(去重跳过 ${dedupeResult.skipped} 条)` - : '未启用任何通知渠道'; + // 给 dispatch 提供主 subId+ruleId(聚合通知用第一条做归属) + const primary = ready[0]; + const dispatchResult = await dispatch( + { title, content }, + config, + { + env, + subId: primary.sub.id, + ruleId: primary.rule.id, + logPrefix: '[定时任务]', + metadata: { + tags: enrichedSubs.map((s) => s.name), + daysRemaining: primary.daysDiff, + ruleType: primary.rule.type, + ruleValue: primary.rule.value } } - } else { - status.sent = false; - status.reason = '本次未命中需要提醒的订阅'; - } + ); + sentCount = dispatchResult.successCount; - await saveSchedulerStatus(env, status); + const entry = await schedulerLogsRepo.writeLog(env, { + startedAt: startedAtIso, + finishedAt: new Date().toISOString(), + timezone, + currentHour: now.hourString, + configuredHours: normalizedHours, + inWindow: true, + checkedCount: activeCount, + matchedCount, + dedupedCount, + sentCount, + autoRenewedCount, + status: dispatchResult.failedCount > 0 && sentCount === 0 ? 'error' : 'ok', + reason: + dispatchResult.attempted > 0 + ? `发送到 ${dispatchResult.attempted} 个渠道,成功 ${dispatchResult.successCount} / 失败 ${dispatchResult.failedCount}` + : '未启用任何通知渠道', + extra: { + candidates: ready.map((c) => ({ + subId: c.sub.id, + subName: c.sub.name, + ruleId: c.rule.id, + ruleType: c.rule.type, + ruleValue: c.rule.value, + daysDiff: c.daysDiff + })), + channelResults: dispatchResult.channelResults + } + }); + return entry; } catch (error) { console.error('[定时任务] 执行失败:', error); - await saveSchedulerStatus(env, { - lastRunAt: new Date().toISOString(), - sent: false, + return schedulerLogsRepo.writeLog(env, { + startedAt: startedAtIso, + finishedAt: new Date().toISOString(), + timezone: 'UTC', + currentHour: '00', + configuredHours: [], + inWindow: false, + checkedCount: 0, + matchedCount: 0, + dedupedCount: 0, + sentCount: 0, + autoRenewedCount: 0, + status: 'error', reason: '执行异常: ' + (error && error.message ? error.message : String(error)), - errorStack: error && error.stack ? error.stack : undefined + extra: { stack: error && error.stack } }); } } -export { checkExpiringSubscriptions }; +/** + * 自动续订:把已过期的订阅按周期推进,生成 auto 类型支付记录。 + * + * 与 v2 行为一致:cycle / reset 模式 + 公历 / 农历分支。 + * + * @param {any} sub + * @param {Date} now UTC 时刻 + * @param {string} timezone + * @param {any} config + * @returns {{ next: any } | null} + */ +function autoRenew(sub, now, timezone, config) { + const mode = sub.subscriptionMode || 'cycle'; + let expiryDate = new Date(sub.expiryDate); + let periodsAdded = 0; + + if (sub.useLunar) { + let lunar = lunarCalendar.solar2lunar( + expiryDate.getFullYear(), + expiryDate.getMonth() + 1, + expiryDate.getDate() + ); + while (expiryDate <= now) { + lunar = lunarBiz.addLunarPeriod(lunar, sub.periodValue, sub.periodUnit); + const solar = lunarBiz.lunar2solar(lunar); + expiryDate = new Date(solar.year, solar.month - 1, solar.day); + periodsAdded++; + if (periodsAdded > 60) break; // 防御 + } + } else { + while (expiryDate <= now) { + if (mode === 'reset') expiryDate = new Date(now); + if (sub.periodUnit === 'day') expiryDate.setDate(expiryDate.getDate() + sub.periodValue); + else if (sub.periodUnit === 'month') expiryDate.setMonth(expiryDate.getMonth() + sub.periodValue); + else if (sub.periodUnit === 'year') expiryDate.setFullYear(expiryDate.getFullYear() + sub.periodValue); + periodsAdded++; + if (periodsAdded > 120) break; + } + } + + if (periodsAdded === 0) return null; + + const newStartDate = mode === 'reset' ? new Date(now) : new Date(sub.expiryDate); + const newExpiryDate = expiryDate; + void timezone; + + const paymentRecord = { + id: Date.now().toString(), + date: now.toISOString(), + amount: sub.amount || 0, + type: 'auto', + note: `自动续订 (${mode === 'reset' ? '重置模式' : '接续模式'}${ + periodsAdded > 1 ? ', 补齐' + periodsAdded + '周期' : '' + })`, + periodStart: newStartDate.toISOString(), + periodEnd: newExpiryDate.toISOString() + }; + + const paymentHistoryLimit = Number(config.PAYMENT_HISTORY_LIMIT) || 100; + const ph = [...(sub.paymentHistory || []), paymentRecord]; + const trimmed = ph.length > paymentHistoryLimit ? ph.slice(-paymentHistoryLimit) : ph; + + return { + next: { + ...sub, + startDate: newStartDate.toISOString(), + expiryDate: newExpiryDate.toISOString(), + lastPaymentDate: now.toISOString(), + paymentHistory: trimmed + } + }; +} diff --git a/tests/services/notify/reminder-engine.test.js b/tests/services/notify/reminder-engine.test.js new file mode 100644 index 0000000..851c35f --- /dev/null +++ b/tests/services/notify/reminder-engine.test.js @@ -0,0 +1,148 @@ +// @ts-check +/** + * 提醒规则触发引擎单元测试(表驱动) + */ +import { describe, it, expect } from 'vitest'; +import { shouldFire } from '../../../src/services/notify/reminder-engine.js'; + +/** + * @param {Partial} r + */ +function rule(r) { + return { + id: 'r1', + type: 'before_expiry', + value: 7, + unit: 'days', + repeatInterval: null, + repeatUntil: 'renewed', + isEnabled: true, + createdAt: '2026-01-01T00:00:00Z', + ...r + }; +} + +describe('shouldFire 通用', () => { + it('isEnabled=false → 不触发', () => { + const r = rule({ isEnabled: false }); + expect(shouldFire(r, { daysDiff: 7, hoursDiff: 168 }).fire).toBe(false); + }); + + it('未知 type → 不触发', () => { + const r = rule({ type: /** @type {any} */ ('xxx') }); + expect(shouldFire(r, { daysDiff: 7, hoursDiff: 168 }).fire).toBe(false); + }); + + it('NaN diff → 不触发', () => { + const r = rule({}); + expect(shouldFire(r, { daysDiff: NaN, hoursDiff: 0 }).fire).toBe(false); + }); +}); + +describe('before_expiry / days', () => { + /** @type {Array<[number, number, boolean, string]>} */ + const cases = [ + [7, 7, true, '到期前 7 天命中 value=7'], + [7, 8, false, '到期前 8 天不命中 value=7'], + [7, 6, false, '到期前 6 天不命中 value=7'], + [7, 0, false, '到期当天不命中 value=7(要 on_expiry 类型)'], + [7, -1, false, '已过期 1 天不命中 before_expiry'], + [3, 3, true, '到期前 3 天命中 value=3'], + [1, 1, true, '到期前 1 天命中 value=1'], + [0, 0, true, 'value=0 到期当天命中(等价 on_expiry)'], + [0, 1, false, 'value=0 非到期日不命中'] + ]; + cases.forEach(([v, days, expected, desc]) => { + it(desc, () => { + const r = rule({ type: 'before_expiry', value: v, unit: 'days' }); + expect(shouldFire(r, { daysDiff: days, hoursDiff: days * 24 }).fire).toBe(expected); + }); + }); +}); + +describe('before_expiry / hours', () => { + it('value=12, hoursDiff=12 → 命中', () => { + const r = rule({ type: 'before_expiry', value: 12, unit: 'hours' }); + expect(shouldFire(r, { daysDiff: 0, hoursDiff: 12 }).fire).toBe(true); + }); + + it('value=12, hoursDiff=11 → 不命中(精确匹配)', () => { + const r = rule({ type: 'before_expiry', value: 12, unit: 'hours' }); + expect(shouldFire(r, { daysDiff: 0, hoursDiff: 11 }).fire).toBe(false); + }); + + it('value=0 hours, 0<=hoursDiff<1 → 命中', () => { + const r = rule({ type: 'before_expiry', value: 0, unit: 'hours' }); + expect(shouldFire(r, { daysDiff: 0, hoursDiff: 0.5 }).fire).toBe(true); + expect(shouldFire(r, { daysDiff: 0, hoursDiff: 0 }).fire).toBe(true); + expect(shouldFire(r, { daysDiff: 0, hoursDiff: 1 }).fire).toBe(false); + }); + + it('hoursDiff=-1(已过期)→ 不命中', () => { + const r = rule({ type: 'before_expiry', value: 12, unit: 'hours' }); + expect(shouldFire(r, { daysDiff: 0, hoursDiff: -1 }).fire).toBe(false); + }); +}); + +describe('on_expiry', () => { + it('daysDiff=0 → 命中', () => { + const r = rule({ type: 'on_expiry', value: 0, unit: 'days' }); + expect(shouldFire(r, { daysDiff: 0, hoursDiff: 1 }).fire).toBe(true); + }); + + it('daysDiff=1 → 不命中', () => { + const r = rule({ type: 'on_expiry', value: 0, unit: 'days' }); + expect(shouldFire(r, { daysDiff: 1, hoursDiff: 24 }).fire).toBe(false); + }); + + it('daysDiff=-1(昨天到期)→ 不命中', () => { + const r = rule({ type: 'on_expiry', value: 0, unit: 'days' }); + expect(shouldFire(r, { daysDiff: -1, hoursDiff: -24 }).fire).toBe(false); + }); +}); + +describe('after_expiry', () => { + it('未到期(daysDiff>=0)→ 不命中', () => { + const r = rule({ type: 'after_expiry', value: 0, unit: 'days', repeatInterval: 24 }); + expect(shouldFire(r, { daysDiff: 0, hoursDiff: 1 }).fire).toBe(false); + expect(shouldFire(r, { daysDiff: 7, hoursDiff: 168 }).fire).toBe(false); + }); + + it('已过期 + 没有 lastFireAt → 首次触发', () => { + const r = rule({ type: 'after_expiry', value: 0, unit: 'days', repeatInterval: 24 }); + expect(shouldFire(r, { daysDiff: -1, hoursDiff: -24 }).fire).toBe(true); + }); + + it('已过期 + lastFireAt 在 interval 内 → 不重复触发', () => { + const r = rule({ type: 'after_expiry', value: 0, unit: 'days', repeatInterval: 24 }); + const result = shouldFire(r, { + daysDiff: -2, + hoursDiff: -48, + lastFireAtIso: '2026-05-24T10:00:00Z', + nowIso: '2026-05-24T18:00:00Z' // 8h 后,未到 24h + }); + expect(result.fire).toBe(false); + }); + + it('已过期 + lastFireAt 超过 interval → 重新触发', () => { + const r = rule({ type: 'after_expiry', value: 0, unit: 'days', repeatInterval: 24 }); + const result = shouldFire(r, { + daysDiff: -2, + hoursDiff: -48, + lastFireAtIso: '2026-05-23T10:00:00Z', + nowIso: '2026-05-24T18:00:00Z' // 32h 后 + }); + expect(result.fire).toBe(true); + }); + + it('未指定 repeatInterval → 默认 24h', () => { + const r = rule({ type: 'after_expiry', value: 0, unit: 'days', repeatInterval: null }); + const result = shouldFire(r, { + daysDiff: -2, + hoursDiff: -48, + lastFireAtIso: '2026-05-24T10:00:00Z', + nowIso: '2026-05-24T20:00:00Z' // 10h 后,未到 24h + }); + expect(result.fire).toBe(false); + }); +}); diff --git a/tests/services/scheduler.test.js b/tests/services/scheduler.test.js new file mode 100644 index 0000000..0486942 --- /dev/null +++ b/tests/services/scheduler.test.js @@ -0,0 +1,277 @@ +// @ts-check +/** + * 调度器 v3 集成测试 + * + * 4 个核心场景(修复 #91 / #52 / #166): + * 1. UTC 0点 + TZ Asia/Shanghai + NOTIFICATION_HOURS=["08"] + 北京 8 点 → 应发送 + * 2. 同上但 NOTIFICATION_HOURS=["00"] → 不发送 + * 3. 多规则订阅(7/3/1/当天):到期前 3 天 → 仅命中 value:3 + * 4. 同规则同小时第二次调用 → 去重跳过 + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +// @ts-ignore +import { env } from 'cloudflare:test'; + +import { checkExpiringSubscriptions } from '../../src/services/scheduler.js'; +import * as subRepo from '../../src/data/subscriptions.repo.js'; +import * as remindersRepo from '../../src/data/reminders.repo.js'; +import { getRecent } from '../../src/data/scheduler-logs.repo.js'; +import { query as queryNotifyLogs } from '../../src/data/notification-logs.repo.js'; + +async function clearKv() { + const list = await env.SUBSCRIPTIONS_KV.list(); + await Promise.all(list.keys.map((k) => env.SUBSCRIPTIONS_KV.delete(k.name))); +} + +/** 写入一条系统配置 */ +async function setConfig(cfg) { + await env.SUBSCRIPTIONS_KV.put('config', JSON.stringify(cfg)); +} + +/** mock fetch 返回 Telegram 成功 */ +function mockTelegramOk() { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); +} + +beforeEach(async () => { + await clearKv(); + vi.useRealTimers(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('scheduler v3 - 时区 + 通知时段', () => { + it('场景1:UTC 0点 + TZ Asia/Shanghai + NOTIFICATION_HOURS=[08] + 北京 8 点 → 发送', async () => { + // mock 当前时间为 UTC 2026-05-24 00:00 = 北京 5/24 08:00 + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T00:00:00.000Z')); + + await setConfig({ + ADMIN_USERNAME: 'admin', + ADMIN_PASSWORD: 'password', + JWT_SECRET: 'secret', + TIMEZONE: 'Asia/Shanghai', + NOTIFICATION_HOURS: ['08'], + ENABLED_NOTIFIERS: ['telegram'], + TG_BOT_TOKEN: 'B', + TG_CHAT_ID: 'C' + }); + + // 一条订阅,5 月 31 日北京时间到期,距今 7 天 + await subRepo.save(env, { + id: 's-netflix', + name: 'Netflix', + isActive: true, + autoRenew: false, + expiryDate: '2026-05-31T03:00:00.000Z', // 北京 5/31 11:00 → 距 5/24 7 天 + currency: 'CNY', + periodValue: 1, + periodUnit: 'month', + reminderUnit: 'day', + reminderValue: 7 + }); + await remindersRepo.replaceForSubscription(env, 's-netflix', [ + remindersRepo.normalizeRule({ type: 'before_expiry', value: 7, unit: 'days' }) + ]); + + const fetchSpy = mockTelegramOk(); + const log = await checkExpiringSubscriptions(env); + expect(log.status).toBe('ok'); + expect(log.sentCount).toBe(1); + expect(log.matchedCount).toBe(1); + expect(log.dedupedCount).toBe(0); + expect(log.timezone).toBe('Asia/Shanghai'); + expect(log.currentHour).toBe('08'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('场景2:同样设置但 NOTIFICATION_HOURS=[00] → 跳过不发送', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T00:00:00.000Z')); // 北京 08:00 + + await setConfig({ + JWT_SECRET: 's', + TIMEZONE: 'Asia/Shanghai', + NOTIFICATION_HOURS: ['00'], // 用户配的是北京 0 点 + ENABLED_NOTIFIERS: ['telegram'], + TG_BOT_TOKEN: 'B', + TG_CHAT_ID: 'C' + }); + await subRepo.save(env, { + id: 's-x', + name: 'X', + isActive: true, + autoRenew: false, + expiryDate: '2026-05-31T00:00:00Z', + currency: 'CNY', + reminderUnit: 'day', + reminderValue: 7 + }); + + const fetchSpy = mockTelegramOk(); + const log = await checkExpiringSubscriptions(env); + expect(log.status).toBe('skipped'); + expect(log.inWindow).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('场景3:多规则订阅(7/3/1/0),到期前 3 天 → 仅 value=3 命中', async () => { + // 北京 5/24 08:00 → 到期 5/27 11:00 北京 → 距 3 天 + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T00:00:00.000Z')); + + await setConfig({ + JWT_SECRET: 's', + TIMEZONE: 'Asia/Shanghai', + NOTIFICATION_HOURS: [], + ENABLED_NOTIFIERS: ['telegram'], + TG_BOT_TOKEN: 'B', + TG_CHAT_ID: 'C' + }); + await subRepo.save(env, { + id: 's-multi', + name: 'Multi', + isActive: true, + autoRenew: false, + expiryDate: '2026-05-27T03:00:00.000Z' + }); + await remindersRepo.replaceForSubscription(env, 's-multi', [ + remindersRepo.normalizeRule({ type: 'before_expiry', value: 7, unit: 'days' }), + remindersRepo.normalizeRule({ type: 'before_expiry', value: 3, unit: 'days' }), + remindersRepo.normalizeRule({ type: 'before_expiry', value: 1, unit: 'days' }), + remindersRepo.normalizeRule({ type: 'on_expiry', value: 0, unit: 'days' }) + ]); + + mockTelegramOk(); + const log = await checkExpiringSubscriptions(env); + expect(log.matchedCount).toBe(1); // 只命中 value=3 + expect(log.sentCount).toBe(1); + expect(log.extra.candidates).toHaveLength(1); + expect(log.extra.candidates[0].ruleValue).toBe(3); + }); + + it('场景4:同规则同小时第二次调用 → 去重跳过', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T00:00:00.000Z')); + await setConfig({ + JWT_SECRET: 's', + TIMEZONE: 'Asia/Shanghai', + NOTIFICATION_HOURS: [], + ENABLED_NOTIFIERS: ['telegram'], + TG_BOT_TOKEN: 'B', + TG_CHAT_ID: 'C' + }); + await subRepo.save(env, { + id: 's-dedupe', + name: 'Dedupe', + isActive: true, + autoRenew: false, + expiryDate: '2026-05-25T03:00:00.000Z' + }); + await remindersRepo.replaceForSubscription(env, 's-dedupe', [ + remindersRepo.normalizeRule({ type: 'before_expiry', value: 1, unit: 'days' }) + ]); + + const fetchSpy = mockTelegramOk(); + + const log1 = await checkExpiringSubscriptions(env); + expect(log1.sentCount).toBe(1); + expect(log1.dedupedCount).toBe(0); + + const log2 = await checkExpiringSubscriptions(env); + expect(log2.sentCount).toBe(0); + expect(log2.dedupedCount).toBe(1); + expect(log2.matchedCount).toBe(1); + + expect(fetchSpy).toHaveBeenCalledTimes(1); // 只发了一次 + }); +}); + +describe('scheduler v3 - 自动续订', () => { + it('已过期 + autoRenew=true → 推进到期日 + 写 auto 支付记录', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T00:00:00.000Z')); + + await setConfig({ + JWT_SECRET: 's', + TIMEZONE: 'Asia/Shanghai', + NOTIFICATION_HOURS: [], + ENABLED_NOTIFIERS: [] + }); + await subRepo.save(env, { + id: 's-renew', + name: 'Renew', + isActive: true, + autoRenew: true, + subscriptionMode: 'cycle', + expiryDate: '2026-04-01T00:00:00.000Z', // 已过期 ~1.5 月 + periodValue: 1, + periodUnit: 'month', + amount: 10, + currency: 'CNY', + paymentHistory: [] + }); + + const log = await checkExpiringSubscriptions(env); + expect(log.autoRenewedCount).toBe(1); + + const next = await subRepo.getById(env, 's-renew'); + expect(new Date(next.expiryDate).getTime()).toBeGreaterThan(Date.now()); + expect(next.paymentHistory.length).toBeGreaterThan(0); + expect(next.paymentHistory[next.paymentHistory.length - 1].type).toBe('auto'); + }); +}); + +describe('scheduler v3 - 写入日志', () => { + it('每次执行都写一条 sched_log', async () => { + await setConfig({ + JWT_SECRET: 's', + TIMEZONE: 'UTC', + NOTIFICATION_HOURS: [], + ENABLED_NOTIFIERS: [] + }); + + await checkExpiringSubscriptions(env); + const logs = await getRecent(env, 5); + expect(logs).toHaveLength(1); + }); + + it('成功发送时写 notify_log', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-24T00:00:00.000Z')); + await setConfig({ + JWT_SECRET: 's', + TIMEZONE: 'Asia/Shanghai', + NOTIFICATION_HOURS: [], + ENABLED_NOTIFIERS: ['telegram'], + TG_BOT_TOKEN: 'B', + TG_CHAT_ID: 'C' + }); + await subRepo.save(env, { + id: 's-log', + name: 'L', + isActive: true, + autoRenew: false, + expiryDate: '2026-05-25T03:00:00.000Z' + }); + await remindersRepo.replaceForSubscription(env, 's-log', [ + remindersRepo.normalizeRule({ type: 'before_expiry', value: 1, unit: 'days' }) + ]); + + mockTelegramOk(); + await checkExpiringSubscriptions(env); + + const notifyLogs = await queryNotifyLogs(env, { subId: 's-log' }); + expect(notifyLogs).toHaveLength(1); + expect(notifyLogs[0].channel).toBe('telegram'); + expect(notifyLogs[0].status).toBe('success'); + }); +});