Files
photo-live/packages/contracts/src/events.ts
T
浅念 66d1eb3e9f feat(embed,ui04): E01 v2 contract skeleton — displayMode/scrollSpeed/reactionsEnabled, v2 loader entry and fixed 195x280 shell
- contracts: EmbedSlotSettings optional v2 fields (carousel default) + SlotSettingsPayload; gen + redocly green
- api: DTO validation + normalization full-set (cn manage + ov public projection share normalizeStoredSettings)
- embed-loader: params v2 parse/merge/url family, loader-v2.js fixed shell (border/radius/#10161E/safe-area, resize ignored, stack gap 12), dual-artifact build+sync; v1 source frozen
- viewer: EmbedPage v=2 branch, EmbedStream (viewport/track/real anchors/collapse/empty), stream.ts pure fns, i18n zh/en
- admin: types + utils (create default vertical-stream, clampScrollSpeed, v1/v2 snippet selection)
- tests: loader 36 (+9 v2), viewer 222, admin 82, api unit 51, v1 embed e2e 20, v2 E01 gate 2 (195x280 desktop+mobile cross-origin, no footer)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 12:50:28 +08:00

216 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// packages/contracts/src/events.ts
// Outbox 事件契约(docs/03-技术实施方案.md §8.2):P1a 在业务事务内写 outbox,
// P2 海外副本按 eventId 去重、按 entityVersion 单调递增消费。P2 Task 3 起补全
// album/category/asset 载荷(cn 事件补全);photo.* 载荷自 P1a 起落定。
export interface OutboxEvent<T = unknown> {
eventId: string; // cuid,持久唯一
type:
| 'photo.published'
| 'photo.hidden'
| 'photo.deleted'
| 'photo.likes.updated'
| 'album.updated'
| 'category.updated'
| 'category.deleted'
| 'asset.updated'
| 'slot.updated';
albumId: string;
photoId?: string;
entityVersion: number; // 单调递增,P2 inbox 消费依据
payload: T;
occurredAt: string; // ISO UTC
}
// photo.published 载荷:P2 海外副本展示一张照片所需的全部字段。
export interface PhotoPublishedPayload {
photoId: string;
albumId: string;
categoryId: string | null; // 分类过滤(ov 公开列表 ?category=)与单图同形所需
objectKey: string; // 原图对象键
thumbKey: string; // 缩略图对象键
browseKey: string; // 浏览图对象键
width: number;
height: number;
publishedAt: string; // ISO UTC
entityVersion: number;
}
// album.updated 载荷:ov 副本(albums_ov)展示一张相册所需的标量 + 翻译全集。
// categories 默认不在此列——分类是独立事件(category.*,各自携带 entityVersion);
// banner 对象键经 asset.updated 事件同步(albums_ov.bannerAssetKey)。
// 例外:bootstrap/reconcile 的快照事件携带可选 categories 全量清单(Task 4
// digest 补漏的收敛原语,见 AlbumCategorySnapshot 注释)。
export interface AlbumUpdatedPayload {
albumId: string;
publicToken: string;
status: string; // active|offline(cn 副本值)
uploadEnabled: boolean;
publicEnabled: boolean;
downloadEnabled: boolean;
originalDownloadEnabled: boolean;
/** P4a A18:水印副本位(ov 模式键服务)。语义见 watermark.ts 注释——取「有效版本」而非最新创建版本。 */
watermarkEnabled: boolean;
/** ov 侧模式键 `variants/{photoId}/{watermarkConfigVersion}/watermark.jpg` 的版本段。 */
watermarkConfigVersion: number;
defaultLocale: string;
timezone: string;
startAt: string; // ISO UTC
endAt: string | null; // ISO UTC;null 表示未定
translations: AlbumTranslationPayload[]; // locale 升序(确定性输出)
/** 分类全量清单(可选):存在时 ov **整体替换** albums_ov.categories。 */
categories?: AlbumCategorySnapshot[];
entityVersion: number;
}
// 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 升序
}
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;
}
// —— 展示位(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;
}
/**
* 展示位设置载荷(契约 EmbedSlotSettings 的载荷形状;字段恒齐全)。
* UI-01 新增字段为可选:cn 写入侧恒发归一化全集,但部署切替期间 outbox/
* sync_inbox 中在途事件仍是旧形状——消费侧(ov 投影/公开读)须对缺字段按
* 契约默认值归一化,不得假设恒在。
*/
export interface SlotSettingsPayload {
position: string; // left|right
size: string; // sm|md
intervalMs: number;
lang: string; // auto|zh-CN|en
collapsedDefault: boolean;
imageFit?: string; // contain|cover(默认 contain)
offsetX?: number; // 0..120(默认 16)
offsetBottom?: number; // 0..240(默认 24)
zIndex?: number; // 1..2147483000(默认 1000)
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 仅隐藏气泡)
}
// 运行时校验用:全部合法事件类型字符串。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',
'category.updated',
'category.deleted',
'asset.updated',
'slot.updated',
] as const satisfies readonly OutboxEvent['type'][];
// ── outbox 类型 → 内部同步线格式(openapi SyncEvent.entityType)的映射 ──
// 发送器(services/sync)与 cn reconcile 端点共用,单一实现防两侧漂移;
// exhaustive switch:新增事件类型漏映射会在此编译报错。
/** 内部同步实体类型(对齐 openapi SyncEntityType;slot-state 自 P4b Task 2 起 landing)。 */
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';
case 'slot.updated':
return 'slot-state';
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';
}