feat(sync): 对账清单 ad 实体当前态清扫

Task 8(ads-carousel):cn reconcile 增 entityType='ad' 分支——纯 SELECT
当前态映射(sortOrder/createdAt 确定性排序,albumId 恒空串信封约定),
载荷复用 ads/ad-payload.ts 单一出处(与业务事务内即时事件逐字节同形,
纯函数模块无依赖环);DTO SYNC_ENTITY_TYPES 追加 'ad'(对齐契约 enum)。

随附质量审查两项:
- ads.service list() 行→AdminAdItem 映射复用 toItem(删 ~14 行内联重复,
  ⑮ 补 admin list 200 形状断言覆盖重构路径);
- 新增 test/ads-extensions.spec.ts 守护 AD_IMAGE_EXTENSIONS ===
  UPLOAD_CONTENT_TYPES 派生归一扩展名集合(jpeg→jpg),防三处白名单漂移。

测试基建:reset.ts RESET_TABLES 补 'ads'(Task 6 建表遗漏,固定 id 直种
依赖跨 spec 清场);reconcile.e2e-spec 追加 ⑤(红→绿,5/5)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 00:22:13 +08:00
parent 4979091d2c
commit 60b675a908
8 changed files with 256 additions and 38 deletions
+124
View File
@@ -13,12 +13,21 @@
// 收敛为空;
// ④ 稳态空转:一切一致时 runOnce 无 bootstrap 触发、重放全部判重/判旧
// (applied=0)、outbox 无新增未投递。
// ⑤ (ads-carousel Task 8)cn 对账 'ad' 清扫:直种两行 ads(全局+定向)→
// 签名 POST /internal/sync/reconcile {entityType:'ad'} → 2 条当前态
// wire 事件(albumId 恒空串、载荷=全量快照、确定性 recon:ad:{id}:{v}
// 合成 eventId,sortOrder 升序确定性排序)。
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 {
SYNC_SIGNATURE_HEADER,
SYNC_TIMESTAMP_HEADER,
signSyncBody,
} from '@photo/contracts';
import {
createStorageFromEnv,
type MinioStorageAdapter,
@@ -53,6 +62,19 @@ async function makeJpeg(
.toBuffer();
}
/** reconcile 清单线格式事件(openapi SyncEvent 的断言形状,⑤ 用)。 */
interface ReconcileListedEvent {
eventId: string;
schemaVersion: number;
entityType: string;
entityId: string;
albumId: string;
entityVersion: number;
tombstone: boolean;
priority: string;
payload: Record<string, unknown>;
}
describe('sync reconcile + digest healing (e2e)', () => {
let cnApp: NestExpressApplication;
let ovApp: NestExpressApplication;
@@ -132,6 +154,24 @@ describe('sync reconcile + digest healing (e2e)', () => {
expect(res.status).toBe(200);
}
/**
* 签名 POST 到 cn 内部同步端点(⑤ 直拉 reconcile 清单用;HMAC 原始字节
* 签名同 sync.e2e-spec 的 signedPost 约定——同一 JSON.stringify 产物既作
* 签名输入也作请求体)。
*/
async function signedPost(path: string, body: unknown): Promise<Response> {
const raw = JSON.stringify(body);
return fetch(`${cnBase}${path}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
[SYNC_TIMESTAMP_HEADER]: String(Math.floor(Date.now() / 1000)),
[SYNC_SIGNATURE_HEADER]: signSyncBody(SYNC_KEY, raw),
},
body: raw,
});
}
beforeAll(async () => {
expect(SYNC_KEY).not.toBe('');
await resetDb();
@@ -344,4 +384,88 @@ describe('sync reconcile + digest healing (e2e)', () => {
).toBe(0);
expect(await prismaOv.photos_ov.count({ where: { albumId } })).toBe(2);
});
// ── ads-carousel Task 8:cn 对账 'ad' 清扫(reconcile 清单直拉)──
it('⑤ ad 清扫:两行 ads(全局+定向)→ 2 条当前态事件(albumId 空串/全量载荷)', async () => {
// 直种 cn ads 当前态权威表(reconcile 数据源,不经 CRUD 全链路):一全局
// (albumIds 缺省=SQL NULL)+ 一定向(绑定 beforeAll 相册);sortOrder 拉开
// 以断言清单确定性排序。
const globalAd = await prismaCn.ads.create({
data: {
id: 'recon-ad-global',
imageObjectKey: 'photo-live/dev/ads/recon-ad-global/v1.jpg',
linkUrl: 'https://sponsor.example/global',
sortOrder: 10,
},
});
const targetedAd = await prismaCn.ads.create({
data: {
id: 'recon-ad-targeted',
imageObjectKey: 'photo-live/dev/ads/recon-ad-targeted/v1.png',
linkUrl: 'https://sponsor.example/targeted',
sortOrder: 20,
albumIds: [albumId],
},
});
const res = await signedPost('/v1/internal/sync/reconcile', {
entityType: 'ad',
sinceVersion: 0,
limit: 100,
});
expect(res.status).toBe(200);
const body = (await res.json()) as { events: ReconcileListedEvent[] };
expect(body.events).toHaveLength(2);
// sortOrder 升序(与 ads list 同口径的全量确定性排序)。
expect(body.events.map((e) => e.entityId)).toEqual([
'recon-ad-global',
'recon-ad-targeted',
]);
// 全量 wire 形状(openapi SyncEvent):确定性合成 eventId recon:ad:{id}:{v}
// ——与真实 outbox eventId(cuid)永不冲突,重放幂等锚。
expect(body.events[0]).toEqual({
eventId: 'recon:ad:recon-ad-global:1',
schemaVersion: 1,
entityType: 'ad',
entityId: 'recon-ad-global',
// 广告无相册归属:albumId 恒空串(与 outbox 写入口径一致,设计 §4)。
albumId: '',
entityVersion: 1,
tombstone: false,
priority: 'normal',
payload: {
adId: 'recon-ad-global',
imageObjectKey: 'photo-live/dev/ads/recon-ad-global/v1.jpg',
linkUrl: 'https://sponsor.example/global',
enabled: true,
sortOrder: 10,
albumIds: null,
createdAt: globalAd.createdAt.toISOString(),
entityVersion: 1,
},
});
// 定向广告:绑定关系在载荷 albumIds(信封 albumId 仍空串)。
expect(body.events[1]).toEqual({
eventId: 'recon:ad:recon-ad-targeted:1',
schemaVersion: 1,
entityType: 'ad',
entityId: 'recon-ad-targeted',
albumId: '',
entityVersion: 1,
tombstone: false,
priority: 'normal',
payload: {
adId: 'recon-ad-targeted',
imageObjectKey: 'photo-live/dev/ads/recon-ad-targeted/v1.png',
linkUrl: 'https://sponsor.example/targeted',
enabled: true,
sortOrder: 20,
albumIds: [albumId],
createdAt: targetedAd.createdAt.toISOString(),
entityVersion: 1,
},
});
});
});