From af8f3b78dd5fa471e6cbe324076836c12f946b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 28 Sep 2026 21:58:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(contracts):=20ad.updated/ad.deleted=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E7=B1=BB=E5=9E=8B=E4=B8=8E=20ad=20=E5=AE=9E?= =?UTF-8?q?=E4=BD=93=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- .../contracts/src/__tests__/events.test.ts | 16 +++++++- packages/contracts/src/events.ts | 39 +++++++++++++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/contracts/src/__tests__/events.test.ts b/packages/contracts/src/__tests__/events.test.ts index 60337d2..a05d91e 100644 --- a/packages/contracts/src/__tests__/events.test.ts +++ b/packages/contracts/src/__tests__/events.test.ts @@ -6,9 +6,11 @@ import { ErrorCodes, type ErrorCode } from '../errors.js'; import type { components } from '../generated/openapi.js'; import { + isTombstoneOutboxType, OUTBOX_EVENT_TYPES, type OutboxEvent, type PhotoPublishedPayload, + syncEntityTypeOfOutboxType, } from '../events.js'; // 类型恒等辅助:仅当 X 与 Y 是完全相同的类型(双向兼容,多/少一个成员都为 false)。 @@ -60,7 +62,7 @@ describe('OutboxEvent', () => { }); describe('OUTBOX_EVENT_TYPES', () => { - it('恰好包含全部 9 个事件类型字符串', () => { + it('恰好包含全部 11 个事件类型字符串', () => { expect([...OUTBOX_EVENT_TYPES]).toEqual([ 'photo.published', 'photo.hidden', @@ -71,6 +73,8 @@ describe('OUTBOX_EVENT_TYPES', () => { 'category.deleted', 'asset.updated', 'slot.updated', + 'ad.updated', + 'ad.deleted', ]); }); @@ -83,6 +87,16 @@ describe('OUTBOX_EVENT_TYPES', () => { }); }); +describe('syncEntityTypeOfOutboxType / isTombstoneOutboxType', () => { + // ad.* 映射与墓碑(广告位轮播 2026-09-28) + it('ad.updated/ad.deleted → entityType ad;ad.deleted 为墓碑', () => { + expect(syncEntityTypeOfOutboxType('ad.updated')).toBe('ad'); + expect(syncEntityTypeOfOutboxType('ad.deleted')).toBe('ad'); + expect(isTombstoneOutboxType('ad.deleted')).toBe(true); + expect(isTombstoneOutboxType('ad.updated')).toBe(false); + }); +}); + describe('openapi Error.code enum ↔ errors.ts ErrorCodes 同步', () => { it('两侧 code 联合类型双向恒等(1:1)', () => { const apiEqualsErrors: Equal = true; diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index 1bd0a14..2aa443a 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -13,7 +13,9 @@ export interface OutboxEvent { | 'category.updated' | 'category.deleted' | 'asset.updated' - | 'slot.updated'; + | 'slot.updated' + | 'ad.updated' + | 'ad.deleted'; albumId: string; photoId?: string; entityVersion: number; // 单调递增,P2 inbox 消费依据 @@ -162,6 +164,27 @@ export interface SlotSettingsPayload { reactionsEnabled?: boolean; // 默认 true(UI-04;false 仅隐藏气泡) } +// —— 广告位(2026-09-28 设计 §4)—— +// ad.updated 载荷:ads_ov 投影重建所需的**全量快照**——每次管理面变更整行替换 +// (slot.updated 同款);adId 供发送器推导 entityId(outbox 表无此列)。 +// albumIds null=全局投放;enabled=false 消费侧按「删投影行」处理(无墓碑下线)。 +export interface AdUpdatedPayload { + adId: string; + imageObjectKey: string; + linkUrl: string; + enabled: boolean; + sortOrder: number; + albumIds: string[] | null; + createdAt: string; // ISO UTC;ov 排序 tie-break(设计 §5.2) + entityVersion: number; +} +// ad.deleted 载荷:墓碑(ov 删 ads_ov 行且不被旧事件复活);cn 行已删, +// entityVersion 为删除时刻终值(末次版本+1)。 +export interface AdDeletedPayload { + adId: string; + entityVersion: number; +} + // 运行时校验用:全部合法事件类型字符串。satisfies 保证 数组 ⊆ 接口 union; // __tests__/events.test.ts 的 Equal 断言反向锁定 union ⊆ 数组,两侧永远 1:1。 export const OUTBOX_EVENT_TYPES = [ @@ -174,6 +197,8 @@ export const OUTBOX_EVENT_TYPES = [ 'category.deleted', 'asset.updated', 'slot.updated', + 'ad.updated', + 'ad.deleted', ] as const satisfies readonly OutboxEvent['type'][]; // ── outbox 类型 → 内部同步线格式(openapi SyncEvent.entityType)的映射 ── @@ -186,7 +211,8 @@ export type SyncEntityType = | 'category' | 'photo' | 'asset' - | 'slot-state'; + | 'slot-state' + | 'ad'; export function syncEntityTypeOfOutboxType( type: OutboxEvent['type'], @@ -201,6 +227,9 @@ export function syncEntityTypeOfOutboxType( return 'asset'; case 'slot.updated': return 'slot-state'; + case 'ad.updated': + case 'ad.deleted': + return 'ad'; case 'photo.published': case 'photo.hidden': case 'photo.deleted': @@ -211,5 +240,9 @@ export function syncEntityTypeOfOutboxType( /** 墓碑类事件(副本删除且不被旧事件复活,A26)。 */ export function isTombstoneOutboxType(type: OutboxEvent['type']): boolean { - return type === 'category.deleted' || type === 'photo.deleted'; + return ( + type === 'category.deleted' || + type === 'photo.deleted' || + type === 'ad.deleted' + ); }