feat(api): public album info with locale fallback
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Generated
+8
-5
@@ -46,7 +46,7 @@ importers:
|
|||||||
version: 7.13.0(typescript@5.9.3)
|
version: 7.13.0(typescript@5.9.3)
|
||||||
ts-jest:
|
ts-jest:
|
||||||
specifier: ^29.2.0
|
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:
|
typescript:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.9.3
|
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))
|
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:
|
ts-jest:
|
||||||
specifier: ^29.2.5
|
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:
|
typescript:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -167,12 +167,15 @@ importers:
|
|||||||
jest:
|
jest:
|
||||||
specifier: ^29.7.0
|
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))
|
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:
|
supertest:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.2.2
|
version: 7.2.2
|
||||||
ts-jest:
|
ts-jest:
|
||||||
specifier: ^29.2.5
|
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:
|
ts-node:
|
||||||
specifier: ^10.9.2
|
specifier: ^10.9.2
|
||||||
version: 10.9.2(@types/node@22.20.3)(typescript@5.9.3)
|
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))
|
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:
|
ts-jest:
|
||||||
specifier: ^29.2.5
|
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:
|
typescript:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -6970,7 +6973,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
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)(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:
|
dependencies:
|
||||||
bs-logger: 0.2.6
|
bs-logger: 0.2.6
|
||||||
fast-json-stable-stringify: 2.1.0
|
fast-json-stable-stringify: 2.1.0
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
"sharp": "^0.35.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { AllExceptionsFilter } from './all-exceptions.filter';
|
|||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { PublicModule } from './public/public.module';
|
||||||
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
|
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
|
||||||
import { UploadsModule } from './uploads/uploads.module';
|
import { UploadsModule } from './uploads/uploads.module';
|
||||||
import { resolveLogLevel } from './shared/log-level';
|
import { resolveLogLevel } from './shared/log-level';
|
||||||
@@ -34,6 +35,8 @@ import type { ServerRequestWithId } from './shared/request-with-id';
|
|||||||
AlbumsModule,
|
AlbumsModule,
|
||||||
// 上传域(Task 18 init;Task 19 renew/complete/history)。
|
// 上传域(Task 18 init;Task 19 renew/complete/history)。
|
||||||
UploadsModule,
|
UploadsModule,
|
||||||
|
// 公开(观众端)域(Task 22 getPublicAlbum;Task 23 公开照片读)。
|
||||||
|
PublicModule,
|
||||||
LoggerModule.forRoot({
|
LoggerModule.forRoot({
|
||||||
pinoHttp: {
|
pinoHttp: {
|
||||||
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
||||||
|
|||||||
@@ -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<PublicAlbumResponse> {
|
||||||
|
return this.publicService.getPublicAlbum(token, locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<Prisma.albumsDefaultArgs>;
|
||||||
|
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<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<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() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Buffer> {
|
||||||
|
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<string> {
|
||||||
|
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<string, string>) {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<Record<string, unknown>>;
|
||||||
|
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}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user