1f2a0c658f
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>
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;
|
||
}
|
||
}
|