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:
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user