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

430 lines
13 KiB
TypeScript
Raw Normal View History

// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版;
2026-09-18 08:40:32 +08:00
// Task 17 增补成员/分类;P1b Task 3 增补资产)。校验约束对齐契约
// AlbumCreateRequest / AlbumUpdateRequest / MemberAddRequest /
2026-09-18 08:40:32 +08:00
// 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';
2026-09-18 08:40:32 +08:00
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[];
}
2026-09-18 08:40:32 +08:00
// ── 负责人交接(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;
}
2026-09-18 08:40:32 +08:00
// ── 资产(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;
}