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>
This commit is contained in:
浅念
2026-09-18 18:14:01 +08:00
parent 85b2da3db5
commit 1f2a0c658f
14 changed files with 1398 additions and 25 deletions
@@ -0,0 +1,16 @@
// services/api/src/cleanup/ —— ov 互动流水清理模块(P2 Task 8)。仅 ov 入口
// 装配(app-ov.module 显式挑选;cn 入口不感知——cn 侧无互动流水表)。
// CleanupService 同时导出:调度(setInterval,见 cleanup.service.ts 头注)
// 与 runCleanupOnce(e2e 直调断言)。语义边界:仅清 like_batches /
// visit_requests / visit_sessions 超窗口行,累计总数(album_stats、
// photos_ov.likeTotal)永不触碰。
import { Module } from '@nestjs/common';
import { OvPrismaModule } from '../prisma-ov/ov-prisma.module';
import { CleanupService } from './cleanup.service';
@Module({
imports: [OvPrismaModule],
providers: [CleanupService],
exports: [CleanupService],
})
export class CleanupModule {}
+142
View File
@@ -0,0 +1,142 @@
// 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;
}
}