141 lines
4.4 KiB
TypeScript
141 lines
4.4 KiB
TypeScript
|
|
// 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;
|
|||
|
|
}
|