diff --git a/apps/api/src/services/channel-notification.service.ts b/apps/api/src/services/channel-notification.service.ts index 641f8e5..58ec938 100644 --- a/apps/api/src/services/channel-notification.service.ts +++ b/apps/api/src/services/channel-notification.service.ts @@ -17,6 +17,7 @@ import { getAppTimezone, getNotificationChannelSettings, getSetting, setSetting import { validateNotificationTargetUrl } from './notification-url.service' import { toIsoDate } from '../utils/date' import { formatDateInTimezone } from '../utils/timezone' +import { prisma } from '../db' type NotificationDispatchParams = { eventType: WebhookEventType @@ -70,11 +71,33 @@ export type NotificationChannelResult = { message?: string } +const NOTIFICATION_DEDUP_KEY_PREFIX = 'notification:' +export const NOTIFICATION_DEDUP_RETENTION_DAYS = 30 + function buildNotificationKey( channel: 'email' | 'pushplus' | 'telegram' | 'serverchan' | 'gotify', params: NotificationDispatchParams ) { - return `notification:${channel}:${params.eventType}:${params.resourceKey}:${params.periodKey}` + return `${NOTIFICATION_DEDUP_KEY_PREFIX}${channel}:${params.eventType}:${params.resourceKey}:${params.periodKey}` +} + +export async function cleanupOldNotificationDedupSettings( + now = new Date(), + retentionDays = NOTIFICATION_DEDUP_RETENTION_DAYS +) { + const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000) + const result = await prisma.setting.deleteMany({ + where: { + key: { + startsWith: NOTIFICATION_DEDUP_KEY_PREFIX + }, + updatedAt: { + lt: cutoff + } + } + }) + + return result.count } const CHANNEL_LABELS: Record = { diff --git a/apps/api/src/services/scheduler.service.ts b/apps/api/src/services/scheduler.service.ts index c43b7f1..7f302cf 100644 --- a/apps/api/src/services/scheduler.service.ts +++ b/apps/api/src/services/scheduler.service.ts @@ -5,6 +5,7 @@ import { scanRenewalNotifications } from './notification.service' import { parseDailyCronForTimezoneGate, runDailyTaskAtLocalHour } from './cron-gate.service' import { getAppTimezone } from './settings.service' import { autoRenewDueSubscriptions, reconcileExpiredSubscriptions } from './subscription.service' +import { cleanupOldNotificationDedupSettings } from './channel-notification.service' type NotificationScan = Awaited> @@ -50,6 +51,15 @@ function logReminderScan(result: NotificationScan) { ) } +export async function cleanupNotificationDedupSettingsOncePerDay(now = new Date()) { + return runDailyTaskAtLocalHour('cleanupNotificationDedupSettings', await getAppTimezone(), 3, now, async () => { + const deleted = await cleanupOldNotificationDedupSettings(now) + if (deleted > 0) { + console.log(`[cron] notification dedup cleanup:清理 ${deleted} 条旧记录`) + } + }) +} + export function startSchedulers() { const refreshRatesCronGate = parseDailyCronForTimezoneGate(config.cronRefreshRates) cron.schedule(refreshRatesCronGate?.triggerCron ?? config.cronRefreshRates, async () => { @@ -81,6 +91,7 @@ export function startSchedulers() { try { await autoRenewDueSubscriptions() await reconcileExpiredSubscriptions() + await cleanupNotificationDedupSettingsOncePerDay() const result = await scanRenewalNotifications() logReminderScan(result) } catch (e) { diff --git a/apps/api/tests/unit/notification-dedup-cleanup.test.ts b/apps/api/tests/unit/notification-dedup-cleanup.test.ts new file mode 100644 index 0000000..71eb8f9 --- /dev/null +++ b/apps/api/tests/unit/notification-dedup-cleanup.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const cleanupState = vi.hoisted(() => ({ + settingDeleteManyMock: vi.fn(), + getAppTimezoneMock: vi.fn(), + runDailyTaskAtLocalHourMock: vi.fn() +})) + +vi.mock('../../src/db', () => ({ + prisma: { + setting: { + deleteMany: cleanupState.settingDeleteManyMock + } + } +})) + +vi.mock('../../src/services/settings.service', () => ({ + getAppTimezone: cleanupState.getAppTimezoneMock +})) + +vi.mock('../../src/services/cron-gate.service', () => ({ + parseDailyCronForTimezoneGate: vi.fn(), + runDailyTaskAtLocalHour: cleanupState.runDailyTaskAtLocalHourMock +})) + +vi.mock('node-cron', () => ({ + default: { + schedule: vi.fn() + } +})) + +import { + cleanupOldNotificationDedupSettings, + NOTIFICATION_DEDUP_RETENTION_DAYS +} from '../../src/services/channel-notification.service' +import { cleanupNotificationDedupSettingsOncePerDay } from '../../src/services/scheduler.service' + +describe('notification dedup cleanup', () => { + beforeEach(() => { + cleanupState.settingDeleteManyMock.mockReset() + cleanupState.getAppTimezoneMock.mockReset() + cleanupState.runDailyTaskAtLocalHourMock.mockReset() + cleanupState.settingDeleteManyMock.mockResolvedValue({ count: 2 }) + cleanupState.getAppTimezoneMock.mockResolvedValue('Asia/Shanghai') + cleanupState.runDailyTaskAtLocalHourMock.mockImplementation(async (_key, _timezone, _hour, _now, task) => { + await task() + return true + }) + }) + + it('uses a 30-day default retention window for notification dedup settings', async () => { + const now = new Date('2026-05-01T12:00:00.000Z') + const deleted = await cleanupOldNotificationDedupSettings(now) + + expect(NOTIFICATION_DEDUP_RETENTION_DAYS).toBe(30) + expect(deleted).toBe(2) + expect(cleanupState.settingDeleteManyMock).toHaveBeenCalledWith({ + where: { + key: { + startsWith: 'notification:' + }, + updatedAt: { + lt: new Date('2026-04-01T12:00:00.000Z') + } + } + }) + }) + + it('allows callers to override the retention window', async () => { + const now = new Date('2026-05-01T12:00:00.000Z') + await cleanupOldNotificationDedupSettings(now, 90) + + expect(cleanupState.settingDeleteManyMock).toHaveBeenCalledWith({ + where: { + key: { + startsWith: 'notification:' + }, + updatedAt: { + lt: new Date('2026-01-31T12:00:00.000Z') + } + } + }) + }) + + it('runs the cleanup through a daily local-time gate', async () => { + const now = new Date('2026-05-01T12:00:00.000Z') + await cleanupNotificationDedupSettingsOncePerDay(now) + + expect(cleanupState.runDailyTaskAtLocalHourMock).toHaveBeenCalledWith( + 'cleanupNotificationDedupSettings', + 'Asia/Shanghai', + 3, + now, + expect.any(Function) + ) + expect(cleanupState.settingDeleteManyMock).toHaveBeenCalledTimes(1) + }) +})