feat(api): album creation with translations and owner membership

Task 15: POST/GET /v1/albums, GET /v1/albums/{id}.
- create: single transaction writes albums + album_translations +
  album_members(owner) + audit_logs(album.create); publicToken 24-char
  [0-9a-z] with one P2002 retry; duplicate-locale input rejected as 400.
- dto: class-validator per contract AlbumCreateRequest, incl. cross-field
  rules (defaultLocale must be covered by translations; endAt>startAt when
  present; timezone string-only per P1a scope).
- get: PermissionsService.canViewAlbum gate -> 404 ALBUM_NOT_FOUND for
  non-member/nonexistent alike (anti-enumeration); admin bypass; joins
  categories (empty until Task 17).
- list: admin sees all, members see joined albums; stable createdAt DESC,
  id DESC keyset pagination via new shared/cursor.ts (encode/decode +
  typed where-builder, designed for Task 23 photo list reuse).
- e2e: services/api/test/albums.e2e-spec.ts (12 cases, seeded sessions).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 21:51:50 +08:00
parent 2d9b9f9fad
commit 1c48a4108b
6 changed files with 1130 additions and 5 deletions
+140
View File
@@ -0,0 +1,140 @@
// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15)。
// 校验约束对齐契约 AlbumCreateRequest / AlbumTranslation:
// · translations 至少 1 条(minItems: 1),locale 限 P1a 双语枚举;
// · defaultLocale 必须有对应翻译条目(跨字段校验,契约 required 语义);
// · endAt 可空(契约 [string,'null']),给值时须晚于 startAt(跨字段校验);
// · timezone 只做字符串/长度校验——IANA 合法性校验为 P4a 事项(计划 P1a
// 明确不实现,非法时区最多影响展示侧回显,不构成存储安全问题);
// · 两个下载开关 @IsBoolean/@IsOptional,缺省值由服务层落库(true/false)。
// 跨字段约束用 ValidatorConstraint 而非服务层 if:失败与其余 DTO 错误一样经
// 全局 ValidationPipe → 400 VALIDATION_FAILED,单一出口。
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsBoolean,
IsIn,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
Validate,
ValidateNested,
ValidatorConstraint,
type ValidatorConstraintInterface,
type ValidationArguments,
} from 'class-validator';
/** 契约 Locale 枚举(P1a 双语)。 */
export const ALBUM_LOCALES = ['zh-CN', 'en'] as const;
export type AlbumLocale = (typeof ALBUM_LOCALES)[number];
/** 列宽对齐 db-cn schema:title/venue/address VarChar(255),timezone VarChar(64)。 */
const TITLE_MAX_LENGTH = 255;
const VENUE_ADDRESS_MAX_LENGTH = 255;
const TIMEZONE_MAX_LENGTH = 64;
export class AlbumTranslationDto {
@IsIn(ALBUM_LOCALES)
locale!: AlbumLocale;
@IsString()
@IsNotEmpty()
@MaxLength(TITLE_MAX_LENGTH)
title!: string;
/** 契约 [string,'null']:可不填或显式 null,落库为 null。 */
@IsOptional()
@IsString()
@MaxLength(65_535) // TEXT 列宽
description?: string | null;
@IsOptional()
@IsString()
@MaxLength(VENUE_ADDRESS_MAX_LENGTH)
venue?: string | null;
@IsOptional()
@IsString()
@MaxLength(VENUE_ADDRESS_MAX_LENGTH)
address?: string | null;
}
/** defaultLocale 必须能在 translations 里找到同 locale 条目(契约必含语义)。 */
@ValidatorConstraint({ name: 'albumDefaultLocaleCovered', async: false })
class DefaultLocaleCoveredConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments): boolean {
const { translations } = args.object as { translations?: unknown };
// translations 本身缺失/非数组/为空 → 交给 @ArrayMinSize 报告,这里不重复报。
if (!Array.isArray(translations) || translations.length === 0) {
return true;
}
return translations.some(
(entry) =>
entry !== null &&
typeof entry === 'object' &&
(entry as { locale?: unknown }).locale === value,
);
}
defaultMessage(): string {
return 'translations 必须包含 defaultLocale 对应语言的条目';
}
}
/** endAt(给值时)必须晚于 startAt。 */
@ValidatorConstraint({ name: 'albumEndAfterStart', async: false })
class EndAfterStartConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments): boolean {
// 值缺失/类型不符交给字段级装饰器报告;此处只裁决先后关系。
if (typeof value !== 'string' || value === '') {
return true;
}
const { startAt } = args.object as { startAt?: unknown };
if (typeof startAt !== 'string') {
return true;
}
const startMs = Date.parse(startAt);
const endMs = Date.parse(value);
if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
return true; // 非法日期串交给 @IsISO8601 报告
}
return endMs > startMs;
}
defaultMessage(): string {
return 'endAt 必须晚于 startAt';
}
}
export class AlbumCreateRequestDto {
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => AlbumTranslationDto)
translations!: AlbumTranslationDto[];
@IsIn(ALBUM_LOCALES)
@Validate(DefaultLocaleCoveredConstraint)
defaultLocale!: AlbumLocale;
@IsISO8601({ strict: true })
startAt!: string;
@IsOptional()
@IsISO8601({ strict: true })
@Validate(EndAfterStartConstraint)
endAt?: string | null;
@IsOptional()
@IsString()
@MaxLength(TIMEZONE_MAX_LENGTH)
timezone?: string;
@IsOptional()
@IsBoolean()
downloadEnabled?: boolean;
@IsOptional()
@IsBoolean()
originalDownloadEnabled?: boolean;
}