feat(api): embed slots with ov replica and public feed

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 22:22:19 +08:00
parent eb51069a12
commit aee4b7ac46
23 changed files with 1890 additions and 10 deletions
@@ -60,7 +60,7 @@ describe('OutboxEvent', () => {
});
describe('OUTBOX_EVENT_TYPES', () => {
it('恰好包含全部 8 个事件类型字符串', () => {
it('恰好包含全部 9 个事件类型字符串', () => {
expect([...OUTBOX_EVENT_TYPES]).toEqual([
'photo.published',
'photo.hidden',
@@ -70,6 +70,7 @@ describe('OUTBOX_EVENT_TYPES', () => {
'category.updated',
'category.deleted',
'asset.updated',
'slot.updated',
]);
});
+4
View File
@@ -50,3 +50,7 @@ export const EMBED_POLL_INTERVAL_MS = 30000;
export const EMBED_POLL_MAX_FAILURES = 5;
/** 轮询失败退避倍数(连续失败达上限后拉长间隔,成功即恢复正常间隔)。 */
export const EMBED_POLL_BACKOFF_MULTIPLIER = 5;
/** 展示位公开端点基础限速:slotToken+IP 固定窗(EMBED_WINDOW_SECONDS)内请求数上限(含 304)。 */
export const EMBED_WINDOW_REQUESTS = 120;
/** 展示位限速固定窗秒数(轮询 30s/次 + 页面重载突发,窗口内余量充足)。 */
export const EMBED_WINDOW_SECONDS = 60;
+33 -2
View File
@@ -12,7 +12,8 @@ export interface OutboxEvent<T = unknown> {
| 'album.updated'
| 'category.updated'
| 'category.deleted'
| 'asset.updated';
| 'asset.updated'
| 'slot.updated';
albumId: string;
photoId?: string;
entityVersion: number; // 单调递增,P2 inbox 消费依据
@@ -122,6 +123,33 @@ export interface AssetUpdatedPayload {
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 的载荷形状;字段恒齐全)。 */
export interface SlotSettingsPayload {
position: string; // left|right
size: string; // sm|md
intervalMs: number;
lang: string; // auto|zh-CN|en
collapsedDefault: boolean;
}
// 运行时校验用:全部合法事件类型字符串。satisfies 保证 数组 ⊆ 接口 union;
// __tests__/events.test.ts 的 Equal 断言反向锁定 union ⊆ 数组,两侧永远 1:1。
export const OUTBOX_EVENT_TYPES = [
@@ -133,13 +161,14 @@ export const OUTBOX_EVENT_TYPES = [
'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 为预留)。 */
/** 内部同步实体类型(对齐 openapi SyncEntityType;slot-state 自 P4b Task 2 起 landing)。 */
export type SyncEntityType =
| 'album'
| 'category'
@@ -158,6 +187,8 @@ export function syncEntityTypeOfOutboxType(
return 'category';
case 'asset.updated':
return 'asset';
case 'slot.updated':
return 'slot-state';
case 'photo.published':
case 'photo.hidden':
case 'photo.deleted':
@@ -0,0 +1,39 @@
-- P4b Task 2:展示位域(embed_slots/embed_slot_members)。
-- 校对手决:Prisma 生成 DDL 固定 utf8mb4_unicode_ci,逐表改写为库级约定的
-- utf8mb4_0900_bin(publicToken 的大小写敏感唯一语义依赖它,同 albums 口径)。
-- · embed_slots 双版本位:configVersion(嵌入端可见配置版本,任一管理面
-- 变更递增)/entityVersion(slot.updated 同步事件版本);无删除端点,
-- enabled=false 即下线。settings 为归一化 EmbedSlotSettings 全集 JSON。
-- · embed_slot_members:授权成员表**建而未用**(P4b 权限简化 admin-only,
-- 登记偏差;P5 授权面按需启用)。
-- · 两表均扁平字符串外键(albumId/userId/slotId 无 FK 约束,同相册域约定)。
-- CreateTable
CREATE TABLE `embed_slots` (
`id` VARCHAR(191) NOT NULL,
`publicToken` VARCHAR(32) NOT NULL,
`name` VARCHAR(64) NOT NULL,
`enabled` BOOLEAN NOT NULL DEFAULT true,
`albumId` VARCHAR(64) NULL,
`configVersion` INTEGER NOT NULL DEFAULT 1,
`settings` JSON NOT NULL,
`entityVersion` INTEGER NOT NULL DEFAULT 1,
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`updatedAt` DATETIME(6) NOT NULL,
UNIQUE INDEX `embed_slots_publicToken_key`(`publicToken`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `embed_slot_members` (
`id` VARCHAR(191) NOT NULL,
`slotId` VARCHAR(64) NOT NULL,
`userId` VARCHAR(64) NOT NULL,
`permission` VARCHAR(32) NOT NULL,
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX `embed_slot_members_userId_idx`(`userId`),
UNIQUE INDEX `embed_slot_members_slotId_userId_key`(`slotId`, `userId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
+30
View File
@@ -332,3 +332,33 @@ model user_sync_state {
runCount Int @default(0) // 已启动批次数(含失败),驱动周期全量对账
updatedAt DateTime @updatedAt @db.DateTime(6)
}
// —— 展示位域(P4b Task 2,悬浮轮播组件挂载点)——
// cn 权威实体:admin 建槽/绑定相册,经 outbox slot.updated 事件同步 ov 副本
// (embed_slots_ov,载荷全量快照)。扁平字符串外键(albumId 无 relation/FK,
// 同相册域约定)。双版本位:configVersion 是「嵌入端可见」的配置版本(任一
// 管理面变更递增,公开端 ETag 组成部分);entityVersion 是同步版本(同相册
// 模式,事务内自增)。无删除端点:enabled=false 即下线(软停用)。
// embed_slot_members:授权成员表**建而未用**(P4b 权限简化为 admin-only——
// 需求「创建者只操作被授权展示位」的授权面登记偏差,P5 按需出 API/UI)。
model embed_slots {
id String @id @default(cuid())
publicToken String @unique @db.VarChar(32) // 嵌入代码 data-slot 引用(24 字符 [0-9a-z],同相册令牌口径)
name String @db.VarChar(64)
enabled Boolean @default(true)
albumId String? @db.VarChar(64) // 绑定相册;null=未绑定(公开端 404)
configVersion Int @default(1) // 任一管理面变更递增(嵌入端整体重置渲染依据)
settings Json // EmbedSlotSettings 归一化全集(字段恒齐全)
entityVersion Int @default(1) // slot.updated 事件版本(同 photos 模式)
createdAt DateTime @default(now()) @db.DateTime(6)
updatedAt DateTime @updatedAt @db.DateTime(6)
}
model embed_slot_members {
id String @id @default(cuid())
slotId String @db.VarChar(64)
userId String @db.VarChar(64)
permission String @db.VarChar(32) // manage|view(P4b 未用,P5 授权面按需启用)
createdAt DateTime @default(now()) @db.DateTime(6)
@@unique([slotId, userId])
@@index([userId])
}
@@ -0,0 +1,18 @@
-- P4b Task 2:展示位副本表(cn embed_slots 的展示投影,slot-state 事件应用)。
-- 校对手决:Prisma 生成 DDL 固定 utf8mb4_unicode_ci,改写为库级约定的
-- utf8mb4_0900_bin(slotToken 主键/albumPublicToken 的大小写敏感寻址语义
-- 依赖它,同 albums_ov.publicToken 口径)。无墓碑:下线是 enabled=false 的
-- 全量快照替换(cn 无删除端点)。
-- CreateTable
CREATE TABLE `embed_slots_ov` (
`slotToken` VARCHAR(32) NOT NULL,
`enabled` BOOLEAN NOT NULL DEFAULT true,
`albumPublicToken` VARCHAR(32) NULL,
`configVersion` INTEGER NOT NULL DEFAULT 1,
`settings` JSON NOT NULL,
`entityVersion` INTEGER NOT NULL,
`updatedAt` DATETIME(6) NOT NULL,
PRIMARY KEY (`slotToken`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
+17
View File
@@ -162,6 +162,23 @@ model visit_requests {
@@index([createdAt])
}
// —— 副本域:embed_slots_ov(cn embed_slots 的展示投影,P4b Task 2)——
// slotToken 即 cn embed_slots.publicToken(公开端点按它寻址,稳定不变);
// albumPublicToken 为绑定相册的公开令牌(null=未绑定 → 公开端 404)——副本
// 外键落公开令牌而非 cn id:嵌入侧只认 token(A22 换绑不认相册身份)。
// entityVersion 是 cn 权威版本(应用写入,ov 不自增);settings 为载荷原文
// (当前公开响应不含设置——loader 经 data-* 配置,副本位为对账/后续使用保留)。
// 无墓碑:下线是 enabled=false 的全量快照替换(cn 无删除端点)。
model embed_slots_ov {
slotToken String @id @db.VarChar(32)
enabled Boolean @default(true)
albumPublicToken String? @db.VarChar(32)
configVersion Int @default(1)
settings Json
entityVersion Int
updatedAt DateTime @updatedAt @db.DateTime(6)
}
// —— 统计回传队列:outbox_stat_events(ov→cn,Task 9 消费)——
// 与 cn outbox_events 同构的发送位:sentAt 为空即重发(中断恢复),statVersion
// 让 cn 侧「仅更高版本替换」幂等。total 恒为权威快照值(非增量)。