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 全路径)
- [ ] **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 公开端点