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
@@ -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 全路径) **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 ```ts
// 广告位 e2e(2026-09-28 设计 §8):cn CRUD → outbox → drainSender 推送 → // 广告位 e2e(2026-09-28 设计 §8):cn CRUD → outbox → drainSender 推送 →
// ads_ov 投影/版本账本断言;public-ov 端到端断言在 public-ov.e2e-spec 补。 // 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); 3. 再推 `ad.updated` v3(enabled=true)→ 行复活(同 adId upsert);
4. `ad.deleted` 墓碑 v4 → 行删除; 4. `ad.deleted` 墓碑 v4 → 行删除;
5. 旧版本重放(v2 迟到于 v3 后)→ 行保持 v3(版本单调不被回滚)。 5. 旧版本重放(v2 迟到于 v3 后)→ 行保持 v3(版本单调不被回滚)。
- [ ] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e`,预期 FAIL(entityType 'ad' 被 DTO `@IsIn` 拒 / 无分支不落行)。 - [x] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e`,预期 FAIL(entityType 'ad' 被 DTO `@IsIn` 拒 / 无分支不落行)。
- [ ] **Step 3: 实现**: - [x] **Step 3: 实现**:
```ts ```ts
// ① sync-inbox/dto.ts:SYNC_ENTITY_TYPES 数组追加 'ad' // ① sync-inbox/dto.ts:SYNC_ENTITY_TYPES 数组追加 'ad'
// ②【关键,缺此步全链路不通】decideAndApply ③依赖检查豁免(~L173-185): // ②【关键,缺此步全链路不通】decideAndApply ③依赖检查豁免(~L173-185):
@@ -341,21 +341,21 @@ export function adObjectKey(adId: string, version: number, ext: string): string
// sync/payloads.ts:对照 SlotStateUpdatedPayload 导出 AdUpdatedPayload 的 // sync/payloads.ts:对照 SlotStateUpdatedPayload 导出 AdUpdatedPayload 的
// ov 侧类型再导出(或直接 import @photo/contracts——跟随该文件现有导入风格)。 // ov 侧类型再导出(或直接 import @photo/contracts——跟随该文件现有导入风格)。
``` ```
- [ ] **Step 4: 跑测试确认通过** —— Step 2 命令,5 用例全 PASS。 - [x] **Step 4: 跑测试确认通过** —— Step 2 命令,5 用例全 PASS。
- [ ] **Step 5: Commit** —— `feat(sync): ov inbox ad 分支与 sender adId 推导(disabled/墓碑=删投影行)` - [x] **Step 5: Commit** —— `feat(sync): ov inbox ad 分支与 sender adId 推导(disabled/墓碑=删投影行)`
### Task 6: cn 服务端——上传初始化(ads module 上半) ### 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`(追加段) **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 ```ts
// uploads init:admin 201 返回 staging 键+credential(URL 可真实 PUT,对照 // uploads init:admin 201 返回 staging 键+credential(URL 可真实 PUT,对照
// uploads.e2e 的 presign 断言写法);非 admin 403;匿名 401;bytes>2MB 400; // uploads.e2e 的 presign 断言写法);非 admin 403;匿名 401;bytes>2MB 400;
// contentType 非白名单 400;坏扩展名 400。 // contentType 非白名单 400;坏扩展名 400。
``` ```
- [ ] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e`(404 路由不存在)。 - [x] **Step 2: 跑测试确认失败** —— `pnpm --filter api run test:e2e -- ads.e2e`(404 路由不存在)。
- [ ] **Step 3: 实现**: - [x] **Step 3: 实现**:
```ts ```ts
// dto.ts:AdUploadInitRequestDto(filename/bytes/contentType @IsIn 白名单)、 // dto.ts:AdUploadInitRequestDto(filename/bytes/contentType @IsIn 白名单)、
// AdCreateRequestDto、AdUpdateRequestDto——字段与 openapi Task 2 一一对应; // AdCreateRequestDto、AdUpdateRequestDto——字段与 openapi Task 2 一一对应;
@@ -400,14 +400,14 @@ async initUpload(user: AuthUser, dto: AdUploadInitRequestDto) {
return { objectKey, credential }; return { objectKey, credential };
} }
``` ```
- [ ] **Step 4: 跑测试确认通过**。 - [x] **Step 4: 跑测试确认通过**。
- [ ] **Step 5: Commit** —— `feat(api): /admin/ads/uploads 广告图 staging 预签名(2MB/白名单/admin-only)` - [x] **Step 5: Commit** —— `feat(api): /admin/ads/uploads 广告图 staging 预签名(2MB/白名单/admin-only)`
### Task 7: cn 服务端——CRUD + outbox + audit ### Task 7: cn 服务端——CRUD + outbox + audit
**Files:** Modify `services/api/src/ads/ads.service.ts`;Test `services/api/test/ads.e2e-spec.ts` **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 ```ts
// ① 创建:上传真图(getBytes 生成最小 JPEG 或复用 uploads.e2e 的测试字节)→ // ① 创建:上传真图(getBytes 生成最小 JPEG 或复用 uploads.e2e 的测试字节)→
// POST /admin/ads → 201:行就位(albumIds 空归一 null)、outbox 行 // POST /admin/ads → 201:行就位(albumIds 空归一 null)、outbox 行
@@ -425,8 +425,8 @@ async initUpload(user: AuthUser, dto: AdUploadInitRequestDto) {
// ⑦ 同步贯通(衔接 Task 5 骨架改真路径):创建→drainSender→ads_ov 行就位; // ⑦ 同步贯通(衔接 Task 5 骨架改真路径):创建→drainSender→ads_ov 行就位;
// 停用→drainSender→行删;删除→drainSender→行删。 // 停用→drainSender→行删;删除→drainSender→行删。
``` ```
- [ ] **Step 2: 跑测试确认失败**。 - [x] **Step 2: 跑测试确认失败**。
- [ ] **Step 3: 实现** ads.service.ts: - [x] **Step 3: 实现** ads.service.ts:
```ts ```ts
// 核心片段(完整对照 embed.service.ts 的事务/audit/payload 构造风格): // 核心片段(完整对照 embed.service.ts 的事务/audit/payload 构造风格):
private buildPayload(ad: AdRow): AdUpdatedPayload { private buildPayload(ad: AdRow): AdUpdatedPayload {
@@ -458,16 +458,16 @@ private buildPayload(ad: AdRow): AdUpdatedPayload {
// list:requireAdmin → findMany orderBy [{sortOrder:'asc'},{createdAt:'asc'}] // list:requireAdmin → findMany orderBy [{sortOrder:'asc'},{createdAt:'asc'}]
// → Promise.all map imageUrl = storage.presignGet(imageObjectKey, signedUrlTtl()) // → Promise.all map imageUrl = storage.presignGet(imageObjectKey, signedUrlTtl())
``` ```
- [ ] **Step 4: 跑测试确认通过**(ads.e2e 全量)。 - [x] **Step 4: 跑测试确认通过**(ads.e2e 全量)。
- [ ] **Step 5: Commit** —— `feat(api): 广告位 CRUD——outbox ad.* 事件/audit/停用高优先级/版本化换图` - [x] **Step 5: Commit** —— `feat(api): 广告位 CRUD——outbox ad.* 事件/audit/停用高优先级/版本化换图`
### Task 8: cn 对账 'ad' 清扫 ### 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`(追加段) **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 前缀)。 - [x] **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)。 - [x] **Step 2: 跑测试确认失败** —— `pnpm --filter sync run test`(或该包 e2e 脚本名,对照 services/sync/package.json)。
- [ ] **Step 3: 实现**: - [x] **Step 3: 实现**:
```ts ```ts
// sync/dto.ts:SYNC_ENTITY_TYPES 追加 'ad' // sync/dto.ts:SYNC_ENTITY_TYPES 追加 'ad'
// sync.service.ts reconcile(asset 分支之后): // 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,两处单一出处)。 注意:payload builder 若与 ads.service 重复,提取到 `services/api/src/ads/ad-payload.ts` 共享(DRY,两处单一出处)。
- [ ] **Step 4: 跑测试确认通过** + `pnpm --filter api run test:e2e -- embed`(既有对账回归不破)。 - [x] **Step 4: 跑测试确认通过** + `pnpm --filter api run test:e2e -- embed`(既有对账回归不破)。
- [ ] **Step 5: Commit** —— `feat(sync): 对账清单 ad 实体当前态清扫` - [x] **Step 5: Commit** —— `feat(sync): 对账清单 ad 实体当前态清扫`
### Task 9: ov 公开端点 ### Task 9: ov 公开端点
+9 -17
View File
@@ -68,8 +68,12 @@ export type AdUploadInitResponse = components['schemas']['AdUploadInitResponse']
export type AdminAdItem = components['schemas']['AdminAdItem']; export type AdminAdItem = components['schemas']['AdminAdItem'];
export type AdminAdListResponse = components['schemas']['AdminAdListResponse']; 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 属性序, * updateAd 审计 metadata.fields 的字段名序列(契约 AdUpdateRequest 属性序,
@@ -166,21 +170,9 @@ export class AdsService {
const rows = await this.prisma.ads.findMany({ const rows = await this.prisma.ads.findMany({
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
}); });
const ttl = signedUrlTtlSeconds(); // 行→AdminAdItem 映射复用 toItem(loadItem 同一出口;TTL 惰性读取在
const items = await Promise.all( // toItem 内逐行进行——与 create/update 的单品响应口径一致)。
rows.map(async (row) => ({ const items = await Promise.all(rows.map((row) => this.toItem(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(),
})),
);
return { items }; return { items };
} }
+2 -1
View File
@@ -18,13 +18,14 @@ import {
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
/** 契约 SyncEntityType(slot-state 预留——与 openapi enum 同步)。 */ /** 契约 SyncEntityType(slot-state 预留;ad 自 ads-carousel Task 8 落地——与 openapi enum 同步)。 */
export const SYNC_ENTITY_TYPES = [ export const SYNC_ENTITY_TYPES = [
'album', 'album',
'category', 'category',
'photo', 'photo',
'asset', 'asset',
'slot-state', 'slot-state',
'ad',
] as const; ] as const;
/** 契约 BootstrapRequest:albumId 缺省 = 全部相册。 */ /** 契约 BootstrapRequest:albumId 缺省 = 全部相册。 */
+28
View File
@@ -30,6 +30,11 @@ import { Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts'; import type { components } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn'; import type { Prisma } from '@photo/db-cn';
import { PrismaService } from '../prisma/prisma.service'; 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 { StatSnapshotDto } from './dto';
import { import {
type AlbumCategoryInput, 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 预留:恒空清单。 // slot-state 预留:恒空清单。
return { events }; return { events };
+41
View File
@@ -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<string> {
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();
});
});
+29
View File
@@ -835,6 +835,35 @@ describe('ads domain (e2e)', () => {
imageContentType: 'image/jpeg', 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) const staffList = await request(cnBase)
.get('/v1/admin/ads') .get('/v1/admin/ads')
.set('Authorization', `Bearer ${staffToken}`); .set('Authorization', `Bearer ${staffToken}`);
+3
View File
@@ -24,6 +24,9 @@ export const RESET_TABLES: readonly string[] = [
// 照片与上传域 + outbox(Task 10):均为扁平字符串外键(无 FK 约束), // 照片与上传域 + outbox(Task 10):均为扁平字符串外键(无 FK 约束),
// 顺序按逻辑依赖排列(事件/作业/变体/会话先于 photos)。 // 顺序按逻辑依赖排列(事件/作业/变体/会话先于 photos)。
'outbox_events', 'outbox_events',
// 广告位域(ads-carousel Task 6):扁平表无 FK;e2e 直种固定 id 行 +
// reconcile 清单计数断言依赖跨 spec 清场(此前 cuid id 未见残留冲突)。
'ads',
// 展示位域(P4b Task 2):授权成员表先于展示位(建而未用,P5 激活)。 // 展示位域(P4b Task 2):授权成员表先于展示位(建而未用,P5 激活)。
'embed_slot_members', 'embed_slot_members',
'embed_slots', 'embed_slots',
+124
View File
@@ -13,12 +13,21 @@
// 收敛为空; // 收敛为空;
// ④ 稳态空转:一切一致时 runOnce 无 bootstrap 触发、重放全部判重/判旧 // ④ 稳态空转:一切一致时 runOnce 无 bootstrap 触发、重放全部判重/判旧
// (applied=0)、outbox 无新增未投递。 // (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 { NestFactory } from '@nestjs/core';
import { type NestExpressApplication } from '@nestjs/platform-express'; import { type NestExpressApplication } from '@nestjs/platform-express';
import request from 'supertest'; import request from 'supertest';
import sharp from 'sharp'; import sharp from 'sharp';
import { PrismaClient } from '@photo/db-cn'; import { PrismaClient } from '@photo/db-cn';
import { PrismaClient as OvPrismaClient } from '@photo/db-ov'; import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
import {
SYNC_SIGNATURE_HEADER,
SYNC_TIMESTAMP_HEADER,
signSyncBody,
} from '@photo/contracts';
import { import {
createStorageFromEnv, createStorageFromEnv,
type MinioStorageAdapter, type MinioStorageAdapter,
@@ -53,6 +62,19 @@ async function makeJpeg(
.toBuffer(); .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)', () => { describe('sync reconcile + digest healing (e2e)', () => {
let cnApp: NestExpressApplication; let cnApp: NestExpressApplication;
let ovApp: NestExpressApplication; let ovApp: NestExpressApplication;
@@ -132,6 +154,24 @@ describe('sync reconcile + digest healing (e2e)', () => {
expect(res.status).toBe(200); 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 () => { beforeAll(async () => {
expect(SYNC_KEY).not.toBe(''); expect(SYNC_KEY).not.toBe('');
await resetDb(); await resetDb();
@@ -344,4 +384,88 @@ describe('sync reconcile + digest healing (e2e)', () => {
).toBe(0); ).toBe(0);
expect(await prismaOv.photos_ov.count({ where: { albumId } })).toBe(2); 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,
},
});
});
}); });