Files
photo-live/services/api/src/public-ov/public-ov.service.ts
T
浅念 2bb6f291d3 feat(ov,viewer): two-tier download with wechat guidance
P4a Task 4(T12 收口):ov 域公开照片下载端点 + viewer 下载 UI。

- interactions 增 downloads:GET /public/albums/{token}/photos/{photoId}
  /download?variant=browse|original。两级开关(A18):browse 需
  downloadEnabled;original 需叠加 originalDownloadEnabled(级联,browse 关
  则两者均 403 DOWNLOAD_DISABLED)。水印相册 browse 现签 watermark pattern
  key(browse-key 解析抽取 shared/ov-browse-key 与 public-ov 复用);原图
  现签 originalObjectKey(SIGNED_URL_TTL)。来源 IP 固定窗限速
  (DOWNLOAD_WINDOW_REQUESTS/DOWNLOAD_WINDOW_SECONDS,env 可调);零浏览
  计数写入(排除规则)。STORAGE_PORT_OV 经新 StorageOvModule 与 public-ov
  共享同一实例。
- 契约微调登记:PublicAlbum 增 readOnly 必返 downloadEnabled/
  originalDownloadEnabled(cn+ov 同形返回;public e2e 精确键断言同步)。
- viewer:lightbox 下载入口随开关渲染(无/单按钮/分体菜单两选项)、过期
  凭证静默重取一次、微信内置浏览器(MicroMessenger UA)出保存图片指引
  浮层(复制深链 + 反馈)替代直接下载;i18n zh/en 词条。
- tests:api e2e +7(开关矩阵 + URL/字节级断言、水印键一致、隐藏 404、
  限速 429、零计数行);viewer vitest +9(UA/矩阵/菜单/重取/失败/指引/
  复制);viewer Playwright +3(入口矩阵、微信 UA 浮层、真实端点 200 +
  签名 URL 模式)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 18:32:34 +08:00

