Files
photo-live/services/sync/test/stat-return.e2e-spec.ts
T
浅念 955bea49b5 feat(sync): stat snapshot return path with version replacement
P2 Task 9 统计回传(ov→cn):
- cn 迁移:albums 增 viewCountReplica/statsVersion/statsSyncedAt、
  photos 增 statsVersion(like_count 已有副本位;无 VARCHAR 新列不涉校对手决)
- cn 接收端 POST /v1/internal/sync/stat-snapshots(InternalSyncGuard):
  整批单事务,逐快照 updateMany WHERE statsVersion < statVersion —— 仅更高
  statVersion 以权威 total 整体替换副本(绝不相加),重复/乱序幂等忽略;
  accepted 只数实际应用条数;views 应用同时推进 statsSyncedAt(A27 展示)
- ov 回传发送器 services/sync/stat-return.ts:聚合 outbox_stat_events 未发送
  行按(kind, photoId|albumId)取 MAX(statVersion) 折叠突发 → HMAC 推送 →
  200 后按计数器标记截至该版本全部源行 sentAt;失败留待下轮重发;main.ts
  第三循环 STAT_RETURN_INTERVAL_MS(默认 60s,env 可调)
- 契约 AlbumSummary + statsSyncedAt(string|null readOnly,+gen);listAlbums/
  getAlbum 经 toSummaryResponse 暴露(管理 UI 列留 P4a)
- e2e(services/sync stat-return.e2e-spec,7 例):逐次点赞回传替换式收敛
  (2→5→10 非累加)、浏览窗口去重回传 + statsSyncedAt、重复回传 accepted=0
  值不动(A27 不重复累计)、乱序高版本替换(total 可下调)+ 低版本忽略、
  发送端折叠只发最高版本且被折叠行随批确认、管理列表 likeCount/statsSyncedAt
  展示、新端点 401/400 守卫

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 18:45:43 +08:00

