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