diff --git a/.env.example b/.env.example index b89d88d..134041e 100644 --- a/.env.example +++ b/.env.example @@ -70,3 +70,7 @@ CLEANUP_INTERVAL_MINUTES=60 EMBED_POLL_INTERVAL_MS=30000 EMBED_POLL_MAX_FAILURES=5 EMBED_POLL_BACKOFF_MULTIPLIER=5 +# P4b Task 2:展示位公开端点基础限速(slotToken+来源 IP 固定窗;GET 无幂等面, +# 含 304 命中——轮询本身 30s/次,余量给页面重载突发,P5 速率层接真实防护)。 +EMBED_WINDOW_REQUESTS=120 +EMBED_WINDOW_SECONDS=60 diff --git a/packages/contracts/src/__tests__/events.test.ts b/packages/contracts/src/__tests__/events.test.ts index eaf19f2..60337d2 100644 --- a/packages/contracts/src/__tests__/events.test.ts +++ b/packages/contracts/src/__tests__/events.test.ts @@ -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', ]); }); diff --git a/packages/contracts/src/constants.ts b/packages/contracts/src/constants.ts index ae39e38..2de3937 100644 --- a/packages/contracts/src/constants.ts +++ b/packages/contracts/src/constants.ts @@ -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; diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index abc12c6..e783692 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -12,7 +12,8 @@ export interface OutboxEvent { | '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': diff --git a/packages/db-cn/prisma/migrations/20260920140205_embed_slots_domain/migration.sql b/packages/db-cn/prisma/migrations/20260920140205_embed_slots_domain/migration.sql new file mode 100644 index 0000000..a3bd721 --- /dev/null +++ b/packages/db-cn/prisma/migrations/20260920140205_embed_slots_domain/migration.sql @@ -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; diff --git a/packages/db-cn/prisma/schema.prisma b/packages/db-cn/prisma/schema.prisma index e7f53c6..d59691d 100644 --- a/packages/db-cn/prisma/schema.prisma +++ b/packages/db-cn/prisma/schema.prisma @@ -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]) +} diff --git a/packages/db-ov/prisma/migrations/20260920140341_embed_slots_ov/migration.sql b/packages/db-ov/prisma/migrations/20260920140341_embed_slots_ov/migration.sql new file mode 100644 index 0000000..33da576 --- /dev/null +++ b/packages/db-ov/prisma/migrations/20260920140341_embed_slots_ov/migration.sql @@ -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; diff --git a/packages/db-ov/prisma/schema.prisma b/packages/db-ov/prisma/schema.prisma index 75051c7..b44751c 100644 --- a/packages/db-ov/prisma/schema.prisma +++ b/packages/db-ov/prisma/schema.prisma @@ -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 恒为权威快照值(非增量)。 diff --git a/services/api/src/app-cn.module.ts b/services/api/src/app-cn.module.ts index 7ee9392..aa8f3f1 100644 --- a/services/api/src/app-cn.module.ts +++ b/services/api/src/app-cn.module.ts @@ -18,6 +18,7 @@ import { AlbumsModule } from './albums/albums.module'; import { AdminModule } from './admin/admin.module'; import { AllExceptionsFilter } from './all-exceptions.filter'; import { AuthModule } from './auth/auth.module'; +import { EmbedModule } from './embed/embed.module'; import { HealthController } from './health.controller'; import { PhotosModule } from './photos/photos.module'; import { PrismaModule } from './prisma/prisma.module'; @@ -46,6 +47,8 @@ import { createLoggerOptions } from './shared/logger-options'; PhotosModule, // 管理辅助域(P1b Task 4:/admin/users 账号前缀查询)。 AdminModule, + // 展示位管理域(P4b Task 2:/admin/embed-slots,slot-state 事件源)。 + EmbedModule, // 内部同步域(P2 Task 3:/internal/sync/bootstrap|reconcile,HMAC 鉴权, // 仅 cn 入口装配——cn 是内容同步的源端)。 SyncModule, diff --git a/services/api/src/app-ov.module.ts b/services/api/src/app-ov.module.ts index 0760528..4239a58 100644 --- a/services/api/src/app-ov.module.ts +++ b/services/api/src/app-ov.module.ts @@ -22,6 +22,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { LoggerModule } from 'nestjs-pino'; import { AllExceptionsFilter } from './all-exceptions.filter'; import { CleanupModule } from './cleanup/cleanup.module'; +import { EmbedOvModule } from './embed-ov/embed-ov.module'; import { EmergencyModule } from './emergency/emergency.module'; import { HealthController } from './health.controller'; import { InteractionsModule } from './interactions/interactions.module'; @@ -43,6 +44,9 @@ import { SyncInboxModule } from './sync-inbox/sync-inbox.module'; EmergencyModule, // ov 公开读(P2 Task 6):/public/albums/* 读 ov 副本(含 sort=hot 快照)。 PublicOvModule, + // 展示位公开读(P4b Task 2):/public/embed-slots/* 读 slot-state 副本 + // (嵌入组件数据源:近期照片 + ETag/304 + slotToken+IP 限速)。 + EmbedOvModule, // ov 公开互动(P2 Task 7):POST /public/albums/*/photos/*/likes。 InteractionsModule, // ov 实时广播(P2 Task 7):WS /v1/realtime(适配器见 main-ov)。 diff --git a/services/api/src/embed-ov/embed-ov.controller.ts b/services/api/src/embed-ov/embed-ov.controller.ts new file mode 100644 index 0000000..f3e18d6 --- /dev/null +++ b/services/api/src/embed-ov/embed-ov.controller.ts @@ -0,0 +1,74 @@ +// services/api/src/embed-ov/embed-ov.controller.ts —— 展示位公开端点 +// (P4b Task 2,ov 域)。路由与契约 getPublicEmbedSlot 一致(tags [public], +// /public/embed-slots/{slotToken},匿名只读——嵌入 iframe 轮询调用)。 +// ETag/304 判定在控制器(需读 If-None-Match 头;服务产出 body+ETag): +// · If-None-Match 匹配(含 * 通配与弱校验器 W/ 前缀、逗号多值)→ 304 +// 无响应体、仍携带 ETag(passthrough 模式下 return undefined 即空 body); +// · 200 携带 ETag 头 + 完整载荷。 +// IP 取 req.ip 并归一 IPv6 映射前缀(同 visits 口径),仅作限速键。 +// 响应类型取结构子集(status/set 两方法)而非 express 类型——api 包不直接 +// 依赖 express 类型声明(同 request-id.middleware 的 node:http 口径)。 +import { + Controller, + Get, + Headers, + Ip, + Param, + Query, + Res, +} from '@nestjs/common'; +import { + EmbedOvService, + type PublicEmbedSlotResponse, +} from './embed-ov.service'; + +/** passthrough 响应的结构子集:写状态码与响应头(返回值仍走 Nest 序列化)。 */ +interface PassthroughResponse { + status(code: number): PassthroughResponse; + set(field: string, value: string): PassthroughResponse; +} + +/** + * If-None-Match 匹配判定:逗号分隔多值,逐个剥离 W/ 前缀后与当前 ETag + * 全等比较;`*` 匹配任意当前状态(RFC 9110 §13.1.2最小实现)。 + */ +export function etagMatches(ifNoneMatch: string, etag: string): boolean { + for (const raw of ifNoneMatch.split(',')) { + let candidate = raw.trim(); + if (candidate.startsWith('W/')) { + candidate = candidate.slice(2).trim(); + } + if (candidate === etag || candidate === '*') { + return true; + } + } + return false; +} + +@Controller('public/embed-slots') +export class EmbedOvController { + constructor(private readonly embedOvService: EmbedOvService) {} + + @Get(':slotToken') + async getPublicEmbedSlot( + @Param('slotToken') slotToken: string, + @Query('lang') lang: string | undefined, + @Headers('if-none-match') ifNoneMatch: string | undefined, + @Ip() ip: string | undefined, + @Res({ passthrough: true }) res: PassthroughResponse, + ): Promise { + const clientIp = ip === undefined ? undefined : ip.replace(/^::ffff:/, ''); + const { body, etag } = await this.embedOvService.getPublicEmbedSlot( + slotToken, + lang, + clientIp, + ); + if (ifNoneMatch !== undefined && etagMatches(ifNoneMatch, etag)) { + // 304:无响应体,仍携带 ETag(契约 304 语义;passthrough 下不写 body)。 + res.status(304).set('ETag', etag); + return undefined; + } + res.set('ETag', etag); + return body; + } +} diff --git a/services/api/src/embed-ov/embed-ov.module.ts b/services/api/src/embed-ov/embed-ov.module.ts new file mode 100644 index 0000000..2a4e8cb --- /dev/null +++ b/services/api/src/embed-ov/embed-ov.module.ts @@ -0,0 +1,18 @@ +// services/api/src/embed-ov/ —— 展示位公开读模块(P4b Task 2,仅 ov 入口装配: +// 嵌入组件数据源部署在观众域名,读 slot-state 副本)。装配 EmbedOvController/ +// EmbedOvService + OvPrismaModule + OvRedisModule(限速)+ StorageOvModule +// (thumb 现签,与 public-ov/interactions 共享同一只读实例)。无认证语义 +// (公开端点匿名,持有 slotToken 即可访问)。 +import { Module } from '@nestjs/common'; +import { OvPrismaModule } from '../prisma-ov/ov-prisma.module'; +import { OvRedisModule } from '../redis-ov/ov-redis.module'; +import { StorageOvModule } from '../public-ov/storage-ov.module'; +import { EmbedOvController } from './embed-ov.controller'; +import { EmbedOvService } from './embed-ov.service'; + +@Module({ + imports: [OvPrismaModule, OvRedisModule, StorageOvModule], + controllers: [EmbedOvController], + providers: [EmbedOvService], +}) +export class EmbedOvModule {} diff --git a/services/api/src/embed-ov/embed-ov.service.ts b/services/api/src/embed-ov/embed-ov.service.ts new file mode 100644 index 0000000..3a1b3b8 --- /dev/null +++ b/services/api/src/embed-ov/embed-ov.service.ts @@ -0,0 +1,230 @@ +// services/api/src/embed-ov/embed-ov.service.ts —— 展示位公开数据(P4b Task 2, +// ov 域)。契约权威:getPublicEmbedSlot(openapi tags [public],路径前缀不变、 +// 部署域名区分——嵌入 iframe 轮询的数据源)。流程: +// +// ① 展示位副本:slotToken → embed_slots_ov;不存在 / enabled=false / +// albumPublicToken null 统一 404 NOT_FOUND(同码同文案防探测,契约语义); +// ② 相册副本:albumPublicToken → albums_ov + isAlbumVisibleOv(含紧急停用; +// 相册下线/关闭公开 → 同 404——嵌入须尊重相册公开状态); +// ③ 基础限速(Redis ov 固定窗,slotToken+IP 键,EMBED_WINDOW_REQUESTS/ +// EMBED_WINDOW_SECONDS;404 校验不消耗预算——同 visits ①→③ 排序口径); +// ④ 照片:photos_ov published 近期 20(publishedAt+photoId 稳定倒序), +// thumb 现签(小尺寸省流量,与公开列表 browse 大图区分);宽高缺失跳过 +// (同 public-ov 防御语义); +// ⑤ 标题解析:lang=auto(缺省)或不支持时按相册 defaultLocale 回退(不报 +// 400,与公开相册一致);defaultLocale 翻译缺失属装配异常 → 5xx 兜底; +// ⑥ ETag 四元组 `"{configVersion}-{photoCount}-{maxPublishedAtMs}-{maxPhotoId}"` +// (契约评审修正:含照片条数与最新发布时间——新照片发布必变,避免仅比对 +// 实体版本时新照片小版本导致 304 陈旧);If-None-Match 匹配 → 304 无body +// (判定在控制器,服务只产出 body+ETag)。空照片列:后两元取 0 与空串。 +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import type { components } from '@photo/contracts'; +import { + EMBED_WINDOW_REQUESTS, + EMBED_WINDOW_SECONDS, + ErrorCodes, +} from '@photo/contracts'; +import { ApiException } from '../api-exception'; +import { isAlbumVisibleOv } from '../shared/album-visibility'; +import { OvPrismaService } from '../prisma-ov/ov-prisma.module'; +import { OvRedisService } from '../redis-ov/ov-redis.module'; +import { signedUrlTtlSeconds } from '../uploads/support'; +import { STORAGE_PORT_OV } from '../public-ov/storage-ov.provider'; +import type { StoragePort } from '@photo/storage'; + +export type PublicEmbedSlotResponse = components['schemas']['PublicEmbedSlot']; +type PublicEmbedAlbumResponse = NonNullable< + PublicEmbedSlotResponse['album'] +>; +type SignedUrlResponse = components['schemas']['SignedUrl']; + +/** 公开照片条数上限(契约 description:近期已发布 ≤20)。 */ +export const EMBED_FEED_LIMIT = 20; + +/** slotToken+IP 固定窗内请求数上限(惰性读 env,同 visits 口径)。 */ +export function embedWindowRequests(): number { + const parsed = Number.parseInt(process.env.EMBED_WINDOW_REQUESTS ?? '', 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : EMBED_WINDOW_REQUESTS; +} + +/** 展示位限速固定窗秒数(惰性读 env)。 */ +export function embedWindowSeconds(): number { + const parsed = Number.parseInt(process.env.EMBED_WINDOW_SECONDS ?? '', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : EMBED_WINDOW_SECONDS; +} + +/** 防探测统一 404(NOT_FOUND;不存在/停用/未绑定/相册不可见同码同文案)。 */ +function embedSlotNotFound(): ApiException { + return new ApiException( + ErrorCodes.NOT_FOUND, + '展示位不存在或不可用', + HttpStatus.NOT_FOUND, + ); +} + +function rateLimited(): ApiException { + return new ApiException( + ErrorCodes.RATE_LIMITED, + '请求过于频繁,请稍后再试', + HttpStatus.TOO_MANY_REQUESTS, + ); +} + +/** albums_ov.translations JSON 元素形状(sync payload 原文)。 */ +interface TranslationEntry { + locale: string; + title: string; +} + +/** 服务产出:响应体 + 强 ETag(304 判定归控制器——需读 If-None-Match 头)。 */ +export interface EmbedSlotFeed { + body: PublicEmbedSlotResponse; + etag: string; +} + +@Injectable() +export class EmbedOvService { + constructor( + private readonly prisma: OvPrismaService, + private readonly redis: OvRedisService, + @Inject(STORAGE_PORT_OV) private readonly storage: StoragePort, + ) {} + + /** + * 展示位公开数据(①-⑥ 见文件头)。IP 仅作限速键——不落库、不进日志 + * (匿名观众端无 IP 留存需求,同 visits/likes 约定)。 + */ + async getPublicEmbedSlot( + slotToken: string, + langRaw: string | undefined, + clientIp: string | undefined, + ): Promise { + // ① 展示位副本(防探测 404)。 + const slot = await this.prisma.embed_slots_ov.findUnique({ + where: { slotToken }, + }); + if (slot === null || !slot.enabled || slot.albumPublicToken === null) { + throw embedSlotNotFound(); + } + // ② 相册副本(下线/关闭公开/紧急停用 → 同 404)。 + const album = await this.prisma.albums_ov.findUnique({ + where: { publicToken: slot.albumPublicToken }, + }); + if (album === null || !isAlbumVisibleOv(album)) { + throw embedSlotNotFound(); + } + // ③ 基础限速(校验通过后才计预算)。 + await this.assertRateLimit(slotToken, clientIp); + + // ④ 近期已发布照片(稳定倒序,上限 20;宽高缺失跳过)。 + const rows = ( + await this.prisma.photos_ov.findMany({ + where: { albumId: album.albumId, visibility: 'published' }, + orderBy: [{ publishedAt: 'desc' }, { photoId: 'desc' }], + take: EMBED_FEED_LIMIT, + }) + ).filter((p) => p.width !== null && p.height !== null); + const photos: PublicEmbedSlotResponse['photos'] = []; + for (const photo of rows) { + photos.push({ + photoId: photo.photoId, + thumb: await this.presign(photo.thumbObjectKey), + width: photo.width as number, + height: photo.height as number, + }); + } + + // ⑤ 标题回退解析(auto/缺省/不支持 → defaultLocale,不报 400)。 + const title = this.resolveTitle( + this.translationEntries(album.translations), + album.defaultLocale, + langRaw, + ); + + // ⑥ ETag 四元组:取「实际返回」的首行(倒序即最新)——计数与发布时间均 + // 以响应内容为准,被防御跳过的行不影响校验器(响应不变则 304 语义成立)。 + const newest = rows[0]; + const etag = `"${slot.configVersion}-${photos.length}-${ + newest?.publishedAt?.getTime() ?? 0 + }-${newest?.photoId ?? ''}"`; + + return { + body: { + slotToken, + configVersion: slot.configVersion, + album: { + publicToken: album.publicToken, + title, + // DB 存自由字符串;取值来自相册创建/更新入参的枚举校验,收窄为契约 Locale。 + defaultLocale: album.defaultLocale as PublicEmbedAlbumResponse['defaultLocale'], + }, + photos, + }, + etag, + }; + } + + /** slotToken+IP 固定窗限速(Redis INCR + 首次 EXPIRE;同 visits 模式)。 */ + private async assertRateLimit( + slotToken: string, + clientIp: string | undefined, + ): Promise { + const key = `embed:window:${slotToken}|${clientIp ?? ''}`; + const counted = await this.redis.incr(key); + if (counted === 1) { + await this.redis.expire(key, embedWindowSeconds()).catch(() => undefined); + } + if (counted > embedWindowRequests()) { + throw rateLimited(); + } + } + + /** translations JSON → 类型化条目(非数组属投影异常,按空集走 5xx 兜底)。 */ + private translationEntries(value: unknown): TranslationEntry[] { + return Array.isArray(value) + ? (value as TranslationEntry[]).filter( + (t) => + typeof t?.locale === 'string' && typeof t?.title === 'string', + ) + : []; + } + + /** + * 标题解析:lang 合法(契约 EmbedSlotLang 非 auto)且副本有条目 → 用之; + * 否则 defaultLocale(defaultLocale 条目缺失属装配异常 → 5xx,同公开相册 + * 口径:公开端点不得以半翻译状态 200)。 + */ + private resolveTitle( + entries: TranslationEntry[], + defaultLocale: string, + langRaw: string | undefined, + ): string { + const byLocale = new Map(entries.map((row) => [row.locale, row.title])); + const defaultTitle = byLocale.get(defaultLocale); + if (defaultTitle === undefined) { + throw new ApiException( + ErrorCodes.INTERNAL_ERROR, + 'Internal server error', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + if ( + langRaw !== undefined && + langRaw !== 'auto' && + (langRaw === 'zh-CN' || langRaw === 'en') + ) { + const title = byLocale.get(langRaw); + if (title !== undefined) { + return title; + } + } + return defaultTitle; + } + + /** 现签短期 GET 凭证(TTL 惰性读取;SigV4 本地计算无网络副作用)。 */ + private async presign(objectKey: string): Promise { + const ttl = signedUrlTtlSeconds(); + const url = await this.storage.presignGet(objectKey, ttl); + return { url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() }; + } +} diff --git a/services/api/src/embed/dto.ts b/services/api/src/embed/dto.ts new file mode 100644 index 0000000..0178de4 --- /dev/null +++ b/services/api/src/embed/dto.ts @@ -0,0 +1,92 @@ +// services/api/src/embed/dto.ts —— 展示位管理端点入参 DTO(P4b Task 2)。 +// 校验约束对齐契约 EmbedSlotCreateRequest / EmbedSlotUpdateRequest / +// EmbedSlotSettings: +// · name 1..64(VarChar(64) 列宽); +// · settings 整体提交语义:创建可整体缺省(各字段按缺省值落库,服务层 +// 归一),提供时字段齐全——嵌套 DTO 无 @IsOptional,缺字段 400; +// · albumId [string,'null']:非 null 绑定/换绑(≤64,绑定存在性校验在 +// 服务层事务内),null 解绑。@IsOptional 对 undefined/null 均跳过校验 +// 且 null 值保留在 DTO 上——服务层以 `!== undefined` 区分「未提交」与 +// 「显式 null」(@ValidateIf 回调收到的是整个 DTO 对象而非属性值, +// 不能用于按值判空); +// · 更新请求 minProperties:1(空体 400)在服务层裁决(DTO 层无法表达, +// 同相册 update 口径)。 +import { Type } from 'class-transformer'; +import { + IsBoolean, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +/** 契约 EmbedSlotLang 枚举(auto=按相册 defaultLocale)。 */ +export const EMBED_SLOT_LANGS = ['auto', 'zh-CN', 'en'] as const; +export type EmbedSlotLang = (typeof EMBED_SLOT_LANGS)[number]; + +/** 契约 EmbedSlotSettings 字段枚举与边界(intervalMs 3000..30000)。 */ +const EMBED_SLOT_POSITIONS = ['left', 'right'] as const; +const EMBED_SLOT_SIZES = ['sm', 'md'] as const; +export const EMBED_INTERVAL_MS_MIN = 3000; +export const EMBED_INTERVAL_MS_MAX = 30000; + +export class EmbedSlotSettingsDto { + @IsIn(EMBED_SLOT_POSITIONS) + position!: (typeof EMBED_SLOT_POSITIONS)[number]; + + @IsIn(EMBED_SLOT_SIZES) + size!: (typeof EMBED_SLOT_SIZES)[number]; + + @IsInt() + @Min(EMBED_INTERVAL_MS_MIN) + @Max(EMBED_INTERVAL_MS_MAX) + intervalMs!: number; + + @IsIn(EMBED_SLOT_LANGS) + lang!: EmbedSlotLang; + + @IsBoolean() + collapsedDefault!: boolean; +} + +export class EmbedSlotCreateRequestDto { + @IsString() + @IsNotEmpty() + @MaxLength(64) + name!: string; + + /** 缺省整体落库默认值;提供时字段齐全(嵌套必填)。null 视同缺省(服务层)。 */ + @IsOptional() + @ValidateNested() + @Type(() => EmbedSlotSettingsDto) + settings?: EmbedSlotSettingsDto | null; +} + +export class EmbedSlotUpdateRequestDto { + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(64) + name?: string; + + @IsOptional() + @IsBoolean() + enabled?: boolean; + + /** 整体替换;null 视同未提交(服务层以 !== undefined 判定提交与否)。 */ + @IsOptional() + @ValidateNested() + @Type(() => EmbedSlotSettingsDto) + settings?: EmbedSlotSettingsDto | null; + + /** 非字符串给值时拒绝(null 走 @IsOptional 放行 = 解绑语义)。 */ + @IsOptional() + @IsString() + @MaxLength(64) + albumId?: string | null; +} diff --git a/services/api/src/embed/embed.controller.ts b/services/api/src/embed/embed.controller.ts new file mode 100644 index 0000000..ee29069 --- /dev/null +++ b/services/api/src/embed/embed.controller.ts @@ -0,0 +1,64 @@ +// services/api/src/embed/embed.controller.ts —— /admin/embed-slots 端点 +// (P4b Task 2,仅 cn 入口装配)。方法名与契约 operationId 一一对应 +// (listEmbedSlots/createEmbedSlot/updateEmbedSlot,tag [embed-slots])。 +// 控制器级 SessionGuard:三端点全需认证(契约 bearerAuth);admin 判定在 +// 服务层(AuthUser.role,403 AUTH_FORBIDDEN——同 admin-users 口径)。查询参数 +// 规整复用 shared/pagination(缺省 20、1..100,与契约 limit bounds 一致)。 +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { userOf } from '../albums/albums.controller'; +import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard'; +import { parseCursor, parseLimit } from '../shared/pagination'; +import { + EmbedSlotCreateRequestDto, + EmbedSlotUpdateRequestDto, +} from './dto'; +import { + EmbedSlotsService, + type EmbedSlotListResponse, + type EmbedSlotResponse, +} from './embed.service'; + +@Controller('admin/embed-slots') +@UseGuards(SessionGuard) +export class EmbedSlotsController { + constructor(private readonly embedSlotsService: EmbedSlotsService) {} + + /** 展示位列表(createdAt+id 稳定倒序键集游标)。 */ + @Get() + listEmbedSlots( + @Query('cursor') cursor: string | undefined, + @Query('limit') limit: string | undefined, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.embedSlotsService.list(userOf(req), parseCursor(cursor), parseLimit(limit)); + } + + /** 创建展示位(publicToken 服务端生成;初始未绑定、configVersion=1)。 */ + @Post() + createEmbedSlot( + @Body() dto: EmbedSlotCreateRequestDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.embedSlotsService.create(userOf(req), dto); + } + + /** 部分更新(任一管理面变更递增 configVersion 并触发 slot-state 同步)。 */ + @Patch(':id') + updateEmbedSlot( + @Param('id') id: string, + @Body() dto: EmbedSlotUpdateRequestDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.embedSlotsService.update(userOf(req), id, dto); + } +} diff --git a/services/api/src/embed/embed.module.ts b/services/api/src/embed/embed.module.ts new file mode 100644 index 0000000..0eb39f2 --- /dev/null +++ b/services/api/src/embed/embed.module.ts @@ -0,0 +1,12 @@ +// services/api/src/embed/ —— 展示位管理模块(P4b Task 2,仅 cn 入口装配: +// 展示位是 cn 权威实体,ov 侧只承接 slot-state 副本与公开读端点)。 +// PrismaModule 为 @Global,直接注入;无 Redis/存储依赖(管理面纯 DB 写)。 +import { Module } from '@nestjs/common'; +import { EmbedSlotsController } from './embed.controller'; +import { EmbedSlotsService } from './embed.service'; + +@Module({ + controllers: [EmbedSlotsController], + providers: [EmbedSlotsService], +}) +export class EmbedModule {} diff --git a/services/api/src/embed/embed.service.ts b/services/api/src/embed/embed.service.ts new file mode 100644 index 0000000..e25d6a6 --- /dev/null +++ b/services/api/src/embed/embed.service.ts @@ -0,0 +1,406 @@ +// services/api/src/embed/embed.service.ts —— 展示位管理核心逻辑(P4b Task 2)。 +// 契约权威:packages/contracts/openapi.yaml tags [embed-slots] +// (listEmbedSlots/createEmbedSlot/updateEmbedSlot)。要点: +// · admin-only(AuthUser.role;P4b 权限简化:embed_slot_members 建而未用, +// 被授权面 P5 按需补——计划 Task 1 登记偏差),非 admin 403 AUTH_FORBIDDEN; +// · publicToken:24 字符 [0-9a-z](randomBytes 均匀映射,同 albums 口径), +// P2002 撞唯一索引时整事务重试一次; +// · create:单事务 embed_slots + outbox slot.updated(v1 全量快照——创建即 +// 未绑定态也同步,ov 副本与 sync_state 从第一版起对齐)+ audit_logs; +// · update:条件更新(WHERE entityVersion=读取版本,同 albums 乐观重试 +// 模式)。任一管理面字段提交即 configVersion+1 **且** entityVersion+1 +// (双版本位,同一 updateMany 原子推进),事务内写 outbox slot.updated +// 载荷=更新后全量。albumId 绑定/换绑校验相册存在(404 ALBUM_NOT_FOUND, +// 相册行顺带取 publicToken 供载荷),null 解绑(albumPublicToken null)。 +// 停用迁移(enabled true→false)高优先级(ov 观众侧尽快 404,同相册 +// publicEnabled 口径); +// · settings 整体替换;存储值恒为归一化全集(normalizeSettings); +// · 列表:createdAt+id 稳定倒序键集游标(shared/cursor),albumTitle 按绑定 +// 相册 defaultLocale 解析(album_translations 二次查询,应用层组装—— +// 扁平字符串外键无 relation,同相册域约定)。 +// 无删除端点:enabled=false 即下线(软停用,契约语义)。 +import { randomBytes } from 'node:crypto'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { ErrorCodes } from '@photo/contracts'; +import type { components } from '@photo/contracts'; +import type { Prisma } from '@photo/db-cn'; +import { ApiException } from '../api-exception'; +import type { AuthUser } from '../auth/session.guard'; +import { PrismaService } from '../prisma/prisma.service'; +import { + decodeKeysetCursor, + encodeKeysetCursor, + keysetWhereDesc, +} from '../shared/cursor'; +import { buildSlotUpdatedPayload } from '../sync/payloads'; +import type { + EmbedSlotCreateRequestDto, + EmbedSlotSettingsDto, + EmbedSlotUpdateRequestDto, +} from './dto'; +import { + albumNotFound, + isUniqueViolationOn, + validationFailed, +} from '../albums/support'; + +export type EmbedSlotResponse = components['schemas']['EmbedSlot']; +export type EmbedSlotListResponse = components['schemas']['EmbedSlotList']; +export type EmbedSlotSettingsResponse = + components['schemas']['EmbedSlotSettings']; + +const PUBLIC_TOKEN_LENGTH = 24; +const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; + +/** 契约 EmbedSlotSettings 各字段缺省值(整体缺省时落库形态)。 */ +export const DEFAULT_EMBED_SLOT_SETTINGS: EmbedSlotSettingsResponse = { + position: 'right', + size: 'md', + intervalMs: 5000, + lang: 'auto', + collapsedDefault: false, +}; + +/** + * updateEmbedSlot 审计 metadata.fields 的字段名序列(契约 EmbedSlotUpdateRequest + * 属性序,同 albums UPDATE_FIELD_ORDER 约定:固定顺序保证同形请求同形审计)。 + */ +const UPDATE_FIELD_ORDER = [ + 'name', + 'enabled', + 'settings', + 'albumId', +] as const satisfies readonly (keyof EmbedSlotUpdateRequestDto)[]; + +function generatePublicToken(): string { + const bytes = randomBytes(PUBLIC_TOKEN_LENGTH); + let token = ''; + for (let i = 0; i < PUBLIC_TOKEN_LENGTH; i++) { + // %36 模偏差对 124bit 令牌熵无实际影响(同 albums 口径)。 + token += PUBLIC_TOKEN_ALPHABET[bytes[i] % PUBLIC_TOKEN_ALPHABET.length]; + } + return token; +} + +/** 入参 settings → 归一化存储值(null/undefined=整体缺省 → 契约缺省值)。 */ +function normalizeSettings( + settings: EmbedSlotSettingsDto | null | undefined, +): EmbedSlotSettingsResponse { + if (settings === null || settings === undefined) { + return { ...DEFAULT_EMBED_SLOT_SETTINGS }; + } + return { + position: settings.position, + size: settings.size, + intervalMs: settings.intervalMs, + lang: settings.lang, + collapsedDefault: settings.collapsedDefault, + }; +} + +function forbidden(): ApiException { + return new ApiException( + ErrorCodes.AUTH_FORBIDDEN, + '仅管理员可管理展示位', + HttpStatus.FORBIDDEN, + ); +} + +function slotNotFound(): ApiException { + return new ApiException( + ErrorCodes.NOT_FOUND, + '展示位不存在', + HttpStatus.NOT_FOUND, + ); +} + +type EmbedSlotRow = Prisma.embed_slotsGetPayload; + +@Injectable() +export class EmbedSlotsService { + constructor(private readonly prisma: PrismaService) {} + + /** P4b 权限简化:仅公司 admin 可管理展示位(被授权面 P5)。 */ + private assertAdmin(user: AuthUser): void { + if (user.role !== 'admin') { + throw forbidden(); + } + } + + /** 展示位列表:createdAt+id 稳定倒序键集游标(同 albums.list 模式)。 */ + async list( + user: AuthUser, + cursor: string | undefined, + limit: number, + ): Promise { + this.assertAdmin(user); + let decoded: ReturnType = null; + if (cursor !== undefined && cursor !== '') { + decoded = decodeKeysetCursor(cursor); + if (decoded === null) { + throw validationFailed('cursor 不合法'); + } + } + const rows = await this.prisma.embed_slots.findMany({ + where: decoded === null ? {} : keysetWhereDesc('createdAt', 'id', decoded), + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: limit + 1, // 多取一条探测 hasMore(避免额外 count) + }); + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const titles = await this.albumTitlesByDefaultLocale( + this.prisma, + page.map((s) => s.albumId), + ); + const last = page.at(-1); + const nextCursor = + hasMore && last !== undefined + ? encodeKeysetCursor({ + sort: last.createdAt.toISOString(), + id: last.id, + }) + : null; + return { + items: page.map((row) => this.toResponse(row, titles.get(row.albumId ?? '') ?? null)), + nextCursor, + hasMore: nextCursor !== null, + }; + } + + /** 创建展示位:事务三写(embed_slots/outbox slot.updated v1/audit)。 */ + async create( + user: AuthUser, + dto: EmbedSlotCreateRequestDto, + ): Promise { + this.assertAdmin(user); + const settings = normalizeSettings(dto.settings); + + let slotId: string; + for (let attempt = 0; ; attempt++) { + try { + slotId = await this.prisma.$transaction(async (tx) => { + const slot = await tx.embed_slots.create({ + data: { + publicToken: generatePublicToken(), + name: dto.name, + settings: settings as unknown as Prisma.InputJsonValue, + // enabled/configVersion/entityVersion 走 schema 默认。 + }, + }); + // 创建即同步未绑定态(albumPublicToken null):ov 副本行与 sync_state + // 版本账本从 v1 起对齐,后续换绑/停用事件有单调基准。outbox.albumId + // NOT NULL,未绑定以空串占位(ov 侧 slot-state 不做相册依赖暂存)。 + await tx.outbox_events.create({ + data: { + type: 'slot.updated', + albumId: slot.albumId ?? '', + entityVersion: slot.entityVersion, + payload: buildSlotUpdatedPayload( + slot, + null, + ) as unknown as Prisma.InputJsonValue, + }, + }); + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'embed_slot.create', + resourceId: slot.id, + }, + }); + return slot.id; + }); + break; + } catch (err) { + // publicToken 撞唯一索引:换令牌整事务重来一次(同 albums 口径)。 + if (attempt === 0 && isUniqueViolationOn(err, 'publicToken')) { + continue; + } + throw err; + } + } + return this.loadSlot(slotId); + } + + /** + * 部分更新(契约 updateEmbedSlot):仅提交的字段被修改;任一管理面字段 + * 提交即 configVersion+1 且 entityVersion+1(同一条件 updateMany 原子推进, + * WHERE entityVersion=读取版本,并发败方整事务重跑——同 albums.update)。 + * albumId 非空校验相册存在(404 ALBUM_NOT_FOUND)并取 publicToken 入载荷; + * null 解绑。settings 整体替换。事务内写 outbox slot.updated(载荷=更新后 + * 全量快照)+ audit(metadata.fields=提交字段名)。 + */ + async update( + user: AuthUser, + slotId: string, + dto: EmbedSlotUpdateRequestDto, + ): Promise { + this.assertAdmin(user); + const fields = UPDATE_FIELD_ORDER.filter( + (key) => dto[key] !== undefined, + ) as string[]; + if (fields.length === 0) { + throw validationFailed('请求体至少需要包含一个待更新字段'); + } + + let outcome: 'done' | 'retry' = 'retry'; + for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) { + outcome = await this.prisma.$transaction(async (tx) => { + const slot = await tx.embed_slots.findUnique({ where: { id: slotId } }); + if (slot === null) { + throw slotNotFound(); + } + + // albumId 裁决:显式 null=解绑;字符串=绑定/换绑(相册须存在,顺带取 + // publicToken 供事件载荷);未提交=保持(载荷仍需现绑相册的 token)。 + let nextAlbumId = slot.albumId; + let nextAlbumPublicToken: string | null = null; + if (dto.albumId !== undefined) { + nextAlbumId = dto.albumId; + } + if (nextAlbumId !== null) { + const album = await tx.albums.findUnique({ + where: { id: nextAlbumId }, + select: { publicToken: true }, + }); + if (album === null) { + throw albumNotFound(); + } + nextAlbumPublicToken = album.publicToken; + } + + const claimed = await tx.embed_slots.updateMany({ + where: { id: slotId, entityVersion: slot.entityVersion }, + data: { + name: dto.name, + enabled: dto.enabled, + settings: + dto.settings === undefined || dto.settings === null + ? undefined + : (normalizeSettings(dto.settings) as unknown as Prisma.InputJsonValue), + albumId: dto.albumId === undefined ? undefined : dto.albumId, + configVersion: { increment: 1 }, + entityVersion: { increment: 1 }, + }, + }); + if (claimed.count === 0) { + return 'retry' as const; + } + + const updated = await tx.embed_slots.findUniqueOrThrow({ + where: { id: slotId }, + }); + // 停用迁移(true→false)高优先级:ov 观众侧须尽快 404(同相册 + // publicEnabled 关闭口径);已在停用态的常规更新保持 normal。 + await tx.outbox_events.create({ + data: { + type: 'slot.updated', + albumId: updated.albumId ?? '', + entityVersion: updated.entityVersion, + priority: + dto.enabled === false && slot.enabled === true ? 'high' : 'normal', + payload: buildSlotUpdatedPayload( + updated, + nextAlbumPublicToken, + ) as unknown as Prisma.InputJsonValue, + }, + }); + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'embed_slot.update', + resourceId: slotId, + metadata: { fields }, + }, + }); + return 'done' as const; + }); + } + if (outcome !== 'done') { + throw new ApiException( + ErrorCodes.INTERNAL_ERROR, + 'Internal server error', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + return this.loadSlot(slotId); + } + + /** 组装 EmbedSlot 响应(albumTitle 按绑定相册 defaultLocale 解析)。 */ + private async loadSlot(slotId: string): Promise { + const slot = await this.prisma.embed_slots.findUnique({ + where: { id: slotId }, + }); + if (slot === null) { + throw slotNotFound(); + } + const title = slot.albumId === null + ? null + : (await this.albumTitlesByDefaultLocale(this.prisma, [slot.albumId])).get( + slot.albumId, + ) ?? null; + return this.toResponse(slot, title); + } + + private toResponse( + slot: EmbedSlotRow, + albumTitle: string | null, + ): EmbedSlotResponse { + return { + id: slot.id, + publicToken: slot.publicToken, + name: slot.name, + enabled: slot.enabled, + albumId: slot.albumId, + albumTitle, + configVersion: slot.configVersion, + settings: slot.settings as EmbedSlotSettingsResponse, + updatedAt: slot.updatedAt.toISOString(), + }; + } + + /** + * 相册 id 集 → albumTitle(按各相册 defaultLocale 解析)。绑定相册的 + * defaultLocale 翻译行由相册创建/更新路径保证必在;缺行(防御)回退该相册 + * 任意翻译行(locale 升序首个,确定性),再缺则为 null(与未绑定同形—— + * 管理列表展示字段,不因脏数据 5xx)。 + */ + private async albumTitlesByDefaultLocale( + client: PrismaService | Prisma.TransactionClient, + albumIds: Array, + ): Promise> { + const ids = [...new Set(albumIds.filter((id): id is string => id !== null))]; + const result = new Map(); + if (ids.length === 0) { + return result; + } + const albums = await client.albums.findMany({ + where: { id: { in: ids } }, + select: { id: true, defaultLocale: true }, + }); + const localesByAlbum = new Map(albums.map((a) => [a.id, a.defaultLocale])); + const translations = await client.album_translations.findMany({ + where: { albumId: { in: ids } }, + orderBy: { locale: 'asc' }, + }); + const byAlbum = new Map(); + for (const row of translations) { + const bucket = byAlbum.get(row.albumId); + if (bucket === undefined) { + byAlbum.set(row.albumId, [row]); + } else { + bucket.push(row); + } + } + for (const [albumId, defaultLocale] of localesByAlbum) { + const rows = byAlbum.get(albumId) ?? []; + const title = + rows.find((r) => r.locale === defaultLocale)?.title ?? + rows[0]?.title; + if (title !== undefined) { + result.set(albumId, title); + } + } + return result; + } +} diff --git a/services/api/src/sync-inbox/sync-inbox.service.ts b/services/api/src/sync-inbox/sync-inbox.service.ts index 68e55b7..02e926b 100644 --- a/services/api/src/sync-inbox/sync-inbox.service.ts +++ b/services/api/src/sync-inbox/sync-inbox.service.ts @@ -21,7 +21,8 @@ // 保留 likeTotal/countVersion 权威计数);photo.hidden/ // deleted → photos_ov.visibility 置位(行不存在 = 从未复制即不可见,缺席 // 即撤下语义);asset → banner 投影 albums_ov.bannerAssetKey(cover/logo -// 仅推进版本,ov schema 无位); +// 仅推进版本,ov schema 无位);slot-state(P4b Task 2)→ embed_slots_ov +// 全量快照直投(albumPublicToken 弱关联相册、无依赖暂存,见 ③ 注); // ⑤ sync_state upsert appliedVersion(watermark=false 实体行;水位行预留)。 // ⑥ 实时转发(Task 7):photo 事件**应用事务提交后**发布 OV_PHOTO_APPLIED // 进程内事件 → realtime 网关向相册房间广播契约 photo.* 消息(stale/ @@ -40,6 +41,7 @@ import type { CategoryUpdatedPayload, PhotoPublishedPayload, PhotoVisibilityChangedPayload, + SlotStateUpdatedPayload, } from '@photo/contracts'; import type { Prisma } from '@photo/db-ov'; // 值导入(非 import type):构造器注入的 design:paramtypes 依赖类引用, @@ -168,8 +170,11 @@ export class SyncInboxService { }); return { outcome: 'stale' }; } - // ③ 依赖检查:非 album 事件须所属相册副本已存在,否则暂存。 - if (event.entityType !== 'album') { + // ③ 依赖检查:非 album 事件须所属相册副本已存在,否则暂存。slot-state + // 豁免:展示位副本以 albumPublicToken 弱关联相册(公开读时按 albums_ov + // 现查,缺席=404 与相册下线同形),且解绑事件 albumId 为空串——相册行的 + // 到达顺序对副本正确性无影响,暂存反而会让解绑事件无限积压。 + if (event.entityType !== 'album' && event.entityType !== 'slot-state') { const album = await tx.albums_ov.findUnique({ where: { albumId: event.albumId }, select: { albumId: true }, @@ -399,7 +404,31 @@ export class SyncInboxService { // cover/logo:ov schema 无投影位(YAGNI,schema 注释),仅推进版本账本。 return undefined; } - // slot-state 预留:受理即 applied(无投影),版本账本照常推进。 + // slot-state(P4b Task 2):全量快照直投 embed_slots_ov(版本单调已由 ② + // 保证;同版本重断言同样走这里——整行替换幂等)。albumPublicToken null = + // 解绑(公开端随即 404);无墓碑,下线是 enabled=false 的快照替换。 + if (event.entityType === 'slot-state') { + const p = event.payload as unknown as SlotStateUpdatedPayload; + await tx.embed_slots_ov.upsert({ + where: { slotToken: p.publicToken }, + create: { + slotToken: p.publicToken, + enabled: p.enabled, + albumPublicToken: p.albumPublicToken ?? null, + configVersion: p.configVersion, + settings: p.settings as unknown as Prisma.InputJsonValue, + entityVersion: event.entityVersion, + }, + update: { + enabled: p.enabled, + albumPublicToken: p.albumPublicToken ?? null, + configVersion: p.configVersion, + settings: p.settings as unknown as Prisma.InputJsonValue, + entityVersion: event.entityVersion, + }, + }); + return undefined; + } return undefined; } diff --git a/services/api/src/sync/payloads.ts b/services/api/src/sync/payloads.ts index fdb3b3f..33d90f9 100644 --- a/services/api/src/sync/payloads.ts +++ b/services/api/src/sync/payloads.ts @@ -17,6 +17,8 @@ import type { CategoryDeletedPayload, CategoryUpdatedPayload, PhotoPublishedPayload, + SlotSettingsPayload, + SlotStateUpdatedPayload, } from '@photo/contracts'; import type { Prisma } from '@photo/db-cn'; import { ApiException } from '../api-exception'; @@ -51,6 +53,16 @@ type PhotoRow = Prisma.photosGetPayload; type PhotoVariantRow = Prisma.photo_variantsGetPayload< Prisma.photo_variantsDefaultArgs >; +type EmbedSlotRow = Pick< + Prisma.embed_slotsGetPayload, + | 'id' + | 'publicToken' + | 'enabled' + | 'albumId' + | 'configVersion' + | 'settings' + | 'entityVersion' +>; /** * album.updated 载荷:albums_ov 展示所需标量 + 翻译全集(locale 升序)。 @@ -131,6 +143,31 @@ export function buildAssetUpdatedPayload(asset: AssetRow): AssetUpdatedPayload { }; } +/** + * slot.updated 载荷(P4b Task 2):ov 副本重建展示位所需的全量快照。 + * albumPublicToken 由调用方按绑定相册行解析传入(未绑定传 null)——embed_slots + * 行只存 albumId,跨表解析归业务事务(绑定校验时已读取相册行,顺带取 + * publicToken,避免载荷构建器再查一次)。settings 为归一化存储值直通(写入 + * 侧已保证字段齐全)。 + */ +export function buildSlotUpdatedPayload( + slot: EmbedSlotRow, + albumPublicToken: string | null, +): SlotStateUpdatedPayload { + return { + slotId: slot.id, + publicToken: slot.publicToken, + enabled: slot.enabled, + albumId: slot.albumId, + albumPublicToken, + configVersion: slot.configVersion, + // Prisma JsonValue → 载荷接口(写入侧已保证形状;接口类型无索引签名, + // 需经 unknown 中转,同 outbox 写入侧约定)。 + settings: slot.settings as unknown as SlotSettingsPayload, + entityVersion: slot.entityVersion, + }; +} + /** * photo.published 载荷(契约全集)。能进入 published/hidden 态的照片必经发布 * 事务(变体行+尺寸+publishedAt 齐备);缺任一项属 post-publish 不变量破坏 diff --git a/services/api/test/embed.e2e-spec.ts b/services/api/test/embed.e2e-spec.ts new file mode 100644 index 0000000..d71cd47 --- /dev/null +++ b/services/api/test/embed.e2e-spec.ts @@ -0,0 +1,758 @@ +// services/api/test/embed.e2e-spec.ts —— P4b Task 2 展示位后端 e2e。 +// ONE spec 串「cn 管理 → slot-state 同步 → ov 公开读」全链路:进程内起两个 +// 真实入口(cn + ov,同 public-ov.e2e-spec 模式),管理动作全走 cn API, +// 发送器为生产 OutboxSender(指向 ov 入口真实 URL)——ov 读到的数据与生产 +// 链路逐字节同源。 +// +// 覆盖(任务验收): +// ① admin CRUD:创建缺省 settings/publicToken 形状/未绑定;非 admin 403; +// 未认证 401;settings 部分提交/越界 400;列表倒序 + 键集游标翻页; +// 创建事件同步后 ov 副本行就位(未绑定 → 公开端 404); +// ② 绑定相册:albumTitle 按相册 defaultLocale 解析;ov 公开数据 photos +// publishedAt 稳定倒序、thumb 现签可取回真实字节;lang 回退 +// (缺省/zh-CN/en/auto/非法值不 400);ETag 四元组 200 → If-None-Match +// 重复请求 304(无 body,ETag 仍携带); +// ③ 新照片发布 → 同步 → ETag 变化 → 200 含新照片(A11 数据路径); +// ④ 换绑相册:configVersion+1,ov 服务新相册照片(A22 换绑不改官网—— +// loader 只认 token);近期照片 20 上限与倒序; +// ⑤ 相册下线(publicEnabled=false)→ ov 公开端 404;恢复 → 200; +// ⑥ 解绑 → 404;复绑 → 200;停用(enabled=false)→ 404; +// ⑦ slot-state 幂等:同 eventId 重复受理不计应用(duplicate);同版本 +// 不同 eventId 重断言应用(幂等重投影);更低版本 stale 不回卷;更高 +// 版本直投生效(乱序收敛); +// ⑧ 限速:压小窗口(EMBED_WINDOW_REQUESTS 惰性 env)→ 超额 429 +// RATE_LIMITED(304 也消耗预算)。 +// 真实 MySQL×2 + MinIO(真实 JPEG 上传发布)+ HMAC 同步链路字节一致。 +import { randomBytes } from 'node:crypto'; +import { NestFactory } from '@nestjs/core'; +import { type NestExpressApplication } from '@nestjs/platform-express'; +import request from 'supertest'; +import sharp from 'sharp'; +import IORedis from 'ioredis'; +import { PrismaClient } from '@photo/db-cn'; +import { PrismaClient as OvPrismaClient } from '@photo/db-ov'; +import { ErrorCodes, signSyncBody } from '@photo/contracts'; +import { + createStorageFromEnv, + type MinioStorageAdapter, +} from '@photo/storage'; +import { AppCnModule } from '../src/app-cn.module'; +import { AppOvModule } from '../src/app-ov.module'; +import { seedAdmin } from '../src/scripts/seed-admin'; +import { configureApp } from '../src/shared/configure-app'; +import { hashSessionToken } from '../src/auth/session.guard'; +import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter'; +import { PublishService } from '../../worker/src/publish.service'; +import { resetDb } from './reset'; +import { resetOvDb } from './reset-ov'; +import { OutboxSender } from '../../sync/src/sender'; + +jest.setTimeout(180_000); + +const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me'; +const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me'; + +/** 测试内生成真实 JPEG(同 public-ov.e2e-spec;无二进制入库)。 */ +async function makeJpeg( + width: number, + height: number, + rgb: { r: number; g: number; b: number }, +): Promise { + return sharp({ create: { width, height, channels: 3, background: rgb } }) + .jpeg({ quality: 85 }) + .toBuffer(); +} + +/** 签名 POST(content-batches 内部端点,⑦ 幂等直推)。 */ +async function signedPost( + base: string, + path: string, + body: unknown, +): Promise { + const raw = JSON.stringify(body); + return fetch(`${base}${path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-sync-timestamp': String(Math.floor(Date.now() / 1000)), + 'x-sync-signature': signSyncBody(SYNC_KEY, raw), + }, + body: raw, + }); +} + +describe('embed slots (e2e)', () => { + let cnApp: NestExpressApplication; + let ovApp: NestExpressApplication; + let cnBase: string; + let ovBase: string; + let prismaCn: PrismaClient; + let prismaOv: OvPrismaClient; + let storage: MinioStorageAdapter; + let sender: OutboxSender; + let redisOv: IORedis; + let adminToken: string; + let staffToken: string; + + let slotId: string; + let slotToken: string; + /** 相册 A:zh-CN + en 双语、真实上传发布照片。 */ + let albumAId: string; + let albumAToken: string; + /** 相册 B:zh 单语 + 直种 photos_ov 副本行(20 上限验证)。 */ + let albumBId: string; + let albumBToken: string; + let albumBSeedIds: string[]; + + /** 排空发送器:循环 runOnce 直至无未投递事件(sync 侧 flush)。 */ + async function drainSender(): Promise { + for (let round = 0; round < 20; round++) { + const stats = await sender.runOnce(); + if (stats.picked === 0) { + return; + } + } + throw new Error('drainSender 超时:20 轮后 outbox 仍有未投递事件'); + } + + /** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */ + async function uploadAndPublish( + targetAlbumId: string, + clientTaskId: string, + jpeg: Buffer, + ): Promise { + const init = await request(cnBase) + .post('/v1/uploads') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + albumId: targetAlbumId, + clientTaskId, + filename: 'embed-e2e.jpg', + bytes: jpeg.length, + contentType: 'image/jpeg', + }); + expect(init.status).toBe(201); + const { uploadId, photoId, credential } = init.body as { + uploadId: string; + photoId: string; + credential: { url: string; method: string; headers: Record }; + }; + const put = await fetch(credential.url, { + method: credential.method, + headers: credential.headers, + body: new Uint8Array(jpeg), + }); + expect(put.ok).toBe(true); + const done = await request(cnBase) + .post(`/v1/uploads/${uploadId}/complete`) + .set('Authorization', `Bearer ${adminToken}`); + expect(done.status).toBe(200); + expect( + await new PublishService(prismaCn, storage).processPhoto(photoId), + ).toBe('published'); + return photoId; + } + + /** ov 公开端点 GET(可选 If-None-Match)。 */ + async function getEmbed( + token: string, + query = '', + ifNoneMatch?: string, + ): Promise { + const req = request(ovBase) + .get(`/v1/public/embed-slots/${token}${query}`) + .set('accept', 'application/json'); + if (ifNoneMatch !== undefined) { + req.set('If-None-Match', ifNoneMatch); + } + return req; + } + + beforeAll(async () => { + expect(SYNC_KEY).not.toBe(''); + await resetDb(); + await resetOvDb(); + + cnApp = await NestFactory.create(AppCnModule, { + rawBody: true, + }); + configureApp(cnApp); + await cnApp.listen(0); + cnBase = await cnApp.getUrl(); + + ovApp = await NestFactory.create(AppOvModule, { + rawBody: true, + }); + ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp)); + configureApp(ovApp); + await ovApp.listen(0); + ovBase = await ovApp.getUrl(); + + prismaCn = new PrismaClient(); + prismaOv = new OvPrismaClient(); + storage = createStorageFromEnv(); + sender = new OutboxSender({ + prisma: prismaCn, + ovApiUrl: ovBase, + internalKey: SYNC_KEY, + }); + redisOv = new IORedis(process.env.REDIS_URL_OV ?? 'redis://127.0.0.1:6381', { + maxRetriesPerRequest: 2, + }); + redisOv.on('error', () => undefined); + + await seedAdmin(prismaCn, ADMIN_ACCOUNT, ADMIN_PASSWORD); + const login = await request(cnBase).post('/v1/auth/password').send({ + account: ADMIN_ACCOUNT, + password: ADMIN_PASSWORD, + }); + expect(login.status).toBe(200); + adminToken = login.body.sessionToken as string; + + // 非 admin 对照账号(staff):直插 users + 会话(同 members-categories 模式)。 + const staff = await prismaCn.users.create({ + data: { + account: 'embed-staff', + role: 'staff', + passwordHash: 'x', + displayName: 'embed-staff', + }, + select: { id: true }, + }); + const staffSession = randomBytes(32).toString('hex'); + await prismaCn.sessions.create({ + data: { + userId: staff.id, + tokenHash: hashSessionToken(staffSession), + expiresAt: new Date(Date.now() + 3_600_000), + }, + }); + staffToken = staffSession; + }); + + afterAll(async () => { + await cnApp.close(); + await ovApp.close(); + await prismaCn.$disconnect(); + await prismaOv.$disconnect(); + await redisOv.quit().catch(() => undefined); + }); + + it('① admin 创建/列表 + 权限 + 校验 + 创建事件同步(未绑定 → 公开 404)', async () => { + // 缺省 settings 创建。 + const created = await request(cnBase) + .post('/v1/admin/embed-slots') + .set('Authorization', `Bearer ${adminToken}`) + .send({ name: '官网首页悬浮位' }); + expect(created.status).toBe(201); + slotId = created.body.id as string; + slotToken = created.body.publicToken as string; + expect(created.body).toMatchObject({ + id: expect.any(String), + publicToken: expect.stringMatching(/^[0-9a-z]{24}$/), + name: '官网首页悬浮位', + enabled: true, + albumId: null, + albumTitle: null, + configVersion: 1, + settings: { + position: 'right', + size: 'md', + intervalMs: 5000, + lang: 'auto', + collapsedDefault: false, + }, + updatedAt: expect.any(String), + }); + + // 完整 settings 创建(第二槽,验证字段落库)。 + const second = await request(cnBase) + .post('/v1/admin/embed-slots') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + name: '活动页左侧位', + settings: { + position: 'left', + size: 'sm', + intervalMs: 8000, + lang: 'zh-CN', + collapsedDefault: true, + }, + }); + expect(second.status).toBe(201); + expect(second.body.settings).toEqual({ + position: 'left', + size: 'sm', + intervalMs: 8000, + lang: 'zh-CN', + collapsedDefault: true, + }); + + // 校验失败:settings 缺字段 / intervalMs 越界 / 空 name。 + const partial = await request(cnBase) + .post('/v1/admin/embed-slots') + .set('Authorization', `Bearer ${adminToken}`) + .send({ name: 'x', settings: { position: 'left' } as never }); + expect(partial.status).toBe(400); + expect(partial.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + const outOfRange = await request(cnBase) + .post('/v1/admin/embed-slots') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + name: 'x', + settings: { + position: 'left', + size: 'sm', + intervalMs: 2000, + lang: 'auto', + collapsedDefault: false, + }, + }); + expect(outOfRange.status).toBe(400); + const emptyName = await request(cnBase) + .post('/v1/admin/embed-slots') + .set('Authorization', `Bearer ${adminToken}`) + .send({ name: '' }); + expect(emptyName.status).toBe(400); + + // 权限:staff 三端点 403(入参合法——校验管道先于服务层 admin 判定, + // 带病请求会先 400 而测不到权限路径);匿名 401。 + const staffAttempts = [ + request(cnBase) + .get('/v1/admin/embed-slots') + .set('Authorization', `Bearer ${staffToken}`), + request(cnBase) + .post('/v1/admin/embed-slots') + .set('Authorization', `Bearer ${staffToken}`) + .send({ name: '越权创建' }), + request(cnBase) + .patch(`/v1/admin/embed-slots/${slotId}`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ enabled: false }), + ]; + for (const attempt of staffAttempts) { + const res = await attempt; + expect(res.status).toBe(403); + expect(res.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + } + const anon = await request(cnBase).get('/v1/admin/embed-slots'); + expect(anon.status).toBe(401); + + // 列表:createdAt+id 稳定倒序 + limit=1 键集游标翻页。 + const page1 = await request(cnBase) + .get('/v1/admin/embed-slots?limit=1') + .set('Authorization', `Bearer ${adminToken}`); + expect(page1.status).toBe(200); + expect(page1.body.items).toHaveLength(1); + expect(page1.body.items[0].id).toBe(second.body.id); // 后创建在前 + expect(page1.body.hasMore).toBe(true); + const page2 = await request(cnBase) + .get( + `/v1/admin/embed-slots?limit=1&cursor=${page1.body.nextCursor as string}`, + ) + .set('Authorization', `Bearer ${adminToken}`); + expect(page2.status).toBe(200); + expect(page2.body.items).toHaveLength(1); + expect(page2.body.items[0].id).toBe(slotId); + expect(page2.body.hasMore).toBe(false); + expect(page2.body.nextCursor).toBeNull(); + + // 创建事件同步:ov 副本行就位(未绑定 albumPublicToken null)+ 版本账本。 + await drainSender(); + const replica = await prismaOv.embed_slots_ov.findUnique({ + where: { slotToken }, + }); + expect(replica).toMatchObject({ + slotToken, + enabled: true, + albumPublicToken: null, + configVersion: 1, + entityVersion: 1, + }); + const state = await prismaOv.sync_state.findUnique({ + where: { + entityType_entityId: { entityType: 'slot-state', entityId: slotId }, + }, + }); + expect(state?.appliedVersion).toBe(1); + // 公开端:未绑定 → 404 NOT_FOUND(防探测同码)。 + const unbound = await getEmbed(slotToken); + expect(unbound.status).toBe(404); + expect(unbound.body.code).toBe(ErrorCodes.NOT_FOUND); + }); + + it('② 绑定相册 → ov 公开数据:照片倒序/标题回退/ETag 304', async () => { + // 相册 A(zh-CN + en,defaultLocale zh-CN)+ 3 张真实发布照片。 + const album = await request(cnBase) + .post('/v1/albums') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + translations: [ + { locale: 'zh-CN', title: '周年晚会' }, + { locale: 'en', title: 'Anniversary Gala' }, + ], + defaultLocale: 'zh-CN', + startAt: '2026-10-01T10:00:00.000Z', + }); + expect(album.status).toBe(201); + albumAId = album.body.id as string; + albumAToken = album.body.publicToken as string; + + const shapes = [ + { w: 900, h: 600, rgb: { r: 180, g: 120, b: 60 } }, + { w: 800, h: 500, rgb: { r: 60, g: 130, b: 180 } }, + { w: 700, h: 450, rgb: { r: 120, g: 180, b: 90 } }, + ] as const; + const photoIds: string[] = []; + for (let i = 0; i < shapes.length; i++) { + const s = shapes[i]!; + photoIds.push( + await uploadAndPublish( + albumAId, + `embed-e2e-a-${i}`, + await makeJpeg(s.w, s.h, s.rgb), + ), + ); + } + await drainSender(); + + // 绑定:albumTitle 按相册 defaultLocale(zh-CN)解析;configVersion+1。 + const bind = await request(cnBase) + .patch(`/v1/admin/embed-slots/${slotId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ albumId: albumAId }); + expect(bind.status).toBe(200); + expect(bind.body).toMatchObject({ + albumId: albumAId, + albumTitle: '周年晚会', + configVersion: 2, + }); + // 绑定不存在的相册 → 404 ALBUM_NOT_FOUND。 + const ghost = await request(cnBase) + .patch(`/v1/admin/embed-slots/${slotId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ albumId: 'no-such-album' }); + expect(ghost.status).toBe(404); + expect(ghost.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + await drainSender(); + + // ov 公开数据:照片 publishedAt 稳定倒序 + ETag 四元组。 + const first = await getEmbed(slotToken); + expect(first.status).toBe(200); + expect(first.body.slotToken).toBe(slotToken); + expect(first.body.configVersion).toBe(2); + expect(first.body.album).toEqual({ + publicToken: albumAToken, + title: '周年晚会', + defaultLocale: 'zh-CN', + }); + expect(first.body.photos).toHaveLength(3); + expect(first.body.photos.map((p: { photoId: string }) => p.photoId)).toEqual( + [photoIds[2], photoIds[1], photoIds[0]], + ); + // thumb 现签:URL 可取回真实字节(走完整上传/处理/发布管线)。 + const thumbUrl = first.body.photos[0].thumb.url as string; + const fetched = await fetch(thumbUrl); + expect(fetched.ok).toBe(true); + expect((await fetched.arrayBuffer()).byteLength).toBeGreaterThan(0); + expect(Date.parse(first.body.photos[0].thumb.expiresAt as string)).toBeGreaterThan( + Date.now(), + ); + + // ETag 四元组:"{configVersion}-{photoCount}-{maxPublishedAtMs}-{maxPhotoId}"。 + const newest = await prismaOv.photos_ov.findFirst({ + where: { albumId: albumAId, visibility: 'published' }, + orderBy: [{ publishedAt: 'desc' }, { photoId: 'desc' }], + select: { photoId: true, publishedAt: true }, + }); + const expectedEtag = `"2-3-${newwestMs(newest)}-${newest?.photoId ?? ''}"`; + const etag = first.headers.etag as string; + expect(etag).toBe(expectedEtag); + + // lang 回退:en 显式 / auto(缺省)→ defaultLocale / 非法值不 400。 + const en = await getEmbed(slotToken, '?lang=en'); + expect(en.status).toBe(200); + expect(en.body.album.title).toBe('Anniversary Gala'); + const auto = await getEmbed(slotToken, '?lang=auto'); + expect(auto.body.album.title).toBe('周年晚会'); + const invalid = await getEmbed(slotToken, '?lang=fr'); + expect(invalid.status).toBe(200); + expect(invalid.body.album.title).toBe('周年晚会'); + // ETag 不含 lang:语言变化不影响校验器(客户端同槽轮询 304 语义稳定)。 + expect(en.headers.etag).toBe(etag); + + // If-None-Match 匹配 → 304(无 body,仍携带 ETag);W/ 前缀与 * 同样命中。 + const notModified = await getEmbed(slotToken, '', etag); + expect(notModified.status).toBe(304); + expect(notModified.text).toBe(''); + expect(notModified.headers.etag).toBe(etag); + expect((await getEmbed(slotToken, '', `W/${etag}`)).status).toBe(304); + expect((await getEmbed(slotToken, '', '*')).status).toBe(304); + // 不匹配的旧值 → 200 全量。 + expect((await getEmbed(slotToken, '', '"stale-1-0-0-"')).status).toBe(200); + }); + + it('③ 新照片发布 → 同步 → ETag 变化 → 200 含新照片(A11 数据路径)', async () => { + const before = await getEmbed(slotToken); + const etagBefore = before.headers.etag as string; + + const newPhotoId = await uploadAndPublish( + albumAId, + 'embed-e2e-a-new', + await makeJpeg(640, 420, { r: 200, g: 80, b: 80 }), + ); + await drainSender(); + + // 旧 If-None-Match 不再匹配 → 200 全量,新照片居首。 + const refreshed = await getEmbed(slotToken, '', etagBefore); + expect(refreshed.status).toBe(200); + expect(refreshed.body.photos).toHaveLength(4); + expect(refreshed.body.photos[0].photoId).toBe(newPhotoId); + const etagAfter = refreshed.headers.etag as string; + expect(etagAfter).not.toBe(etagBefore); + // 新 ETag 立即命中 304。 + expect((await getEmbed(slotToken, '', etagAfter)).status).toBe(304); + }); + + it('④ 换绑相册:configVersion+1 → ov 服务新相册照片(A22)+ 20 上限', async () => { + // 相册 B(zh 单语)+ 直种 25 行 photos_ov 副本(不同 publishedAt)。 + const album = await request(cnBase) + .post('/v1/albums') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + translations: [{ locale: 'zh-CN', title: '品牌发布会' }], + defaultLocale: 'zh-CN', + startAt: '2026-11-01T10:00:00.000Z', + }); + expect(album.status).toBe(201); + albumBId = album.body.id as string; + albumBToken = album.body.publicToken as string; + await drainSender(); // 相册副本先落位 + + albumBSeedIds = []; + const base = Date.now() - 26 * 60_000; + for (let i = 0; i < 25; i++) { + const photoId = `embedseedB${String(i).padStart(2, '0')}`; + albumBSeedIds.push(photoId); + await prismaOv.photos_ov.create({ + data: { + photoId, + albumId: albumBId, + width: 320 + i, + height: 240 + i, + originalObjectKey: `embed-e2e/b/${photoId}/original.jpg`, + thumbObjectKey: `embed-e2e/b/${photoId}/thumb.jpg`, + browseObjectKey: `embed-e2e/b/${photoId}/browse.jpg`, + visibility: 'published', + publishedAt: new Date(base + i * 1000), + entityVersion: 1, + }, + }); + } + + // 换绑:configVersion+1(未提交 settings/enabled/name 不受影响)。 + const rebind = await request(cnBase) + .patch(`/v1/admin/embed-slots/${slotId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ albumId: albumBId }); + expect(rebind.status).toBe(200); + expect(rebind.body).toMatchObject({ + albumId: albumBId, + albumTitle: '品牌发布会', + configVersion: expect.any(Number), + name: '官网首页悬浮位', + enabled: true, + }); + const configVersionAfterRebind = rebind.body.configVersion as number; + expect(configVersionAfterRebind).toBeGreaterThanOrEqual(3); + + await drainSender(); + + // ov:新相册照片、20 上限、publishedAt 倒序(最新种子居首)。 + const feed = await getEmbed(slotToken); + expect(feed.status).toBe(200); + expect(feed.body.album).toEqual({ + publicToken: albumBToken, + title: '品牌发布会', + defaultLocale: 'zh-CN', + }); + expect(feed.body.photos).toHaveLength(20); + const expectedOrder = [...albumBSeedIds].slice(-20).reverse(); + expect( + feed.body.photos.map((p: { photoId: string }) => p.photoId), + ).toEqual(expectedOrder); + // ETag 首元随相册切换(configVersion 与照片集合都变了)。 + expect(feed.headers.etag).toBe( + `"${configVersionAfterRebind as number}-20-${(base + 24 * 1000).toFixed(0)}-embedseedB24"`, + ); + // 相册 B 仅 zh:lang=en 回退 defaultLocale(不 400)。 + const en = await getEmbed(slotToken, '?lang=en'); + expect(en.body.album.title).toBe('品牌发布会'); + }); + + it('⑤ 相册下线(publicEnabled=false)→ ov 公开端 404;恢复 → 200', async () => { + const offline = await request(cnBase) + .patch(`/v1/albums/${albumBId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ publicEnabled: false }); + expect(offline.status).toBe(200); + await drainSender(); + expect((await getEmbed(slotToken)).status).toBe(404); + + const back = await request(cnBase) + .patch(`/v1/albums/${albumBId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ publicEnabled: true }); + expect(back.status).toBe(200); + await drainSender(); + expect((await getEmbed(slotToken)).status).toBe(200); + }); + + it('⑥ 解绑 → 404;复绑 → 200;停用 → 404', async () => { + // 解绑:albumId 显式 null。 + const unbind = await request(cnBase) + .patch(`/v1/admin/embed-slots/${slotId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ albumId: null }); + expect(unbind.status).toBe(200); + expect(unbind.body.albumId).toBeNull(); + expect(unbind.body.albumTitle).toBeNull(); + await drainSender(); + expect((await getEmbed(slotToken)).status).toBe(404); + + // 复绑相册 A → 恢复(loader 只认 token:换绑不改官网嵌入代码)。 + const rebind = await request(cnBase) + .patch(`/v1/admin/embed-slots/${slotId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ albumId: albumAId }); + expect(rebind.status).toBe(200); + await drainSender(); + const feed = await getEmbed(slotToken); + expect(feed.status).toBe(200); + expect(feed.body.album.publicToken).toBe(albumAToken); + expect(feed.body.photos).toHaveLength(4); + + // 停用(enabled=false)→ 404(软下线,无删除端点)。 + const disable = await request(cnBase) + .patch(`/v1/admin/embed-slots/${slotId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ enabled: false, name: '官网首页悬浮位(下线)' }); + expect(disable.status).toBe(200); + expect(disable.body.enabled).toBe(false); + expect(disable.body.name).toBe('官网首页悬浮位(下线)'); + await drainSender(); + expect((await getEmbed(slotToken)).status).toBe(404); + }); + + it('⑦ slot-state 乱序/重复幂等(直推 content-batches)', async () => { + // 基准:cn 当前权威行(此刻停用态)。 + const slot = await prismaCn.embed_slots.findUniqueOrThrow({ + where: { id: slotId }, + }); + const currentVersion = slot.entityVersion; + const settings = slot.settings as Record; + + // 更高版本「复活」事件(enabled=true,绑定相册 A):直投生效。 + const revivePayload = { + slotId, + publicToken: slotToken, + enabled: true, + albumId: albumAId, + albumPublicToken: albumAToken, + configVersion: slot.configVersion + 1, + settings, + entityVersion: currentVersion + 1, + }; + const postBatch = async (eventId: string, entityVersion: number) => + signedPost(ovBase, '/v1/internal/sync/content-batches', { + batch: [ + { + eventId, + schemaVersion: 1, + entityType: 'slot-state', + entityId: slotId, + albumId: '', + entityVersion, + tombstone: false, + priority: 'normal', + payload: revivePayload, + }, + ], + }); + + const revived = await postBatch('e2e-slot-revive-1', currentVersion + 1); + expect(revived.status).toBe(200); + expect(await revived.json()).toEqual({ + acceptedEventIds: ['e2e-slot-revive-1'], + applied: 1, + }); + expect((await getEmbed(slotToken)).status).toBe(200); + + // 同 eventId 重复投递 → duplicate(受理不计应用),公开数据不变。 + const dup = await postBatch('e2e-slot-revive-1', currentVersion + 1); + expect(await dup.json()).toEqual({ + acceptedEventIds: ['e2e-slot-revive-1'], + applied: 0, + }); + + // 同版本不同 eventId → 重断言应用(幂等重投影,applied=1)。 + const reassert = await postBatch('e2e-slot-reassert-1', currentVersion + 1); + expect(await reassert.json()).toEqual({ + acceptedEventIds: ['e2e-slot-reassert-1'], + applied: 1, + }); + expect((await getEmbed(slotToken)).status).toBe(200); + + // 更低版本(旧快照:停用态)→ stale 不回卷:公开端仍 200。 + const stale = await postBatch('e2e-slot-stale-1', currentVersion - 1); + expect(await stale.json()).toEqual({ + acceptedEventIds: ['e2e-slot-stale-1'], + applied: 0, + }); + expect((await getEmbed(slotToken)).status).toBe(200); + + // 版本账本收敛在最高版本。 + const state = await prismaOv.sync_state.findUnique({ + where: { + entityType_entityId: { entityType: 'slot-state', entityId: slotId }, + }, + }); + expect(state?.appliedVersion).toBe(currentVersion + 1); + }); + + it('⑧ 限速:slotToken+IP 固定窗超额 429(304 同样消耗预算)', async () => { + // 清本 spec 造成的限速计数(窗口 60s,跨用例残留会污染压小窗口断言)。 + const keys = await redisOv.keys('embed:window:*'); + if (keys.length > 0) { + await redisOv.del(keys); + } + process.env.EMBED_WINDOW_REQUESTS = '2'; + try { + const first = await getEmbed(slotToken); + expect(first.status).toBe(200); + const etag = first.headers.etag as string; + // 第二次(304 命中)也计预算 → 窗口耗尽。 + expect((await getEmbed(slotToken, '', etag)).status).toBe(304); + const third = await getEmbed(slotToken); + expect(third.status).toBe(429); + expect(third.body.code).toBe(ErrorCodes.RATE_LIMITED); + // 404 路径不消耗预算(校验先于限速,同 visits 排序口径)。 + expect((await getEmbed('no-such-slot-token-0000000000')).status).toBe(404); + } finally { + delete process.env.EMBED_WINDOW_REQUESTS; + const again = await redisOv.keys('embed:window:*'); + if (again.length > 0) { + await redisOv.del(again); + } + } + }); +}); + +/** ETag 第二元:副本最新已发布照片的 publishedAt 毫秒(缺行 0)。 */ +function newwestMs( + row: { publishedAt: Date | null } | null, +): string { + return String(row?.publishedAt?.getTime() ?? 0); +} diff --git a/services/api/test/reset-ov.ts b/services/api/test/reset-ov.ts index 368ef1b..6b3fbfd 100644 --- a/services/api/test/reset-ov.ts +++ b/services/api/test/reset-ov.ts @@ -21,9 +21,10 @@ export const RESET_TABLES_OV: readonly string[] = [ // 同步账本(Task 4 inbox 消费)。 'sync_inbox', 'sync_state', - // 副本域(photos 先于 albums)。 + // 副本域(photos 先于 albums;展示位副本相册无关,随副本域一并清)。 'photos_ov', 'albums_ov', + 'embed_slots_ov', ]; /** TRUNCATE photo_ov 中 RESET_TABLES_OV 里**实际存在**的表(空库安全)。 */ diff --git a/services/api/test/reset.ts b/services/api/test/reset.ts index 32c4307..80166e5 100644 --- a/services/api/test/reset.ts +++ b/services/api/test/reset.ts @@ -24,6 +24,9 @@ export const RESET_TABLES: readonly string[] = [ // 照片与上传域 + outbox(Task 10):均为扁平字符串外键(无 FK 约束), // 顺序按逻辑依赖排列(事件/作业/变体/会话先于 photos)。 'outbox_events', + // 展示位域(P4b Task 2):授权成员表先于展示位(建而未用,P5 激活)。 + 'embed_slot_members', + 'embed_slots', 'photo_jobs', 'photo_variants', 'upload_sessions', diff --git a/services/sync/src/sender.ts b/services/sync/src/sender.ts index ad0b8c6..34f937a 100644 --- a/services/sync/src/sender.ts +++ b/services/sync/src/sender.ts @@ -188,8 +188,8 @@ export class OutboxSender { /** * outbox 行 → SyncEvent 线格式。entityId 按实体类型取自不同位置: - * album → albumId 列;photo → photoId 列;category/asset → 载荷内的 - * categoryId/assetId(outbox 表无此二列)。映射缺料抛错(fail-fast: + * album → albumId 列;photo → photoId 列;category/asset/slot-state → 载荷内 + * 的 categoryId/assetId/slotId(outbox 表无此三列)。映射缺料抛错(fail-fast: * 本轮不推、不标记,错误日志暴露;cn 侧发射器按构造保证不缺料)。 */ private toWireEvent(row: OutboxRow): SyncEventWire { @@ -204,6 +204,11 @@ export class OutboxSender { throw new Error(`outbox 事件 ${row.id}(${type})缺 photoId,无法推导 entityId`); } entityId = row.photoId; + } else if (entityType === 'slot-state') { + if (typeof payload.slotId !== 'string') { + throw new Error(`outbox 事件 ${row.id}(${type})载荷缺 slotId,无法推导 entityId`); + } + entityId = payload.slotId; } else if (typeof payload.categoryId === 'string') { entityId = payload.categoryId; } else if (typeof payload.assetId === 'string') {