// services/api/src/cleanup/cleanup.service.ts —— ov 互动流水清理调度 // (P2 Task 8,评审缺口 2)。清理对象与边界: // // · like_batches:createdAt 早于 LIKE_BATCH_WINDOW_HOURS(24h)——点赞幂等 // 账本。清理即「窗口外重放拒绝点」:行被清后重放同 batchId 会以新批次 // 重新加计(无永久幂等账本与无界增长的取舍,likes.service.ts 头注已 // 登记偏差;sessionId/IP 滑窗上限钳制重放影响); // · visit_requests:createdAt 早于 VISIT_REQUEST_TTL_MINUTES(35min ≥ // 去重窗口 30min)——过期行先于清理即被重放拒绝(counted=false), // 清理只回收空间;TTL ≥ 窗口保证行被清后重放至多以「新 requestId」 // 身份走窗口判定,不会绕过 visit_sessions 去重双计; // · visit_sessions:lastCountedAt 早于 VISIT_WINDOW_MINUTES——去重锚点 // 只需覆盖窗口内状态,窗外行等价「无行」(重访走新窗计入); // · **累计总数永不清理**:album_stats(viewCount)与 photos_ov.likeTotal // 是权威计数,长期保留(Task 9 回传 cn 的数据源)。 // // 调度:setInterval 驱动(CLEANUP_INTERVAL_MINUTES,默认 60,≤0 停用), // 首轮延迟一个间隔(进程刚起无需立即清)。不引入 @nestjs/schedule—— // realtime.gateway 心跳 / sync main 循环同款 setInterval 约定,env 可调 // 间隔用原生定时器最直白;runCleanupOnce 导出为可注入方法,e2e 直接调用 // 断言(不等待真实定时)。窗口阈值惰性读 env(同 visits/likes 服务约定, // 缺省回落契约常量)。 import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit, } from '@nestjs/common'; import { CLEANUP_INTERVAL_MINUTES, LIKE_BATCH_WINDOW_HOURS, VISIT_REQUEST_TTL_MINUTES, VISIT_WINDOW_MINUTES, } from '@photo/contracts'; import { OvPrismaService } from '../prisma-ov/ov-prisma.module'; /** 清理调度间隔分钟(惰性读 env;≤0 = 停用调度,runCleanupOnce 仍可直调)。 */ export function cleanupIntervalMinutes(): number { const parsed = Number.parseInt( process.env.CLEANUP_INTERVAL_MINUTES ?? '', 10, ); return Number.isFinite(parsed) && parsed >= 0 ? parsed : CLEANUP_INTERVAL_MINUTES; } /** 单轮清理观测口径(日志打点;e2e 断言删除行数)。 */ export interface CleanupStats { likeBatches: number; visitRequests: number; visitSessions: number; } /** like_batches 保留窗毫秒(LIKE_BATCH_WINDOW_HOURS,惰性 env)。 */ function likeBatchWindowMs(): number { const parsed = Number.parseInt(process.env.LIKE_BATCH_WINDOW_HOURS ?? '', 10); const hours = Number.isFinite(parsed) && parsed > 0 ? parsed : LIKE_BATCH_WINDOW_HOURS; return hours * 3_600_000; } /** visit_requests 保留窗毫秒(VISIT_REQUEST_TTL_MINUTES,惰性 env)。 */ function visitRequestTtlMs(): number { const parsed = Number.parseInt( process.env.VISIT_REQUEST_TTL_MINUTES ?? '', 10, ); const minutes = Number.isFinite(parsed) && parsed > 0 ? parsed : VISIT_REQUEST_TTL_MINUTES; return minutes * 60_000; } /** visit_sessions 保留窗毫秒(VISIT_WINDOW_MINUTES,惰性 env)。 */ function visitWindowMs(): number { const parsed = Number.parseInt(process.env.VISIT_WINDOW_MINUTES ?? '', 10); const minutes = Number.isFinite(parsed) && parsed > 0 ? parsed : VISIT_WINDOW_MINUTES; return minutes * 60_000; } @Injectable() export class CleanupService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(CleanupService.name); private timer: NodeJS.Timeout | null = null; constructor(private readonly prisma: OvPrismaService) {} onModuleInit(): void { const minutes = cleanupIntervalMinutes(); if (minutes <= 0) { this.logger.log('清理调度停用(CLEANUP_INTERVAL_MINUTES≤0)'); return; } this.timer = setInterval(() => { this.runCleanupOnce().catch((err: unknown) => { this.logger.error( `清理轮失败: ${err instanceof Error ? err.message : String(err)}`, ); }); }, minutes * 60_000); // 不阻塞进程退出(应用关闭经 onModuleDestroy 显式清理定时器)。 this.timer.unref?.(); this.logger.log(`清理调度就绪:每 ${minutes} 分钟一轮`); } onModuleDestroy(): void { if (this.timer !== null) { clearInterval(this.timer); this.timer = null; } } /** 单轮清理:三表超窗口行删除,累计总数(album_stats/photos_ov)不触碰。 */ async runCleanupOnce(): Promise { const now = Date.now(); const [likeBatches, visitRequests, visitSessions] = await Promise.all([ this.prisma.like_batches.deleteMany({ where: { createdAt: { lt: new Date(now - likeBatchWindowMs()) } }, }), this.prisma.visit_requests.deleteMany({ where: { createdAt: { lt: new Date(now - visitRequestTtlMs()) } }, }), this.prisma.visit_sessions.deleteMany({ where: { lastCountedAt: { lt: new Date(now - visitWindowMs()) } }, }), ]); const stats: CleanupStats = { likeBatches: likeBatches.count, visitRequests: visitRequests.count, visitSessions: visitSessions.count, }; this.logger.log( `清理完成:like_batches=${stats.likeBatches} ` + `visit_requests=${stats.visitRequests} ` + `visit_sessions=${stats.visitSessions}`, ); return stats; } }