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 };
+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',
};
// 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}`);
+3
View File
@@ -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',
+124
View File
@@ -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,
},
});
});
});