feat(api): embed slots with ov replica and public feed
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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() };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user