// 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 升序取首);分类创建必附翻译,末级分支纯防御。 // · banner(P1b Task 3 起对接上传链路):album_assets(kind=banner, // state='effective') 中按语言偏好解析(需求 4.7)——resolved locale 行 → // 全局行(locale NULL)→ null;同级取最新(createdAt,id DESC 决胜,同 // (kind, locale) 再上传即替换语义)。 // · 签名 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'; import { validationFailed } from '../albums/support'; import { decodeKeysetCursor, encodeKeysetCursor, keysetWhereDesc, } from '../shared/cursor'; import { PrismaService } from '../prisma/prisma.service'; import { STORAGE_PORT } from '../uploads/storage.provider'; import { signedUrlTtlSeconds } from '../uploads/support'; import { albumNotPublic, photoNotPublic } from './support'; 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; type AlbumTranslationRow = Prisma.album_translationsGetPayload< Prisma.album_translationsDefaultArgs >; type PhotoVariantRow = Prisma.photo_variantsGetPayload< Prisma.photo_variantsDefaultArgs >; /** 契约 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 { 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>(); 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); } // 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' }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], }); let bannerRow = bannerAssets.find((row) => row.locale === resolved.locale) ?? bannerAssets.find((row) => row.locale === null) ?? null; // 宣传图/封面共用(用户决策 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' }], }); } const banner = bannerRow === null ? null : await this.presign(bannerRow.objectKey); 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, // 下载两级开关(P4a Task 4 契约 PublicAlbum 新增必返字段):cn 同形返回 // (下载端点本身仅 ov 域装配——观众动作端点,见 interactions 模块)。 downloadEnabled: album.downloadEnabled, originalDownloadEnabled: album.originalDownloadEnabled, }; } /** * 公开照片列表(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 { 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 = 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>(); 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); } // P4a Task 3 水印浏览图选择(cn):albums.watermarkEnabled(有效版本开关) // 且照片有 watermark 变体行 → 现签水印对象键;否则 browse。行是对象 put // 成功后才落库的(worker 先写对象再 upsert 行),故行存在 ⇒ 对象存在; // 失败照片行保留旧版本键(重生成失败不覆盖行——A20「失败照片保留旧图」), // 新发布照片行在最新版本键(WatermarkJobCreated:新照片即刻套用)——两者 // 都直接用行的 objectKey,无需版本比较。disable 版任务逐张删行:删到哪张 // 哪张回落 browse(渐进撤下,语义见 contracts watermark.ts)。 const serveWatermark = album.watermarkEnabled; 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; } const watermarkRow = serveWatermark ? bucket?.get('watermark') : undefined; const browseKey = watermarkRow !== undefined ? watermarkRow.objectKey : browseRow.objectKey; // 宽高取 browse 变体尺寸(契约「浏览图宽高,用于列表占位」);变体行缺 // 尺寸时回退 photos 主行(原始尺寸),两侧均缺 → 同样跳过(必返字段)。 // 水印图与 browse 同源同尺寸(compose 基于 browse 管线),占位一致。 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), browse: await this.presign(browseKey), 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 }; } /** * 公开单图(Task 24):深链定位 + 签名 URL 过期后的刷新入口(每次现签)。 * 服务端校验相册与照片归属:照片不存在 / 不属于该相册 / 非 published * (隐藏、软删除后的旧链接)统一 404 PHOTO_NOT_PUBLIC(不泄露状态,A16)。 * 变体行缺失的防御语义同列表(跳过)——此处无「跳过」可选,落 404。 */ async getPublicPhoto( token: string, photoId: string, ): Promise { 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'); // 水印浏览图选择同列表(有效开关 + 行存在 ⇒ 用行的对象键;A20 失败照片 // 保留旧图——行未被失败任务覆盖,仍指向旧版本键)。 const watermarkRow = album.watermarkEnabled ? variants.find((v) => v.variant === 'watermark') : undefined; 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(); } const browseKey = watermarkRow !== undefined ? watermarkRow.objectKey : browseRow.objectKey; return { photoId: photo.id, width, height, thumb: await this.presign(thumbRow.objectKey), browse: await this.presign(browseKey), likeCount: photo.likeCount, publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(), categoryId: photo.categoryId, }; } /** * 相册定位:publicToken 唯一索引;不存在 / offline / 未公开统一 404 * ALBUM_NOT_PUBLIC。 */ private async findPublicAlbum(token: string): Promise { 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 | 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 { const ttl = signedUrlTtlSeconds(); const url = await this.storage.presignGet(objectKey, ttl); return { url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() }; } }