feat(api): album update with independent upload/public switches

PATCH /v1/albums/{albumId}(契约 updateAlbum):部分更新,各开关互相独立;
translations 全量替换(deleteMany+createMany),defaultLocale 可同请求切换但
替换后的翻译集必须覆盖最终 defaultLocale。权限顺序 404(不可见)→403(可见
不可管)与 getAlbum 一致防枚举;合并校验(endAt vs 存量 startAt、单边提交)
在事务内对最终状态裁决。audit album.update 记录 metadata.fields(契约属性序)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 22:05:36 +08:00
parent 1c48a4108b
commit 1faefa4227
4 changed files with 544 additions and 12 deletions
+86 -2
View File
@@ -1,5 +1,5 @@
// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15)。
// 校验约束对齐契约 AlbumCreateRequest / AlbumTranslation:
// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版)。
// 校验约束对齐契约 AlbumCreateRequest / AlbumUpdateRequest / AlbumTranslation:
// · translations 至少 1 条(minItems: 1),locale 限 P1a 双语枚举;
// · defaultLocale 必须有对应翻译条目(跨字段校验,契约 required 语义);
// · endAt 可空(契约 [string,'null']),给值时须晚于 startAt(跨字段校验);
@@ -138,3 +138,87 @@ export class AlbumCreateRequestDto {
@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;
}