// services/api/test/visits.e2e-spec.ts —— P2 Task 8 浏览统计 + 去重数据清理 // e2e。与 likes-realtime.e2e-spec 同模式:进程内起 cn + ov 两个真实入口, // 业务动作全走 cn API(建相册/上传 complete/直驱发布事务/开关),OutboxSender // (生产实现)指向 ov 入口真实 URL——浏览计数作用在与生产链路同源的数据上。 // // 覆盖(A14 仿真级 + Task 8 全部工作项): // ① A14 计数主干:首访 counted=true viewCount 1 → GET album 读到真实值 // (Task 6 接线验证);窗口内刷新(新 requestId)counted=false 不加计; // 不同 visitorId counted=true 2;visit_requests/visit_sessions/ // album_stats/outbox_stat_events(kind views)行级断言; // ② requestId 幂等重试:重放回显原结局(counted=true 行仍 true / false 行 // 仍 false),viewCount 当前权威,不加计; // ③ 新窗口再计:OvPrisma 手工回退 visit_sessions.lastCountedAt 越窗 → // 新 requestId counted=true 3; // ④ 并发首访:Promise.all 同 visitorId 两个 requestId → 恰一个 // counted=true(唯一约束 + 冲突后重读),总计数只 +1; // ⑤ 过期 requestId 重放:回退 visit_requests.createdAt 越 TTL → // counted=false(无论原结局),不加计; // ⑥ 404/400:幽灵 token / publicEnabled=false 同步后的真实相册 → // ALBUM_NOT_PUBLIC;visitorId 7 字符 / requestId 129 字符 / 缺字段 → // VALIDATION_FAILED; // ⑦ 基础限速:VISIT_WINDOW_REQUESTS 压小(惰性 env)→ 窗口超额 429 // RATE_LIMITED;幂等快速通道不消耗限速预算(重放已确认 requestId 仍 200); // ⑧ 上报排除规则:GET album / 列表 / 单图 / 点赞 / WS 帧(subscribe+ping) // 一律不触碰 visit_* / album_stats(行数与 viewCount 快照不变); // ⑨ WS stats.updated:计入 → 相册房间收 {albumId, viewCount, // countVersion=statVersion};窗口内重复(counted=false)不广播; // ⑩ runCleanupOnce:fabricate 超窗旧行(like_batches 25h / visit_requests // 36min / visit_sessions 31min)→ 全删;窗口内新行全留;累计总数 // (album_stats / photos_ov.likeTotal)分毫不动; // ⑪ 过期批次重放(Task 7 已登记偏差的文档化行为):清理删除 like_batches // 旧行后,重放同 batchId 以「新批次」身份重新加计(batchAccepted=true, // within session cap)——无永久幂等账本的取舍边界,见 likes.service.ts // 头注;此处固化该已登记行为(确定性:回退行 + 直调清理,无时间等待)。 import { NestFactory } from '@nestjs/core'; import { type NestExpressApplication } from '@nestjs/platform-express'; import request from 'supertest'; import sharp from 'sharp'; import WebSocket from 'ws'; import Redis from 'ioredis'; import { PrismaClient } from '@photo/db-cn'; import { PrismaClient as OvPrismaClient } from '@photo/db-ov'; import { type ClientMessage, type ServerMessage, ErrorCodes, REALTIME_WS_PATH, } from '@photo/contracts'; import { createStorageFromEnv, type MinioStorageAdapter, } from '@photo/storage'; import { AppCnModule } from '../src/app-cn.module'; import { AppOvModule } from '../src/app-ov.module'; import { CleanupService } from '../src/cleanup/cleanup.service'; import { seedAdmin } from '../src/scripts/seed-admin'; import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter'; import { configureApp } from '../src/shared/configure-app'; import { PublishService } from '../../worker/src/publish.service'; import { resetDb } from './reset'; import { resetOvDb } from './reset-ov'; import { OutboxSender } from '../../sync/src/sender'; jest.setTimeout(180_000); const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me'; const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me'; /** subscribe 为异步校验(DB 查询):发送后的就绪等待(本地查询 ~ms 级)。 */ const SUBSCRIBE_SETTLE_MS = 300; /** 「无新增广播」断言的观察窗(远大于任何提交路径时延)。 */ const QUIET_MS = 1000; /** 测试内生成真实 JPEG(同 likes-realtime.e2e-spec;无二进制入库)。 */ async function makeJpeg( width: number, height: number, rgb: { r: number; g: number; b: number }, ): Promise { return sharp({ create: { width, height, channels: 3, background: rgb } }) .jpeg({ quality: 85 }) .toBuffer(); } /** 浏览上报 POST(返回 supertest Response,断言交给调用方;body 宽松以覆盖 400 用例)。 */ async function visit( ovBase: string, token: string, body: { visitorId: string; requestId?: string }, ): Promise { return request(ovBase) .post(`/v1/public/albums/${token}/visits`) .send(body); } /** 契约帧 WS 测试客户端:收帧队列 + 条件等待(同 likes-realtime spec)。 */ class WsClient { private readonly ws: WebSocket; private readonly frames: ServerMessage[] = []; private readonly waiters: Array<{ predicate: (frame: ServerMessage) => boolean; resolve: (frame: ServerMessage) => void; reject: (err: Error) => void; timer: NodeJS.Timeout; }> = []; private constructor(ws: WebSocket) { this.ws = ws; this.ws.on('message', (data) => { let frame: ServerMessage; try { frame = JSON.parse(String(data)) as ServerMessage; } catch { return; } this.frames.push(frame); const index = this.waiters.findIndex((w) => w.predicate(frame)); if (index >= 0) { const [waiter] = this.waiters.splice(index, 1); clearTimeout(waiter.timer); waiter.resolve(frame); } }); } static async open(url: string): Promise { const ws = new WebSocket(url); await new Promise((resolve, reject) => { ws.once('open', () => resolve()); ws.once('error', (err: Error) => reject(err)); }); return new WsClient(ws); } send(frame: ClientMessage): void { this.ws.send(JSON.stringify(frame)); } /** 等待首条满足谓词的帧(含历史帧;超时拒绝)。 */ waitFor( predicate: (frame: ServerMessage) => boolean, timeoutMs: number, label: string, ): Promise { const existing = this.frames.find(predicate); if (existing !== undefined) { return Promise.resolve(existing); } return new Promise((resolve, reject) => { const timer = setTimeout(() => { const index = this.waiters.findIndex((w) => w.reject === reject); if (index >= 0) { this.waiters.splice(index, 1); } reject( new Error( `waitFor(${label}) 超时 ${timeoutMs}ms;已收帧: ${JSON.stringify(this.frames)}`, ), ); }, timeoutMs); this.waiters.push({ predicate, resolve, reject, timer }); }); } /** 按类型统计(「不广播」断言用)。 */ countOf(type: ServerMessage['type']): number { return this.frames.filter((f) => f.type === type).length; } close(): void { this.ws.close(); } } describe('visits + cleanup (e2e)', () => { let cnApp: NestExpressApplication; let ovApp: NestExpressApplication; let cnBase: string; let ovBase: string; let prismaCn: PrismaClient; let prismaOv: OvPrismaClient; let storage: MinioStorageAdapter; let sender: OutboxSender; let adminToken: string; let tokenA: string; let tokenB: string; let albumAId: string; let photoA1: string; /** 排空发送器:循环 runOnce 直至无未投递事件。 */ async function drainSender(): Promise { for (let round = 0; round < 20; round++) { const stats = await sender.runOnce(); if (stats.picked === 0) { return; } } throw new Error('drainSender 超时:20 轮后 outbox 仍有未投递事件'); } /** cn 建相册 → 返回 {albumId, publicToken}。 */ async function createAlbum(title: string): Promise<{ albumId: string; publicToken: string; }> { const album = await request(cnBase) .post('/v1/albums') .set('Authorization', `Bearer ${adminToken}`) .send({ translations: [{ locale: 'zh-CN', title }], defaultLocale: 'zh-CN', startAt: '2026-10-01T10:00:00.000Z', }); expect(album.status).toBe(201); return { albumId: album.body.id as string, publicToken: album.body.publicToken as string, }; } /** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */ async function uploadAndPublish( albumId: string, clientTaskId: string, jpeg: Buffer, ): Promise { const init = await request(cnBase) .post('/v1/uploads') .set('Authorization', `Bearer ${adminToken}`) .send({ albumId, clientTaskId, filename: 'visits.jpg', bytes: jpeg.length, contentType: 'image/jpeg', }); expect(init.status).toBe(201); const { uploadId, photoId, credential } = init.body as { uploadId: string; photoId: string; credential: { url: string; method: string; headers: Record }; }; const put = await fetch(credential.url, { method: credential.method, headers: credential.headers, body: new Uint8Array(jpeg), }); expect(put.ok).toBe(true); const done = await request(cnBase) .post(`/v1/uploads/${uploadId}/complete`) .set('Authorization', `Bearer ${adminToken}`); expect(done.status).toBe(200); expect(await new PublishService(prismaCn, storage).processPhoto(photoId)).toBe( 'published', ); return photoId; } /** ov 入口 WS 基址(同端口 HTTP+WS)。 */ function wsUrl(): string { return ovBase.replace(/^http/, 'ws') + REALTIME_WS_PATH; } beforeAll(async () => { expect(SYNC_KEY).not.toBe(''); await resetDb(); await resetOvDb(); // 限速固定窗键跨 spec 运行残留(TTL=去重窗口 30min):清场保证本 spec // 从零计数(同 likes spec 的 like:window:* 清场约定)。 const redis = new Redis(process.env.REDIS_URL_OV ?? 'redis://127.0.0.1:6381'); let cursor = '0'; do { const [next, keys] = await redis.scan( cursor, 'MATCH', 'visit:window:*', 'COUNT', 1000, ); cursor = next; if (keys.length > 0) { await redis.del(...keys); } } while (cursor !== '0'); await redis.quit(); cnApp = await NestFactory.create(AppCnModule, { rawBody: true, }); configureApp(cnApp); await cnApp.listen(0); cnBase = await cnApp.getUrl(); ovApp = await NestFactory.create(AppOvModule, { rawBody: true, }); // main-ov 同形:契约帧适配器(WS 与 HTTP 同端口)。 ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp)); configureApp(ovApp); await ovApp.listen(0); ovBase = await ovApp.getUrl(); prismaCn = new PrismaClient(); prismaOv = new OvPrismaClient(); storage = createStorageFromEnv(); sender = new OutboxSender({ prisma: prismaCn, ovApiUrl: ovBase, internalKey: SYNC_KEY, }); await seedAdmin(prismaCn, ADMIN_ACCOUNT, ADMIN_PASSWORD); const login = await request(cnBase).post('/v1/auth/password').send({ account: ADMIN_ACCOUNT, password: ADMIN_PASSWORD, }); expect(login.status).toBe(200); adminToken = login.body.sessionToken as string; // —— 种数据:相册 A(浏览主目标 + 1 张照片供排除规则/过期批次用例)、 // 相册 B(publicEnabled=false → ov 同步后不可见,404 用例)—— const albumA = await createAlbum('浏览统计 A'); tokenA = albumA.publicToken; albumAId = albumA.albumId; const albumB = await createAlbum('浏览统计 B(未公开)'); tokenB = albumB.publicToken; photoA1 = await uploadAndPublish( albumAId, 'visits-a1', await makeJpeg(900, 600, { r: 200, g: 100, b: 80 }), ); const off = await request(cnBase) .patch(`/v1/albums/${albumB.albumId}`) .set('Authorization', `Bearer ${adminToken}`) .send({ publicEnabled: false }); expect(off.status).toBe(200); await drainSender(); }); afterAll(async () => { delete process.env.VISIT_WINDOW_REQUESTS; await ovApp.close(); await cnApp.close(); await prismaCn.$disconnect(); await prismaOv.$disconnect(); }); it('① A14 计数主干:首访计入、窗口内去重、跨访客计入、GET album 真实 viewCount', async () => { // 首访:counted=true,viewCount 1。 const first = await visit(ovBase, tokenA, { visitorId: 'visitor-a14-1', requestId: 'req-a14-first', }); expect(first.status).toBe(200); expect(first.body).toEqual({ viewCount: 1, counted: true }); // Task 6 接线:GET 公开相册读到 album_stats 真实值(非占位)。 const album1 = await request(ovBase) .get(`/v1/public/albums/${tokenA}`) .query({ locale: 'zh-CN' }); expect(album1.status).toBe(200); expect(album1.body.viewCount).toBe(1); // 窗口内刷新(同 visitor 新 requestId):counted=false,不加计。 const refresh = await visit(ovBase, tokenA, { visitorId: 'visitor-a14-1', requestId: 'req-a14-refresh', }); expect(refresh.status).toBe(200); expect(refresh.body).toEqual({ viewCount: 1, counted: false }); // 不同 visitorId:新访次计入。 const second = await visit(ovBase, tokenA, { visitorId: 'visitor-a14-2', requestId: 'req-a14-second', }); expect(second.status).toBe(200); expect(second.body).toEqual({ viewCount: 2, counted: true }); const album2 = await request(ovBase) .get(`/v1/public/albums/${tokenA}`) .query({ locale: 'zh-CN' }); expect(album2.body.viewCount).toBe(2); // 行级断言:幂等行(counted 回映各自结局)、去重锚点、权威位、回传队列。 const requests = await prismaOv.visit_requests.findMany({ where: { albumId: albumAId }, orderBy: { createdAt: 'asc' }, }); expect(requests.map((r) => [r.requestId, r.counted])).toEqual([ ['req-a14-first', true], ['req-a14-refresh', false], ['req-a14-second', true], ]); expect(await prismaOv.visit_sessions.count()).toBe(2); const stats = await prismaOv.album_stats.findUniqueOrThrow({ where: { albumId: albumAId }, }); expect([stats.viewCount, stats.statVersion]).toEqual([2, 2]); const outbox = await prismaOv.outbox_stat_events.findMany({ where: { kind: 'views', albumId: albumAId }, orderBy: { statVersion: 'asc' }, }); expect(outbox.map((r) => [r.total, r.statVersion])).toEqual([ [1, 1], [2, 2], ]); }); it('② requestId 幂等重试:回显原结局、viewCount 权威、不加计', async () => { // 重放已计入的 requestId:counted 仍 true(确认丢失重试的闭环语义)。 const retryCounted = await visit(ovBase, tokenA, { visitorId: 'visitor-a14-1', requestId: 'req-a14-first', }); expect(retryCounted.status).toBe(200); expect(retryCounted.body).toEqual({ viewCount: 2, counted: true }); // 重放未计入(窗口内重复)的 requestId:counted 仍 false。 const retrySkipped = await visit(ovBase, tokenA, { visitorId: 'visitor-a14-1', requestId: 'req-a14-refresh', }); expect(retrySkipped.body).toEqual({ viewCount: 2, counted: false }); // 不加计:幂等行不增、权威位不动。 expect(await prismaOv.visit_requests.count()).toBe(3); const stats = await prismaOv.album_stats.findUniqueOrThrow({ where: { albumId: albumAId }, }); expect([stats.viewCount, stats.statVersion]).toEqual([2, 2]); }); it('③ 新窗口再计:回退 lastCountedAt 越窗 → 同 visitor 再计入', async () => { // 手工回退 visitor1 的去重锚点至窗口(30min)外。 await prismaOv.visit_sessions.updateMany({ where: { albumId: albumAId, visitorId: 'visitor-a14-1' }, data: { lastCountedAt: new Date(Date.now() - 32 * 60_000) }, }); const again = await visit(ovBase, tokenA, { visitorId: 'visitor-a14-1', requestId: 'req-a14-newwindow', }); expect(again.status).toBe(200); expect(again.body).toEqual({ viewCount: 3, counted: true }); // 锚点被重新置为当前时刻(窗口重新起算)。 const session = await prismaOv.visit_sessions.findUniqueOrThrow({ where: { albumId_visitorId: { albumId: albumAId, visitorId: 'visitor-a14-1' }, }, }); expect( Date.now() - session.lastCountedAt!.getTime(), ).toBeLessThan(60_000); }); it('④ 并发首访:同 visitorId 两 requestId → 恰一个 counted=true', async () => { const [r1, r2] = await Promise.all([ visit(ovBase, tokenA, { visitorId: 'visitor-conc-1', requestId: 'req-conc-1', }), visit(ovBase, tokenA, { visitorId: 'visitor-conc-1', requestId: 'req-conc-2', }), ]); expect(r1.status).toBe(200); expect(r2.status).toBe(200); const outcomes = [r1.body, r2.body].sort( (a: { counted: boolean }, b: { counted: boolean }) => Number(b.counted) - Number(a.counted), ); // 恰一个计入(权威 viewCount=4),另一个窗口内重复(权威值同为 4)。 expect(outcomes).toEqual([ { viewCount: 4, counted: true }, { viewCount: 4, counted: false }, ]); // 去重锚点唯一:并发只落一行 session;权威位只 +1。 expect( await prismaOv.visit_sessions.count({ where: { visitorId: 'visitor-conc-1' }, }), ).toBe(1); const stats = await prismaOv.album_stats.findUniqueOrThrow({ where: { albumId: albumAId }, }); expect([stats.viewCount, stats.statVersion]).toEqual([4, 4]); }); it('⑤ 过期 requestId 重放:越 TTL → counted=false(无论原结局),不加计', async () => { // 回退首个已计入 requestId 的行龄至 TTL(35min)外。 await prismaOv.visit_requests.update({ where: { requestId: 'req-a14-first' }, data: { createdAt: new Date(Date.now() - 36 * 60_000) }, }); const replay = await visit(ovBase, tokenA, { visitorId: 'visitor-a14-1', requestId: 'req-a14-first', }); expect(replay.status).toBe(200); expect(replay.body).toEqual({ viewCount: 4, counted: false }); const stats = await prismaOv.album_stats.findUniqueOrThrow({ where: { albumId: albumAId }, }); expect(stats.viewCount).toBe(4); }); it('⑥ 404/400:幽灵 token / 未公开相册 / 参数越界', async () => { const ghost = await visit(ovBase, 'g' + '0'.repeat(23), { visitorId: 'visitor-a14-1', requestId: 'req-404-ghost', }); expect(ghost.status).toBe(404); expect(ghost.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC); // 真实相册 publicEnabled=false(cn 已同步):同码 404。 const invisible = await visit(ovBase, tokenB, { visitorId: 'visitor-a14-1', requestId: 'req-404-invisible', }); expect(invisible.status).toBe(404); expect(invisible.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC); for (const bad of [ { visitorId: 'v'.repeat(7), requestId: 'req-bad-1' }, // visitorId < 8 { visitorId: 'visitor-a14-1', requestId: 'r'.repeat(129) }, // requestId > 128 { visitorId: 'visitor-a14-1' }, // 缺 requestId ]) { const res = await visit(ovBase, tokenA, bad); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); } }); it('⑦ 基础限速:visitorId+IP 固定窗超额 429;幂等快速通道不耗预算', async () => { process.env.VISIT_WINDOW_REQUESTS = '3'; try { const visitor = 'visitor-rate-1'; // 窗口预算 3:n1 计入(viewCount 5)、n2/n3 窗口内重复(仍消耗预算)。 const n1 = await visit(ovBase, tokenA, { visitorId: visitor, requestId: 'req-rate-1', }); expect(n1.body).toEqual({ viewCount: 5, counted: true }); const n2 = await visit(ovBase, tokenA, { visitorId: visitor, requestId: 'req-rate-2', }); expect(n2.body).toEqual({ viewCount: 5, counted: false }); const n3 = await visit(ovBase, tokenA, { visitorId: visitor, requestId: 'req-rate-3', }); expect(n3.body).toEqual({ viewCount: 5, counted: false }); // 第 4 次:超额 → 429 RATE_LIMITED。 const n4 = await visit(ovBase, tokenA, { visitorId: visitor, requestId: 'req-rate-4', }); expect(n4.status).toBe(429); expect(n4.body.code).toBe(ErrorCodes.RATE_LIMITED); // 幂等快速通道:重放已确认 requestId 不进限速(客户端重试是正常路径)。 const retry = await visit(ovBase, tokenA, { visitorId: visitor, requestId: 'req-rate-1', }); expect(retry.status).toBe(200); expect(retry.body).toEqual({ viewCount: 5, counted: true }); // 被拒请求未入账:visit_requests 无 req-rate-4 行。 expect( await prismaOv.visit_requests.count({ where: { requestId: 'req-rate-4' }, }), ).toBe(0); } finally { delete process.env.VISIT_WINDOW_REQUESTS; } }); it('⑧ 上报排除规则:读接口/点赞/WS 帧一律不触碰浏览数据', async () => { const before = { requests: await prismaOv.visit_requests.count(), sessions: await prismaOv.visit_sessions.count(), statsRows: await prismaOv.album_stats.count(), viewCount: ( await prismaOv.album_stats.findUniqueOrThrow({ where: { albumId: albumAId }, }) ).viewCount, }; // 公开读三端点。 expect( ( await request(ovBase) .get(`/v1/public/albums/${tokenA}`) .query({ locale: 'zh-CN' }) ).status, ).toBe(200); expect( ( await request(ovBase).get(`/v1/public/albums/${tokenA}/photos`) ).status, ).toBe(200); expect( ( await request(ovBase) .get(`/v1/public/albums/${tokenA}/photos/${photoA1}`) ).status, ).toBe(200); // 点赞(含照片计数写入——但与浏览表无关)。 const like = await request(ovBase) .post(`/v1/public/albums/${tokenA}/photos/${photoA1}/likes`) .send({ sessionId: 'excl-session', batchId: 'excl-1', delta: 1 }); expect(like.status).toBe(200); // WS 帧:subscribe(相册校验读)+ ping/pong。 const client = await WsClient.open(wsUrl()); try { client.send({ type: 'subscribe', albumToken: tokenA }); await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS)); client.send({ type: 'ping', ts: Date.now() }); await client.waitFor((f) => f.type === 'pong', 3000, 'pong'); } finally { client.close(); } // 浏览域零变更:行数与权威 viewCount 均不变。 expect(await prismaOv.visit_requests.count()).toBe(before.requests); expect(await prismaOv.visit_sessions.count()).toBe(before.sessions); expect(await prismaOv.album_stats.count()).toBe(before.statsRows); expect( ( await prismaOv.album_stats.findUniqueOrThrow({ where: { albumId: albumAId }, }) ).viewCount, ).toBe(before.viewCount); }); it('⑨ WS stats.updated:计入即广播(countVersion=statVersion);重复不广播', async () => { const client = await WsClient.open(wsUrl()); try { client.send({ type: 'subscribe', albumToken: tokenA }); await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS)); // 新访客计入 → 房间收 stats.updated(viewCount 6 / countVersion 6)。 const counted = await visit(ovBase, tokenA, { visitorId: 'visitor-ws-1', requestId: 'req-ws-1', }); expect(counted.body).toEqual({ viewCount: 6, counted: true }); const frame = await client.waitFor( (f) => f.type === 'stats.updated', 5000, 'stats.updated', ); if (frame.type !== 'stats.updated') { throw new Error('unreachable'); } expect(frame.albumId).toBe(albumAId); expect(frame.viewCount).toBe(6); expect(frame.countVersion).toBe(6); // 窗口内重复(counted=false):不广播。 const skip = await visit(ovBase, tokenA, { visitorId: 'visitor-ws-1', requestId: 'req-ws-2', }); expect(skip.body).toEqual({ viewCount: 6, counted: false }); await new Promise((resolve) => setTimeout(resolve, QUIET_MS)); expect(client.countOf('stats.updated')).toBe(1); } finally { client.close(); } }); it('⑩ runCleanupOnce:超窗行全删、窗口内行全留、累计总数不动', async () => { // fabricate 三类超窗旧行(直接落库,无时间等待)。 await prismaOv.like_batches.create({ data: { photoId: photoA1, sessionId: 'cleanup-old-session', batchId: 'cleanup-old-batch', delta: 5, createdAt: new Date(Date.now() - 25 * 3_600_000), }, }); await prismaOv.visit_requests.create({ data: { requestId: 'cleanup-old-req', albumId: albumAId, visitorId: 'cleanup-old-visitor', createdAt: new Date(Date.now() - 36 * 60_000), }, }); await prismaOv.visit_sessions.create({ data: { albumId: albumAId, visitorId: 'cleanup-old-visitor', lastCountedAt: new Date(Date.now() - 31 * 60_000), }, }); // 清理前基线(新鲜行数 + 两个权威总数)。 const requestsBefore = await prismaOv.visit_requests.count(); const sessionsBefore = await prismaOv.visit_sessions.count(); const batchesBefore = await prismaOv.like_batches.count(); const likeTotalBefore = ( await prismaOv.photos_ov.findUniqueOrThrow({ where: { photoId: photoA1 } }) ).likeTotal; const stats = await ovApp.get(CleanupService).runCleanupOnce(); // 删除量:like_batches 1 行旧批;visit_requests 2 行(fabricate 旧行 + // ⑤ 回退的 req-a14-first,行龄 36min > TTL 35min);visit_sessions 1 行。 expect(stats.likeBatches).toBe(1); expect(stats.visitRequests).toBe(2); expect(stats.visitSessions).toBe(1); // 旧行确实消失。 expect( await prismaOv.like_batches.count({ where: { batchId: 'cleanup-old-batch' }, }), ).toBe(0); expect( await prismaOv.visit_requests.count({ where: { requestId: 'cleanup-old-req' }, }), ).toBe(0); expect( await prismaOv.visit_requests.count({ where: { requestId: 'req-a14-first' }, }), ).toBe(0); expect( await prismaOv.visit_sessions.count({ where: { visitorId: 'cleanup-old-visitor' }, }), ).toBe(0); // 窗口内新行全留(各减去被删的旧行数)。 expect(await prismaOv.visit_requests.count()).toBe(requestsBefore - 2); expect(await prismaOv.visit_sessions.count()).toBe(sessionsBefore - 1); expect(await prismaOv.like_batches.count()).toBe(batchesBefore - 1); // 累计总数分毫不动:album_stats(viewCount/statVersion)与 photos_ov。 const albumStats = await prismaOv.album_stats.findUniqueOrThrow({ where: { albumId: albumAId }, }); expect([albumStats.viewCount, albumStats.statVersion]).toEqual([6, 6]); expect( ( await prismaOv.photos_ov.findUniqueOrThrow({ where: { photoId: photoA1 }, }) ).likeTotal, ).toBe(likeTotalBefore); }); it('⑪ 过期批次重放(已登记偏差):清理后重放旧 batchId 重新加计', async () => { // 提交一批 → 回退行龄越窗 → 清理删除 → 重放同 batchId。 const first = await request(ovBase) .post(`/v1/public/albums/${tokenA}/photos/${photoA1}/likes`) .send({ sessionId: 'dev-old-session', batchId: 'dev-old-batch', delta: 2 }); expect(first.status).toBe(200); const totalBefore = first.body.total as number; await prismaOv.like_batches.updateMany({ where: { batchId: 'dev-old-batch' }, data: { createdAt: new Date(Date.now() - 25 * 3_600_000) }, }); const cleanup = await ovApp.get(CleanupService).runCleanupOnce(); expect(cleanup.likeBatches).toBe(1); // 已登记偏差(likes.service.ts 头注):行被清后与「从未提交」不可区分, // 重放以新批次身份重新加计(batchAccepted=true)——文档化行为固化; // session 滑窗上限仍钳制其总量(delta 累计远低于默认 500 上限)。 const replay = await request(ovBase) .post(`/v1/public/albums/${tokenA}/photos/${photoA1}/likes`) .send({ sessionId: 'dev-old-session', batchId: 'dev-old-batch', delta: 2 }); expect(replay.status).toBe(200); expect(replay.body.batchAccepted).toBe(true); expect(replay.body.total).toBe(totalBefore + 2); expect( ( await prismaOv.photos_ov.findUniqueOrThrow({ where: { photoId: photoA1 }, }) ).likeTotal, ).toBe(totalBefore + 2); }); });