feat(api): public album info with locale fallback
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user