mirror of
https://github.com/Smile-QWQ/SubTracker.git
synced 2026-06-09 07:22:52 +08:00
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:
@@ -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> = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
98
apps/api/tests/unit/notification-dedup-cleanup.test.ts
Normal file
98
apps/api/tests/unit/notification-dedup-cleanup.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user