243b2f8721
Co-Authored-By: Claude Code <noreply@anthropic.com>
430 lines
13 KiB
TypeScript
430 lines
13 KiB
TypeScript
// 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;
|
||
}
|