mirror of
https://github.com/wangwangit/SubsTracker.git
synced 2026-06-02 14:00:50 +08:00
refactor(scheduler): v3 重写 - TZ 感知 + 多规则 + 结构化日志(修复 #91/#52/#166)
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.
This commit is contained in:
145
src/services/notify/reminder-engine.js
Normal file
145
src/services/notify/reminder-engine.js
Normal file
@@ -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` };
|
||||
}
|
||||
@@ -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<import('../data/scheduler-logs.repo.js').SchedulerLogEntry|null>}
|
||||
*/
|
||||
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<any>} */
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
148
tests/services/notify/reminder-engine.test.js
Normal file
148
tests/services/notify/reminder-engine.test.js
Normal file
@@ -0,0 +1,148 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* 提醒规则触发引擎单元测试(表驱动)
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shouldFire } from '../../../src/services/notify/reminder-engine.js';
|
||||
|
||||
/**
|
||||
* @param {Partial<import('../../../src/data/reminders.repo.js').ReminderRule>} 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);
|
||||
});
|
||||
});
|
||||
277
tests/services/scheduler.test.js
Normal file
277
tests/services/scheduler.test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user