2026-09-17 18:18:06 +08:00
|
|
|
|
// packages/contracts/src/events.ts
|
|
|
|
|
|
// Outbox 事件契约(docs/03-技术实施方案.md §8.2):P1a 在业务事务内写 outbox,
|
2026-09-18 15:21:41 +08:00
|
|
|
|
// P2 海外副本按 eventId 去重、按 entityVersion 单调递增消费。P2 Task 3 起补全
|
|
|
|
|
|
// album/category/asset 载荷(cn 事件补全);photo.* 载荷自 P1a 起落定。
|
2026-09-17 18:18:06 +08:00
|
|
|
|
export interface OutboxEvent<T = unknown> {
|
|
|
|
|
|
eventId: string; // cuid,持久唯一
|
|
|
|
|
|
type:
|
|
|
|
|
|
| 'photo.published'
|
|
|
|
|
|
| 'photo.hidden'
|
|
|
|
|
|
| 'photo.deleted'
|
|
|
|
|
|
| 'photo.likes.updated'
|
2026-09-18 15:21:41 +08:00
|
|
|
|
| 'album.updated'
|
|
|
|
|
|
| 'category.updated'
|
|
|
|
|
|
| 'category.deleted'
|
2026-09-20 22:22:19 +08:00
|
|
|
|
| 'asset.updated'
|
|
|
|
|
|
| 'slot.updated';
|
2026-09-17 18:18:06 +08:00
|
|
|
|
albumId: string;
|
|
|
|
|
|
photoId?: string;
|
|
|
|
|
|
entityVersion: number; // 单调递增,P2 inbox 消费依据
|
|
|
|
|
|
payload: T;
|
|
|
|
|
|
occurredAt: string; // ISO UTC
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// photo.published 载荷:P2 海外副本展示一张照片所需的全部字段。
|
|
|
|
|
|
export interface PhotoPublishedPayload {
|
|
|
|
|
|
photoId: string;
|
|
|
|
|
|
albumId: string;
|
2026-09-18 17:05:56 +08:00
|
|
|
|
categoryId: string | null; // 分类过滤(ov 公开列表 ?category=)与单图同形所需
|
2026-09-17 18:18:06 +08:00
|
|
|
|
objectKey: string; // 原图对象键
|
|
|
|
|
|
thumbKey: string; // 缩略图对象键
|
|
|
|
|
|
browseKey: string; // 浏览图对象键
|
|
|
|
|
|
width: number;
|
|
|
|
|
|
height: number;
|
|
|
|
|
|
publishedAt: string; // ISO UTC
|
|
|
|
|
|
entityVersion: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-18 15:21:41 +08:00
|
|
|
|
// album.updated 载荷:ov 副本(albums_ov)展示一张相册所需的标量 + 翻译全集。
|
2026-09-18 16:18:31 +08:00
|
|
|
|
// categories 默认不在此列——分类是独立事件(category.*,各自携带 entityVersion);
|
2026-09-18 15:21:41 +08:00
|
|
|
|
// banner 对象键经 asset.updated 事件同步(albums_ov.bannerAssetKey)。
|
2026-09-18 16:18:31 +08:00
|
|
|
|
// 例外:bootstrap/reconcile 的快照事件携带可选 categories 全量清单(Task 4
|
|
|
|
|
|
// digest 补漏的收敛原语,见 AlbumCategorySnapshot 注释)。
|
2026-09-18 15:21:41 +08:00
|
|
|
|
export interface AlbumUpdatedPayload {
|
|
|
|
|
|
albumId: string;
|
|
|
|
|
|
publicToken: string;
|
|
|
|
|
|
status: string; // active|offline(cn 副本值)
|
|
|
|
|
|
uploadEnabled: boolean;
|
|
|
|
|
|
publicEnabled: boolean;
|
|
|
|
|
|
downloadEnabled: boolean;
|
|
|
|
|
|
originalDownloadEnabled: boolean;
|
2026-09-20 17:53:44 +08:00
|
|
|
|
/** P4a A18:水印副本位(ov 模式键服务)。语义见 watermark.ts 注释——取「有效版本」而非最新创建版本。 */
|
|
|
|
|
|
watermarkEnabled: boolean;
|
|
|
|
|
|
/** ov 侧模式键 `variants/{photoId}/{watermarkConfigVersion}/watermark.jpg` 的版本段。 */
|
|
|
|
|
|
watermarkConfigVersion: number;
|
2026-09-18 15:21:41 +08:00
|
|
|
|
defaultLocale: string;
|
|
|
|
|
|
timezone: string;
|
|
|
|
|
|
startAt: string; // ISO UTC
|
|
|
|
|
|
endAt: string | null; // ISO UTC;null 表示未定
|
|
|
|
|
|
translations: AlbumTranslationPayload[]; // locale 升序(确定性输出)
|
2026-09-18 16:18:31 +08:00
|
|
|
|
/** 分类全量清单(可选):存在时 ov **整体替换** albums_ov.categories。 */
|
|
|
|
|
|
categories?: AlbumCategorySnapshot[];
|
2026-09-18 15:21:41 +08:00
|
|
|
|
entityVersion: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-18 16:18:31 +08:00
|
|
|
|
// albums_ov.categories JSON 元素的载荷形状。日常增量由 category.updated 逐个
|
|
|
|
|
|
// upsert / category.deleted 墓碑逐个移除;但**错过的墓碑**(cn 行已删,对账
|
|
|
|
|
|
// 无法为不存在的行重放事件)只有「全量清单整体替换」才能收敛——bootstrap/
|
|
|
|
|
|
// reconcile 的 album.updated 快照事件据此携带当前全集(Task 4 digest→bootstrap
|
|
|
|
|
|
// 补漏通道)。缺省(增量事件不携带)时 ov 不触碰 categories,语义不变。
|
|
|
|
|
|
export interface AlbumCategorySnapshot {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
sortOrder: number;
|
|
|
|
|
|
translations: CategoryTranslationPayload[]; // locale 升序
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-18 15:21:41 +08:00
|
|
|
|
export interface AlbumTranslationPayload {
|
|
|
|
|
|
locale: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
description: string | null;
|
|
|
|
|
|
venue: string | null;
|
|
|
|
|
|
address: string | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// category.updated 载荷:相册分类(ov 落 albums_ov.categories JSON 数组元素)。
|
|
|
|
|
|
export interface CategoryUpdatedPayload {
|
|
|
|
|
|
categoryId: string;
|
|
|
|
|
|
albumId: string;
|
|
|
|
|
|
sortOrder: number;
|
|
|
|
|
|
translations: CategoryTranslationPayload[]; // locale 升序
|
|
|
|
|
|
entityVersion: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface CategoryTranslationPayload {
|
|
|
|
|
|
locale: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// category.deleted 载荷:墓碑(ov 从 albums_ov.categories 移除该元素且不被旧
|
|
|
|
|
|
// 事件复活)。删除后 cn 行已不存在,entityVersion 为删除时刻的终值。
|
|
|
|
|
|
export interface CategoryDeletedPayload {
|
|
|
|
|
|
categoryId: string;
|
|
|
|
|
|
albumId: string;
|
|
|
|
|
|
entityVersion: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// photo.hidden / photo.deleted 载荷:可见性变更最小事实(ov 副本据此置
|
|
|
|
|
|
// photos_ov.visibility;无展示字段——撤下语义只需标识与版本)。
|
|
|
|
|
|
export interface PhotoVisibilityChangedPayload {
|
|
|
|
|
|
photoId: string;
|
|
|
|
|
|
albumId: string;
|
|
|
|
|
|
visibility: string; // hidden|deleted
|
|
|
|
|
|
entityVersion: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// asset.updated 载荷:相册资产生效(state pending→effective 的迁移才发事件)。
|
|
|
|
|
|
// ov 侧 kind=banner 投影到 albums_ov.bannerAssetKey;cover/logo 仅推进版本。
|
|
|
|
|
|
export interface AssetUpdatedPayload {
|
|
|
|
|
|
assetId: string;
|
|
|
|
|
|
albumId: string;
|
|
|
|
|
|
kind: string; // banner|cover|logo
|
|
|
|
|
|
locale: string | null;
|
|
|
|
|
|
objectKey: string;
|
|
|
|
|
|
entityVersion: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 22:22:19 +08:00
|
|
|
|
// —— 展示位(P4b Task 2)——
|
|
|
|
|
|
// slot.updated 载荷:ov 副本(embed_slots_ov)重建展示位所需的**全量快照**——
|
|
|
|
|
|
// 展示位无墓碑(无删除端点,enabled=false 即下线),每次管理面变更整行替换。
|
|
|
|
|
|
// slotId 供发送器推导 entityId(同 categoryId/assetId 约定,outbox 表无此列);
|
|
|
|
|
|
// albumId/albumPublicToken 为绑定相册双标识(未绑定时均 null):ov 副本外键
|
|
|
|
|
|
// 落 albumPublicToken(公开端点以它关联 albums_ov),照片按副本实时查询、
|
|
|
|
|
|
// 不随载荷携带(新照片发布走 photo.published,ETag 四元组含发布时间)。
|
|
|
|
|
|
export interface SlotStateUpdatedPayload {
|
|
|
|
|
|
slotId: string;
|
|
|
|
|
|
publicToken: string;
|
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
|
albumId: string | null;
|
|
|
|
|
|
albumPublicToken: string | null;
|
|
|
|
|
|
configVersion: number;
|
|
|
|
|
|
settings: SlotSettingsPayload;
|
|
|
|
|
|
entityVersion: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 14:09:35 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 展示位设置载荷(契约 EmbedSlotSettings 的载荷形状;字段恒齐全)。
|
|
|
|
|
|
* UI-01 新增字段为可选:cn 写入侧恒发归一化全集,但部署切替期间 outbox/
|
|
|
|
|
|
* sync_inbox 中在途事件仍是旧形状——消费侧(ov 投影/公开读)须对缺字段按
|
|
|
|
|
|
* 契约默认值归一化,不得假设恒在。
|
|
|
|
|
|
*/
|
2026-09-20 22:22:19 +08:00
|
|
|
|
export interface SlotSettingsPayload {
|
|
|
|
|
|
position: string; // left|right
|
|
|
|
|
|
size: string; // sm|md
|
|
|
|
|
|
intervalMs: number;
|
|
|
|
|
|
lang: string; // auto|zh-CN|en
|
|
|
|
|
|
collapsedDefault: boolean;
|
2026-09-21 14:09:35 +08:00
|
|
|
|
imageFit?: string; // contain|cover(默认 contain)
|
|
|
|
|
|
offsetX?: number; // 0..120(默认 16)
|
|
|
|
|
|
offsetBottom?: number; // 0..240(默认 24)
|
|
|
|
|
|
zIndex?: number; // 1..2147483000(默认 1000)
|
2026-09-22 12:50:28 +08:00
|
|
|
|
displayMode?: string; // carousel|vertical-stream(默认 carousel,UI-04)
|
|
|
|
|
|
scrollSpeed?: number; // 8..32 CSS px/s(默认 16,UI-04;仅 v2 生效)
|
|
|
|
|
|
reactionsEnabled?: boolean; // 默认 true(UI-04;false 仅隐藏气泡)
|
2026-09-20 22:22:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 18:18:06 +08:00
|
|
|
|
// 运行时校验用:全部合法事件类型字符串。satisfies 保证 数组 ⊆ 接口 union;
|
|
|
|
|
|
// __tests__/events.test.ts 的 Equal 断言反向锁定 union ⊆ 数组,两侧永远 1:1。
|
|
|
|
|
|
export const OUTBOX_EVENT_TYPES = [
|
|
|
|
|
|
'photo.published',
|
|
|
|
|
|
'photo.hidden',
|
|
|
|
|
|
'photo.deleted',
|
|
|
|
|
|
'photo.likes.updated',
|
|
|
|
|
|
'album.updated',
|
2026-09-18 15:21:41 +08:00
|
|
|
|
'category.updated',
|
|
|
|
|
|
'category.deleted',
|
|
|
|
|
|
'asset.updated',
|
2026-09-20 22:22:19 +08:00
|
|
|
|
'slot.updated',
|
2026-09-17 18:18:06 +08:00
|
|
|
|
] as const satisfies readonly OutboxEvent['type'][];
|
2026-09-18 15:21:41 +08:00
|
|
|
|
|
|
|
|
|
|
// ── outbox 类型 → 内部同步线格式(openapi SyncEvent.entityType)的映射 ──
|
|
|
|
|
|
// 发送器(services/sync)与 cn reconcile 端点共用,单一实现防两侧漂移;
|
|
|
|
|
|
// exhaustive switch:新增事件类型漏映射会在此编译报错。
|
|
|
|
|
|
|
2026-09-20 22:22:19 +08:00
|
|
|
|
/** 内部同步实体类型(对齐 openapi SyncEntityType;slot-state 自 P4b Task 2 起 landing)。 */
|
2026-09-18 15:21:41 +08:00
|
|
|
|
export type SyncEntityType =
|
|
|
|
|
|
| 'album'
|
|
|
|
|
|
| 'category'
|
|
|
|
|
|
| 'photo'
|
|
|
|
|
|
| 'asset'
|
|
|
|
|
|
| 'slot-state';
|
|
|
|
|
|
|
|
|
|
|
|
export function syncEntityTypeOfOutboxType(
|
|
|
|
|
|
type: OutboxEvent['type'],
|
|
|
|
|
|
): SyncEntityType {
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case 'album.updated':
|
|
|
|
|
|
return 'album';
|
|
|
|
|
|
case 'category.updated':
|
|
|
|
|
|
case 'category.deleted':
|
|
|
|
|
|
return 'category';
|
|
|
|
|
|
case 'asset.updated':
|
|
|
|
|
|
return 'asset';
|
2026-09-20 22:22:19 +08:00
|
|
|
|
case 'slot.updated':
|
|
|
|
|
|
return 'slot-state';
|
2026-09-18 15:21:41 +08:00
|
|
|
|
case 'photo.published':
|
|
|
|
|
|
case 'photo.hidden':
|
|
|
|
|
|
case 'photo.deleted':
|
|
|
|
|
|
case 'photo.likes.updated':
|
|
|
|
|
|
return 'photo';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 墓碑类事件(副本删除且不被旧事件复活,A26)。 */
|
|
|
|
|
|
export function isTombstoneOutboxType(type: OutboxEvent['type']): boolean {
|
|
|
|
|
|
return type === 'category.deleted' || type === 'photo.deleted';
|
|
|
|
|
|
}
|