143 lines
5.5 KiB
TypeScript
143 lines
5.5 KiB
TypeScript
|
|
// 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<CleanupStats> {
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|