Files
photo-live/services/api/src/albums/dto.ts
T
2026-09-20 17:53:44 +08:00

430 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}