474 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// services/sync/test/stat-return.e2e-spec.ts —— P2 Task 9 统计回传(ov→cn)
// E2E。复用 sync.e2e-spec.ts 的进程内双入口 harness(cn/ov 真实 HTTP + 真实
// 双 MySQL + MinIO + HMAC 全链路字节一致),被测对象为生产 StatReturnSender
// (runReturnOnce 直驱,指向 cn 入口真实 URL)+ cn 侧 stat-snapshots 接收端。
//
// 覆盖(A27 仿真级 + Task 9 全部工作项):
// ① 点赞逐次回传:3 次点赞、每次回传一轮 → cn likeCount 逐次被**权威快照
// 替换**(2→5→10),绝非增量累加(累加口径会是 2+5+10=17);statsVersion
// 随 statVersion 推进;源行全部确认;
// ② 浏览回传:首访 counted + 窗口内刷新不计 → 恰 1 条 views 快照 → cn
// viewCountReplica=1、statsSyncedAt 落位(A27 展示同步时间);
// ③ 重复回传幂等(A27 不重复累计):清 sentAt 模拟「cn 已应用、ov 未收到
// 200」重发 → 聚合折叠后同版本全判旧 accepted=0,cn 值分毫不动;
// ④ 乱序快照:接收端直推更高 statVersion 但更低 total(版本权威,可表达
// 下调修正)→ 替换生效;更低 statVersion 旧快照 → accepted=0 忽略;
// 发送端聚合:同相册 v5/v3 两行未发送 → 只发 v5、两行(含被折叠的 v3)
// 全部确认;
// ⑤ cn 管理展示:listAlbumPhotos.likeCount = 副本现值;listAlbums 条目
// statsSyncedAt 非空(A27 同步时间展示,P4a 前仅 API 暴露);
// ⑥ 新端点守卫/校验:缺签名 401 SYNC_AUTH_FAILED;likes 缺 photoId →
// 400 VALIDATION_FAILED(契约「likes 时 photoId 必填」)。
import { NestFactory } from '@nestjs/core';
import { type NestExpressApplication } from '@nestjs/platform-express';
import request from 'supertest';
import sharp from 'sharp';
import { PrismaClient } from '@photo/db-cn';
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
import { ErrorCodes, signSyncBody } from '@photo/contracts';
import {
createStorageFromEnv,
type MinioStorageAdapter,
} from '@photo/storage';
import { AppCnModule } from '../../api/src/app-cn.module';
import { AppOvModule } from '../../api/src/app-ov.module';
import { seedAdmin } from '../../api/src/scripts/seed-admin';
import { configureApp } from '../../api/src/shared/configure-app';
// AppOvModule 含 realtime 网关:进程内 ov 入口须与生产 main-ov 一致安装契约
// WS 适配器(同 sync.e2e-spec 约定)。
import { ContractWsAdapter } from '../../api/src/realtime/contract-ws.adapter';
import { PublishService } from '../../worker/src/publish.service';
import { resetDb } from '../../api/test/reset';
import { resetOvDb } from '../../api/test/reset-ov';
import { OutboxSender } from '../src/sender';
import { StatReturnSender } from '../src/stat-return';
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';
const STAT_SNAPSHOTS_PATH = '/v1/internal/sync/stat-snapshots';
/** 签名 POST(直驱 cn 接收端构造乱序/越权场景;可省签名头)。 */
async function signedPost(
base: string,
path: string,
body: unknown,
omitSignature = false,
): Promise<Response> {
const raw = JSON.stringify(body);
const headers: Record<string, string> = {
'content-type': 'application/json',
'x-sync-timestamp': String(Math.floor(Date.now() / 1000)),
};
if (!omitSignature) {
headers['x-sync-signature'] = signSyncBody(SYNC_KEY, raw);
}
return fetch(`${base}${path}`, { method: 'POST', headers, body: raw });
}
/** 测试内生成真实 JPEG(同 sync.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();
}
/** 线格式快照(直推场景手工构造)。 */
interface WireSnapshot {
photoId?: string;
albumId: string;
kind: 'likes' | 'views';
total: number;
statVersion: number;
source: string;
confirmedAt: string;
}
describe('stat return ov→cn (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 statReturn: StatReturnSender;
let adminToken: string;
let albumId: string;
let publicToken: string;
let photoId: string;
/** 排空内容发送器(真实管线把相册/照片同步到 ov 副本)。 */
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 仍有未投递事件');
}
/** ov 公开点赞(真实入口,批次幂等键每调用唯一)。 */
async function like(
sessionId: string,
batchId: string,
delta: number,
): Promise<{ total: number; countVersion: number }> {
const res = await request(ovBase)
.post(`/v1/public/albums/${publicToken}/photos/${photoId}/likes`)
.send({ sessionId, batchId, delta });
expect(res.status).toBe(200);
const body = res.body as {
total: number;
countVersion: number;
batchAccepted: boolean;
};
expect(body.batchAccepted).toBe(true);
// 只取断言口径字段(响应含 batchAccepted,直接 toEqual 整体会因多属性失败)。
return { total: body.total, countVersion: body.countVersion };
}
/** ov 公开浏览上报(真实入口)。 */
async function visit(
visitorId: string,
requestId: string,
): Promise<{ viewCount: number; counted: boolean }> {
const res = await request(ovBase)
.post(`/v1/public/albums/${publicToken}/visits`)
.send({ visitorId, requestId });
expect(res.status).toBe(200);
return res.body as { viewCount: number; counted: boolean };
}
/** cn 照片副本现值断言口径。 */
async function cnPhotoStats(): Promise<{
likeCount: number;
statsVersion: number;
}> {
const row = await prismaCn.photos.findUniqueOrThrow({
where: { id: photoId },
select: { likeCount: true, statsVersion: true },
});
return row;
}
/** cn 相册统计副本现值断言口径。 */
async function cnAlbumStats(): Promise<{
viewCountReplica: number;
statsVersion: number;
statsSyncedAt: Date | null;
}> {
const row = await prismaCn.albums.findUniqueOrThrow({
where: { id: albumId },
select: {
viewCountReplica: true,
statsVersion: true,
statsSyncedAt: true,
},
});
return row;
}
beforeAll(async () => {
expect(SYNC_KEY).not.toBe('');
await resetDb();
await resetOvDb();
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,
});
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,
});
statReturn = new StatReturnSender({
prismaOv,
cnApiUrl: cnBase,
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;
// 建相册 + 上传发布一张照片,经真实同步管线到 ov 副本。
const album = await request(cnBase)
.post('/v1/albums')
.set('Authorization', `Bearer ${adminToken}`)
.send({
translations: [
{ locale: 'zh-CN', title: '统计回传晚会' },
{ locale: 'en', title: 'Stat Return Gala' },
],
defaultLocale: 'zh-CN',
startAt: '2026-10-01T10:00:00.000Z',
});
expect(album.status).toBe(201);
albumId = album.body.id as string;
publicToken = album.body.publicToken as string;
const jpeg = await makeJpeg(960, 640, { r: 90, g: 160, b: 90 });
const init = await request(cnBase)
.post('/v1/uploads')
.set('Authorization', `Bearer ${adminToken}`)
.send({
albumId,
clientTaskId: 'stat-return-a',
filename: 'stat-return.jpg',
bytes: jpeg.length,
contentType: 'image/jpeg',
});
expect(init.status).toBe(201);
const { uploadId, credential } = init.body as {
uploadId: string;
credential: { url: string; method: string; headers: Record<string, string> };
};
photoId = (init.body as { photoId: string }).photoId;
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');
await drainSender();
expect(
await prismaOv.photos_ov.findUniqueOrThrow({
where: { photoId },
select: { visibility: true },
}),
).toMatchObject({ visibility: 'published' });
});
afterAll(async () => {
await cnApp.close();
await ovApp.close();
await prismaCn.$disconnect();
await prismaOv.$disconnect();
});
it('① 逐次点赞回传:cn likeCount 被权威快照替换(2→5→10),绝非累加', async () => {
// 三次点赞、每次独立回传一轮——多轮回传是「替换而非累加」的判别性场景
// (单轮回传时 total==Σdelta 不可判别;累加口径最终会到 2+5+10=17)。
const first = await like('stat-ret-s1', 'stat-ret-b1', 2);
expect(first).toEqual({ total: 2, countVersion: 1 });
let ret = await statReturn.runReturnOnce();
expect(ret).toEqual({ picked: 1, snapshots: 1, accepted: 1 });
expect(await cnPhotoStats()).toEqual({ likeCount: 2, statsVersion: 1 });
const second = await like('stat-ret-s2', 'stat-ret-b2', 3);
expect(second).toEqual({ total: 5, countVersion: 2 });
ret = await statReturn.runReturnOnce();
expect(ret).toEqual({ picked: 1, snapshots: 1, accepted: 1 });
expect(await cnPhotoStats()).toEqual({ likeCount: 5, statsVersion: 2 });
const third = await like('stat-ret-s3', 'stat-ret-b3', 5);
expect(third).toEqual({ total: 10, countVersion: 3 });
ret = await statReturn.runReturnOnce();
expect(ret).toEqual({ picked: 1, snapshots: 1, accepted: 1 });
// 最终一致 = ov 权威 total(10),不是快照之和(2+5+10)也不是 delta 之和
// 的二次累加——A27「绝不相加」。
expect(await cnPhotoStats()).toEqual({ likeCount: 10, statsVersion: 3 });
// 源行全部确认(sentAt 非空)。
expect(
await prismaOv.outbox_stat_events.count({ where: { sentAt: null } }),
).toBe(0);
expect(
await prismaOv.outbox_stat_events.count({ where: { kind: 'likes' } }),
).toBe(3);
});
it('② 浏览回传:窗口去重后恰 1 条 views 快照 → viewCountReplica/statsSyncedAt', async () => {
const first = await visit('stat-ret-visitor-1', 'stat-ret-req-1');
expect(first).toEqual({ viewCount: 1, counted: true });
// 窗口内刷新(新 requestId、同 visitor):不计入,也不产生新快照行。
const refresh = await visit('stat-ret-visitor-1', 'stat-ret-req-2');
expect(refresh).toEqual({ viewCount: 1, counted: false });
expect(
await prismaOv.outbox_stat_events.count({ where: { sentAt: null } }),
).toBe(1);
const ret = await statReturn.runReturnOnce();
expect(ret).toEqual({ picked: 1, snapshots: 1, accepted: 1 });
const album = await cnAlbumStats();
expect(album.viewCountReplica).toBe(1);
expect(album.statsVersion).toBe(1);
// A27:展示同步时间——首份 views 快照应用即落位。
expect(album.statsSyncedAt).not.toBeNull();
});
it('③ 重复回传幂等:重发已应用快照 accepted=0,cn 值分毫不动(A27)', async () => {
// 模拟「cn 已应用、ov 未收到 200」:清 sentAt 让回传器重发同一批。
await prismaOv.outbox_stat_events.updateMany({ data: { sentAt: null } });
const ret = await statReturn.runReturnOnce();
// 4 源行(3 likes + 1 views)折叠为 2 快照;cn 侧同版本全判旧。
expect(ret).toEqual({ picked: 4, snapshots: 2, accepted: 0 });
expect(await cnPhotoStats()).toEqual({ likeCount: 10, statsVersion: 3 });
const album = await cnAlbumStats();
expect(album.viewCountReplica).toBe(1);
expect(album.statsVersion).toBe(1);
expect(album.statsSyncedAt).not.toBeNull();
// 重发确认照常落库(200 = 受理,应用与判旧都是终态)。
expect(
await prismaOv.outbox_stat_events.count({ where: { sentAt: null } }),
).toBe(0);
});
it('④ 乱序快照:接收端仅更高 statVersion 替换(版本权威,total 可下调)', async () => {
// 更高版本 + 更低 total:替换生效——证明权威是 statVersion 而非 total
// (未来下调修正可表达),也再次否定「max(total) 口径」。
const higher = await signedPost(cnBase, STAT_SNAPSHOTS_PATH, {
snapshots: [
{
photoId,
albumId,
kind: 'likes',
total: 7,
statVersion: 50,
source: 'ov',
confirmedAt: new Date().toISOString(),
},
],
});
expect(higher.status).toBe(200);
expect(await higher.json()).toEqual({ accepted: 1 });
expect(await cnPhotoStats()).toEqual({ likeCount: 7, statsVersion: 50 });
// 更低版本旧快照(无论 total 多大):忽略。
const lower = await signedPost(cnBase, STAT_SNAPSHOTS_PATH, {
snapshots: [
{
photoId,
albumId,
kind: 'likes',
total: 999,
statVersion: 30,
source: 'ov',
confirmedAt: new Date().toISOString(),
},
],
});
expect(lower.status).toBe(200);
expect(await lower.json()).toEqual({ accepted: 0 });
expect(await cnPhotoStats()).toEqual({ likeCount: 7, statsVersion: 50 });
});
it('④b 发送端聚合:同相册 v5/v3 未发送行 → 只发 v5,两行全确认', async () => {
// 手工构造乱序源行:v5 先插、v3 后插(createdAt 更晚)——聚合必须按
// statVersion 取 max,不受插入顺序影响;v3 属「被更高版本覆盖」行,
// 随批确认(重发永无语义)。
await prismaOv.outbox_stat_events.create({
data: { kind: 'views', albumId, total: 42, statVersion: 5 },
});
await prismaOv.outbox_stat_events.create({
data: { kind: 'views', albumId, total: 99, statVersion: 3 },
});
const ret = await statReturn.runReturnOnce();
expect(ret).toEqual({ picked: 2, snapshots: 1, accepted: 1 });
const album = await cnAlbumStats();
expect(album.viewCountReplica).toBe(42);
expect(album.statsVersion).toBe(5);
expect(
await prismaOv.outbox_stat_events.count({ where: { sentAt: null } }),
).toBe(0);
});
it('⑤ cn 管理展示:listAlbumPhotos.likeCount=副本现值;listAlbums.statsSyncedAt', async () => {
const photos = await request(cnBase)
.get(`/v1/albums/${albumId}/photos`)
.set('Authorization', `Bearer ${adminToken}`);
expect(photos.status).toBe(200);
const item = (
photos.body.items as Array<{ photoId: string; likeCount: number }>
).find((p) => p.photoId === photoId);
expect(item?.likeCount).toBe(7);
const albums = await request(cnBase)
.get('/v1/albums')
.set('Authorization', `Bearer ${adminToken}`);
expect(albums.status).toBe(200);
const summary = (
albums.body.items as Array<{
id: string;
statsSyncedAt: string | null;
}>
).find((a) => a.id === albumId);
expect(summary).toBeDefined();
expect(summary?.statsSyncedAt).not.toBeNull();
expect(Date.parse(summary?.statsSyncedAt as string)).not.toBeNaN();
});
it('⑥ 新端点守卫/校验:缺签名 401 SYNC_AUTH_FAILED;likes 缺 photoId 400', async () => {
const snapshot: WireSnapshot = {
albumId,
kind: 'views',
total: 1,
statVersion: 1,
source: 'ov',
confirmedAt: new Date().toISOString(),
};
const unauth = await signedPost(
cnBase,
STAT_SNAPSHOTS_PATH,
{ snapshots: [snapshot] },
true,
);
expect(unauth.status).toBe(401);
expect(((await unauth.json()) as { code: string }).code).toBe(
ErrorCodes.SYNC_AUTH_FAILED,
);
const malformed = await signedPost(cnBase, STAT_SNAPSHOTS_PATH, {
snapshots: [
{ albumId, kind: 'likes', total: 1, statVersion: 1, source: 'ov', confirmedAt: new Date().toISOString() },
],
});
expect(malformed.status).toBe(400);
expect(((await malformed.json()) as { code: string }).code).toBe(
ErrorCodes.VALIDATION_FAILED,
);
});
});