2026-09-17 23:33:57 +08:00
|
|
|
|
// services/api/src/public/public.service.ts —— 公开(观众端)读逻辑(Task 22
|
|
|
|
|
|
// 起步:getPublicAlbum;Task 23 增补 listPublicPhotos / getPublicPhoto)。
|
|
|
|
|
|
// 契约权威:packages/contracts/openapi.yaml tags [public]。要点:
|
|
|
|
|
|
// · 相册定位只认 publicToken + status=active + publicEnabled——三者任一不满足
|
|
|
|
|
|
// 统一 404 ALBUM_NOT_PUBLIC(同码防枚举,持有链接即可访问,无认证语义)。
|
|
|
|
|
|
// · 语言回退(需求 4.7):请求 locale ∈ 契约枚举(zh-CN/en)且相册有该语言
|
|
|
|
|
|
// 翻译条目 → 用之;否则回退 defaultLocale(创建/更新路径保证 defaultLocale
|
|
|
|
|
|
// 条目必在,缺行属装配异常按 5xx 兜底)。非法 locale 值不报 400(Task 5
|
|
|
|
|
|
// 决策),与缺省同样走回退;resolvedLocale 向客户端指示实际返回语言。
|
|
|
|
|
|
// · 分类名称同语言解析:resolved locale → defaultLocale → 任一既有条目
|
|
|
|
|
|
// (确定性:locale 升序取首);分类创建必附翻译,末级分支纯防御。
|
2026-09-18 08:40:32 +08:00
|
|
|
|
// · banner(P1b Task 3 起对接上传链路):album_assets(kind=banner,
|
|
|
|
|
|
// state='effective') 中按语言偏好解析(需求 4.7)——resolved locale 行 →
|
|
|
|
|
|
// 全局行(locale NULL)→ null;同级取最新(createdAt,id DESC 决胜,同
|
|
|
|
|
|
// (kind, locale) 再上传即替换语义)。
|
2026-09-17 23:33:57 +08:00
|
|
|
|
// · 签名 URL 一律现签(SIGNED_URL_TTL_SECONDS 惰性读取,SigV4 本地计算无
|
|
|
|
|
|
// 网络副作用);单图定位端点是契约规定的过期刷新入口。
|
|
|
|
|
|
// · viewCount:P1a 占位 null(§7.1 /visits 端点后续提供)。
|
|
|
|
|
|
// · 限流:公开端点 P1a 不做(P2 引入点赞限流时一并评估),此处仅记录约定。
|
|
|
|
|
|
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
|
|
|
|
|
import type { components } from '@photo/contracts';
|
|
|
|
|
|
import { ErrorCodes } from '@photo/contracts';
|
|
|
|
|
|
import type { Prisma } from '@photo/db-cn';
|
|
|
|
|
|
import type { StoragePort } from '@photo/storage';
|
|
|
|
|
|
import { ApiException } from '../api-exception';
|
|
|
|
|
|
import { ALBUM_LOCALES, type AlbumLocale } from '../albums/dto';
|
2026-09-17 23:39:50 +08:00
|
|
|
|
import { validationFailed } from '../albums/support';
|
|
|
|
|
|
import {
|
|
|
|
|
|
decodeKeysetCursor,
|
|
|
|
|
|
encodeKeysetCursor,
|
|
|
|
|
|
keysetWhereDesc,
|
|
|
|
|
|
} from '../shared/cursor';
|
2026-09-17 23:33:57 +08:00
|
|
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
|
|
|
|
import { STORAGE_PORT } from '../uploads/storage.provider';
|
|
|
|
|
|
import { signedUrlTtlSeconds } from '../uploads/support';
|
2026-09-17 23:51:29 +08:00
|
|
|
|
import { albumNotPublic, photoNotPublic } from './support';
|
2026-09-17 23:33:57 +08:00
|
|
|
|
|
|
|
|
|
|
export type PublicAlbumResponse = components['schemas']['PublicAlbum'];
|
|
|
|
|
|
export type PublicPhotoListResponse =
|
|
|
|
|
|
components['schemas']['PublicPhotoList'];
|
|
|
|
|
|
export type PublicPhotoDetailResponse =
|
|
|
|
|
|
components['schemas']['PublicPhotoDetail'];
|
|
|
|
|
|
type SignedUrlResponse = components['schemas']['SignedUrl'];
|
|
|
|
|
|
|
|
|
|
|
|
type AlbumRow = Prisma.albumsGetPayload<Prisma.albumsDefaultArgs>;
|
|
|
|
|
|
type AlbumTranslationRow = Prisma.album_translationsGetPayload<
|
|
|
|
|
|
Prisma.album_translationsDefaultArgs
|
|
|
|
|
|
>;
|
2026-09-17 23:39:50 +08:00
|
|
|
|
type PhotoVariantRow = Prisma.photo_variantsGetPayload<
|
|
|
|
|
|
Prisma.photo_variantsDefaultArgs
|
|
|
|
|
|
>;
|
2026-09-17 23:33:57 +08:00
|
|
|
|
|
|
|
|
|
|
/** 契约 listPublicPhotos:limit 缺省 30,上下界 1..60(§7 建议 30-60)。 */
|
|
|
|
|
|
export const DEFAULT_PUBLIC_PHOTO_PAGE_SIZE = 30;
|
|
|
|
|
|
export const MAX_PUBLIC_PHOTO_PAGE_SIZE = 60;
|
|
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
|
export class PublicService {
|
|
|
|
|
|
constructor(
|
|
|
|
|
|
private readonly prisma: PrismaService,
|
|
|
|
|
|
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 公开相册信息:token → 相册(active+public)→ 翻译回退解析 → 分类(同语言
|
|
|
|
|
|
* 名称)→ banner 签名。响应不含工作人员信息与内部存储键(契约 PublicAlbum)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async getPublicAlbum(
|
|
|
|
|
|
token: string,
|
|
|
|
|
|
localeRaw: string | undefined,
|
|
|
|
|
|
): Promise<PublicAlbumResponse> {
|
|
|
|
|
|
const album = await this.findPublicAlbum(token);
|
|
|
|
|
|
const translations = await this.prisma.album_translations.findMany({
|
|
|
|
|
|
where: { albumId: album.id },
|
|
|
|
|
|
orderBy: { locale: 'asc' },
|
|
|
|
|
|
});
|
|
|
|
|
|
const resolved = this.resolveTranslation(
|
|
|
|
|
|
album.defaultLocale,
|
|
|
|
|
|
translations,
|
|
|
|
|
|
localeRaw,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 分类:sortOrder 升序(契约),id 决胜保证确定性输出。
|
|
|
|
|
|
const categories = await this.prisma.categories.findMany({
|
|
|
|
|
|
where: { albumId: album.id },
|
|
|
|
|
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
|
|
|
|
|
});
|
|
|
|
|
|
const categoryTranslations =
|
|
|
|
|
|
categories.length === 0
|
|
|
|
|
|
? []
|
|
|
|
|
|
: await this.prisma.category_translations.findMany({
|
|
|
|
|
|
where: { categoryId: { in: categories.map((c) => c.id) } },
|
|
|
|
|
|
orderBy: { locale: 'asc' },
|
|
|
|
|
|
});
|
|
|
|
|
|
const namesByCategory = new Map<string, Map<string, string>>();
|
|
|
|
|
|
for (const row of categoryTranslations) {
|
|
|
|
|
|
let bucket = namesByCategory.get(row.categoryId);
|
|
|
|
|
|
if (bucket === undefined) {
|
|
|
|
|
|
bucket = new Map();
|
|
|
|
|
|
namesByCategory.set(row.categoryId, bucket);
|
|
|
|
|
|
}
|
|
|
|
|
|
bucket.set(row.locale, row.name);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-18 08:40:32 +08:00
|
|
|
|
// banner(P1b Task 3 起对接上传链路):仅 state='effective' 行参与;语言
|
|
|
|
|
|
// 偏好解析(需求 4.7)——resolved locale 行 → 全局行(locale NULL)→ null。
|
|
|
|
|
|
// 以「实际展示语言」(translation 解析结果)为偏好驱动:banner 跟随观众
|
|
|
|
|
|
// 看到的文案语言,而非原始请求值(请求 fr 回退 zh 时配 zh banner)。
|
|
|
|
|
|
// 同级多行取最新(createdAt,id DESC 决胜):同 (kind, locale) 再上传即替换
|
|
|
|
|
|
// 语义——新行生效、旧行保留,此处取最新 effective 行。
|
|
|
|
|
|
const bannerAssets = await this.prisma.album_assets.findMany({
|
|
|
|
|
|
where: { albumId: album.id, kind: 'banner', state: 'effective' },
|
2026-09-17 23:33:57 +08:00
|
|
|
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
|
|
|
|
|
});
|
2026-09-23 10:13:08 +08:00
|
|
|
|
let bannerRow =
|
2026-09-18 08:40:32 +08:00
|
|
|
|
bannerAssets.find((row) => row.locale === resolved.locale) ??
|
|
|
|
|
|
bannerAssets.find((row) => row.locale === null) ??
|
|
|
|
|
|
null;
|
2026-09-23 10:13:08 +08:00
|
|
|
|
// 宣传图/封面共用(用户决策 2026-09-23):banner 缺失回退最新 effective
|
|
|
|
|
|
// cover 行(传一张即可两用;与 ov 公开侧同语义)。
|
|
|
|
|
|
if (bannerRow === null) {
|
|
|
|
|
|
bannerRow = await this.prisma.album_assets.findFirst({
|
|
|
|
|
|
where: { albumId: album.id, kind: 'cover', state: 'effective' },
|
|
|
|
|
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-17 23:33:57 +08:00
|
|
|
|
const banner =
|
2026-09-18 08:40:32 +08:00
|
|
|
|
bannerRow === null ? null : await this.presign(bannerRow.objectKey);
|
2026-09-17 23:33:57 +08:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
publicToken: album.publicToken,
|
|
|
|
|
|
resolvedLocale: resolved.locale,
|
|
|
|
|
|
defaultLocale: album.defaultLocale as AlbumLocale,
|
|
|
|
|
|
timezone: album.timezone,
|
|
|
|
|
|
startAt: album.startAt.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(
|
|
|
|
|
|
namesByCategory.get(c.id),
|
|
|
|
|
|
resolved.locale,
|
|
|
|
|
|
album.defaultLocale,
|
|
|
|
|
|
),
|
|
|
|
|
|
sortOrder: c.sortOrder,
|
|
|
|
|
|
})),
|
|
|
|
|
|
banner,
|
|
|
|
|
|
// P1a 占位:浏览计数由后续 /visits 端点提供(技术实施方案 §7.1)。
|
|
|
|
|
|
viewCount: null,
|
2026-09-20 18:32:34 +08:00
|
|
|
|
// 下载两级开关(P4a Task 4 契约 PublicAlbum 新增必返字段):cn 同形返回
|
|
|
|
|
|
// (下载端点本身仅 ov 域装配——观众动作端点,见 interactions 模块)。
|
|
|
|
|
|
downloadEnabled: album.downloadEnabled,
|
|
|
|
|
|
originalDownloadEnabled: album.originalDownloadEnabled,
|
2026-09-17 23:33:57 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 23:39:50 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 公开照片列表(Task 23):仅 published,(publishedAt,id) 双列稳定倒序键集
|
|
|
|
|
|
* 游标(shared/cursor,同批 publishedAt 以 id DESC 决胜)。签名 URL 逐条现签
|
|
|
|
|
|
* (2×30 次 presign 为 P1a 可接受量级——SigV4 本地计算无网络往返;P5 若成
|
|
|
|
|
|
* 瓶颈再考虑批量/缩短 TTL 策略,见任务注记)。locale 参数接受但列表响应无
|
|
|
|
|
|
* 语言敏感字段,语义上仅保证「不因非法值报 400」(Task 5 决策)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async listPublicPhotos(
|
|
|
|
|
|
token: string,
|
|
|
|
|
|
query: {
|
|
|
|
|
|
category?: string;
|
|
|
|
|
|
cursor?: string;
|
|
|
|
|
|
limit: number;
|
|
|
|
|
|
},
|
|
|
|
|
|
): Promise<PublicPhotoListResponse> {
|
|
|
|
|
|
const album = await this.findPublicAlbum(token);
|
|
|
|
|
|
|
|
|
|
|
|
// category 过滤:须为本相册分类;幽灵/跨册同码 400(契约 category 属查询
|
|
|
|
|
|
// 参数校验;不区分两种失败,防探测)。
|
|
|
|
|
|
if (query.category !== undefined && query.category !== '') {
|
|
|
|
|
|
const category = await this.prisma.categories.findUnique({
|
|
|
|
|
|
where: { id: query.category },
|
|
|
|
|
|
select: { albumId: true },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (category === null || category.albumId !== album.id) {
|
|
|
|
|
|
throw validationFailed('category 不属于该相册');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
|
|
|
|
|
|
if (query.cursor !== undefined && query.cursor !== '') {
|
|
|
|
|
|
decoded = decodeKeysetCursor(query.cursor);
|
|
|
|
|
|
if (decoded === null) {
|
|
|
|
|
|
throw validationFailed('cursor 不合法');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const where: Prisma.photosWhereInput = {
|
|
|
|
|
|
albumId: album.id,
|
|
|
|
|
|
visibility: 'published',
|
|
|
|
|
|
...(query.category !== undefined && query.category !== ''
|
|
|
|
|
|
? { categoryId: query.category }
|
|
|
|
|
|
: {}),
|
|
|
|
|
|
};
|
|
|
|
|
|
if (decoded !== null) {
|
|
|
|
|
|
Object.assign(where, keysetWhereDesc('publishedAt', 'id', decoded));
|
|
|
|
|
|
}
|
|
|
|
|
|
const rows = await this.prisma.photos.findMany({
|
|
|
|
|
|
where,
|
|
|
|
|
|
orderBy: [{ publishedAt: 'desc' }, { id: 'desc' }],
|
|
|
|
|
|
take: query.limit + 1, // 多取一条探测 hasMore(同相册列表)
|
|
|
|
|
|
});
|
|
|
|
|
|
const hasMore = rows.length > query.limit;
|
|
|
|
|
|
const page = hasMore ? rows.slice(0, query.limit) : rows;
|
|
|
|
|
|
|
|
|
|
|
|
// 变体行二次查询(扁平外键,应用层组装;发布事务保证 published 照片必有两
|
|
|
|
|
|
// 变体行)。缺行/缺尺寸属 post-publish 不变量破坏:防御性跳过而非 5xx——
|
|
|
|
|
|
// 公开页不因单张脏数据整页失败;宽高缺失同理(契约 width/height 必返)。
|
|
|
|
|
|
const variants =
|
|
|
|
|
|
page.length === 0
|
|
|
|
|
|
? []
|
|
|
|
|
|
: await this.prisma.photo_variants.findMany({
|
|
|
|
|
|
where: { photoId: { in: page.map((p) => p.id) } },
|
|
|
|
|
|
});
|
|
|
|
|
|
const variantsByPhoto = new Map<string, Map<string, PhotoVariantRow>>();
|
|
|
|
|
|
for (const row of variants) {
|
|
|
|
|
|
let bucket = variantsByPhoto.get(row.photoId);
|
|
|
|
|
|
if (bucket === undefined) {
|
|
|
|
|
|
bucket = new Map();
|
|
|
|
|
|
variantsByPhoto.set(row.photoId, bucket);
|
|
|
|
|
|
}
|
|
|
|
|
|
bucket.set(row.variant, row);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 17:53:44 +08:00
|
|
|
|
// P4a Task 3 水印浏览图选择(cn):albums.watermarkEnabled(有效版本开关)
|
|
|
|
|
|
// 且照片有 watermark 变体行 → 现签水印对象键;否则 browse。行是对象 put
|
|
|
|
|
|
// 成功后才落库的(worker 先写对象再 upsert 行),故行存在 ⇒ 对象存在;
|
|
|
|
|
|
// 失败照片行保留旧版本键(重生成失败不覆盖行——A20「失败照片保留旧图」),
|
|
|
|
|
|
// 新发布照片行在最新版本键(WatermarkJobCreated:新照片即刻套用)——两者
|
|
|
|
|
|
// 都直接用行的 objectKey,无需版本比较。disable 版任务逐张删行:删到哪张
|
|
|
|
|
|
// 哪张回落 browse(渐进撤下,语义见 contracts watermark.ts)。
|
|
|
|
|
|
const serveWatermark = album.watermarkEnabled;
|
|
|
|
|
|
|
2026-09-17 23:39:50 +08:00
|
|
|
|
const items: PublicPhotoListResponse['items'] = [];
|
|
|
|
|
|
for (const photo of page) {
|
|
|
|
|
|
const bucket = variantsByPhoto.get(photo.id);
|
|
|
|
|
|
const thumbRow = bucket?.get('thumb');
|
|
|
|
|
|
const browseRow = bucket?.get('browse');
|
|
|
|
|
|
if (thumbRow === undefined || browseRow === undefined) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-09-20 17:53:44 +08:00
|
|
|
|
const watermarkRow = serveWatermark
|
|
|
|
|
|
? bucket?.get('watermark')
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const browseKey =
|
|
|
|
|
|
watermarkRow !== undefined ? watermarkRow.objectKey : browseRow.objectKey;
|
2026-09-17 23:39:50 +08:00
|
|
|
|
// 宽高取 browse 变体尺寸(契约「浏览图宽高,用于列表占位」);变体行缺
|
|
|
|
|
|
// 尺寸时回退 photos 主行(原始尺寸),两侧均缺 → 同样跳过(必返字段)。
|
2026-09-20 17:53:44 +08:00
|
|
|
|
// 水印图与 browse 同源同尺寸(compose 基于 browse 管线),占位一致。
|
2026-09-17 23:39:50 +08:00
|
|
|
|
const width = browseRow.width ?? photo.width;
|
|
|
|
|
|
const height = browseRow.height ?? photo.height;
|
|
|
|
|
|
if (width === null || height === null) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
items.push({
|
|
|
|
|
|
photoId: photo.id,
|
|
|
|
|
|
width,
|
|
|
|
|
|
height,
|
|
|
|
|
|
thumb: await this.presign(thumbRow.objectKey),
|
2026-09-20 17:53:44 +08:00
|
|
|
|
browse: await this.presign(browseKey),
|
2026-09-17 23:39:50 +08:00
|
|
|
|
likeCount: photo.likeCount,
|
|
|
|
|
|
// published 行的 publishedAt 必非空(worker 发布事务写入);?? 兜底仅
|
|
|
|
|
|
// 收窄类型,不构成可观察行为。
|
|
|
|
|
|
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 游标取自「DB 页末行」而非「末个发射条目」:被跳过的行不应在下一页重扫
|
|
|
|
|
|
// (末行被跳过时若以末个发射条目为游标,跳过的行会被反复重取再跳过;取
|
|
|
|
|
|
// DB 行位置则一次越过)。
|
|
|
|
|
|
const last = page.at(-1);
|
|
|
|
|
|
const nextCursor =
|
|
|
|
|
|
hasMore && last !== undefined
|
|
|
|
|
|
? encodeKeysetCursor({
|
|
|
|
|
|
sort: (last.publishedAt ?? new Date(0)).toISOString(),
|
|
|
|
|
|
id: last.id,
|
|
|
|
|
|
})
|
|
|
|
|
|
: null;
|
|
|
|
|
|
return { items, nextCursor, hasMore: nextCursor !== null };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 23:51:29 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 公开单图(Task 24):深链定位 + 签名 URL 过期后的刷新入口(每次现签)。
|
|
|
|
|
|
* 服务端校验相册与照片归属:照片不存在 / 不属于该相册 / 非 published
|
|
|
|
|
|
* (隐藏、软删除后的旧链接)统一 404 PHOTO_NOT_PUBLIC(不泄露状态,A16)。
|
|
|
|
|
|
* 变体行缺失的防御语义同列表(跳过)——此处无「跳过」可选,落 404。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async getPublicPhoto(
|
|
|
|
|
|
token: string,
|
|
|
|
|
|
photoId: string,
|
|
|
|
|
|
): Promise<PublicPhotoDetailResponse> {
|
|
|
|
|
|
const album = await this.findPublicAlbum(token);
|
|
|
|
|
|
const photo = await this.prisma.photos.findUnique({
|
|
|
|
|
|
where: { id: photoId },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (
|
|
|
|
|
|
photo === null ||
|
|
|
|
|
|
photo.albumId !== album.id ||
|
|
|
|
|
|
photo.visibility !== 'published'
|
|
|
|
|
|
) {
|
|
|
|
|
|
throw photoNotPublic();
|
|
|
|
|
|
}
|
|
|
|
|
|
const variants = await this.prisma.photo_variants.findMany({
|
|
|
|
|
|
where: { photoId: photo.id },
|
|
|
|
|
|
});
|
|
|
|
|
|
const thumbRow = variants.find((v) => v.variant === 'thumb');
|
|
|
|
|
|
const browseRow = variants.find((v) => v.variant === 'browse');
|
2026-09-20 17:53:44 +08:00
|
|
|
|
// 水印浏览图选择同列表(有效开关 + 行存在 ⇒ 用行的对象键;A20 失败照片
|
|
|
|
|
|
// 保留旧图——行未被失败任务覆盖,仍指向旧版本键)。
|
|
|
|
|
|
const watermarkRow = album.watermarkEnabled
|
|
|
|
|
|
? variants.find((v) => v.variant === 'watermark')
|
|
|
|
|
|
: undefined;
|
2026-09-17 23:51:29 +08:00
|
|
|
|
const width = browseRow?.width ?? photo.width;
|
|
|
|
|
|
const height = browseRow?.height ?? photo.height;
|
|
|
|
|
|
if (
|
|
|
|
|
|
thumbRow === undefined ||
|
|
|
|
|
|
browseRow === undefined ||
|
|
|
|
|
|
width === null ||
|
|
|
|
|
|
height === null
|
|
|
|
|
|
) {
|
|
|
|
|
|
// post-publish 不变量破坏:公开面按「不可展示」处理(同列表跳过语义)。
|
|
|
|
|
|
throw photoNotPublic();
|
|
|
|
|
|
}
|
2026-09-20 17:53:44 +08:00
|
|
|
|
const browseKey =
|
|
|
|
|
|
watermarkRow !== undefined ? watermarkRow.objectKey : browseRow.objectKey;
|
2026-09-17 23:51:29 +08:00
|
|
|
|
return {
|
|
|
|
|
|
photoId: photo.id,
|
|
|
|
|
|
width,
|
|
|
|
|
|
height,
|
|
|
|
|
|
thumb: await this.presign(thumbRow.objectKey),
|
2026-09-20 17:53:44 +08:00
|
|
|
|
browse: await this.presign(browseKey),
|
2026-09-17 23:51:29 +08:00
|
|
|
|
likeCount: photo.likeCount,
|
|
|
|
|
|
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
|
|
|
|
|
|
categoryId: photo.categoryId,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 23:33:57 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 相册定位:publicToken 唯一索引;不存在 / offline / 未公开统一 404
|
|
|
|
|
|
* ALBUM_NOT_PUBLIC。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async findPublicAlbum(token: string): Promise<AlbumRow> {
|
|
|
|
|
|
const album = await this.prisma.albums.findUnique({
|
|
|
|
|
|
where: { publicToken: token },
|
|
|
|
|
|
});
|
|
|
|
|
|
if (album === null || album.status !== 'active' || !album.publicEnabled) {
|
|
|
|
|
|
throw albumNotPublic();
|
|
|
|
|
|
}
|
|
|
|
|
|
return album;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 语言回退解析(需求 4.7):请求 locale 合法(契约枚举)且有翻译条目 → 用之;
|
|
|
|
|
|
* 否则 defaultLocale。defaultLocale 条目由创建/更新路径的跨字段校验保证必在,
|
|
|
|
|
|
* 缺行属装配异常,按 5xx 兜底(公开端点不得以半翻译状态 200)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private resolveTranslation(
|
|
|
|
|
|
defaultLocale: string,
|
|
|
|
|
|
rows: AlbumTranslationRow[],
|
|
|
|
|
|
requested: string | undefined,
|
|
|
|
|
|
): { locale: AlbumLocale; row: AlbumTranslationRow } {
|
|
|
|
|
|
const byLocale = new Map(rows.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 升序
|
|
|
|
|
|
* 首,确定性输出)。分类创建必附翻译,末两级纯防御(名称为契约必返 string,
|
|
|
|
|
|
* 不返回 null——真缺行时回退空串并保持 200,公开页不因单分类脏数据整页 5xx)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
private resolveCategoryName(
|
|
|
|
|
|
names: Map<string, string> | undefined,
|
|
|
|
|
|
resolvedLocale: AlbumLocale,
|
|
|
|
|
|
defaultLocale: string,
|
|
|
|
|
|
): string {
|
|
|
|
|
|
if (names === undefined || names.size === 0) {
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
names.get(resolvedLocale) ??
|
|
|
|
|
|
names.get(defaultLocale) ??
|
|
|
|
|
|
([...names.entries()].sort(([a], [b]) => (a < b ? -1 : 1))[0][1] as string)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 现签短期 GET 凭证(TTL 惰性读取;expiresAt 与 URL 同源推导)。 */
|
|
|
|
|
|
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() };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|