From 60b675a90813540337d3046de6f005488c5e575c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 29 Sep 2026 00:22:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(sync):=20=E5=AF=B9=E8=B4=A6=E6=B8=85?= =?UTF-8?q?=E5=8D=95=20ad=20=E5=AE=9E=E4=BD=93=E5=BD=93=E5=89=8D=E6=80=81?= =?UTF-8?q?=E6=B8=85=E6=89=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/plans/2026-09-28-广告位轮播-实施计划.md | 40 +++--- services/api/src/ads/ads.service.ts | 26 ++-- services/api/src/sync/dto.ts | 3 +- services/api/src/sync/sync.service.ts | 28 +++++ services/api/test/ads-extensions.spec.ts | 41 ++++++ services/api/test/ads.e2e-spec.ts | 29 +++++ services/api/test/reset.ts | 3 + services/sync/test/reconcile.e2e-spec.ts | 124 +++++++++++++++++++ 8 files changed, 256 insertions(+), 38 deletions(-) create mode 100644 services/api/test/ads-extensions.spec.ts diff --git a/docs/plans/2026-09-28-广告位轮播-实施计划.md b/docs/plans/2026-09-28-广告位轮播-实施计划.md index 0f01bf5..d462ca7 100644 --- a/docs/plans/2026-09-28-广告位轮播-实施计划.md +++ b/docs/plans/2026-09-28-广告位轮播-实施计划.md @@ -272,7 +272,7 @@ export function adObjectKey(adId: string, version: number, ext: string): string **Files:** Modify `services/api/src/sync-inbox/dto.ts`、`services/api/src/sync-inbox/sync-inbox.service.ts`、`services/sync/src/sender.ts`、`services/api/src/sync/payloads.ts`;Test `services/api/test/embed.e2e-spec.ts` 邻新建 `ads.e2e-spec.ts`(本任务先建同步断言骨架,Task 7 补 CRUD 全路径) -- [ ] **Step 1: 写失败测试** —— 新建 `services/api/test/ads.e2e-spec.ts`,骨架对照 embed.e2e-spec.ts(AppCnModule + ov PrismaClient + drainSender helper 复制其实现): +- [x] **Step 1: 写失败测试** —— 新建 `services/api/test/ads.e2e-spec.ts`,骨架对照 embed.e2e-spec.ts(AppCnModule + ov PrismaClient + drainSender helper 复制其实现): ```ts // 广告位 e2e(2026-09-28 设计 §8):cn CRUD → outbox → drainSender 推送 → // ads_ov 投影/版本账本断言;public-ov 端到端断言在 public-ov.e2e-spec 补。 @@ -285,8 +285,8 @@ export function adObjectKey(adId: string, version: number, ext: string): string 3. 再推 `ad.updated` v3(enabled=true)→ 行复活(同 adId upsert); 4. `ad.deleted` 墓碑 v4 → 行删除; 5. 旧版本重放(v2 迟到于 v3 后)→ 行保持 v3(版本单调不被回滚)。 -- [ ] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e`,预期 FAIL(entityType 'ad' 被 DTO `@IsIn` 拒 / 无分支不落行)。 -- [ ] **Step 3: 实现**: +- [x] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e`,预期 FAIL(entityType 'ad' 被 DTO `@IsIn` 拒 / 无分支不落行)。 +- [x] **Step 3: 实现**: ```ts // ① sync-inbox/dto.ts:SYNC_ENTITY_TYPES 数组追加 'ad' // ②【关键,缺此步全链路不通】decideAndApply ③依赖检查豁免(~L173-185): @@ -341,21 +341,21 @@ export function adObjectKey(adId: string, version: number, ext: string): string // sync/payloads.ts:对照 SlotStateUpdatedPayload 导出 AdUpdatedPayload 的 // ov 侧类型再导出(或直接 import @photo/contracts——跟随该文件现有导入风格)。 ``` -- [ ] **Step 4: 跑测试确认通过** —— Step 2 命令,5 用例全 PASS。 -- [ ] **Step 5: Commit** —— `feat(sync): ov inbox ad 分支与 sender adId 推导(disabled/墓碑=删投影行)` +- [x] **Step 4: 跑测试确认通过** —— Step 2 命令,5 用例全 PASS。 +- [x] **Step 5: Commit** —— `feat(sync): ov inbox ad 分支与 sender adId 推导(disabled/墓碑=删投影行)` ### Task 6: cn 服务端——上传初始化(ads module 上半) **Files:** Create `services/api/src/ads/{ads.module.ts,ads.controller.ts,ads.service.ts,dto.ts}`;Modify `services/api/src/app-cn.module.ts`;Test `services/api/test/ads.e2e-spec.ts`(追加段) -- [ ] **Step 1: 写失败测试**(ads.e2e-spec.ts 追加): +- [x] **Step 1: 写失败测试**(ads.e2e-spec.ts 追加): ```ts // uploads init:admin 201 返回 staging 键+credential(URL 可真实 PUT,对照 // uploads.e2e 的 presign 断言写法);非 admin 403;匿名 401;bytes>2MB 400; // contentType 非白名单 400;坏扩展名 400。 ``` -- [ ] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e`(404 路由不存在)。 -- [ ] **Step 3: 实现**: +- [x] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e`(404 路由不存在)。 +- [x] **Step 3: 实现**: ```ts // dto.ts:AdUploadInitRequestDto(filename/bytes/contentType @IsIn 白名单)、 // AdCreateRequestDto、AdUpdateRequestDto——字段与 openapi Task 2 一一对应; @@ -400,14 +400,14 @@ async initUpload(user: AuthUser, dto: AdUploadInitRequestDto) { return { objectKey, credential }; } ``` -- [ ] **Step 4: 跑测试确认通过**。 -- [ ] **Step 5: Commit** —— `feat(api): /admin/ads/uploads 广告图 staging 预签名(2MB/白名单/admin-only)` +- [x] **Step 4: 跑测试确认通过**。 +- [x] **Step 5: Commit** —— `feat(api): /admin/ads/uploads 广告图 staging 预签名(2MB/白名单/admin-only)` ### Task 7: cn 服务端——CRUD + outbox + audit **Files:** Modify `services/api/src/ads/ads.service.ts`;Test `services/api/test/ads.e2e-spec.ts` -- [ ] **Step 1: 写失败测试**(追加;真实 MinIO PUT 仿 uploads.e2e): +- [x] **Step 1: 写失败测试**(追加;真实 MinIO PUT 仿 uploads.e2e): ```ts // ① 创建:上传真图(getBytes 生成最小 JPEG 或复用 uploads.e2e 的测试字节)→ // POST /admin/ads → 201:行就位(albumIds 空归一 null)、outbox 行 @@ -425,8 +425,8 @@ async initUpload(user: AuthUser, dto: AdUploadInitRequestDto) { // ⑦ 同步贯通(衔接 Task 5 骨架改真路径):创建→drainSender→ads_ov 行就位; // 停用→drainSender→行删;删除→drainSender→行删。 ``` -- [ ] **Step 2: 跑测试确认失败**。 -- [ ] **Step 3: 实现** ads.service.ts: +- [x] **Step 2: 跑测试确认失败**。 +- [x] **Step 3: 实现** ads.service.ts: ```ts // 核心片段(完整对照 embed.service.ts 的事务/audit/payload 构造风格): private buildPayload(ad: AdRow): AdUpdatedPayload { @@ -458,16 +458,16 @@ private buildPayload(ad: AdRow): AdUpdatedPayload { // list:requireAdmin → findMany orderBy [{sortOrder:'asc'},{createdAt:'asc'}] // → Promise.all map imageUrl = storage.presignGet(imageObjectKey, signedUrlTtl()) ``` -- [ ] **Step 4: 跑测试确认通过**(ads.e2e 全量)。 -- [ ] **Step 5: Commit** —— `feat(api): 广告位 CRUD——outbox ad.* 事件/audit/停用高优先级/版本化换图` +- [x] **Step 4: 跑测试确认通过**(ads.e2e 全量)。 +- [x] **Step 5: Commit** —— `feat(api): 广告位 CRUD——outbox ad.* 事件/audit/停用高优先级/版本化换图` ### Task 8: cn 对账 'ad' 清扫 **Files:** Modify `services/api/src/sync/dto.ts`(SYNC_ENTITY_TYPES)、`services/api/src/sync/sync.service.ts`;Test `services/sync/test/reconcile.e2e-spec.ts`(追加段) -- [ ] **Step 1: 写失败测试** —— reconcile.e2e-spec 追加:seed 两行 ads(一全局一定向)→ 以 `?entityType=ad&since=0` 拉对账清单 → 断言 2 条 wire 事件(entityType 'ad'、albumId ''、载荷全量、确定性 eventId 前缀)。 -- [ ] **Step 2: 跑测试确认失败** —— `pnpm --filter sync run test`(或该包 e2e 脚本名,对照 services/sync/package.json)。 -- [ ] **Step 3: 实现**: +- [x] **Step 1: 写失败测试** —— reconcile.e2e-spec 追加:seed 两行 ads(一全局一定向)→ 以 `?entityType=ad&since=0` 拉对账清单 → 断言 2 条 wire 事件(entityType 'ad'、albumId ''、载荷全量、确定性 eventId 前缀)。 +- [x] **Step 2: 跑测试确认失败** —— `pnpm --filter sync run test`(或该包 e2e 脚本名,对照 services/sync/package.json)。 +- [x] **Step 3: 实现**: ```ts // sync/dto.ts:SYNC_ENTITY_TYPES 追加 'ad' // sync.service.ts reconcile(asset 分支之后): @@ -483,8 +483,8 @@ private buildPayload(ad: AdRow): AdUpdatedPayload { } ``` 注意:payload builder 若与 ads.service 重复,提取到 `services/api/src/ads/ad-payload.ts` 共享(DRY,两处单一出处)。 -- [ ] **Step 4: 跑测试确认通过** + `pnpm --filter api run test:e2e -- embed`(既有对账回归不破)。 -- [ ] **Step 5: Commit** —— `feat(sync): 对账清单 ad 实体当前态清扫` +- [x] **Step 4: 跑测试确认通过** + `pnpm --filter api run test:e2e -- embed`(既有对账回归不破)。 +- [x] **Step 5: Commit** —— `feat(sync): 对账清单 ad 实体当前态清扫` ### Task 9: ov 公开端点 diff --git a/services/api/src/ads/ads.service.ts b/services/api/src/ads/ads.service.ts index d981670..06714a1 100644 --- a/services/api/src/ads/ads.service.ts +++ b/services/api/src/ads/ads.service.ts @@ -68,8 +68,12 @@ export type AdUploadInitResponse = components['schemas']['AdUploadInitResponse'] export type AdminAdItem = components['schemas']['AdminAdItem']; export type AdminAdListResponse = components['schemas']['AdminAdListResponse']; -/** staging/终键扩展名合法集(init 扩展名白名单的归一值域)。 */ -const AD_IMAGE_EXTENSIONS = new Set(['jpg', 'png', 'heic']); +/** + * staging/终键扩展名合法集(init 扩展名白名单的归一值域)。导出供 + * test/ads-extensions.spec.ts 与上传域白名单做 1:1 一致性守护(三处同源: + * UPLOAD_CONTENT_TYPES → EXTENSION_WHITELIST 归一值 → 本集合)。 + */ +export const AD_IMAGE_EXTENSIONS = new Set(['jpg', 'png', 'heic']); /** * updateAd 审计 metadata.fields 的字段名序列(契约 AdUpdateRequest 属性序, @@ -166,21 +170,9 @@ export class AdsService { const rows = await this.prisma.ads.findMany({ orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], }); - const ttl = signedUrlTtlSeconds(); - const items = await Promise.all( - rows.map(async (row) => ({ - id: row.id, - title: row.title, - imageObjectKey: row.imageObjectKey, - imageUrl: await this.storage.presignGet(row.imageObjectKey, ttl), - linkUrl: row.linkUrl, - enabled: row.enabled, - sortOrder: row.sortOrder, - albumIds: (row.albumIds as string[] | null) ?? null, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - })), - ); + // 行→AdminAdItem 映射复用 toItem(loadItem 同一出口;TTL 惰性读取在 + // toItem 内逐行进行——与 create/update 的单品响应口径一致)。 + const items = await Promise.all(rows.map((row) => this.toItem(row))); return { items }; } diff --git a/services/api/src/sync/dto.ts b/services/api/src/sync/dto.ts index 2f4d187..2fcb80a 100644 --- a/services/api/src/sync/dto.ts +++ b/services/api/src/sync/dto.ts @@ -18,13 +18,14 @@ import { ValidateNested, } from 'class-validator'; -/** 契约 SyncEntityType(slot-state 预留——与 openapi enum 同步)。 */ +/** 契约 SyncEntityType(slot-state 预留;ad 自 ads-carousel Task 8 落地——与 openapi enum 同步)。 */ export const SYNC_ENTITY_TYPES = [ 'album', 'category', 'photo', 'asset', 'slot-state', + 'ad', ] as const; /** 契约 BootstrapRequest:albumId 缺省 = 全部相册。 */ diff --git a/services/api/src/sync/sync.service.ts b/services/api/src/sync/sync.service.ts index 31c1d6f..14dd4c3 100644 --- a/services/api/src/sync/sync.service.ts +++ b/services/api/src/sync/sync.service.ts @@ -30,6 +30,11 @@ import { Injectable } from '@nestjs/common'; import type { components } from '@photo/contracts'; import type { Prisma } from '@photo/db-cn'; import { PrismaService } from '../prisma/prisma.service'; +// ads 域载荷构建器(ads-carousel Task 8):纯函数模块(仅依赖 @photo/contracts +// 与 @photo/db-cn 类型),与 ads.service 业务事务内即时事件共用同一出处—— +// 「对账快照事件」与「即时事件」载荷逐字节同形(ad-payload.ts 头注约定), +// 无模块环(对照 ./payloads 的既有跨域消费方式)。 +import { buildAdUpdatedPayload } from '../ads/ad-payload'; import type { StatSnapshotDto } from './dto'; import { type AlbumCategoryInput, @@ -424,6 +429,29 @@ export class SyncService { ), ); } + } else if (query.entityType === 'ad') { + // 广告当前态清扫(ads-carousel Task 8):全量拉取,sortOrder/createdAt + // 确定性排序(与 ads list 同口径;运营量级 <20,不分页)。版本/limit 过滤 + // 不适用:ov 侧按 eventId 幂等 + 版本单调自行判重(reconciler 语义,见 + // reconciler.ts 阶段一说明)。广告无相册归属:albumId 恒空串(与 outbox + // 写入口径一致,设计 §4);无墓碑——enabled=false 的全量快照由消费侧按 + // 「删投影行」处理(Task 5 语义),硬删行不再出现在清单(category 同一 + // 已知边界)。 + const rows = await this.prisma.ads.findMany({ + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + }); + events.push( + ...rows.map((row) => + this.toWireEvent( + 'ad', + row.id, + '', + row.entityVersion, + false, + buildAdUpdatedPayload(row), + ), + ), + ); } // slot-state 预留:恒空清单。 return { events }; diff --git a/services/api/test/ads-extensions.spec.ts b/services/api/test/ads-extensions.spec.ts new file mode 100644 index 0000000..c2d8cdf --- /dev/null +++ b/services/api/test/ads-extensions.spec.ts @@ -0,0 +1,41 @@ +// services/api/test/ads-extensions.spec.ts —— 广告图扩展名白名单一致性守护 +// (ads-carousel Task 7 质量审查遗留项,随 Task 8 提交)。三处白名单必须恒等: +// · uploads/support.ts UPLOAD_CONTENT_TYPES(契约 contentType 白名单); +// · uploads/support.ts EXTENSION_WHITELIST(模块私有:filename→归一扩展名); +// · ads/ads.service.ts AD_IMAGE_EXTENSIONS(staging/终键扩展名合法集)。 +// 漂移场景(守护标的):上传域新增 contentType(如 image/webp)而广告域未跟 +// 进——init 会放行 .webp staging 键(extOfFilename 走共享白名单),但 +// verifyStaging 的 AD_IMAGE_EXTENSIONS 核验会拒绝,创建/换图在链路中段断裂。 +// 纯函数 spec(无 DB/网络):从导出的 UPLOAD_CONTENT_TYPES 派生归一化扩展名 +// 集合(jpeg→jpg,同 EXTENSION_WHITELIST 归一关系),断言与 AD_IMAGE_EXTENSIONS +// 恒等;EXTENSION_WHITELIST 不可导出,经 extOfFilename 行为面覆盖(每个合法 +// 扩展名归一通过、jpeg 双扩展名归一 jpg、白名单外拒绝)。 +import { AD_IMAGE_EXTENSIONS } from '../src/ads/ads.service'; +import { UPLOAD_CONTENT_TYPES, extOfFilename } from '../src/uploads/support'; + +/** UPLOAD_CONTENT_TYPES → 归一化扩展名集合(jpeg→jpg;子类型段即扩展名)。 */ +function derivedExtensions(): Set { + return new Set( + UPLOAD_CONTENT_TYPES.map((contentType) => { + const sub = contentType.split('/')[1] ?? ''; + return sub === 'jpeg' ? 'jpg' : sub; + }), + ); +} + +describe('AD_IMAGE_EXTENSIONS 与上传域白名单一致性(守护断言)', () => { + it('AD_IMAGE_EXTENSIONS === 从 UPLOAD_CONTENT_TYPES 派生的归一化扩展名集合', () => { + expect(AD_IMAGE_EXTENSIONS).toEqual(derivedExtensions()); + }); + + it('每个合法扩展名经 extOfFilename 归一通过(staging 键后缀恒落在合法集内)', () => { + for (const ext of derivedExtensions()) { + expect(extOfFilename(`banner.${ext}`)).toBe(ext); + } + // jpeg 双扩展名归一 jpg(EXTENSION_WHITELIST 映射,大小写不敏感)。 + expect(extOfFilename('banner.JPEG')).toBe('jpg'); + // 白名单外/无扩展名 → null(init 与 verifyStaging 均拒绝)。 + expect(extOfFilename('banner.gif')).toBeNull(); + expect(extOfFilename('banner')).toBeNull(); + }); +}); diff --git a/services/api/test/ads.e2e-spec.ts b/services/api/test/ads.e2e-spec.ts index 1f4b31a..826173f 100644 --- a/services/api/test/ads.e2e-spec.ts +++ b/services/api/test/ads.e2e-spec.ts @@ -835,6 +835,35 @@ describe('ads domain (e2e)', () => { imageContentType: 'image/jpeg', }; + // admin list 200(list() 复用 toItem 的映射出口,⑨-⑭ 之后存留 ⑩ 的三行: + // targeted/emptyArr/nullIds,均 sortOrder=0、createdAt 升序):形状精确到 + // 契约 AdminAdItem 全字段键集,albumIds null/数组双形态经 toItem 归一。 + const adminList = await request(cnBase) + .get('/v1/admin/ads') + .set('Authorization', `Bearer ${adminToken}`); + expect(adminList.status).toBe(200); + expect(adminList.body.items).toHaveLength(3); + expect(adminList.body.items[0]).toMatchObject({ + imageObjectKey: expect.stringMatching( + /^photo-live\/dev\/ads\/c[0-9a-z]+\/v1\.(jpg|png)$/, + ), + linkUrl: 'https://sponsor.example/landing', + enabled: true, + imageUrl: expect.any(String), + }); + expect(Object.keys(adminList.body.items[0]).sort()).toEqual([ + 'albumIds', + 'createdAt', + 'enabled', + 'id', + 'imageObjectKey', + 'imageUrl', + 'linkUrl', + 'sortOrder', + 'title', + 'updatedAt', + ]); + const staffList = await request(cnBase) .get('/v1/admin/ads') .set('Authorization', `Bearer ${staffToken}`); diff --git a/services/api/test/reset.ts b/services/api/test/reset.ts index 80166e5..fdb9d1e 100644 --- a/services/api/test/reset.ts +++ b/services/api/test/reset.ts @@ -24,6 +24,9 @@ export const RESET_TABLES: readonly string[] = [ // 照片与上传域 + outbox(Task 10):均为扁平字符串外键(无 FK 约束), // 顺序按逻辑依赖排列(事件/作业/变体/会话先于 photos)。 'outbox_events', + // 广告位域(ads-carousel Task 6):扁平表无 FK;e2e 直种固定 id 行 + + // reconcile 清单计数断言依赖跨 spec 清场(此前 cuid id 未见残留冲突)。 + 'ads', // 展示位域(P4b Task 2):授权成员表先于展示位(建而未用,P5 激活)。 'embed_slot_members', 'embed_slots', diff --git a/services/sync/test/reconcile.e2e-spec.ts b/services/sync/test/reconcile.e2e-spec.ts index 8965ea5..b04af5c 100644 --- a/services/sync/test/reconcile.e2e-spec.ts +++ b/services/sync/test/reconcile.e2e-spec.ts @@ -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; +} + 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 { + 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, + }, + }); + }); });