// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版; // Task 17 增补成员/分类;P1b Task 3 增补资产)。校验约束对齐契约 // AlbumCreateRequest / AlbumUpdateRequest / MemberAddRequest / // CategoryCreateRequest / CategoryUpdateRequest / AlbumTranslation / // AssetInitRequest: // · 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'; import { UPLOAD_CONTENT_TYPES, type UploadContentType, } from '../uploads/support'; /** 契约 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[]; } // ── 负责人交接(P4a Task 2)── /** * 契约 AlbumTransferRequest(A17):targetUserId 须为可被指定为 owner 的账号 * (存在且已开通——credentialStatus=active、!localLocked、sourceStatus≠disabled, * 服务层对存量行校验);keepAsCollaborator=false 时原 owner 移出成员表。 */ export class AlbumTransferRequestDto { @IsString() @IsNotEmpty() @MaxLength(64) // users.id VarChar(64) targetUserId!: string; @IsBoolean() keepAsCollaborator!: boolean; } // ── 水印(P4a Task 3)── /** 契约 WatermarkPosition 枚举(与 contracts WATERMARK_POSITIONS 同值)。 */ export const WATERMARK_POSITION_VALUES = [ 'topLeft', 'topRight', 'bottomLeft', 'bottomRight', 'center', ] as const; export type WatermarkPositionValue = (typeof WATERMARK_POSITION_VALUES)[number]; /** 契约 WatermarkTextConfig:content 1..64;fontSize 8..72。 */ class WatermarkTextConfigDto { @IsOptional() @IsString() @IsNotEmpty() @MaxLength(64) content?: string; @IsOptional() @IsInt() @Min(8) @Max(72) fontSize?: number; } /** * enabled=true 时 text.content 必填(技术方案 7.2:P4a 文字水印——无内容的 * 开启配置在 API 层即 400,worker 不再解释「开启但无字」的形态)。约束挂在 * enabled 上(text 缺席时其字段级装饰器不运行,挂 text 无法拒绝缺省)。 */ @ValidatorConstraint({ name: 'watermarkTextRequired', async: false }) class WatermarkTextRequiredConstraint implements ValidatorConstraintInterface { validate(value: unknown, args: ValidationArguments): boolean { if (value !== true) { return true; // 关闭配置无文字要求。 } const { text } = args.object as { text?: { content?: unknown } }; return ( typeof text?.content === 'string' && (text.content as string).length > 0 ); } defaultMessage(): string { return 'enabled=true 时必须提供 text.content(文字水印内容)'; } } /** 契约 WatermarkConfig(创建请求;数值边界对齐契约 min/max)。 */ export class WatermarkConfigDto { @IsBoolean() @Validate(WatermarkTextRequiredConstraint) enabled!: boolean; @IsOptional() @IsIn(WATERMARK_POSITION_VALUES) position?: WatermarkPositionValue; @IsOptional() @IsInt() @Min(0) @Max(100) opacity?: number; @IsOptional() @IsInt() @Min(5) @Max(50) scalePercent?: number; @IsOptional() @ValidateNested() @Type(() => WatermarkTextConfigDto) text?: WatermarkTextConfigDto; } // ── 资产(P1b Task 3)── /** 契约 AssetKind:banner(观众页宣传图,PublicAlbum.banner 消费方);cover/logo 建链就绪,展示消费随后续阶段。 */ export const ASSET_KINDS = ['banner', 'cover', 'logo'] as const; export type AssetKind = (typeof ASSET_KINDS)[number]; /** * 契约 AssetInitRequest。大小限额 UPLOAD_MAX_BYTES 与 filename 扩展名白名单是 * 环境相关/派生校验,在服务层做(同 UploadInitRequestDto 约定);contentType * 与照片直传共用白名单(image/heic 暂定,G2 真机验证)。 */ export class AssetInitRequestDto { @IsIn(ASSET_KINDS) kind!: AssetKind; /** 可选语言维度:同 (kind, locale) 再上传即替换(新行生效);语言无关资产(如 logo)不传。 */ @IsOptional() @IsIn(ALBUM_LOCALES) locale?: AlbumLocale; /** 原始文件名,仅元数据;扩展名白名单校验在服务层(对象键后缀派生)。 */ @IsString() @IsNotEmpty() @MaxLength(255) filename!: string; @IsInt() @Min(1) bytes!: number; @IsIn(UPLOAD_CONTENT_TYPES) contentType!: UploadContentType; }