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
@@ -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() };
}
}