feat(api): members and categories management

成员(/v1/albums/{albumId}/members):owner/管理员邀请已开通同事为协作者
(重复 409 MEMBER_CONFLICT、owner 目标 400、未开通/停用账号 400)、成员列表
((addedAt,id) DESC 键集分页)、移除协作者(幂等 204;成员行是唯一权限来源,
删除行即失去可见性 A02;owner 不可移除 403)。分类(/albums/{albumId}/categories
与 /categories/{categoryId}):创建/部分更新/删除,translations 全量替换,
相册详情按 sortOrder 升序带出;404 统一 ALBUM_NOT_FOUND(分类不存在与相册
不可见同码)。审计 album.member.add/remove、album.category.add/update/remove
(resourceId=albumId,metadata 记目标 id/字段)。共用错误/P2002 判定抽至
support.ts(修复 meta.target 为 DB 约束名时的误判)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 22:16:16 +08:00
parent 1faefa4227
commit e04e1f3bdf
9 changed files with 1360 additions and 68 deletions
+74 -2
View File
@@ -1,5 +1,7 @@
// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版)。
// 校验约束对齐契约 AlbumCreateRequest / AlbumUpdateRequest / AlbumTranslation:
// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版;
// Task 17 增补成员/分类)。校验约束对齐契约
// AlbumCreateRequest / AlbumUpdateRequest / MemberAddRequest /
// CategoryCreateRequest / CategoryUpdateRequest / AlbumTranslation:
// · translations 至少 1 条(minItems: 1),locale 限 P1a 双语枚举;
// · defaultLocale 必须有对应翻译条目(跨字段校验,契约 required 语义);
// · endAt 可空(契约 [string,'null']),给值时须晚于 startAt(跨字段校验);
@@ -13,11 +15,14 @@ import {
ArrayMinSize,
IsBoolean,
IsIn,
IsInt,
IsISO8601,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
Max,
Min,
Validate,
ValidateNested,
ValidatorConstraint,
@@ -222,3 +227,70 @@ export class AlbumUpdateRequestDto {
@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[];
}