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
+9 -17
View File
@@ -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 };
}
+2 -1
View File
@@ -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 缺省 = 全部相册。 */
+28
View File
@@ -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 };