Files
photo-live/services/api/src/cleanup/cleanup.service.ts
T
浅念 1f2a0c658f feat(ov): windowed dedup visit counting with retention cleanup
Task 8 (P2): POST /public/albums/{token}/visits with visit_sessions
window dedup (VISIT_WINDOW_MINUTES), requestId idempotent replay
(echoes stored outcome; expired beyond VISIT_REQUEST_TTL_MINUTES
rejected), album_stats viewCount/statVersion authority + outbox_stat_events
views snapshots, WS stats.updated broadcast, visitorId+IP fixed-window
rate limit (VISIT_WINDOW_REQUESTS), and ov CleanupModule scheduler
(setInterval, CLEANUP_INTERVAL_MINUTES) deleting like_batches /
visit_requests / visit_sessions past their windows — totals never
touched. Contract prose aligned to replay semantics; constants
VISIT_WINDOW_REQUESTS / CLEANUP_INTERVAL_MINUTES added.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 18:14:01 +08:00

143 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}