From 6863c736c3817df1101a9722b51a505f14064379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 17 Sep 2026 23:33:57 +0800 Subject: [PATCH] feat(api): public album info with locale fallback Co-Authored-By: Claude Code --- pnpm-lock.yaml | 13 +- services/api/package.json | 1 + services/api/src/app-cn.module.ts | 3 + services/api/src/public/public.controller.ts | 61 +++ services/api/src/public/public.module.ts | 14 + services/api/src/public/public.service.ts | 204 ++++++++++ services/api/src/public/support.ts | 28 ++ services/api/test/public.e2e-spec.ts | 372 +++++++++++++++++++ 8 files changed, 691 insertions(+), 5 deletions(-) create mode 100644 services/api/src/public/public.controller.ts create mode 100644 services/api/src/public/public.module.ts create mode 100644 services/api/src/public/public.service.ts create mode 100644 services/api/src/public/support.ts create mode 100644 services/api/test/public.e2e-spec.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60ef444..62e7acb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,7 @@ importers: version: 7.13.0(typescript@5.9.3) ts-jest: specifier: ^29.2.0 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3) typescript: specifier: ^5.6.0 version: 5.9.3 @@ -90,7 +90,7 @@ importers: version: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3) typescript: specifier: ^5.6.0 version: 5.9.3 @@ -167,12 +167,15 @@ importers: jest: specifier: ^29.7.0 version: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)) + sharp: + specifier: ^0.35.0 + version: 0.35.4(@types/node@22.20.3) supertest: specifier: ^7.0.0 version: 7.2.2 ts-jest: specifier: ^29.2.5 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.20.3)(typescript@5.9.3) @@ -221,7 +224,7 @@ importers: version: 29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)) ts-jest: specifier: ^29.2.5 - version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3) typescript: specifier: ^5.6.0 version: 5.9.3 @@ -6970,7 +6973,7 @@ snapshots: dependencies: typescript: 5.9.3 - ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3)(ts-node@10.9.2(@types/node@22.20.3)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.12(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@22.20.3))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 diff --git a/services/api/package.json b/services/api/package.json index 0187a29..9f15bc1 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -39,6 +39,7 @@ "@types/supertest": "^6.0.2", "dotenv": "^16.4.5", "jest": "^29.7.0", + "sharp": "^0.35.0", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", diff --git a/services/api/src/app-cn.module.ts b/services/api/src/app-cn.module.ts index 69114dc..12b55aa 100644 --- a/services/api/src/app-cn.module.ts +++ b/services/api/src/app-cn.module.ts @@ -20,6 +20,7 @@ import { AllExceptionsFilter } from './all-exceptions.filter'; import { AuthModule } from './auth/auth.module'; import { HealthController } from './health.controller'; import { PrismaModule } from './prisma/prisma.module'; +import { PublicModule } from './public/public.module'; import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware'; import { UploadsModule } from './uploads/uploads.module'; import { resolveLogLevel } from './shared/log-level'; @@ -34,6 +35,8 @@ import type { ServerRequestWithId } from './shared/request-with-id'; AlbumsModule, // 上传域(Task 18 init;Task 19 renew/complete/history)。 UploadsModule, + // 公开(观众端)域(Task 22 getPublicAlbum;Task 23 公开照片读)。 + PublicModule, LoggerModule.forRoot({ pinoHttp: { // 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id, diff --git a/services/api/src/public/public.controller.ts b/services/api/src/public/public.controller.ts new file mode 100644 index 0000000..f43ef9a --- /dev/null +++ b/services/api/src/public/public.controller.ts @@ -0,0 +1,61 @@ +// services/api/src/public/public.controller.ts —— /public 端点(Task 22 起步: +// getPublicAlbum;Task 23 增补 listPublicPhotos / getPublicPhoto)。方法名与 +// 契约 operationId 一一对应。匿名访问(契约 security: []): +// · 不挂 SessionGuard——控制器级守卫缺席即匿名,认证由持有 publicToken 本身 +// 取代(令牌不可枚举,持有链接者均可访问); +// · 限流:P1a 不做(P2 引入点赞限流时统一评估公开面配额),此处记录约定。 +// 查询参数规整(limit 1..60 缺省 30、cursor 长度):非法 → 400 +// VALIDATION_FAILED;locale 取值不在此校验——契约规定不支持的语言按 +// defaultLocale 静默回退(Task 5 决策),语义在服务层。 +import { + Controller, + Get, + HttpStatus, + Param, + Query, +} from '@nestjs/common'; +import { ErrorCodes } from '@photo/contracts'; +import { ApiException } from '../api-exception'; +import { + DEFAULT_PUBLIC_PHOTO_PAGE_SIZE, + MAX_PUBLIC_PHOTO_PAGE_SIZE, + type PublicAlbumResponse, + PublicService, +} from './public.service'; + +/** limit 查询参数:缺省 30,须为 1..60 的十进制整数(契约 bounds)。 */ +export function parsePublicPhotoLimit(raw: string | undefined): number { + if (raw === undefined || raw === '') { + return DEFAULT_PUBLIC_PHOTO_PAGE_SIZE; + } + if (!/^\d+$/.test(raw)) { + throw new ApiException( + ErrorCodes.VALIDATION_FAILED, + 'limit 必须是 1-60 的整数', + HttpStatus.BAD_REQUEST, + ); + } + const limit = Number.parseInt(raw, 10); + if (limit < 1 || limit > MAX_PUBLIC_PHOTO_PAGE_SIZE) { + throw new ApiException( + ErrorCodes.VALIDATION_FAILED, + 'limit 必须是 1-60 的整数', + HttpStatus.BAD_REQUEST, + ); + } + return limit; +} + +@Controller('public/albums') +export class PublicController { + constructor(private readonly publicService: PublicService) {} + + /** 公开相册信息:locale 缺省/非法/无条目均按 defaultLocale 回退(200)。 */ + @Get(':token') + getPublicAlbum( + @Param('token') token: string, + @Query('locale') locale: string | undefined, + ): Promise { + return this.publicService.getPublicAlbum(token, locale); + } +} diff --git a/services/api/src/public/public.module.ts b/services/api/src/public/public.module.ts new file mode 100644 index 0000000..f8550b9 --- /dev/null +++ b/services/api/src/public/public.module.ts @@ -0,0 +1,14 @@ +// services/api/src/public/public.module.ts —— 公开(观众端)模块(Task 22 起)。 +// 装配:PublicController/PublicService + StoragePort(经 UploadsModule 导入复用 +// 同一实例——storage.provider 的 DI token 约定)。无认证语义:公开端点匿名。 +import { Module } from '@nestjs/common'; +import { UploadsModule } from '../uploads/uploads.module'; +import { PublicController } from './public.controller'; +import { PublicService } from './public.service'; + +@Module({ + imports: [UploadsModule], + controllers: [PublicController], + providers: [PublicService], +}) +export class PublicModule {} diff --git a/services/api/src/public/public.service.ts b/services/api/src/public/public.service.ts new file mode 100644 index 0000000..25cdad5 --- /dev/null +++ b/services/api/src/public/public.service.ts @@ -0,0 +1,204 @@ +// 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:album_assets(kind=banner) 多行时取最新一条(createdAt DESC, +// id DESC 决胜);P1a 无按语言选封面概念(locale 列为 P1b 上传链路预留)。 +// · 签名 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 { PrismaService } from '../prisma/prisma.service'; +import { STORAGE_PORT } from '../uploads/storage.provider'; +import { signedUrlTtlSeconds } from '../uploads/support'; +import { albumNotPublic } 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 +>; + +/** 契约 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:最新 banner 资产行现签(无行 → null)。 + const bannerAsset = await this.prisma.album_assets.findFirst({ + where: { albumId: album.id, kind: 'banner' }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + const banner = + bannerAsset === null + ? null + : await this.presign(bannerAsset.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, + }; + } + + /** + * 相册定位: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() }; + } +} diff --git a/services/api/src/public/support.ts b/services/api/src/public/support.ts new file mode 100644 index 0000000..ee9be88 --- /dev/null +++ b/services/api/src/public/support.ts @@ -0,0 +1,28 @@ +// services/api/src/public/support.ts —— 公开(观众端)域共用小工具(Task 22 起)。 +import { HttpStatus } from '@nestjs/common'; +import { ErrorCodes } from '@photo/contracts'; +import { ApiException } from '../api-exception'; + +/** + * 统一 404 ALBUM_NOT_PUBLIC:相册不存在 / offline / publicEnabled=false 同码 + * (契约语义:不区分以避免 publicToken 枚举)。 + */ +export function albumNotPublic(): ApiException { + return new ApiException( + ErrorCodes.ALBUM_NOT_PUBLIC, + '相册不存在或未公开', + HttpStatus.NOT_FOUND, + ); +} + +/** + * 统一 404 PHOTO_NOT_PUBLIC:照片不存在 / 未发布 / 已隐藏或删除 / 不属于该相册 + * 同码(契约语义:旧深链在隐藏/删除后一律 404,不泄露状态)。 + */ +export function photoNotPublic(): ApiException { + return new ApiException( + ErrorCodes.PHOTO_NOT_PUBLIC, + '照片不存在或未公开', + HttpStatus.NOT_FOUND, + ); +} diff --git a/services/api/test/public.e2e-spec.ts b/services/api/test/public.e2e-spec.ts new file mode 100644 index 0000000..c5c1299 --- /dev/null +++ b/services/api/test/public.e2e-spec.ts @@ -0,0 +1,372 @@ +// services/api/test/public.e2e-spec.ts —— Task 22/23 公开读端点 e2e(先写后实现)。 +// 真实 app + MySQL(Prisma 直种)+ 真实 MinIO(storage.put 真 JPEG:banner 与 +// thumb/browse 变体对象)。公开端点匿名访问(契约 security: []):全程不带 +// Authorization 头——若误挂 SessionGuard 会以 401 暴露,测试即失败。 +// Task 22 覆盖(getPublicAlbum): +// · locale 解析:en/zh-CN 精确命中;缺省 → defaultLocale;翻译缺失 → 回退 +// defaultLocale(需求 4.7);非法 locale('fr')→ 静默回退(Task 5 决策, +// 不报 400); +// · 可见性:offline / publicEnabled=false / 幽灵 token → 404 ALBUM_NOT_PUBLIC; +// · categories:按请求 locale 解析名称、sortOrder 升序;分类缺 resolved 语言 +// 条目时按 defaultLocale 回退; +// · banner:album_assets(kind=banner) + 真实对象 → 签名 URL 可取回真实字节、 +// expiresAt 晚于现在;无资产行 → null; +// · viewCount P1a 占位 null;startAt/endAt ISO + timezone 原样返回。 +import { randomBytes } from 'node:crypto'; +import { type INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import sharp from 'sharp'; +import { PrismaClient } from '@photo/db-cn'; +import { ErrorCodes } from '@photo/contracts'; +import { + createStorageFromEnv, + type MinioStorageAdapter, +} from '@photo/storage'; +import { AppCnModule } from '../src/app-cn.module'; +import { API_PREFIX } from '../src/shared/constants'; +import { resetDb } from './reset'; + +jest.setTimeout(120_000); + +/** 契约 pattern ^[0-9a-z]{20,32}$:24 字符 [0-9a-z](同 albums.service 生成式)。 */ +function makePublicToken(): string { + const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; + let token = ''; + const bytes = randomBytes(24); + for (let i = 0; i < 24; i++) { + token += alphabet[bytes[i] % alphabet.length]; + } + return token; +} + +/** 测试内生成真实 JPEG(无二进制入库,同 worker publish e2e)。 */ +async function makeJpeg( + width: number, + height: number, +): Promise { + return sharp({ + create: { width, height, channels: 3, background: { r: 96, g: 130, b: 182 } }, + }) + .jpeg({ quality: 85 }) + .toBuffer(); +} + +const KEY_ROOT = 'photo-live/dev'; + +interface SeedAlbumOptions { + alias: string; + defaultLocale?: string; + translations: Array<{ + locale: string; + title: string; + description?: string; + venue?: string; + address?: string; + }>; + status?: string; + publicEnabled?: boolean; + timezone?: string; + startAt?: Date; + endAt?: Date | null; +} + +describe('public (e2e)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let storage: MinioStorageAdapter; + + /** 经 Prisma 直种相册 + 翻译,返回完整行(publicToken 由调用方持久化使用)。 */ + async function seedAlbum( + options: SeedAlbumOptions, + ): Promise<{ id: string; publicToken: string }> { + const album = await prisma.albums.create({ + data: { + publicToken: makePublicToken(), + ownerId: `u-${options.alias}`, + startAt: options.startAt ?? new Date('2026-10-01T02:00:00.000Z'), + endAt: + options.endAt === undefined + ? new Date('2026-10-01T12:00:00.000Z') + : options.endAt, + timezone: options.timezone ?? 'Asia/Shanghai', + status: options.status ?? 'active', + defaultLocale: options.defaultLocale ?? 'zh-CN', + publicEnabled: options.publicEnabled ?? true, + }, + select: { id: true, publicToken: true }, + }); + await prisma.album_translations.createMany({ + data: options.translations.map((t) => ({ + albumId: album.id, + locale: t.locale, + title: t.title, + description: t.description ?? null, + venue: t.venue ?? null, + address: t.address ?? null, + })), + }); + return album; + } + + /** 直种分类 + 翻译,返回 id。 */ + async function seedCategory( + albumId: string, + sortOrder: number, + translations: Array<{ locale: string; name: string }>, + ): Promise { + const category = await prisma.categories.create({ + data: { albumId, sortOrder }, + select: { id: true }, + }); + await prisma.category_translations.createMany({ + data: translations.map((t) => ({ + categoryId: category.id, + locale: t.locale, + name: t.name, + })), + }); + return category.id; + } + + function getPublic(token: string, query?: Record) { + return request(app.getHttpServer()) + .get(`/${API_PREFIX}/public/albums/${token}`) + .query(query ?? {}); + } + + beforeAll(async () => { + await resetDb(); + const moduleRef = await Test.createTestingModule({ + imports: [AppCnModule], + }).compile(); + app = moduleRef.createNestApplication(); + app.setGlobalPrefix(API_PREFIX); + await app.init(); + + prisma = new PrismaClient(); + storage = createStorageFromEnv(); + }); + + afterAll(async () => { + await app.close(); + await prisma.$disconnect(); + }); + + describe('Task 22:GET /public/albums/{token}(getPublicAlbum)', () => { + let mainToken: string; + let fallbackToken: string; + let offlineToken: string; + let disabledToken: string; + let bannerToken: string; + let noBannerToken: string; + let catAId: string; + let catBId: string; + let catZhOnlyId: string; + + beforeAll(async () => { + // 主相册:zh-CN(default)+en 双语 + 三分类(含一个仅 zh 的分类)。 + const main = await seedAlbum({ + alias: 'pub-main', + translations: [ + { + locale: 'zh-CN', + title: '2026 公司年会', + description: '年度盛会', + venue: '大宴会厅', + address: '上海市浦东新区世纪大道 100 号', + }, + { + locale: 'en', + title: 'Annual Party 2026', + description: 'Yearly gala', + venue: 'Grand Ballroom', + address: '100 Century Ave, Pudong, Shanghai', + }, + ], + }); + mainToken = main.publicToken; + catAId = await seedCategory(main.id, 1, [ + { locale: 'zh-CN', name: '开场' }, + { locale: 'en', name: 'Opening' }, + ]); + catBId = await seedCategory(main.id, 2, [ + { locale: 'zh-CN', name: '颁奖' }, + { locale: 'en', name: 'Awards' }, + ]); + catZhOnlyId = await seedCategory(main.id, 0, [ + { locale: 'zh-CN', name: '签到墙' }, + ]); + + // 回退相册:仅 zh-CN 翻译(defaultLocale=zh-CN)。 + const fallback = await seedAlbum({ + alias: 'pub-fallback', + translations: [{ locale: 'zh-CN', title: '只有中文的相册' }], + }); + fallbackToken = fallback.publicToken; + + const offline = await seedAlbum({ + alias: 'pub-offline', + status: 'offline', + translations: [{ locale: 'zh-CN', title: '已下线相册' }], + }); + offlineToken = offline.publicToken; + + const disabled = await seedAlbum({ + alias: 'pub-disabled', + publicEnabled: false, + translations: [{ locale: 'zh-CN', title: '未公开相册' }], + }); + disabledToken = disabled.publicToken; + + // banner 相册:album_assets(kind=banner) + 真实 MinIO 对象(fixture 直种, + // 上传链路属 P1b)。 + const banner = await seedAlbum({ + alias: 'pub-banner', + translations: [ + { locale: 'zh-CN', title: '有封面的相册' }, + { locale: 'en', title: 'Album With Banner' }, + ], + }); + bannerToken = banner.publicToken; + const bannerKey = `${KEY_ROOT}/albums/${banner.id}/assets/banner.jpg`; + await storage.put(bannerKey, await makeJpeg(1200, 400), 'image/jpeg'); + await prisma.album_assets.create({ + data: { albumId: banner.id, kind: 'banner', objectKey: bannerKey }, + }); + + const noBanner = await seedAlbum({ + alias: 'pub-nobanner', + translations: [{ locale: 'zh-CN', title: '无封面相册' }], + }); + noBannerToken = noBanner.publicToken; + }); + + it('locale=en → 200 translation=en 内容且 resolvedLocale=en;categories 同语言解析且按 sortOrder 升序', async () => { + const res = await getPublic(mainToken, { locale: 'en' }); + expect(res.status).toBe(200); + + const body = res.body as Record; + expect(body.resolvedLocale).toBe('en'); + expect(body.defaultLocale).toBe('zh-CN'); + expect(body.translation).toMatchObject({ + locale: 'en', + title: 'Annual Party 2026', + description: 'Yearly gala', + venue: 'Grand Ballroom', + address: '100 Century Ave, Pudong, Shanghai', + }); + + const categories = body.categories as Array>; + expect(categories.map((c) => c.id)).toEqual([ + catZhOnlyId, + catAId, + catBId, + ]); + // zh-only 分类在 en 请求下按 defaultLocale 回退出名称。 + expect(categories.map((c) => c.name)).toEqual([ + '签到墙', + 'Opening', + 'Awards', + ]); + expect(categories.map((c) => c.sortOrder)).toEqual([0, 1, 2]); + }); + + it('locale=zh-CN → zh 内容;无 locale → defaultLocale(zh-CN)', async () => { + const zh = await getPublic(mainToken, { locale: 'zh-CN' }); + expect(zh.status).toBe(200); + expect(zh.body.resolvedLocale).toBe('zh-CN'); + expect(zh.body.translation).toMatchObject({ + locale: 'zh-CN', + title: '2026 公司年会', + }); + expect( + (zh.body.categories as Array<{ name: string }>).map((c) => c.name), + ).toEqual(['签到墙', '开场', '颁奖']); + + const none = await getPublic(mainToken); + expect(none.status).toBe(200); + expect(none.body.resolvedLocale).toBe('zh-CN'); + expect(none.body.translation).toMatchObject({ + locale: 'zh-CN', + title: '2026 公司年会', + }); + }); + + it('请求语言无翻译条目 → 回退 defaultLocale 内容且 resolvedLocale=defaultLocale(需求 4.7)', async () => { + const res = await getPublic(fallbackToken, { locale: 'en' }); + expect(res.status).toBe(200); + expect(res.body.resolvedLocale).toBe('zh-CN'); + expect(res.body.translation).toMatchObject({ + locale: 'zh-CN', + title: '只有中文的相册', + }); + }); + + it('非法 locale(fr)→ 200 静默回退 defaultLocale(Task 5 决策,不报 400)', async () => { + const res = await getPublic(mainToken, { locale: 'fr' }); + expect(res.status).toBe(200); + expect(res.body.resolvedLocale).toBe('zh-CN'); + expect(res.body.translation).toMatchObject({ + locale: 'zh-CN', + title: '2026 公司年会', + }); + }); + + it('offline / publicEnabled=false / 幽灵 token → 404 ALBUM_NOT_PUBLIC(同码防枚举)', async () => { + for (const token of [offlineToken, disabledToken, 'no-such-token-xyz']) { + const res = await getPublic(token); + expect(res.status).toBe(404); + expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC); + expect(typeof res.body.requestId).toBe('string'); + } + }); + + it('banner:签名 URL 可取回真实对象且 expiresAt 未来;无资产行 → null', async () => { + const withBanner = await getPublic(bannerToken); + expect(withBanner.status).toBe(200); + const banner = withBanner.body.banner as { + url: string; + expiresAt: string; + }; + expect(banner).not.toBeNull(); + expect(Date.parse(banner.expiresAt)).toBeGreaterThan(Date.now()); + + // 预签名 GET 直取 MinIO:真实字节 + JPEG content-type。 + const fetched = await fetch(banner.url); + expect(fetched.status).toBe(200); + expect(fetched.headers.get('content-type')).toBe('image/jpeg'); + const bytes = Buffer.from(await fetched.arrayBuffer()); + expect(bytes.length).toBeGreaterThan(0); + + const without = await getPublic(noBannerToken); + expect(without.status).toBe(200); + expect(without.body.banner).toBeNull(); + }); + + it('viewCount P1a 占位 null;startAt/endAt ISO + timezone 返回', async () => { + const res = await getPublic(mainToken); + expect(res.status).toBe(200); + expect(res.body.viewCount).toBeNull(); + expect(res.body.timezone).toBe('Asia/Shanghai'); + expect(res.body.startAt).toBe('2026-10-01T02:00:00.000Z'); + expect(res.body.endAt).toBe('2026-10-01T12:00:00.000Z'); + expect(Number.isNaN(Date.parse(res.body.startAt))).toBe(false); + expect(Number.isNaN(Date.parse(res.body.endAt))).toBe(false); + // 契约 PublicAlbum 必返字段全集(形状精确断言)。 + expect(Object.keys(res.body).sort()).toEqual([ + 'banner', + 'categories', + 'defaultLocale', + 'endAt', + 'publicToken', + 'resolvedLocale', + 'startAt', + 'timezone', + 'translation', + 'viewCount', + ]); + expect(res.body.publicToken).toMatch(/^[0-9a-z]{20,32}$/); + }); + }); +});