diff --git a/services/api/src/albums/albums.module.ts b/services/api/src/albums/albums.module.ts index d8378df..b7b885d 100644 --- a/services/api/src/albums/albums.module.ts +++ b/services/api/src/albums/albums.module.ts @@ -1,15 +1,20 @@ -// services/api/src/albums/albums.module.ts —— 相册模块(Task 14 起步,Task 15 补全)。 -// PermissionsService(A02 权限基石)+ AlbumsService/AlbumsController(创建/详情/ -// 列表;Task 16 增补 PATCH)。export PermissionsService 供 uploads/photos 等后续 -// 模块经模块间 DI 复用同一份判定逻辑;AlbumsService 暂无外部消费者不 export。 +// services/api/src/albums/albums.module.ts —— 相册模块(Task 14 起步,Task 15 +// 补全,Task 16 增补 PATCH,Task 17 增补成员/分类)。PermissionsService(A02 +// 权限基石)+ AlbumsService/MembersService/CategoriesService 与对应控制器。 +// export PermissionsService 供 uploads/photos 等后续模块经模块间 DI 复用同一 +// 份判定逻辑;AlbumsService 等暂无外部消费者不 export。 import { Module } from '@nestjs/common'; import { AlbumsController } from './albums.controller'; import { AlbumsService } from './albums.service'; +import { CategoriesController } from './categories.controller'; +import { CategoriesService } from './categories.service'; +import { MembersController } from './members.controller'; +import { MembersService } from './members.service'; import { PermissionsService } from './permissions.service'; @Module({ - controllers: [AlbumsController], - providers: [PermissionsService, AlbumsService], + controllers: [AlbumsController, MembersController, CategoriesController], + providers: [PermissionsService, AlbumsService, MembersService, CategoriesService], exports: [PermissionsService], }) export class AlbumsModule {} diff --git a/services/api/src/albums/albums.service.ts b/services/api/src/albums/albums.service.ts index 4b7365d..64f5d1d 100644 --- a/services/api/src/albums/albums.service.ts +++ b/services/api/src/albums/albums.service.ts @@ -1,6 +1,6 @@ -// services/api/src/albums/albums.service.ts —— 相册创建/详情/列表核心逻辑(Task 15)。 -// 契约权威:packages/contracts/openapi.yaml tags [albums](createAlbum/getAlbum/ -// listAlbums;updateAlbum 属 Task 16)。要点: +// services/api/src/albums/albums.service.ts —— 相册创建/详情/更新/列表核心逻辑 +// (Task 15;update 自 Task 16)。契约权威:packages/contracts/openapi.yaml +// tags [albums](createAlbum/getAlbum/updateAlbum/listAlbums)。要点: // · 创建走单事务:albums + album_translations(批量)+ album_members(owner) // + audit_logs(album.create)——四表要么全成要么全无,杜绝「无主相册」 // (权限全部源自成员行,Task 14 结论:漏写 owner 行会让创建者自己不可见)。 @@ -29,6 +29,12 @@ import { } from '../shared/cursor'; import type { AlbumCreateRequestDto, AlbumUpdateRequestDto } from './dto'; import { PermissionsService } from './permissions.service'; +import { + albumForbidden, + albumNotFound, + isUniqueViolationOn, + validationFailed, +} from './support'; export type AlbumTranslationResponse = components['schemas']['AlbumTranslation']; @@ -69,32 +75,6 @@ function generatePublicToken(): string { return token; } -function albumNotFound(): ApiException { - return new ApiException( - ErrorCodes.ALBUM_NOT_FOUND, - '相册不存在或不可见', - HttpStatus.NOT_FOUND, - ); -} - -/** P2002 是否命中指定唯一索引(meta.target 形如 ['publicToken'])。 */ -function isUniqueViolationOn(err: unknown, field: string): boolean { - if (typeof err !== 'object' || err === null) { - return false; - } - const { code, meta } = err as { code?: unknown; meta?: { target?: unknown } }; - if (code !== 'P2002') { - return false; - } - const target = meta?.target; - const fields = Array.isArray(target) - ? target - : typeof target === 'string' - ? [target] - : []; - return fields.includes(field); -} - type AlbumRow = Prisma.albumsGetPayload; type AlbumTranslationRow = Prisma.album_translationsGetPayload< Prisma.album_translationsDefaultArgs @@ -146,7 +126,8 @@ export class AlbumsService { /** * 创建相册:事务内四写(albums/translations/members/audit),返回完整 - * Album 形状(categories 恒 []——分类自 Task 17 起才有写入入口)。 + * Album 形状(categories 恒 []——新建相册尚无分类,写入入口在 + * CategoriesService,Task 17)。 */ async create( userId: string, @@ -156,11 +137,7 @@ export class AlbumsService { // ——那是 500 而非 400,必须在入事务前以校验错误拒绝。 const locales = dto.translations.map((t) => t.locale); if (new Set(locales).size !== locales.length) { - throw new ApiException( - ErrorCodes.VALIDATION_FAILED, - 'translations 存在重复 locale', - HttpStatus.BAD_REQUEST, - ); + throw validationFailed('translations 存在重复 locale'); } let albumId: string; @@ -274,11 +251,7 @@ export class AlbumsService { (key) => dto[key] !== undefined, ) as string[]; if (fields.length === 0) { - throw new ApiException( - ErrorCodes.VALIDATION_FAILED, - '请求体至少需要包含一个待更新字段', - HttpStatus.BAD_REQUEST, - ); + throw validationFailed('请求体至少需要包含一个待更新字段'); } if (dto.translations !== undefined) { // 同 create:重复 locale 会在事务内 createMany 撞唯一索引变 500,先 400。 @@ -296,11 +269,7 @@ export class AlbumsService { throw albumNotFound(); } if (!(await this.permissions.canManageAlbum(user, albumId))) { - throw new ApiException( - ErrorCodes.AUTH_FORBIDDEN, - '仅创建者或管理员可修改相册', - HttpStatus.FORBIDDEN, - ); + throw albumForbidden('仅创建者或管理员可修改相册'); } await this.prisma.$transaction(async (tx) => { @@ -318,21 +287,15 @@ export class AlbumsService { ? null : new Date(dto.endAt); if (endAt !== null && endAt.getTime() <= startAt.getTime()) { - throw new ApiException( - ErrorCodes.VALIDATION_FAILED, - 'endAt 必须晚于 startAt', - HttpStatus.BAD_REQUEST, - ); + throw validationFailed('endAt 必须晚于 startAt'); } // 合并校验②:合并后的 defaultLocale 必须被合并后的翻译集覆盖。 const defaultLocale = dto.defaultLocale ?? album.defaultLocale; if (dto.translations !== undefined) { if (!dto.translations.some((t) => t.locale === defaultLocale)) { - throw new ApiException( - ErrorCodes.VALIDATION_FAILED, + throw validationFailed( 'translations 必须包含(更新后的)defaultLocale 对应语言的条目', - HttpStatus.BAD_REQUEST, ); } } else if (dto.defaultLocale !== undefined) { @@ -342,11 +305,7 @@ export class AlbumsService { select: { locale: true }, }); if (!rows.some((r) => r.locale === defaultLocale)) { - throw new ApiException( - ErrorCodes.VALIDATION_FAILED, - 'defaultLocale 必须有对应翻译条目', - HttpStatus.BAD_REQUEST, - ); + throw validationFailed('defaultLocale 必须有对应翻译条目'); } } @@ -474,8 +433,8 @@ export class AlbumsService { /** * 组装完整 Album 形状:主行 + 翻译(locale 升序,确定性输出)+ 分类 - * (sortOrder 升序,Task 17 前无写入入口故实际为空数组——join 逻辑先就位, - * 分类上线即生效)。相册域无 Prisma relation,全部二次查询应用层组装。 + * (sortOrder 升序;写入入口为 CategoriesService,Task 17 起)。相册域无 + * Prisma relation,全部二次查询应用层组装。 */ private async loadAlbum(albumId: string): Promise { const album = await this.prisma.albums.findUnique({ diff --git a/services/api/src/albums/categories.controller.ts b/services/api/src/albums/categories.controller.ts new file mode 100644 index 0000000..ffb7106 --- /dev/null +++ b/services/api/src/albums/categories.controller.ts @@ -0,0 +1,58 @@ +// services/api/src/albums/categories.controller.ts —— 分类端点(Task 17)。 +// 方法名与契约 operationId 一一对应(createCategory/updateCategory/ +// deleteCategory)。契约把创建挂在 /albums/{albumId}/categories、更新/删除 +// 挂在 /categories/{categoryId}——单控制器 + 显式全路径(两个基地址)。 +// 控制器级 SessionGuard:三端点全需认证;权限与 HTTP 映射(404/403/400) +// 由 CategoriesService 抛 ApiException。 +import { + Body, + Controller, + Delete, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard'; +import { userOf } from './albums.controller'; +import { CategoriesService, type CategoryResponse } from './categories.service'; +import { + CategoryCreateRequestDto, + CategoryUpdateRequestDto, +} from './dto'; + +@Controller() +@UseGuards(SessionGuard) +export class CategoriesController { + constructor(private readonly categoriesService: CategoriesService) {} + + @Post('albums/:albumId/categories') + createCategory( + @Param('albumId') albumId: string, + @Body() dto: CategoryCreateRequestDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.categoriesService.create(userOf(req), albumId, dto); + } + + @Patch('categories/:categoryId') + updateCategory( + @Param('categoryId') categoryId: string, + @Body() dto: CategoryUpdateRequestDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.categoriesService.update(userOf(req), categoryId, dto); + } + + @Delete('categories/:categoryId') + @HttpCode(HttpStatus.NO_CONTENT) + deleteCategory( + @Param('categoryId') categoryId: string, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.categoriesService.remove(userOf(req), categoryId); + } +} diff --git a/services/api/src/albums/categories.service.ts b/services/api/src/albums/categories.service.ts new file mode 100644 index 0000000..c15c483 --- /dev/null +++ b/services/api/src/albums/categories.service.ts @@ -0,0 +1,249 @@ +// services/api/src/albums/categories.service.ts —— 相册分类管理(Task 17)。 +// 契约权威:openapi.yaml tags [categories](createCategory/updateCategory/ +// deleteCategory)。要点: +// · /categories/{categoryId} 端点的 404 语义与契约一致:分类不存在或所属 +// 相册不可见统一 ALBUM_NOT_FOUND(先读分类行→404,再 canView→404、 +// canManage→403;非成员视角「分类不存在」与「不可见」同码防枚举)。 +// · translations 全量替换(deleteMany+createMany);重复 locale 入事务前 +// 400(同 albums 翻译处理:撞唯一索引是 500 不是 400)。 +// · 审计 resourceId=albumId(与 album.* 动作族统一,便于「某相册全部操作」 +// 检索),metadata.categoryId 指向具体分类;update 额外记 fields。 +// · 删除:分类+翻译行同事务删除。契约注「分类下仍有照片时是否允许删除由 +// 服务端策略决定」——P1a 照片写入路径(Task 18+)尚未给 photos.categoryId +// 赋值,此处不做照片计数检查,Task 20 照片分类赋值时再补。 +import { HttpStatus, Injectable } from '@nestjs/common'; +import { ErrorCodes } from '@photo/contracts'; +import { ApiException } from '../api-exception'; +import type { components } from '@photo/contracts'; +import type { Prisma } from '@photo/db-cn'; +import type { AuthUser } from '../auth/session.guard'; +import { PrismaService } from '../prisma/prisma.service'; +import type { + CategoryCreateRequestDto, + CategoryUpdateRequestDto, +} from './dto'; +import { PermissionsService } from './permissions.service'; +import { + albumForbidden, + albumNotFound, + validationFailed, +} from './support'; + +export type CategoryResponse = components['schemas']['Category']; +export type CategoryTranslationResponse = + components['schemas']['CategoryTranslation']; + +type CategoryRow = Prisma.categoriesGetPayload; +type CategoryTranslationRow = Prisma.category_translationsGetPayload< + Prisma.category_translationsDefaultArgs +>; + +/** 契约 CategoryUpdateRequest 属性序(审计 metadata.fields 序列,同 albums)。 */ +const CATEGORY_UPDATE_FIELD_ORDER = [ + 'sortOrder', + 'translations', +] as const satisfies readonly (keyof CategoryUpdateRequestDto)[]; + +/** translations 重复 locale → 400(入事务前,理由同 albums.service)。 */ +function assertNoDuplicateLocale( + translations: Array<{ locale: string }>, +): void { + const locales = translations.map((t) => t.locale); + if (new Set(locales).size !== locales.length) { + throw validationFailed('translations 存在重复 locale'); + } +} + +function toCategoryResponse( + category: CategoryRow, + translations: CategoryTranslationRow[], +): CategoryResponse { + return { + id: category.id, + albumId: category.albumId, + sortOrder: category.sortOrder, + translations: translations.map((row) => ({ + // locale 取值来自入参枚举校验,收窄为契约 Locale(同 albums 翻译)。 + locale: row.locale as CategoryTranslationResponse['locale'], + name: row.name, + })), + }; +} + +@Injectable() +export class CategoriesService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + /** 创建分类(契约 createCategory):201 返回 Category。 */ + async create( + user: AuthUser, + albumId: string, + dto: CategoryCreateRequestDto, + ): Promise { + assertNoDuplicateLocale(dto.translations); + await this.assertAlbumManaged(user, albumId); + + const categoryId = await this.prisma.$transaction(async (tx) => { + const category = await tx.categories.create({ + data: { albumId, sortOrder: dto.sortOrder ?? 0 }, + select: { id: true }, + }); + await tx.category_translations.createMany({ + data: dto.translations.map((t) => ({ + categoryId: category.id, + locale: t.locale, + name: t.name, + })), + }); + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'album.category.add', + resourceId: albumId, + metadata: { categoryId: category.id }, + }, + }); + return category.id; + }); + const category = await this.loadCategory(categoryId); + if (category === null) { + // 刚提交就读不到只可能是并发异常路径,按 5xx 兜底(同 albums.create)。 + throw new ApiException( + ErrorCodes.INTERNAL_ERROR, + 'Internal server error', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + return category; + } + + /** 更新分类(契约 updateCategory):部分更新,translations 全量替换。 */ + async update( + user: AuthUser, + categoryId: string, + dto: CategoryUpdateRequestDto, + ): Promise { + // 契约 minProperties:1(判据同 albums.service.update:plainToInstance 后 + // 声明属性以 undefined 成键,以「任一字段已赋值」为准)。 + const fields = CATEGORY_UPDATE_FIELD_ORDER.filter( + (key) => dto[key] !== undefined, + ); + if (fields.length === 0) { + throw validationFailed('请求体至少需要包含一个待更新字段'); + } + if (dto.translations !== undefined) { + assertNoDuplicateLocale(dto.translations); + } + + const existing = await this.assertCategoryManaged(user, categoryId); + await this.prisma.$transaction(async (tx) => { + await tx.categories.update({ + where: { id: categoryId }, + data: { sortOrder: dto.sortOrder }, + }); + if (dto.translations !== undefined) { + await tx.category_translations.deleteMany({ where: { categoryId } }); + await tx.category_translations.createMany({ + data: dto.translations.map((t) => ({ + categoryId, + locale: t.locale, + name: t.name, + })), + }); + } + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'album.category.update', + resourceId: existing.albumId, + metadata: { categoryId, fields }, + }, + }); + }); + const category = await this.loadCategory(categoryId); + if (category === null) { + throw albumNotFound(); + } + return category; + } + + /** 删除分类(契约 deleteCategory):204 无响应体。 */ + async remove(user: AuthUser, categoryId: string): Promise { + const existing = await this.assertCategoryManaged(user, categoryId); + await this.prisma.$transaction(async (tx) => { + await tx.category_translations.deleteMany({ where: { categoryId } }); + await tx.categories.delete({ where: { id: categoryId } }); + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'album.category.remove', + resourceId: existing.albumId, + metadata: { categoryId }, + }, + }); + }); + } + + /** /albums/{albumId}/categories 的 404/403 顺序映射 + admin 空相册兜底。 */ + private async assertAlbumManaged( + user: AuthUser, + albumId: string, + ): Promise { + if (!(await this.permissions.canViewAlbum(user, albumId))) { + throw albumNotFound(); + } + if (!(await this.permissions.canManageAlbum(user, albumId))) { + throw albumForbidden('仅创建者或管理员可管理分类'); + } + const album = await this.prisma.albums.findUnique({ + where: { id: albumId }, + select: { id: true }, + }); + if (album === null) { + // admin 视角 canView 恒真,空值兜底同码 404。 + throw albumNotFound(); + } + } + + /** + * /categories/{categoryId} 的 404/403 顺序映射:分类行不存在 → 404(契约 + * 语义:分类不存在或所属相册不存在/不可见同码);行存在再按所属相册判 + * canView → 404 / canManage → 403。返回分类行(含 albumId 供审计)。 + */ + private async assertCategoryManaged( + user: AuthUser, + categoryId: string, + ): Promise { + const category = await this.prisma.categories.findUnique({ + where: { id: categoryId }, + }); + if (category === null) { + throw albumNotFound(); + } + if (!(await this.permissions.canViewAlbum(user, category.albumId))) { + throw albumNotFound(); + } + if (!(await this.permissions.canManageAlbum(user, category.albumId))) { + throw albumForbidden('仅创建者或管理员可管理分类'); + } + return category; + } + + /** 组装 Category 形状:主行 + 翻译(locale 升序,确定性输出)。 */ + private async loadCategory(categoryId: string): Promise { + const category = await this.prisma.categories.findUnique({ + where: { id: categoryId }, + }); + if (category === null) { + return null; + } + const translations = await this.prisma.category_translations.findMany({ + where: { categoryId }, + orderBy: { locale: 'asc' }, + }); + return toCategoryResponse(category, translations); + } +} diff --git a/services/api/src/albums/dto.ts b/services/api/src/albums/dto.ts index 9c8f2fc..3b37705 100644 --- a/services/api/src/albums/dto.ts +++ b/services/api/src/albums/dto.ts @@ -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[]; +} diff --git a/services/api/src/albums/members.controller.ts b/services/api/src/albums/members.controller.ts new file mode 100644 index 0000000..d0d8884 --- /dev/null +++ b/services/api/src/albums/members.controller.ts @@ -0,0 +1,68 @@ +// services/api/src/albums/members.controller.ts —— /albums/{albumId}/members +// 端点(Task 17)。方法名与契约 operationId 一一对应(addAlbumMember/ +// listAlbumMembers/removeAlbumMember)。控制器级 SessionGuard:三端点全需 +// 认证。控制器只做认证用户透传 + 查询参数规整(复用 albums.controller 的 +// parseLimit/parseCursor,同一契约 bounds/游标语义);权限与 HTTP 映射由 +// MembersService 抛 ApiException(404 ALBUM_NOT_FOUND / 403 AUTH_FORBIDDEN / +// 409 MEMBER_CONFLICT)。 +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard'; +import { parseCursor, parseLimit, userOf } from './albums.controller'; +import { MemberAddRequestDto } from './dto'; +import { + type MemberListResponse, + type MemberResponse, + MembersService, +} from './members.service'; + +@Controller('albums/:albumId/members') +@UseGuards(SessionGuard) +export class MembersController { + constructor(private readonly membersService: MembersService) {} + + @Post() + addAlbumMember( + @Param('albumId') albumId: string, + @Body() dto: MemberAddRequestDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.membersService.add(userOf(req), albumId, dto); + } + + @Get() + listAlbumMembers( + @Param('albumId') albumId: string, + @Query('cursor') cursor: string | undefined, + @Query('limit') limit: string | undefined, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.membersService.list( + userOf(req), + albumId, + parseCursor(cursor), + parseLimit(limit), + ); + } + + @Delete(':userId') + @HttpCode(HttpStatus.NO_CONTENT) + removeAlbumMember( + @Param('albumId') albumId: string, + @Param('userId') userId: string, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.membersService.remove(userOf(req), albumId, userId); + } +} diff --git a/services/api/src/albums/members.service.ts b/services/api/src/albums/members.service.ts new file mode 100644 index 0000000..ab2ab03 --- /dev/null +++ b/services/api/src/albums/members.service.ts @@ -0,0 +1,234 @@ +// services/api/src/albums/members.service.ts —— 相册成员管理(Task 17)。 +// 契约权威:openapi.yaml tags [members](addAlbumMember/listAlbumMembers/ +// removeAlbumMember)。要点: +// · 权限顺序与 albums 一致防枚举:canViewAlbum=false → 404;canManageAlbum +// =false → 403(协作者可见不可管);admin 视角再由空行兜底 404。 +// · 成员行(album_members)是唯一权限来源(Task 14 结论):移除=删行, +// 被移除者下一次请求即刻失去可见性(A02,PermissionsService 无缓存)。 +// · owner 行不可经本端点产生/删除:邀请 owner 目标 → 400(仅 collaborator +// 可加,owner 转移属 P4a 管理员操作);移除 owner → 403。 +// · 被邀请账号必须已开通(credentialStatus=active 且 sourceStatus≠disabled): +// 邀请属管理动作,明示目标账号有效性不构成成员枚举泄露。 +// · 移除幂等:目标成员行不存在仍 204(契约语义);仅真实删除落审计 +// (幂等重删不重复记 album.member.remove)。 +// · 列表:(addedAt,id) DESC 键集游标(shared/cursor,与相册列表同构); +// users 无 relation(扁平外键),二次查询应用层组装 Member。 +import { HttpStatus, Injectable } from '@nestjs/common'; +import { ErrorCodes } from '@photo/contracts'; +import type { components } from '@photo/contracts'; +import type { Prisma } from '@photo/db-cn'; +import { ApiException } from '../api-exception'; +import type { AuthUser } from '../auth/session.guard'; +import { PrismaService } from '../prisma/prisma.service'; +import { + decodeKeysetCursor, + encodeKeysetCursor, + keysetWhereDesc, +} from '../shared/cursor'; +import type { MemberAddRequestDto } from './dto'; +import { PermissionsService } from './permissions.service'; +import { + albumForbidden, + albumNotFound, + isUniqueViolationOn, + validationFailed, +} from './support'; + +export type MemberResponse = components['schemas']['Member']; +export type MemberListResponse = components['schemas']['MemberList']; + +@Injectable() +export class MembersService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + /** 添加协作者(契约 addAlbumMember):201 返回 Member。 */ + async add( + user: AuthUser, + albumId: string, + dto: MemberAddRequestDto, + ): Promise { + const { ownerId } = await this.assertManageable(user, albumId); + + // 目标账号须存在且已开通(pending 凭据/已停用均不可加)。 + const target = await this.prisma.users.findUnique({ + where: { id: dto.userId }, + select: { + account: true, + displayName: true, + credentialStatus: true, + sourceStatus: true, + }, + }); + if ( + target === null || + target.credentialStatus !== 'active' || + target.sourceStatus === 'disabled' + ) { + throw validationFailed('被邀请的用户不存在或不可用(须为已开通账号)'); + } + // owner 不能经本端点被(重)添加:防重复 owner 行与角色歧义。 + if (dto.userId === ownerId) { + throw validationFailed('owner 由创建流程产生,不能经由本端点添加'); + } + + const role = dto.role ?? 'collaborator'; + try { + await this.prisma.$transaction(async (tx) => { + await tx.album_members.create({ + data: { albumId, userId: dto.userId, role, status: 'active' }, + }); + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'album.member.add', + resourceId: albumId, + metadata: { userId: dto.userId, role }, + }, + }); + }); + } catch (err) { + // 并发窗口内重复邀请:唯一索引 @@unique([albumId,userId]) 兜底成契约 409。 + if (isUniqueViolationOn(err, 'albumId_userId')) { + throw new ApiException( + ErrorCodes.MEMBER_CONFLICT, + '该用户已是相册成员', + HttpStatus.CONFLICT, + ); + } + throw err; + } + return { + userId: dto.userId, + account: target.account, + displayName: target.displayName, + role, + status: 'active', + }; + } + + /** 成员列表(契约 listAlbumMembers):任何成员/admin 可见;(addedAt,id) DESC。 */ + async list( + user: AuthUser, + albumId: string, + cursor: string | undefined, + limit: number, + ): Promise { + if (!(await this.permissions.canViewAlbum(user, albumId))) { + throw albumNotFound(); + } + // admin 对不存在的 id 权限恒真(非 admin 的可见成员行本身蕴含相册存在), + // 空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。 + const albumExists = await this.prisma.albums.findUnique({ + where: { id: albumId }, + select: { id: true }, + }); + if (albumExists === null) { + throw albumNotFound(); + } + let decoded: ReturnType = null; + if (cursor !== undefined && cursor !== '') { + decoded = decodeKeysetCursor(cursor); + if (decoded === null) { + throw validationFailed('cursor 不合法'); + } + } + + const where: Prisma.album_membersWhereInput = { albumId }; + if (decoded !== null) { + Object.assign(where, keysetWhereDesc('addedAt', 'id', decoded)); + } + const rows = await this.prisma.album_members.findMany({ + where, + orderBy: [{ addedAt: 'desc' }, { id: 'desc' }], + take: limit + 1, // 多取一条探测 hasMore(同相册列表,免 count 查询) + }); + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + + const users = await this.prisma.users.findMany({ + where: { id: { in: page.map((m) => m.userId) } }, + select: { id: true, account: true, displayName: true }, + }); + const byId = new Map(users.map((u) => [u.id, u])); + + const items = page.flatMap((row) => { + const u = byId.get(row.userId); + // 用户行缺失(P1a 无用户删除,仅可能来自手工改库):跳过而非报错。 + return u === undefined + ? [] + : [ + { + userId: row.userId, + account: u.account, + displayName: u.displayName, + role: row.role as MemberResponse['role'], + status: row.status as MemberResponse['status'], + }, + ]; + }); + + const last = page.at(-1); + const nextCursor = + hasMore && last !== undefined + ? encodeKeysetCursor({ + sort: last.addedAt.toISOString(), + id: last.id, + }) + : null; + return { items, nextCursor, hasMore: nextCursor !== null }; + } + + /** 移除成员(契约 removeAlbumMember):幂等 204;owner 不可移除(403)。 */ + async remove( + user: AuthUser, + albumId: string, + targetUserId: string, + ): Promise { + const { ownerId } = await this.assertManageable(user, albumId); + if (targetUserId === ownerId) { + throw albumForbidden('不能移除相册创建者(owner 转移属管理员操作)'); + } + // Task 14:成员行是唯一权限来源,移除=删行(无软删除状态)。 + await this.prisma.$transaction(async (tx) => { + const removed = await tx.album_members.deleteMany({ + where: { albumId, userId: targetUserId }, + }); + if (removed.count > 0) { + // 幂等重删(count=0)不重复落审计。 + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'album.member.remove', + resourceId: albumId, + metadata: { userId: targetUserId }, + }, + }); + } + }); + } + + /** 404/403 顺序映射 + admin 空相册兜底(增删均为管理动作,共用入口)。 */ + private async assertManageable( + user: AuthUser, + albumId: string, + ): Promise<{ ownerId: string }> { + if (!(await this.permissions.canViewAlbum(user, albumId))) { + throw albumNotFound(); + } + if (!(await this.permissions.canManageAlbum(user, albumId))) { + throw albumForbidden('仅创建者或管理员可管理成员'); + } + const album = await this.prisma.albums.findUnique({ + where: { id: albumId }, + select: { ownerId: true }, + }); + if (album === null) { + // admin 对不存在的 id 权限恒真,由空值兜底同码 404。 + throw albumNotFound(); + } + return album; + } +} diff --git a/services/api/src/albums/support.ts b/services/api/src/albums/support.ts new file mode 100644 index 0000000..04ddf36 --- /dev/null +++ b/services/api/src/albums/support.ts @@ -0,0 +1,57 @@ +// services/api/src/albums/support.ts —— 相册域服务共用小工具(Task 15 起自 +// albums.service.ts 抽出,Task 17 起 members/categories 服务同用)。 +import { HttpStatus } from '@nestjs/common'; +import { ErrorCodes } from '@photo/contracts'; +import { ApiException } from '../api-exception'; + +/** 统一 404:不可见(含不存在)同码防 id 枚举(契约 albums/categories 404 语义)。 */ +export function albumNotFound(): ApiException { + return new ApiException( + ErrorCodes.ALBUM_NOT_FOUND, + '相册不存在或不可见', + HttpStatus.NOT_FOUND, + ); +} + +/** 统一 403:可见但无管理权限(协作者视角)。 */ +export function albumForbidden(message = '仅创建者或管理员可操作'): ApiException { + return new ApiException(ErrorCodes.AUTH_FORBIDDEN, message, HttpStatus.FORBIDDEN); +} + +/** 统一 400(契约 VALIDATION_FAILED)。 */ +export function validationFailed(message: string): ApiException { + return new ApiException( + ErrorCodes.VALIDATION_FAILED, + message, + HttpStatus.BAD_REQUEST, + ); +} + +/** + * P2002 是否命中指定唯一索引。uniqueName 用 Prisma 唯一选择器名(单列 + * 'publicToken' / 复合 'albumId_userId')。meta.target 两种形态都兼容: + * 字段数组(部分引擎/版本)或 DB 约束名字符串(MySQL 实测形如 + * 'albums_publicToken_key' / 'album_members_albumId_userId_key'—— + * 表名前缀 + 选择器名 + _key 后缀,两种后缀都匹配)。 + */ +export function isUniqueViolationOn(err: unknown, uniqueName: string): boolean { + if (typeof err !== 'object' || err === null) { + return false; + } + const { code, meta } = err as { code?: unknown; meta?: { target?: unknown } }; + if (code !== 'P2002') { + return false; + } + const target = meta?.target; + if (Array.isArray(target)) { + return target.map(String).includes(uniqueName); + } + if (typeof target === 'string') { + return ( + target === uniqueName || + target.endsWith(`${uniqueName}_key`) || + target.endsWith(`_${uniqueName}_key`) + ); + } + return false; +} diff --git a/services/api/test/members-categories.e2e-spec.ts b/services/api/test/members-categories.e2e-spec.ts new file mode 100644 index 0000000..efaf53e --- /dev/null +++ b/services/api/test/members-categories.e2e-spec.ts @@ -0,0 +1,590 @@ +// services/api/test/members-categories.e2e-spec.ts —— Task 17 成员与分类 e2e(先写后实现)。 +// 认证与 Task 15 相同:Prisma 直种 sessions 行换 Bearer 令牌。覆盖: +// 成员:owner 邀请已开通同事(userId)→ 201 契约 Member 形状 + DB 行 + +// audit album.member.add;重复邀请 409 MEMBER_CONFLICT;邀请 owner/ +// role=owner/未知账号/未开通/已停用 → 400;成员列表(成员视角 200、 +// 外人 404)与游标分页;移除协作者 204 幂等 + 被移除者即刻失去相册 +// 可见性(A02:GET album → 404)+ audit album.member.remove;owner +// 自移除 403(转移属 P4a 管理员操作);协作者增删成员 403。 +// 分类:owner 创建 {sortOrder, translations} → 201 契约 Category;相册详情 +// 按 sortOrder 升序带出分类;PATCH 重排/改名(translations 全量替换); +// DELETE 204 且详情不再列出;不存在/越权 404/403 同相册语义 +// (ALBUM_NOT_FOUND);audit album.category.add/update/remove。 +import { randomBytes } from 'node:crypto'; +import { type INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { PrismaClient } from '@photo/db-cn'; +import { ErrorCodes } from '@photo/contracts'; +import { AppCnModule } from '../src/app-cn.module'; +import { hashSessionToken } from '../src/auth/session.guard'; +import { API_PREFIX } from '../src/shared/constants'; +import { resetDb } from './reset'; + +/** 契约 Member 的字段全集(形状精确断言用)。 */ +const MEMBER_KEYS = ['userId', 'account', 'displayName', 'role', 'status'].sort(); +/** 契约 Category 的字段全集。 */ +const CATEGORY_KEYS = ['id', 'albumId', 'sortOrder', 'translations'].sort(); + +describe('members & categories (e2e)', () => { + let app: INestApplication; + let prisma: PrismaClient; + + let ownerId: string; + let inviteeId: string; + let collabId: string; + let outsiderId: string; + let ownerToken: string; + let inviteeToken: string; + let collabToken: string; + let outsiderToken: string; + let adminToken: string; + + let albumId: string; + /** 依次创建的分类 id(cat1=sortOrder 5、cat2=sortOrder 1)。 */ + let cat1Id: string; + let cat2Id: string; + + beforeAll(async () => { + await resetDb(); + const moduleRef = await Test.createTestingModule({ + imports: [AppCnModule], + }).compile(); + app = moduleRef.createNestApplication(); + app.setGlobalPrefix(API_PREFIX); + await app.init(); + + prisma = new PrismaClient(); + + const mkUser = ( + account: string, + extra: { role?: string; sourceStatus?: string; credentialStatus?: string } = {}, + ) => + prisma.users.create({ + data: { + account, + role: extra.role ?? 'staff', + passwordHash: 'x', + displayName: account, + sourceStatus: extra.sourceStatus, + credentialStatus: extra.credentialStatus, + }, + select: { id: true }, + }); + ownerId = (await mkUser('mc-owner')).id; + inviteeId = (await mkUser('mc-invitee')).id; + collabId = (await mkUser('mc-collab')).id; + outsiderId = (await mkUser('mc-outsider')).id; + // 未开通 / 已停用账号:邀请应 400。 + await mkUser('mc-pending', { credentialStatus: 'pending' }); + await mkUser('mc-disabled', { sourceStatus: 'disabled' }); + const adminId = (await mkUser('mc-admin', { role: 'admin' })).id; + + const mkToken = async (userId: string): Promise => { + const token = randomBytes(32).toString('hex'); + await prisma.sessions.create({ + data: { + userId, + tokenHash: hashSessionToken(token), + expiresAt: new Date(Date.now() + 3_600_000), + }, + }); + return token; + }; + ownerToken = await mkToken(ownerId); + inviteeToken = await mkToken(inviteeId); + collabToken = await mkToken(collabId); + outsiderToken = await mkToken(outsiderId); + adminToken = await mkToken(adminId); + + const res = await request(app.getHttpServer()) + .post(`/${API_PREFIX}/albums`) + .set('Authorization', `Bearer ${ownerToken}`) + .send({ + translations: [ + { locale: 'zh-CN', title: '成员与分类相册' }, + { locale: 'en', title: 'Members and Categories' }, + ], + defaultLocale: 'zh-CN', + startAt: '2026-12-01T02:00:00.000Z', + }); + expect(res.status).toBe(201); + albumId = res.body.id as string; + }); + + afterAll(async () => { + await app.close(); + await prisma.$disconnect(); + }); + + // ── 成员 ── + + const postMember = (token: string, body: object) => + request(app.getHttpServer()) + .post(`/${API_PREFIX}/albums/${albumId}/members`) + .set('Authorization', `Bearer ${token}`) + .send(body); + + const listMembers = ( + token: string, + query?: Record, + ) => + request(app.getHttpServer()) + .get(`/${API_PREFIX}/albums/${albumId}/members`) + .set('Authorization', `Bearer ${token}`) + .query(query ?? {}); + + const deleteMember = (token: string, userId: string) => + request(app.getHttpServer()) + .delete(`/${API_PREFIX}/albums/${albumId}/members/${userId}`) + .set('Authorization', `Bearer ${token}`); + + it('owner 邀请已开通同事 → 201 Member;DB 成员行;audit album.member.add', async () => { + const res = await postMember(ownerToken, { userId: inviteeId }); + expect(res.status).toBe(201); + expect(Object.keys(res.body).sort()).toEqual(MEMBER_KEYS); + expect(res.body).toMatchObject({ + userId: inviteeId, + account: 'mc-invitee', + displayName: 'mc-invitee', + role: 'collaborator', + status: 'active', + }); + + const row = await prisma.album_members.findUnique({ + where: { albumId_userId: { albumId, userId: inviteeId } }, + }); + expect(row).toMatchObject({ role: 'collaborator', status: 'active' }); + + const audit = await prisma.audit_logs.findFirst({ + where: { + action: 'album.member.add', + actorId: ownerId, + resourceId: albumId, + }, + }); + expect(audit).not.toBeNull(); + expect((audit?.metadata as { userId?: string })?.userId).toBe(inviteeId); + }); + + it('重复邀请 → 409 MEMBER_CONFLICT', async () => { + const res = await postMember(ownerToken, { userId: inviteeId }); + expect(res.status).toBe(409); + expect(res.body.code).toBe(ErrorCodes.MEMBER_CONFLICT); + }); + + it('owner 邀请自己 / role=owner / 未知账号 / 未开通 / 已停用 → 400', async () => { + const self = await postMember(ownerToken, { userId: ownerId }); + expect(self.status).toBe(400); + expect(self.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + // 本端点仅可添加 collaborator(owner 转移属 P4a 管理员操作)。 + const asOwner = await postMember(ownerToken, { + userId: collabId, + role: 'owner', + }); + expect(asOwner.status).toBe(400); + expect(asOwner.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const unknown = await postMember(ownerToken, { userId: 'no-such-user' }); + expect(unknown.status).toBe(400); + expect(unknown.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + expect(typeof unknown.body.message).toBe('string'); + + // 凭据未开通(credentialStatus=pending)与已停用(sourceStatus=disabled)。 + const pendingUser = await prisma.users.findUnique({ + where: { account: 'mc-pending' }, + select: { id: true }, + }); + const pending = await postMember(ownerToken, { + userId: pendingUser?.id, + }); + expect(pending.status).toBe(400); + + const disabledUser = await prisma.users.findUnique({ + where: { account: 'mc-disabled' }, + select: { id: true }, + }); + const disabled = await postMember(ownerToken, { + userId: disabledUser?.id, + }); + expect(disabled.status).toBe(400); + expect(disabled.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + }); + + it('成员列表:成员/管理员视角 200(owner+协作者);外人员工 404;admin 200', async () => { + const ownerList = await listMembers(ownerToken); + expect(ownerList.status).toBe(200); + expect(ownerList.body.items).toHaveLength(2); + const ids = ownerList.body.items.map((m: { userId: string }) => m.userId); + expect(ids.sort()).toEqual([inviteeId, ownerId].sort()); + expect(Object.keys(ownerList.body.items[0]).sort()).toEqual(MEMBER_KEYS); + // owner 行的 account/displayName 来自 users 表(扁平外键应用层组装)。 + const ownerRow = ownerList.body.items.find( + (m: { userId: string }) => m.userId === ownerId, + ); + expect(ownerRow).toMatchObject({ + account: 'mc-owner', + displayName: 'mc-owner', + role: 'owner', + status: 'active', + }); + + // 被邀请的协作者本人也是成员 → 可见。 + const memberList = await listMembers(inviteeToken); + expect(memberList.status).toBe(200); + expect(memberList.body.items).toHaveLength(2); + + const outsiderList = await listMembers(outsiderToken); + expect(outsiderList.status).toBe(404); + expect(outsiderList.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + const adminList = await listMembers(adminToken); + expect(adminList.status).toBe(200); + expect(adminList.body.items).toHaveLength(2); + }); + + it('成员列表游标分页:addedAt DESC 稳定序,hasMore/nextCursor 成对', async () => { + // 显式回写互不相同的 addedAt:排序断言与真实写入时序解耦。 + await prisma.album_members.updateMany({ + where: { albumId, userId: ownerId }, + data: { addedAt: new Date('2026-09-10T08:00:00.000Z') }, + }); + await prisma.album_members.updateMany({ + where: { albumId, userId: inviteeId }, + data: { addedAt: new Date('2026-09-10T10:00:00.000Z') }, + }); + + const page1 = await listMembers(ownerToken, { limit: 1 }); + expect(page1.status).toBe(200); + expect(page1.body.items.map((m: { userId: string }) => m.userId)).toEqual([ + inviteeId, + ]); + expect(page1.body.hasMore).toBe(true); + expect(typeof page1.body.nextCursor).toBe('string'); + + const page2 = await listMembers(ownerToken, { + limit: 1, + cursor: page1.body.nextCursor, + }); + expect(page2.status).toBe(200); + expect(page2.body.items.map((m: { userId: string }) => m.userId)).toEqual([ + ownerId, + ]); + expect(page2.body.hasMore).toBe(false); + expect(page2.body.nextCursor).toBeNull(); + + const badCursor = await listMembers(ownerToken, { cursor: 'garbage' }); + expect(badCursor.status).toBe(400); + expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + }); + + it('owner 移除协作者 → 204;被移除者即刻失去相册可见性(A02);重复移除幂等 204;audit album.member.remove', async () => { + const res = await deleteMember(ownerToken, inviteeId); + expect(res.status).toBe(204); + + // A02 链路:成员行是唯一权限来源,删除行后下一次请求即刻 404。 + const albumRes = await request(app.getHttpServer()) + .get(`/${API_PREFIX}/albums/${albumId}`) + .set('Authorization', `Bearer ${inviteeToken}`); + expect(albumRes.status).toBe(404); + expect(albumRes.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + // 成员列表回到仅 owner。 + const ownerList = await listMembers(ownerToken); + expect(ownerList.body.items).toHaveLength(1); + + // 幂等:再次移除同一用户仍 204(目标成员行已不存在)。 + const again = await deleteMember(ownerToken, inviteeId); + expect(again.status).toBe(204); + + // 移除成功的那次落审计(幂等重删不重复落)。 + const audits = await prisma.audit_logs.findMany({ + where: { + action: 'album.member.remove', + actorId: ownerId, + resourceId: albumId, + }, + }); + expect(audits).toHaveLength(1); + expect((audits[0]?.metadata as { userId?: string })?.userId).toBe(inviteeId); + }); + + it('owner 自移除 → 403 AUTH_FORBIDDEN(owner 转移属 P4a 管理员操作)', async () => { + const res = await deleteMember(ownerToken, ownerId); + expect(res.status).toBe(403); + expect(res.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + // owner 成员行仍在。 + const row = await prisma.album_members.findUnique({ + where: { albumId_userId: { albumId, userId: ownerId } }, + }); + expect(row).not.toBeNull(); + }); + + it('协作者增删成员 → 403;外人增删 → 404;未认证 → 401', async () => { + // 先把 standing collaborator 加进来(owner 操作,复用前述成功路径)。 + const invite = await postMember(ownerToken, { userId: collabId }); + expect(invite.status).toBe(201); + + const collabAdd = await postMember(collabToken, { userId: outsiderId }); + expect(collabAdd.status).toBe(403); + expect(collabAdd.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const collabRemove = await deleteMember(collabToken, ownerId); + expect(collabRemove.status).toBe(403); + expect(collabRemove.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const outsiderAdd = await postMember(outsiderToken, { userId: collabId }); + expect(outsiderAdd.status).toBe(404); + expect(outsiderAdd.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + const outsiderRemove = await deleteMember(outsiderToken, collabId); + expect(outsiderRemove.status).toBe(404); + expect(outsiderRemove.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + const anon = await request(app.getHttpServer()) + .post(`/${API_PREFIX}/albums/${albumId}/members`) + .send({ userId: collabId }); + expect(anon.status).toBe(401); + expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); + }); + + it('不存在的相册:邀请/列表/移除 → 404 ALBUM_NOT_FOUND', async () => { + const invite = await request(app.getHttpServer()) + .post(`/${API_PREFIX}/albums/no-such-album/members`) + .set('Authorization', `Bearer ${ownerToken}`) + .send({ userId: collabId }); + expect(invite.status).toBe(404); + expect(invite.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + const list = await request(app.getHttpServer()) + .get(`/${API_PREFIX}/albums/no-such-album/members`) + .set('Authorization', `Bearer ${ownerToken}`); + expect(list.status).toBe(404); + + const remove = await request(app.getHttpServer()) + .delete(`/${API_PREFIX}/albums/no-such-album/members/${collabId}`) + .set('Authorization', `Bearer ${ownerToken}`); + expect(remove.status).toBe(404); + + // admin 对不存在的 id 权限恒真:同样兜底 404(而非空列表 200)。 + const adminList = await request(app.getHttpServer()) + .get(`/${API_PREFIX}/albums/no-such-album/members`) + .set('Authorization', `Bearer ${adminToken}`); + expect(adminList.status).toBe(404); + expect(adminList.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + }); + + // ── 分类 ── + + const postCategory = (token: string, body: object) => + request(app.getHttpServer()) + .post(`/${API_PREFIX}/albums/${albumId}/categories`) + .set('Authorization', `Bearer ${token}`) + .send(body); + + const patchCategory = (token: string, categoryId: string, body: object) => + request(app.getHttpServer()) + .patch(`/${API_PREFIX}/categories/${categoryId}`) + .set('Authorization', `Bearer ${token}`) + .send(body); + + const deleteCategory = (token: string, categoryId: string) => + request(app.getHttpServer()) + .delete(`/${API_PREFIX}/categories/${categoryId}`) + .set('Authorization', `Bearer ${token}`); + + const getAlbumCategories = async (): Promise< + Array<{ id: string; sortOrder: number; translations: Array<{ locale: string; name: string }> }> + > => { + const res = await request(app.getHttpServer()) + .get(`/${API_PREFIX}/albums/${albumId}`) + .set('Authorization', `Bearer ${ownerToken}`); + expect(res.status).toBe(200); + return res.body.categories; + }; + + it('owner 创建分类 → 201 Category;DB 行;audit album.category.add', async () => { + const res = await postCategory(ownerToken, { + sortOrder: 5, + translations: [ + { locale: 'zh-CN', name: '外场' }, + { locale: 'en', name: 'Outdoor' }, + ], + }); + expect(res.status).toBe(201); + expect(Object.keys(res.body).sort()).toEqual(CATEGORY_KEYS); + expect(res.body.albumId).toBe(albumId); + expect(res.body.sortOrder).toBe(5); + // 翻译按 locale 升序(确定性输出)。 + expect(res.body.translations).toEqual([ + { locale: 'en', name: 'Outdoor' }, + { locale: 'zh-CN', name: '外场' }, + ]); + cat1Id = res.body.id as string; + + const rows = await prisma.category_translations.findMany({ + where: { categoryId: cat1Id }, + }); + expect(rows.map((r) => r.locale).sort()).toEqual(['en', 'zh-CN']); + + const audit = await prisma.audit_logs.findFirst({ + where: { + action: 'album.category.add', + actorId: ownerId, + resourceId: albumId, + }, + }); + expect(audit).not.toBeNull(); + expect((audit?.metadata as { categoryId?: string })?.categoryId).toBe( + cat1Id, + ); + + // 第二个分类 sortOrder=1:相册详情按 sortOrder 升序带出。 + const res2 = await postCategory(ownerToken, { + sortOrder: 1, + translations: [{ locale: 'zh-CN', name: '签到台' }], + }); + expect(res2.status).toBe(201); + cat2Id = res2.body.id as string; + + const categories = await getAlbumCategories(); + expect(categories.map((c) => c.id)).toEqual([cat2Id, cat1Id]); + expect(categories[0]).toMatchObject({ sortOrder: 1 }); + expect(categories[0].translations).toEqual([ + { locale: 'zh-CN', name: '签到台' }, + ]); + }); + + it('分类校验:重复 locale / 空 translations / 空更新体 → 400', async () => { + const dup = await postCategory(ownerToken, { + translations: [ + { locale: 'zh-CN', name: '重复' }, + { locale: 'zh-CN', name: '重复2' }, + ], + }); + expect(dup.status).toBe(400); + expect(dup.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const empty = await postCategory(ownerToken, { translations: [] }); + expect(empty.status).toBe(400); + expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const emptyPatch = await patchCategory(ownerToken, cat1Id, {}); + expect(emptyPatch.status).toBe(400); + expect(emptyPatch.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + }); + + it('owner PATCH 分类:重排 + 改名(translations 全量替换)→ 200;详情次序更新;audit album.category.update', async () => { + const res = await patchCategory(ownerToken, cat1Id, { + sortOrder: 0, + translations: [{ locale: 'zh-CN', name: '主会场' }], + }); + expect(res.status).toBe(200); + expect(res.body.sortOrder).toBe(0); + // 全量替换:en 名称不复存在。 + expect(res.body.translations).toEqual([{ locale: 'zh-CN', name: '主会场' }]); + + // DB:旧 en 翻译行已删。 + const rows = await prisma.category_translations.findMany({ + where: { categoryId: cat1Id }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ locale: 'zh-CN', name: '主会场' }); + + // 相册详情:cat1(0) 现在排在 cat2(1) 之前。 + const categories = await getAlbumCategories(); + expect(categories.map((c) => c.id)).toEqual([cat1Id, cat2Id]); + + const audit = await prisma.audit_logs.findFirst({ + where: { + action: 'album.category.update', + actorId: ownerId, + resourceId: albumId, + }, + }); + expect(audit).not.toBeNull(); + expect( + (audit?.metadata as { categoryId?: string })?.categoryId, + ).toBe(cat1Id); + }); + + it('owner DELETE 分类 → 204;详情不再列出;再删 → 404;audit album.category.remove', async () => { + const res = await deleteCategory(ownerToken, cat2Id); + expect(res.status).toBe(204); + + const categories = await getAlbumCategories(); + expect(categories.map((c) => c.id)).toEqual([cat1Id]); + + const again = await deleteCategory(ownerToken, cat2Id); + expect(again.status).toBe(404); + expect(again.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + // DB:分类与翻译行俱删。 + const categoryRow = await prisma.categories.findUnique({ + where: { id: cat2Id }, + }); + expect(categoryRow).toBeNull(); + const translationRows = await prisma.category_translations.findMany({ + where: { categoryId: cat2Id }, + }); + expect(translationRows).toHaveLength(0); + + const audit = await prisma.audit_logs.findFirst({ + where: { + action: 'album.category.remove', + actorId: ownerId, + resourceId: albumId, + }, + }); + expect(audit).not.toBeNull(); + }); + + it('不存在的分类 id:PATCH/DELETE → 404 ALBUM_NOT_FOUND(契约分类 404 语义)', async () => { + const patch = await patchCategory(ownerToken, 'no-such-category', { + sortOrder: 2, + }); + expect(patch.status).toBe(404); + expect(patch.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + const del = await deleteCategory(ownerToken, 'no-such-category'); + expect(del.status).toBe(404); + expect(del.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + }); + + it('协作者分类增删改 → 403;外人员工 → 404;admin 创建 → 201', async () => { + const collabCreate = await postCategory(collabToken, { + translations: [{ locale: 'zh-CN', name: '协作者的分类' }], + }); + expect(collabCreate.status).toBe(403); + expect(collabCreate.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const collabPatch = await patchCategory(collabToken, cat1Id, { + sortOrder: 9, + }); + expect(collabPatch.status).toBe(403); + expect(collabPatch.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const collabDelete = await deleteCategory(collabToken, cat1Id); + expect(collabDelete.status).toBe(403); + expect(collabDelete.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const outsiderCreate = await postCategory(outsiderToken, { + translations: [{ locale: 'zh-CN', name: '外人的分类' }], + }); + expect(outsiderCreate.status).toBe(404); + expect(outsiderCreate.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + const adminCreate = await postCategory(adminToken, { + sortOrder: 2, + translations: [{ locale: 'en', name: 'Admin Corner' }], + }); + expect(adminCreate.status).toBe(201); + + const anon = await request(app.getHttpServer()) + .post(`/${API_PREFIX}/albums/${albumId}/categories`) + .send({ translations: [{ locale: 'zh-CN', name: '匿名分类' }] }); + expect(anon.status).toBe(401); + expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); + }); +});