509 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// services/api/src/public-ov/public-ov.service.ts —— ov(海外观众)公开读逻辑
// (P2 Task 6)。契约权威与 cn public.service 同一来源(openapi tags [public],
// 路径不变、部署域名区分)——**契约同形**是本域的验收口径:响应形状一律取
// @photo/contracts 生成类型,与 cn 逐字段对齐(e2e deep-eval 见
// test/public-ov.e2e-spec.ts)。与 cn 实现的本质差异:
// · 数据源是副本表(albums_ov/photos_ov,translations/categories 反规范化
// JSON,三对象键内联 photos_ov——无变体表),无关联行二次查询;
// · 相册可见性叠加 ov 本地紧急停用位(isAlbumVisibleOv,Task 5 单一实现,
// 与 cn findPublicAlbum 同形:任一不满足统一 404 ALBUM_NOT_PUBLIC);
// · viewCount 是真实值:album_stats 权威累计(无行默认 0;计数端点 Task 8
// 接线,此处只读列)——cn 侧仍是占位 null(Task 9 副本回填);
// · banner 是单键(albums_ov.bannerAssetKey,asset.updated 事件投影的「最新
// 生效 banner」):cn 侧语言偏好解析(locale 行 → 全局行)在 ov 副本上
// 退化为最新键——多语言 banner 的 ov 侧精确解析登记为 P5 展示 refinement
// (本地仿真 e2e 用单全局 banner 验证同形);
// · 浏览图水印(P4a Task 3):ov 侧无变体副本表,水印开启时 browse 键由
// 模式推导(variants/{photoId}/{有效版本}/watermark.jpg,cn worker 直写
// 同一对象空间)——对象存在性由 done 任务收尾不变量保证(见 ovBrowseKey);
// · 列表新增 sort=hot(plan Task 6 / 技术方案 §7):likeTotal 倒序 + photoId
// 倒序决胜,**短期快照语义**——每相册内存缓存排好序的 photoId 清单
// (TTL 缺省 30s,env HOT_SORT_TTL_MS 可调,惰性读取),窗口内翻页对同一
// 快照切片(offset 游标在冻结清单上严格稳定),不做失效广播:30s 窗口
// 外自然过期重算(点赞计数仍逐条现读,仅顺序是快照)。
// 防御语义与 cn 对齐:发布副本缺宽高 → 列表跳过 / 单图 404(公开页不因单张
// 脏数据整页失败);defaultLocale 翻译条目缺失属装配异常 → 5xx 兜底。
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import { ErrorCodes } from '@photo/contracts';
import { ApiException } from '../api-exception';
import { ALBUM_LOCALES, type AlbumLocale } from '../albums/dto';
import { validationFailed } from '../albums/support';
import { isAlbumVisibleOv } from '../shared/album-visibility';
import {
decodeKeysetCursor,
encodeKeysetCursor,
keysetWhereDesc,
} from '../shared/cursor';
import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
import { signedUrlTtlSeconds } from '../uploads/support';
import { albumNotPublic, photoNotPublic } from '../public/support';
import { ovBrowseKey, type WatermarkContextOv } from '../shared/ov-browse-key';
import { STORAGE_PORT_OV } from './storage-ov.provider';
import type { StoragePort } from '@photo/storage';
export type PublicAlbumResponse = components['schemas']['PublicAlbum'];
export type PublicPhotoListResponse =
components['schemas']['PublicPhotoList'];
export type PublicPhotoDetailResponse =
components['schemas']['PublicPhotoDetail'];
type SignedUrlResponse = components['schemas']['SignedUrl'];
/** 与 cn public.service 同值(契约 listPublicPhotos limit bounds)。 */
export const DEFAULT_PUBLIC_PHOTO_PAGE_SIZE = 30;
export const MAX_PUBLIC_PHOTO_PAGE_SIZE = 60;
/** HOT_SORT_TTL_MS 缺省 30s(技术方案 §7 快照缓冲;与 .env.example 同步)。 */
export const DEFAULT_HOT_SORT_TTL_MS = 30_000;
/** 逐请求惰性读取快照 TTL(同 signedUrlTtlSeconds 模式:测试可进程内调 env)。 */
export function hotSortTtlMs(): number {
const parsed = Number.parseInt(process.env.HOT_SORT_TTL_MS ?? '', 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_HOT_SORT_TTL_MS;
}
/** albums_ov.translations JSON 元素形状(sync payload 原文,cn 行可空字段保留)。 */
interface TranslationEntry {
locale: string;
title: string;
description: string | null;
venue: string | null;
address: string | null;
}
/** albums_ov.categories JSON 元素形状(sync-inbox 投影结构)。 */
interface CategoryEntry {
id: string;
sortOrder: number;
translations: Array<{ locale: string; name: string }>;
}
/** 热门排序快照:冻结的 photoId 有序清单 + 过期时刻(见文件头快照说明)。 */
interface HotSnapshot {
ids: readonly string[];
expiresAt: number;
}
/** 相册级水印服务上下文(shared/ov-browse-key 单一实现;P4a Task 4 起下载端点共用)。 */
type WatermarkContext = WatermarkContextOv;
/**
* 热门快照 offset 游标:base64url JSON 数字(快照内下一页起始下标)。快照在
* TTL 内冻结,下标分页严格稳定(无并发漂移);解码失败 → null(调用方 400,
* 与 latest 游标同语义)。两种模式的游标互不通用(opaque,各解码各的)。
*/
export function encodeHotCursor(offset: number): string {
return Buffer.from(JSON.stringify(offset), 'utf8').toString('base64url');
}
export function decodeHotCursor(raw: string): number | null {
if (raw === '') {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
} catch {
return null;
}
return typeof parsed === 'number' && Number.isInteger(parsed) && parsed >= 0
? parsed
: null;
}
@Injectable()
export class PublicOvService {
/** 每相册热门快照(进程内单例状态;TTL 过期懒重算,无失效广播)。 */
private readonly hotSnapshots = new Map<string, HotSnapshot>();
constructor(
private readonly prisma: OvPrismaService,
@Inject(STORAGE_PORT_OV) private readonly storage: StoragePort,
) {}
/**
* 公开相册信息:publicToken → albums_ov 副本 → isAlbumVisibleOv → 翻译回退
* 解析(translations JSON)→ 分类(categories JSON,sortOrder 升序 + id 决胜
* —— cn 侧由 ORDER BY 保证,副本在内存排序)→ banner 现签 → viewCount
* (album_stats 真实值,无行默认 0)。
*/
async getPublicAlbum(
token: string,
localeRaw: string | undefined,
): Promise<PublicAlbumResponse> {
const album = await this.findPublicAlbumOv(token);
const translations = this.translationEntries(album.translations);
const resolved = this.resolveTranslation(
album.defaultLocale,
translations,
localeRaw,
);
const categories = this.categoryEntries(album.categories)
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder || (a.id < b.id ? -1 : 1));
const banner =
album.bannerAssetKey === null
? null
: await this.presign(album.bannerAssetKey);
const stats = await this.prisma.album_stats.findUnique({
where: { albumId: album.albumId },
select: { viewCount: true },
});
return {
publicToken: album.publicToken,
resolvedLocale: resolved.locale,
defaultLocale: album.defaultLocale as AlbumLocale,
timezone: album.timezone,
startAt: (album.startAt ?? new Date(0)).toISOString(),
endAt:
album.endAt === null ? null : album.endAt.toISOString(),
translation: {
locale: resolved.locale,
title: resolved.row.title,
description: resolved.row.description,
venue: resolved.row.venue,
address: resolved.row.address,
},
categories: categories.map((c) => ({
id: c.id,
name: this.resolveCategoryName(
c.translations,
resolved.locale,
album.defaultLocale,
),
sortOrder: c.sortOrder,
})),
banner,
// 真实值(Task 8 的 /visits 端点维护计数;此处只读列,无行 = 0)。
viewCount: stats?.viewCount ?? 0,
// 下载两级开关(P4a Task 4):viewer 按此渲染下载入口(仅 ov 域消费,
// cn 同形返回——契约 PublicAlbum 必返字段)。
downloadEnabled: album.downloadEnabled,
originalDownloadEnabled: album.originalDownloadEnabled,
};
}
/**
* 公开照片列表。latest(缺省):(publishedAt, photoId) 双列稳定倒序键集游标
* (shared/cursor,与 cn 同一实现——游标编码逐字节同形)。hot:快照清单
* offset 切片(见文件头)。category 过滤校验同 cn:须出现在本相册 categories
* JSON(幽灵/跨册同码 400,防探测)。签名 URL 逐条现签(ov 只读凭证)。
*/
async listPublicPhotos(
token: string,
query: {
category?: string;
cursor?: string;
limit: number;
sort: 'latest' | 'hot';
},
): Promise<PublicPhotoListResponse> {
const album = await this.findPublicAlbumOv(token);
const categoryIds = new Set(
this.categoryEntries(album.categories).map((c) => c.id),
);
if (query.category !== undefined && query.category !== '') {
if (!categoryIds.has(query.category)) {
throw validationFailed('category 不属于该相册');
}
}
const categoryFilter =
query.category !== undefined && query.category !== ''
? { categoryId: query.category }
: {};
if (query.sort === 'hot') {
return this.listHot(
album.albumId,
categoryFilter,
query,
this.watermarkContext(album),
);
}
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
if (query.cursor !== undefined && query.cursor !== '') {
decoded = decodeKeysetCursor(query.cursor);
if (decoded === null) {
throw validationFailed('cursor 不合法');
}
}
const where = {
albumId: album.albumId,
visibility: 'published',
...categoryFilter,
...(decoded !== null
? keysetWhereDesc('publishedAt', 'photoId', decoded)
: {}),
};
const rows = await this.prisma.photos_ov.findMany({
where,
orderBy: [{ publishedAt: 'desc' }, { photoId: 'desc' }],
take: query.limit + 1, // 多取一条探测 hasMore(同 cn 相册列表)
});
const hasMore = rows.length > query.limit;
const page = hasMore ? rows.slice(0, query.limit) : rows;
const items = await this.toItems(page, this.watermarkContext(album));
// 游标取自「DB 页末行」(同 cn:被跳过的行不进下一页重扫)。
const last = page.at(-1);
const nextCursor =
hasMore && last !== undefined
? encodeKeysetCursor({
sort: (last.publishedAt ?? new Date(0)).toISOString(),
id: last.photoId,
})
: null;
return { items, nextCursor, hasMore: nextCursor !== null };
}
/**
* 公开单图(深链定位 + 签名刷新入口,每次现签)。校验同 cn:照片不存在 /
* 不属于该相册 / 非 published 统一 404 PHOTO_NOT_PUBLIC;副本缺宽高同 cn
* 防御语义(不可展示 → 404)。
*/
async getPublicPhoto(
token: string,
photoId: string,
): Promise<PublicPhotoDetailResponse> {
const album = await this.findPublicAlbumOv(token);
const photo = await this.prisma.photos_ov.findUnique({
where: { photoId },
});
if (
photo === null ||
photo.albumId !== album.albumId ||
photo.visibility !== 'published' ||
photo.width === null ||
photo.height === null
) {
throw photoNotPublic();
}
return {
photoId: photo.photoId,
width: photo.width,
height: photo.height,
thumb: await this.presign(photo.thumbObjectKey),
browse: await this.presign(
ovBrowseKey(
this.watermarkContext(album),
photo.photoId,
photo.browseObjectKey,
),
),
likeCount: photo.likeTotal,
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
categoryId: photo.categoryId,
};
}
/** hot 排序页:快照清单(TTL 缓存)offset 切片 → 现取行(跳过中途被撤下的)。 */
private async listHot(
albumId: string,
categoryFilter: { categoryId?: string },
query: { cursor?: string; limit: number },
watermark: WatermarkContext,
): Promise<PublicPhotoListResponse> {
let offset = 0;
if (query.cursor !== undefined && query.cursor !== '') {
offset = decodeHotCursor(query.cursor) ?? -1;
if (offset < 0) {
throw validationFailed('cursor 不合法');
}
}
const snapshot = await this.hotSnapshot(albumId, categoryFilter);
const pageIds = snapshot.slice(offset, offset + query.limit);
const hasMore = offset + query.limit < snapshot.length;
if (pageIds.length === 0) {
return { items: [], nextCursor: null, hasMore: false };
}
// 行现取:快照只冻结顺序;可见性/计数逐条现读(中途撤下的照片跳过——
// 页短于 limit 不影响游标推进,同 cn「游标取自 DB 页末行」的越过语义)。
const rows = await this.prisma.photos_ov.findMany({
where: { photoId: { in: pageIds }, visibility: 'published' },
});
const byId = new Map(rows.map((row) => [row.photoId, row]));
const ordered = pageIds
.map((id) => byId.get(id))
.filter((row) => row !== undefined);
const items = await this.toItems(ordered, watermark);
const nextCursor = hasMore ? encodeHotCursor(offset + query.limit) : null;
return { items, nextCursor, hasMore: nextCursor !== null };
}
/**
* 热门快照:likeTotal DESC, photoId DESC 的 published photoId 清单,TTL 内
* 复用(category 过滤参与缓存键——不同过滤各自快照,语义独立且互不污染)。
*/
private async hotSnapshot(
albumId: string,
categoryFilter: { categoryId?: string },
): Promise<readonly string[]> {
const key = `${albumId}|${categoryFilter.categoryId ?? ''}`;
const cached = this.hotSnapshots.get(key);
if (cached !== undefined && Date.now() < cached.expiresAt) {
return cached.ids;
}
const rows = await this.prisma.photos_ov.findMany({
where: { albumId, visibility: 'published', ...categoryFilter },
orderBy: [{ likeTotal: 'desc' }, { photoId: 'desc' }],
select: { photoId: true },
});
const ids = rows.map((row) => row.photoId);
this.hotSnapshots.set(key, {
ids,
expiresAt: Date.now() + hotSortTtlMs(),
});
return ids;
}
/** 副本行 → 契约条目(宽高缺失跳过,同 cn 防御语义;键为发布载荷内联键)。 */
private async toItems(
page: Array<{
photoId: string;
width: number | null;
height: number | null;
thumbObjectKey: string;
browseObjectKey: string;
likeTotal: number;
publishedAt: Date | null;
}>,
watermark: WatermarkContext,
): Promise<PublicPhotoListResponse['items']> {
const items: PublicPhotoListResponse['items'] = [];
for (const photo of page) {
if (photo.width === null || photo.height === null) {
continue;
}
items.push({
photoId: photo.photoId,
width: photo.width,
height: photo.height,
thumb: await this.presign(photo.thumbObjectKey),
browse: await this.presign(
ovBrowseKey(watermark, photo.photoId, photo.browseObjectKey),
),
likeCount: photo.likeTotal,
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
});
}
return items;
}
/** albums_ov 水印副本位(watermarkEnabled + watermarkConfigVersion + 归属)。 */
private watermarkContext(album: {
albumId: string;
watermarkEnabled: boolean;
watermarkConfigVersion: number;
}): WatermarkContext {
return {
albumId: album.albumId,
enabled: album.watermarkEnabled,
configVersion: album.watermarkConfigVersion,
};
}
/**
* ov 浏览图对象键(P4a Task 3,Task 4 起为 shared 单一实现):水印开启时用
* **模式键**——variants/{photoId}/{watermarkConfigVersion}/watermark.jpg(同一
* 对象空间,cn worker 直写;ov 无变体副本表,键由 cn 行结构确定)。副本位
* 携带的是**有效版本**(albums.watermarkEffectiveVersion,仅 done 任务推进):
* 该版本键的存在性由重生成任务的收尾不变量保证(每张已发布照片处理成功或
* 回退拷贝旧图到目标键——失败照片保留旧图,A20),disable 版任务完成后
* enabled=false 回落 browse 内联键(水印键对象保留,历史可溯)。
*/
/** publicToken → albums_ov 副本;不存在 / 不可见(含紧急停用)统一 404。 */
private async findPublicAlbumOv(token: string) {
const album = await this.prisma.albums_ov.findUnique({
where: { publicToken: token },
});
if (album === null || !isAlbumVisibleOv(album)) {
throw albumNotPublic();
}
return album;
}
/** 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',
)
: [];
}
/** categories JSON → 类型化条目(防御同上)。 */
private categoryEntries(value: unknown): CategoryEntry[] {
return Array.isArray(value)
? (value as CategoryEntry[]).filter(
(c) => typeof c?.id === 'string' && Array.isArray(c.translations),
)
: [];
}
/**
* 语言回退解析(需求 4.7,与 cn 同形):请求 locale 合法(契约枚举)且副本
* 有条目 → 用之;否则 defaultLocale。defaultLocale 条目由 cn 创建/更新路径
* 保证必在(同步载荷原样携带),缺行属装配异常 → 5xx(公开端点不得以半
* 翻译状态 200)。
*/
private resolveTranslation(
defaultLocale: string,
entries: TranslationEntry[],
requested: string | undefined,
): { locale: AlbumLocale; row: TranslationEntry } {
const byLocale = new Map(entries.map((row) => [row.locale, row]));
const defaultRow = byLocale.get(defaultLocale);
if (defaultRow === undefined) {
throw new ApiException(
ErrorCodes.INTERNAL_ERROR,
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
if (
requested !== undefined &&
(ALBUM_LOCALES as readonly string[]).includes(requested)
) {
const row = byLocale.get(requested);
if (row !== undefined) {
return { locale: requested as AlbumLocale, row };
}
}
return { locale: defaultLocale as AlbumLocale, row: defaultRow };
}
/**
* 分类名称解析:resolved locale → defaultLocale → 任一既有条目(locale 升序
* 首,确定性)——与 cn resolveCategoryName 同形;真缺行回退空串保持 200
* (名称为契约必返 string,公开页不因单分类脏数据整页 5xx)。
*/
private resolveCategoryName(
translations: Array<{ locale: string; name: string }>,
resolvedLocale: AlbumLocale,
defaultLocale: string,
): string {
if (translations.length === 0) {
return '';
}
const byLocale = new Map(translations.map((t) => [t.locale, t.name]));
return (
byLocale.get(resolvedLocale) ??
byLocale.get(defaultLocale) ??
([...translations]
.sort((a, b) => (a.locale < b.locale ? -1 : 1))[0]?.name ?? '')
);
}
/** 现签短期 GET 凭证(TTL 惰性读取;同 cn: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() };
}
}