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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user