fix: clean old notification dedup records

- keep notification idempotency records for 90 days
- clean only Setting rows with the notification: prefix
- run cleanup through a once-per-day local-time gate instead of every scan
- add unit coverage for the retention cutoff and daily gate
This commit is contained in:
SmileQWQ
2026-05-01 19:48:57 +08:00
parent f950c011c8
commit 7a7cc54f1b
3 changed files with 133 additions and 1 deletions

View File

@@ -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<NotificationChannelResult['channel'], string> = {

View File

@@ -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<ReturnType<typeof scanRenewalNotifications>>
@@ -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) {

View File

@@ -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)
})
})