Files
photo-live/services/api/src/albums/dto.ts
T

297 lines
9.0 KiB
TypeScript
Raw Normal View History

// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版;
// Task 17 增补成员/分类)。校验约束对齐契约
// AlbumCreateRequest / AlbumUpdateRequest / MemberAddRequest /
// CategoryCreateRequest / CategoryUpdateRequest / 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,
IsInt,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
Max,
Min,
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;
}
/**
* 更新版 defaultLocale 覆盖约束(契约 AlbumUpdateRequest):
* 仅当同一请求里 defaultLocale 与 translations 同时出现时裁决——
* defaultLocale 未提交时本约束放行(服务层对存量 defaultLocale 做合并校验,
* 覆盖「只换 translations」与「只切 defaultLocale」两种单边情形)。
*/
@ValidatorConstraint({ name: 'albumUpdateDefaultLocaleCovered', async: false })
class UpdateDefaultLocaleCoveredConstraint
implements ValidatorConstraintInterface
{
validate(value: unknown, args: ValidationArguments): boolean {
if (value === undefined || value === null) {
return true; // 本次请求不改 defaultLocale。
}
const { translations } = args.object as { translations?: unknown };
if (!Array.isArray(translations) || translations.length === 0) {
return true; // translations 不在本次请求里——交服务层对存量行校验。
}
return translations.some(
(entry) =>
entry !== null &&
typeof entry === 'object' &&
(entry as { locale?: unknown }).locale === value,
);
}
defaultMessage(): string {
return 'translations 必须包含(更新后的)defaultLocale 对应语言的条目';
}
}
/**
* 契约 AlbumUpdateRequest:全部字段可选 + minProperties:1。
* minProperties 无法用 class-validator 内建表达(空体时任何属性级装饰器都
* 不会运行),空体拒绝由服务层 update() 入口承担(与创建路径的重复 locale
* 检查同一模式);跨字段约束分两层:
* · DTO 层——同请求内 endAt>startAt(复用创建版约束:startAt 缺席时放行)、
* defaultLocale+translations 同请求时的覆盖关系;
* · 服务层——与存量行合并后的时间先后 / defaultLocale 覆盖(单边提交场景:
* 只改 endAt 对比存量 startAt、只切 defaultLocale 对比存量翻译行)。
*/
export class AlbumUpdateRequestDto {
@IsOptional()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => AlbumTranslationDto)
translations?: AlbumTranslationDto[];
@IsOptional()
@IsIn(ALBUM_LOCALES)
@Validate(UpdateDefaultLocaleCoveredConstraint)
defaultLocale?: AlbumLocale;
@IsOptional()
@IsISO8601({ strict: true })
startAt?: string;
@IsOptional()
@IsISO8601({ strict: true })
@Validate(EndAfterStartConstraint)
endAt?: string | null;
@IsOptional()
@IsString()
@MaxLength(TIMEZONE_MAX_LENGTH)
timezone?: string;
@IsOptional()
@IsBoolean()
uploadEnabled?: boolean;
@IsOptional()
@IsBoolean()
publicEnabled?: boolean;
@IsOptional()
@IsBoolean()
downloadEnabled?: boolean;
@IsOptional()
@IsBoolean()
originalDownloadEnabled?: boolean;
}
// ── 成员(Task 17)──
/**
* 契约 MemberAddRequest:userId 必填(已开通账号);role 缺省 collaborator,
* 枚举仅 collaborator——owner 由创建流程产生,经本端点加 owner 属 P4a 管理员
* 操作,role='owner' 在 DTO 层即 400。
*/
export class MemberAddRequestDto {
@IsString()
@IsNotEmpty()
@MaxLength(64) // users.id VarChar(64)
userId!: string;
@IsOptional()
@IsIn(['collaborator'])
role?: 'collaborator';
}
// ── 分类(Task 17)──
/** 契约 CategoryTranslation:name minLength 1;列宽 VarChar(128)。 */
export class CategoryTranslationDto {
@IsIn(ALBUM_LOCALES)
locale!: AlbumLocale;
@IsString()
@IsNotEmpty()
@MaxLength(128)
name!: string;
}
/** sortOrder:契约 integer 无界;Int 列物理边界内收为 400(而非溢出 500)。 */
const SORT_ORDER_MIN = -2_147_483_648;
const SORT_ORDER_MAX = 2_147_483_647;
export class CategoryCreateRequestDto {
@IsOptional()
@IsInt()
@Min(SORT_ORDER_MIN)
@Max(SORT_ORDER_MAX)
sortOrder?: number;
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CategoryTranslationDto)
translations!: CategoryTranslationDto[];
}
/**
* 契约 CategoryUpdateRequest:全字段可选 + minProperties:1。空体拒绝在服务层
* (同 AlbumUpdateRequestDto:plainToInstance 后声明属性以 undefined 成键,
* Object.keys 不可判空,以「任一字段已赋值」为判据)。
*/
export class CategoryUpdateRequestDto {
@IsOptional()
@IsInt()
@Min(SORT_ORDER_MIN)
@Max(SORT_ORDER_MAX)
sortOrder?: number;
@IsOptional()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CategoryTranslationDto)
translations?: CategoryTranslationDto[];
}