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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 缺省 = 全部相册。 */
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user