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
+3
View File
@@ -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,
+4
View File
@@ -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)。
@@ -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<PublicEmbedSlotResponse | undefined> {
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;
}
}
@@ -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 {}
@@ -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<EmbedSlotFeed> {
// ① 展示位副本(防探测 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<void> {
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<SignedUrlResponse> {
const ttl = signedUrlTtlSeconds();
const url = await this.storage.presignGet(objectKey, ttl);
return { url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() };
}
}
+92
View File
@@ -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;
}
@@ -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<EmbedSlotListResponse> {
return this.embedSlotsService.list(userOf(req), parseCursor(cursor), parseLimit(limit));
}
/** 创建展示位(publicToken 服务端生成;初始未绑定、configVersion=1)。 */
@Post()
createEmbedSlot(
@Body() dto: EmbedSlotCreateRequestDto,
@Req() req: AuthenticatedRequest,
): Promise<EmbedSlotResponse> {
return this.embedSlotsService.create(userOf(req), dto);
}
/** 部分更新(任一管理面变更递增 configVersion 并触发 slot-state 同步)。 */
@Patch(':id')
updateEmbedSlot(
@Param('id') id: string,
@Body() dto: EmbedSlotUpdateRequestDto,
@Req() req: AuthenticatedRequest,
): Promise<EmbedSlotResponse> {
return this.embedSlotsService.update(userOf(req), id, dto);
}
}
+12
View File
@@ -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 {}
+406
View File
@@ -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<Prisma.embed_slotsDefaultArgs>;
@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<EmbedSlotListResponse> {
this.assertAdmin(user);
let decoded: ReturnType<typeof decodeKeysetCursor> = 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<EmbedSlotResponse> {
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<EmbedSlotResponse> {
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<EmbedSlotResponse> {
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<string | null>,
): Promise<Map<string, string>> {
const ids = [...new Set(albumIds.filter((id): id is string => id !== null))];
const result = new Map<string, string>();
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<string, typeof translations>();
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;
}
}
@@ -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;
}
+37
View File
@@ -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<Prisma.photosDefaultArgs>;
type PhotoVariantRow = Prisma.photo_variantsGetPayload<
Prisma.photo_variantsDefaultArgs
>;
type EmbedSlotRow = Pick<
Prisma.embed_slotsGetPayload<Prisma.embed_slotsDefaultArgs>,
| '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 不变量破坏