802 lines
29 KiB
TypeScript
802 lines
29 KiB
TypeScript
|
|
// 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<Buffer> {
|
|||
|
|
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<request.Response> {
|
|||
|
|
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<WsClient> {
|
|||
|
|
const ws = new WebSocket(url);
|
|||
|
|
await new Promise<void>((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<ServerMessage> {
|
|||
|
|
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<void> {
|
|||
|
|
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<string> {
|
|||
|
|
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<string, string> };
|
|||
|
|
};
|
|||
|
|
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<NestExpressApplication>(AppCnModule, {
|
|||
|
|
rawBody: true,
|
|||
|
|
});
|
|||
|
|
configureApp(cnApp);
|
|||
|
|
await cnApp.listen(0);
|
|||
|
|
cnBase = await cnApp.getUrl();
|
|||
|
|
|
|||
|
|
ovApp = await NestFactory.create<NestExpressApplication>(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);
|
|||
|
|
});
|
|||
|
|
});
|