feat(contracts): ad.updated/ad.deleted 事件类型与 ad 实体映射

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 21:58:32 +08:00
parent ac37794d43
commit af8f3b78dd
2 changed files with 51 additions and 4 deletions
@@ -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<ApiErrorCode, ErrorCode> = true;
+36 -3
View File
@@ -13,7 +13,9 @@ export interface OutboxEvent<T = unknown> {
| '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'
);
}