2026-09-18 17:05:56 +08:00
|
|
|
|
// 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 验证同形);
|
2026-09-20 17:53:44 +08:00
|
|
|
|
// · 浏览图水印(P4a Task 3):ov 侧无变体副本表,水印开启时 browse 键由
|
|
|
|
|
|
// 模式推导(variants/{photoId}/{有效版本}/watermark.jpg,cn worker 直写
|
|
|
|
|
|
// 同一对象空间)——对象存在性由 done 任务收尾不变量保证(见 ovBrowseKey);
|
2026-09-18 17:05:56 +08:00
|
|
|
|
// · 列表新增 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';
|
2026-09-20 18:32:34 +08:00
|
|
|
|
import { ovBrowseKey, type WatermarkContextOv } from '../shared/ov-browse-key';
|
2026-09-18 17:05:56 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 18:32:34 +08:00
|
|
|
|
/** 相册级水印服务上下文(shared/ov-browse-key 单一实现;P4a Task 4 起下载端点共用)。 */
|
|
|
|
|
|
type WatermarkContext = WatermarkContextOv;
|
2026-09-20 17:53:44 +08:00
|
|
|
|
|
2026-09-18 17:05:56 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 热门快照 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,
|
2026-09-20 18:32:34 +08:00
|
|
|
|
// 下载两级开关(P4a Task 4):viewer 按此渲染下载入口(仅 ov 域消费,
|
|
|
|
|
|
// cn 同形返回——契约 PublicAlbum 必返字段)。
|
|
|
|
|
|
downloadEnabled: album.downloadEnabled,
|
|
|
|
|
|
originalDownloadEnabled: album.originalDownloadEnabled,
|
2026-09-18 17:05:56 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 公开照片列表。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') {
|
2026-09-20 17:53:44 +08:00
|
|
|
|
return this.listHot(
|
|
|
|
|
|
album.albumId,
|
|
|
|
|
|
categoryFilter,
|
|
|
|
|
|
query,
|
|
|
|
|
|
this.watermarkContext(album),
|
|
|
|
|
|
);
|
2026-09-18 17:05:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-09-20 17:53:44 +08:00
|
|
|
|
const items = await this.toItems(page, this.watermarkContext(album));
|
2026-09-18 17:05:56 +08:00
|
|
|
|
// 游标取自「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),
|
2026-09-20 17:53:44 +08:00
|
|
|
|
browse: await this.presign(
|
2026-09-20 18:32:34 +08:00
|
|
|
|
ovBrowseKey(
|
2026-09-20 17:53:44 +08:00
|
|
|
|
this.watermarkContext(album),
|
|
|
|
|
|
photo.photoId,
|
|
|
|
|
|
photo.browseObjectKey,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-18 17:05:56 +08:00
|
|
|
|
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 },
|
2026-09-20 17:53:44 +08:00
|
|
|
|
watermark: WatermarkContext,
|
2026-09-18 17:05:56 +08:00
|
|
|
|
): 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);
|
2026-09-20 17:53:44 +08:00
|
|
|
|
const items = await this.toItems(ordered, watermark);
|
2026-09-18 17:05:56 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}>,
|
2026-09-20 17:53:44 +08:00
|
|
|
|
watermark: WatermarkContext,
|
2026-09-18 17:05:56 +08:00
|
|
|
|
): 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),
|
2026-09-20 17:53:44 +08:00
|
|
|
|
browse: await this.presign(
|
2026-09-20 18:32:34 +08:00
|
|
|
|
ovBrowseKey(watermark, photo.photoId, photo.browseObjectKey),
|
2026-09-20 17:53:44 +08:00
|
|
|
|
),
|
2026-09-18 17:05:56 +08:00
|
|
|
|
likeCount: photo.likeTotal,
|
|
|
|
|
|
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return items;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 17:53:44 +08:00
|
|
|
|
/** albums_ov 水印副本位(watermarkEnabled + watermarkConfigVersion + 归属)。 */
|
|
|
|
|
|
private watermarkContext(album: {
|
|
|
|
|
|
albumId: string;
|
|
|
|
|
|
watermarkEnabled: boolean;
|
|
|
|
|
|
watermarkConfigVersion: number;
|
|
|
|
|
|
}): WatermarkContext {
|
|
|
|
|
|
return {
|
|
|
|
|
|
albumId: album.albumId,
|
|
|
|
|
|
enabled: album.watermarkEnabled,
|
|
|
|
|
|
configVersion: album.watermarkConfigVersion,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-20 18:32:34 +08:00
|
|
|
|
* ov 浏览图对象键(P4a Task 3,Task 4 起为 shared 单一实现):水印开启时用
|
|
|
|
|
|
* **模式键**——variants/{photoId}/{watermarkConfigVersion}/watermark.jpg(同一
|
|
|
|
|
|
* 对象空间,cn worker 直写;ov 无变体副本表,键由 cn 行结构确定)。副本位
|
|
|
|
|
|
* 携带的是**有效版本**(albums.watermarkEffectiveVersion,仅 done 任务推进):
|
|
|
|
|
|
* 该版本键的存在性由重生成任务的收尾不变量保证(每张已发布照片处理成功或
|
|
|
|
|
|
* 回退拷贝旧图到目标键——失败照片保留旧图,A20),disable 版任务完成后
|
|
|
|
|
|
* enabled=false 回落 browse 内联键(水印键对象保留,历史可溯)。
|
2026-09-20 17:53:44 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-09-18 17:05:56 +08:00
|
|
|
|
/** 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() };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|