From a125fcab1a7ff574a3c0718f08f5018ab149384a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sun, 20 Sep 2026 16:36:34 +0800 Subject: [PATCH] feat(api): batch hide with per-item results and owner transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P4a Task 2(A16/A17): - POST /albums/{albumId}/photos/hide-batch:逐项结果(部分成功)—— 不存在/他册 → ok:false PHOTO_NOT_FOUND;非 published(unpublished/ deleted)→ ok:false PHOTO_NOT_PUBLIC;已 hidden → ok:true 幂等 no-op (同值不产生第二次事件/审计/版本,P1 语义);published → 逐照片事务 (条件更新 + outbox photo.hidden 高优先级 + audit photo.hide)。 results 与请求同序;权限同管理照片列表(仅创建者或管理员,404 防枚举); 单批 1..200(DTO 层 400)。 - POST /admin/albums/{albumId}/transfer:admin-only owner 移交——事务内 条件更新 ownerId+entityVersion+1(乐观重试)+ 成员互换(目标 upsert 为 owner,原 owner 按 keepAsCollaborator 降级 collaborator 或删行)+ outbox album.updated(标准载荷,版本推进)+ audit album.transfer {from,to,kept}。目标校验同成员邀请资格谓词(active/未锁定/未停用); 自交接 400;photos.uploaderId 不动(A17 历史上传人不变)。 - e2e(batch-ops.e2e-spec,双入口 harness):8 用例覆盖逐项结果/幂等/ 入参权限矩阵/ov 传播(真实发布链→隐藏→photos_ov=hidden+公开列表剔除)/ keep 两分支/目标校验/交接后权限(新 owner 管理、旧 owner 保留协作者 可上传)。 Co-Authored-By: Claude Code --- .../api/src/albums/admin-albums.controller.ts | 40 + services/api/src/albums/albums.module.ts | 8 +- services/api/src/albums/albums.service.ts | 155 +++- services/api/src/albums/dto.ts | 17 + .../api/src/photos/album-photos.controller.ts | 40 +- services/api/src/photos/dto.ts | 25 +- services/api/src/photos/photos.service.ts | 117 ++- services/api/test/batch-ops.e2e-spec.ts | 698 ++++++++++++++++++ 8 files changed, 1085 insertions(+), 15 deletions(-) create mode 100644 services/api/src/albums/admin-albums.controller.ts create mode 100644 services/api/test/batch-ops.e2e-spec.ts diff --git a/services/api/src/albums/admin-albums.controller.ts b/services/api/src/albums/admin-albums.controller.ts new file mode 100644 index 0000000..851f4ee --- /dev/null +++ b/services/api/src/albums/admin-albums.controller.ts @@ -0,0 +1,40 @@ +// services/api/src/albums/admin-albums.controller.ts —— /admin/albums/{albumId} +// 端点(P4a Task 2)。方法名与契约 operationId 一一对应(transferAlbum, +// tag [admin]——路由挂在 admin 前缀下,域仍属相册治理,装配在 AlbumsModule +// 以复用 AlbumsService 的事务/载荷/组装既有设施)。控制器级 SessionGuard: +// 端点需认证;admin 判定在服务层(AuthUser.role,403 AUTH_FORBIDDEN)。 +// 控制器只做认证用户透传;DTO 值导入(emitDecoratorMetadata 设计期元数据)。 +import { + Body, + Controller, + HttpCode, + HttpStatus, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { userOf } from './albums.controller'; +import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard'; +import { AlbumTransferRequestDto } from './dto'; +import { type AlbumResponse, AlbumsService } from './albums.service'; + +@Controller('admin/albums/:albumId') +@UseGuards(SessionGuard) +export class AdminAlbumsController { + constructor(private readonly albumsService: AlbumsService) {} + + /** + * 负责人交接(A17):owner 移交 + 成员角色互换;历史上传人不变。 + * 契约 200(非 201:交接是既有相册的状态迁移,不产生新资源)。 + */ + @Post('transfer') + @HttpCode(HttpStatus.OK) + transferAlbum( + @Param('albumId') albumId: string, + @Body() dto: AlbumTransferRequestDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.albumsService.transfer(userOf(req), albumId, dto); + } +} diff --git a/services/api/src/albums/albums.module.ts b/services/api/src/albums/albums.module.ts index 742e523..0ca4c6c 100644 --- a/services/api/src/albums/albums.module.ts +++ b/services/api/src/albums/albums.module.ts @@ -1,13 +1,14 @@ // services/api/src/albums/albums.module.ts —— 相册模块(Task 14 起步,Task 15 -// 补全,Task 16 增补 PATCH,Task 17 增补成员/分类,P1b Task 3 增补资产上传)。 -// PermissionsService(A02 权限基石)+ AlbumsService/MembersService/ -// CategoriesService/AssetsService 与对应控制器。 +// 补全,Task 16 增补 PATCH,Task 17 增补成员/分类,P1b Task 3 增补资产上传, +// P4a Task 2 增补 admin 交接端点)。PermissionsService(A02 权限基石)+ +// AlbumsService/MembersService/CategoriesService/AssetsService 与对应控制器。 // export PermissionsService 供 uploads/photos 等后续模块经模块间 DI 复用同一 // 份权限判定逻辑;AlbumsService 等暂无外部消费者不 export。 // StoragePort 装配说明(P1b Task 3):UploadsModule 为 PermissionsService 反向 // import 本模块,本模块若再 import UploadsModule 取 STORAGE_PORT 会成环; // 故直接复用同一 provider 工厂(env 构造、S3Client 懒连接,双实例零成本)。 import { Module } from '@nestjs/common'; +import { AdminAlbumsController } from './admin-albums.controller'; import { AlbumsController } from './albums.controller'; import { AlbumsService } from './albums.service'; import { AssetsController } from './assets.controller'; @@ -25,6 +26,7 @@ import { storageProvider } from '../uploads/storage.provider'; MembersController, CategoriesController, AssetsController, + AdminAlbumsController, ], providers: [ PermissionsService, diff --git a/services/api/src/albums/albums.service.ts b/services/api/src/albums/albums.service.ts index fb68560..16545ba 100644 --- a/services/api/src/albums/albums.service.ts +++ b/services/api/src/albums/albums.service.ts @@ -28,7 +28,11 @@ import { keysetWhereDesc, } from '../shared/cursor'; import { buildAlbumUpdatedPayload } from '../sync/payloads'; -import type { AlbumCreateRequestDto, AlbumUpdateRequestDto } from './dto'; +import type { + AlbumCreateRequestDto, + AlbumTransferRequestDto, + AlbumUpdateRequestDto, +} from './dto'; import { PermissionsService } from './permissions.service'; import { albumForbidden, @@ -440,6 +444,155 @@ export class AlbumsService { return album; } + /** + * 负责人交接(契约 transferAlbum,P4a A17):admin 将相册 owner 移交给目标 + * 用户。仅 admin(组织级治理动作,区别于相册级配置修改——现任 owner 也不可 + * 自行交接);keepAsCollaborator=true 时原 owner 转为 collaborator 成员, + * 否则移出成员表。历史上传人不变(photos.uploaderId 不动——上传事实与 + * 相册归属是两个维度)。 + * + * 事务四写(同 update 的乐观重试模式):条件更新 ownerId(WHERE ownerId= + * 读取值 + entityVersion=读取版本,并发交接/更新败方整事务重跑)+ 成员表 + * 互换(目标 upsert 为 owner——原非成员也接管;原 owner 降级/删行)+ + * outbox album.updated(entityVersion+1,标准载荷:ov 副本无需 owner 字段, + * 但版本必须推进)+ audit album.transfer {from,to,kept}。 + * 目标校验(存在且已开通:credentialStatus=active、!localLocked、 + * sourceStatus≠disabled,与成员邀请同一资格谓词)与自交接拒绝都在事务内 + * 对读取行裁决,失败 400 VALIDATION_FAILED;相册不存在 → 404。 + */ + async transfer( + user: AuthUser, + albumId: string, + dto: AlbumTransferRequestDto, + ): Promise { + if (user.role !== 'admin') { + throw albumForbidden('仅管理员可交接相册负责人'); + } + + let outcome: 'done' | 'retry' = 'retry'; + for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) { + outcome = await this.prisma.$transaction(async (tx) => { + const album = await tx.albums.findUnique({ where: { id: albumId } }); + if (album === null) { + // admin 视角无可见性兜底问题:不存在即 404。 + throw albumNotFound(); + } + if (dto.targetUserId === album.ownerId) { + throw validationFailed('目标用户已是该相册负责人'); + } + const target = await tx.users.findUnique({ + where: { id: dto.targetUserId }, + select: { + credentialStatus: true, + sourceStatus: true, + localLocked: true, + }, + }); + if ( + target === null || + target.credentialStatus !== 'active' || + target.localLocked || + target.sourceStatus === 'disabled' + ) { + throw validationFailed( + '目标用户不存在或不可被指定为负责人(须为已开通且未停用账号)', + ); + } + + const claimed = await tx.albums.updateMany({ + where: { + id: albumId, + ownerId: album.ownerId, + entityVersion: album.entityVersion, + }, + data: { ownerId: dto.targetUserId, entityVersion: { increment: 1 } }, + }); + if (claimed.count === 0) { + return 'retry' as const; + } + const nextVersion = album.entityVersion + 1; + + // 成员表互换:目标 upsert 为 owner(原非成员直接接管);原 owner 按参 + // 数降级 collaborator 或移出(成员行是唯一权限来源,删行即即刻失去 + // 相册可见性)。 + await tx.album_members.upsert({ + where: { + albumId_userId: { albumId, userId: dto.targetUserId }, + }, + update: { role: 'owner' }, + create: { + albumId, + userId: dto.targetUserId, + role: 'owner', + status: 'active', + }, + }); + if (dto.keepAsCollaborator) { + await tx.album_members.updateMany({ + where: { albumId, userId: album.ownerId }, + data: { role: 'collaborator' }, + }); + } else { + await tx.album_members.deleteMany({ + where: { albumId, userId: album.ownerId }, + }); + } + + const updated = await tx.albums.findUniqueOrThrow({ + where: { id: albumId }, + }); + const translations = await tx.album_translations.findMany({ + where: { albumId }, + orderBy: { locale: 'asc' }, + }); + await tx.outbox_events.create({ + data: { + type: 'album.updated', + albumId, + entityVersion: nextVersion, + // 交接不改变公开可见性(owner 是 cn 侧治理字段,ov 载荷不含), + // 常规优先级;版本推进保证 ov 副本与权威版本单调对齐。 + priority: 'normal', + payload: buildAlbumUpdatedPayload( + updated, + translations, + ) as unknown as Prisma.InputJsonValue, + }, + }); + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'album.transfer', + resourceId: albumId, + metadata: { + from: album.ownerId, + to: dto.targetUserId, + kept: dto.keepAsCollaborator, + }, + }, + }); + return 'done' as const; + }); + } + if (outcome !== 'done') { + throw new ApiException( + ErrorCodes.INTERNAL_ERROR, + 'Internal server error', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const album = await this.loadAlbum(albumId); + if (album === null) { + throw new ApiException( + ErrorCodes.INTERNAL_ERROR, + 'Internal server error', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + return album; + } + /** 相册列表:admin 全量;否则成员行(status=active)对应的相册。 */ async list( user: AuthUser, diff --git a/services/api/src/albums/dto.ts b/services/api/src/albums/dto.ts index 133596d..231c5fa 100644 --- a/services/api/src/albums/dto.ts +++ b/services/api/src/albums/dto.ts @@ -300,6 +300,23 @@ export class CategoryUpdateRequestDto { 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; +} + // ── 资产(P1b Task 3)── /** 契约 AssetKind:banner(观众页宣传图,PublicAlbum.banner 消费方);cover/logo 建链就绪,展示消费随后续阶段。 */ diff --git a/services/api/src/photos/album-photos.controller.ts b/services/api/src/photos/album-photos.controller.ts index 7f0af1f..bfe5e65 100644 --- a/services/api/src/photos/album-photos.controller.ts +++ b/services/api/src/photos/album-photos.controller.ts @@ -1,14 +1,20 @@ // services/api/src/photos/album-photos.controller.ts —— /albums/{albumId}/photos -// 管理列表端点(P1b Task 4)。方法名与契约 operationId 一一对应 -// (listAlbumPhotos,tag [photos]——路由挂在相册子路径下,域仍属照片管理)。 -// 控制器级 SessionGuard:端点需认证。控制器只做认证用户透传 + 查询参数规整 -// (visibility 本域 dto / categoryId 空串归一 / cursor+limit 走 shared/pagination, -// 同契约 bounds);权限与 HTTP 映射由 PhotosService 抛 ApiException -// (404 ALBUM_NOT_FOUND / 403 AUTH_FORBIDDEN / 400 VALIDATION_FAILED)。 +// 管理列表端点(P1b Task 4)+ 批量隐藏(P4a Task 2)。方法名与契约 +// operationId 一一对应(listAlbumPhotos/hidePhotosBatch,tag [photos]——路由 +// 挂在相册子路径下,域仍属照片管理)。控制器级 SessionGuard:端点需认证。 +// 控制器只做认证用户透传 + 查询参数规整(visibility 本域 dto / categoryId +// 空串归一 / cursor+limit 走 shared/pagination,同契约 bounds);权限与 HTTP +// 映射由 PhotosService 抛 ApiException(404 ALBUM_NOT_FOUND / 403 +// AUTH_FORBIDDEN / 400 VALIDATION_FAILED)。注意 DTO 值导入(非 import type): +// @Body 参数类型经 emitDecoratorMetadata 进入设计期元数据。 import { + Body, Controller, Get, + HttpCode, + HttpStatus, Param, + Post, Query, Req, UseGuards, @@ -16,8 +22,12 @@ import { import { userOf } from '../albums/albums.controller'; import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard'; import { parseCursor, parseLimit } from '../shared/pagination'; -import { parseVisibilityFilter } from './dto'; -import { PhotosService, type AdminPhotoListResponse } from './photos.service'; +import { PhotoHideBatchDto, parseVisibilityFilter } from './dto'; +import { + PhotosService, + type AdminPhotoListResponse, + type BatchHideResult, +} from './photos.service'; @Controller('albums/:albumId/photos') @UseGuards(SessionGuard) @@ -45,4 +55,18 @@ export class AlbumPhotosController { limit: parseLimit(limit), }); } + + /** + * 批量隐藏(A16):逐项结果(部分成功);单批 1..200(DTO 层 400)。 + * 契约 200(非 201:批量结果不是新建资源的表述)。 + */ + @Post('hide-batch') + @HttpCode(HttpStatus.OK) + hidePhotosBatch( + @Param('albumId') albumId: string, + @Body() dto: PhotoHideBatchDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.photosService.hideBatch(userOf(req), albumId, dto.photoIds); + } } diff --git a/services/api/src/photos/dto.ts b/services/api/src/photos/dto.ts index b1d7325..aa816d4 100644 --- a/services/api/src/photos/dto.ts +++ b/services/api/src/photos/dto.ts @@ -1,10 +1,19 @@ // services/api/src/photos/dto.ts —— 照片域入参规整(Task 24 起步:PATCH body; -// P1b Task 4 增补:管理列表 visibility 查询参数)。校验约束对齐契约: +// P1b Task 4 增补:管理列表 visibility 查询参数;P4a Task 2 增补:批量隐藏 +// 请求体)。校验约束对齐契约: // PhotoVisibilityUpdateRequest.visibility 仅允许 hidden/published(enum 之外 // 任何值 → 400 VALIDATION_FAILED);deleted 只能经 DELETE 进入,不开放 PATCH // 直达(单向终态,见 photos.service 状态机)。 +// PhotoHideBatchRequest.photoIds:1..200(minItems/maxItems,执行注意「批量 +// 上限 200/次」),元素非空串(minLength:1)。 import { HttpStatus } from '@nestjs/common'; -import { IsIn } from 'class-validator'; +import { + ArrayMaxSize, + ArrayMinSize, + IsIn, + IsNotEmpty, + IsString, +} from 'class-validator'; import { ErrorCodes } from '@photo/contracts'; import { ApiException } from '../api-exception'; @@ -17,6 +26,18 @@ export class PhotoVisibilityUpdateDto { visibility!: PhotoVisibilityTarget; } +/** 契约 PhotoHideBatchRequest.photoIds 单批上限(maxItems:200)。 */ +export const PHOTO_HIDE_BATCH_MAX = 200; + +/** 契约 PhotoHideBatchRequest(P4a A16 批量隐藏)。 */ +export class PhotoHideBatchDto { + @ArrayMinSize(1) + @ArrayMaxSize(PHOTO_HIDE_BATCH_MAX) + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + photoIds!: string[]; +} + /** 契约 PhotoVisibility 全集(管理列表 visibility 过滤参数的合法值域)。 */ export const PHOTO_VISIBILITIES = [ 'unpublished', diff --git a/services/api/src/photos/photos.service.ts b/services/api/src/photos/photos.service.ts index 5108c92..02f990a 100644 --- a/services/api/src/photos/photos.service.ts +++ b/services/api/src/photos/photos.service.ts @@ -20,7 +20,7 @@ // + audit。幂等前态(同值 PATCH / 重复 DELETE)直接回显当前记录,不产生 // 第二次事件/审计(entityVersion 不动——副本侧按版本去重的不变量)。 import { Injectable } from '@nestjs/common'; -import type { components } from '@photo/contracts'; +import { ErrorCodes, type components } from '@photo/contracts'; import type { Prisma } from '@photo/db-cn'; import type { AuthUser } from '../auth/session.guard'; import { PermissionsService } from '../albums/permissions.service'; @@ -45,6 +45,8 @@ import { export type PhotoRecord = components['schemas']['PhotoRecord']; export type AdminPhotoListResponse = components['schemas']['AdminPhotoList']; +export type BatchHideResult = components['schemas']['BatchHideResult']; +export type BatchHideItem = components['schemas']['BatchHideItem']; type PhotoRow = Prisma.photosGetPayload; @@ -195,6 +197,119 @@ export class PhotosService { return { items, nextCursor, hasMore: nextCursor !== null }; } + /** + * 批量隐藏(契约 hidePhotosBatch,P4a A16):逐项处理、部分成功——单项失败 + * (不存在/已删除/状态不可切换)只标记该项 ok=false + code,不影响其余项。 + * results 与请求 photoIds 同序。权限与 404/403 映射同 listAlbumPhotos(仅 + * 创建者或管理员;ALBUM_NOT_FOUND 防枚举)——批量是管理动作,协作者对 + * 「自己上传」也不放宽(单张 PATCH 路径保留其权限,见 canManagePhoto)。 + * 单项语义与 updateVisibility(hidden) 一致:仅 published→hidden 迁移;已 + * hidden 幂等成功(同值 no-op,无事件/审计/版本,P1 语义);重复 id 同批 + * 第二次按幂等 no-op 处理。 + */ + async hideBatch( + user: AuthUser, + albumId: string, + photoIds: string[], + ): Promise { + if (!(await this.permissions.canViewAlbum(user, albumId))) { + throw albumNotFound(); + } + if (!(await this.permissions.canManageAlbum(user, albumId))) { + throw albumForbidden('仅创建者或管理员可批量隐藏'); + } + // admin 对不存在 id 的 canView/canManage 恒真,空相册兜底同码 404(同 + // listAlbumMembers/listAlbumPhotos 先例)。 + const albumExists = await this.prisma.albums.findUnique({ + where: { id: albumId }, + select: { id: true }, + }); + if (albumExists === null) { + throw albumNotFound(); + } + + const results: BatchHideItem[] = []; + for (const photoId of photoIds) { + results.push(await this.hideOne(user, albumId, photoId)); + } + return { results }; + } + + /** + * 单项隐藏:不存在/他册 → ok:false PHOTO_NOT_FOUND;已 hidden → ok:true 幂等 + * no-op;非 published(unpublished/deleted)→ ok:false PHOTO_NOT_PUBLIC + * (不可隐藏态,契约 BatchHideItem.code 语义);published → 逐照片事务 + * (条件更新 + outbox photo.hidden 高优先级 + audit photo.hide,同 transition + * 的 hidden 分支)。条件更新败方重读:已达 hidden 按幂等成功,否则按不可 + * 隐藏态失败——批量语义下不抛 400(失败只落在该项结果里)。 + */ + private async hideOne( + user: AuthUser, + albumId: string, + photoId: string, + ): Promise { + const photo = await this.prisma.photos.findUnique({ where: { id: photoId } }); + // 他册照片与不存在同码:相册作用域内「查无此照片」,不泄露他册存在性。 + if (photo === null || photo.albumId !== albumId) { + return { photoId, ok: false, code: ErrorCodes.PHOTO_NOT_FOUND }; + } + if (photo.visibility === 'hidden') { + return { photoId, ok: true }; + } + if (photo.visibility !== 'published') { + return { photoId, ok: false, code: ErrorCodes.PHOTO_NOT_PUBLIC }; + } + + const nextVersion = photo.entityVersion + 1; + let transitioned = false; + await this.prisma.$transaction(async (tx) => { + const race = await tx.photos.updateMany({ + where: { id: photo.id, visibility: 'published' }, + data: { visibility: 'hidden', entityVersion: { increment: 1 } }, + }); + if (race.count === 0) { + return; + } + await tx.outbox_events.create({ + data: { + type: 'photo.hidden', + albumId: photo.albumId, + photoId: photo.id, + entityVersion: nextVersion, + // 同 transition:隐藏是「即刻从公开面撤下」——高优先级投递。 + priority: 'high', + payload: { + photoId: photo.id, + albumId: photo.albumId, + visibility: 'hidden', + entityVersion: nextVersion, + }, + }, + }); + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'photo.hide', + resourceId: photo.id, + metadata: { from: 'published', to: 'hidden' }, + }, + }); + transitioned = true; + }); + + if (transitioned) { + return { photoId, ok: true }; + } + // 并发败方:不猜结果,重读裁决(已达目标态幂等成功)。 + const current = await this.prisma.photos.findUnique({ + where: { id: photo.id }, + select: { visibility: true }, + }); + return current?.visibility === 'hidden' + ? { photoId, ok: true } + : { photoId, ok: false, code: ErrorCodes.PHOTO_NOT_PUBLIC }; + } + /** * 调整可见性(契约 updatePhoto):hidden↔published 间切换。权限 → 状态机 → * 条件更新事务(entityVersion+1 + outbox + audit)→ 回读回显。 diff --git a/services/api/test/batch-ops.e2e-spec.ts b/services/api/test/batch-ops.e2e-spec.ts new file mode 100644 index 0000000..9f50759 --- /dev/null +++ b/services/api/test/batch-ops.e2e-spec.ts @@ -0,0 +1,698 @@ +// services/api/test/batch-ops.e2e-spec.ts —— P4a Task 2 批量隐藏 + 负责人交接 +// e2e(先写后实现)。真实 app ×2(cn=AppCnModule + ov=AppOvModule,同 +// public-ov.e2e-spec 模式)+ MySQL×2 + MinIO + HMAC 同步链路。覆盖: +// ① 批量隐藏主链路:owner POST hide-batch [p1,p2,p3,ghost] → 200 逐项结果 +// (与请求同序;ghost ok:false code PHOTO_NOT_FOUND);DB p1-3 hidden + +// entityVersion+1;outbox photo.hidden×3(priority high,A16 即刻撤下语义) +// + audit photo.hide×3(逐成功项一条); +// ② 部分失败语义:[已隐藏(p4), 已删除] → 已隐藏 ok:true 幂等无操作(同值 +// = no-op 成功,P1 语义:无新事件/审计/版本,entityVersion 不动); +// deleted ok:false code PHOTO_NOT_PUBLIC(不可隐藏态); +// ③ 权限与入参:协作者(非 owner)→ 403;外人员工 → 404(防枚举);幽灵 +// 相册 → 404;空数组 / >200 / 含空串 → 400 VALIDATION_FAILED; +// ④ ov 传播:真实发布链(上传→complete→worker 发布事务)→ 排空后 ov 副本 +// published/公开列表含 p1-3;隐藏 → 再排空(deliverPendingOnce)→ +// photos_ov.visibility=hidden + ov 公开列表剔除(A16 观众侧); +// ⑤ 交接 keep=true:admin transfer → 200 Album(ownerId=staffB);成员角色 +// 互换(oldOwner owner→collaborator、staffB collaborator→owner,唯一 +// owner 行);photos.uploaderId 不动(A17 历史上传人不变);albums +// entityVersion+1 + outbox album.updated;audit album.transfer +// {from,to,kept}; +// ⑥ 交接 keep=false:oldOwner 成员行删除;目标原非成员 → owner 行 upsert +// 产生; +// ⑦ 权限与目标校验:非 admin(含现 owner)→ 403;幽灵用户/停用(锁定/ +// 上游 disabled/待激活)→ 400;目标=现任 owner → 400;幽灵相册 → 404; +// 交接后 oldOwner(保留协作者)管理相册 → 403、上传 → 201;新 owner +// 管理 → 200。 +import { randomBytes } from 'node:crypto'; +import { NestFactory } from '@nestjs/core'; +import { type NestExpressApplication } from '@nestjs/platform-express'; +import request from 'supertest'; +import sharp from 'sharp'; +import { PrismaClient } from '@photo/db-cn'; +import { PrismaClient as OvPrismaClient } from '@photo/db-ov'; +import { ErrorCodes } from '@photo/contracts'; +import { + createStorageFromEnv, + type MinioStorageAdapter, +} from '@photo/storage'; +import { AppCnModule } from '../src/app-cn.module'; +import { AppOvModule } from '../src/app-ov.module'; +import { hashSessionToken } from '../src/auth/session.guard'; +import { configureApp } from '../src/shared/configure-app'; +// ov 入口含 realtime 网关:进程内装配须与生产 main-ov 一致安装契约 WS 适配器。 +import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter'; +import { PublishService } from '../../worker/src/publish.service'; +import { resetDb } from './reset'; +import { resetOvDb } from './reset-ov'; +import { deliverPendingOnce } from '../../sync/src/sender'; + +jest.setTimeout(180_000); + +const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me'; + +/** 测试内生成真实 JPEG(同 public-ov.e2e-spec;无二进制入库)。 */ +async function makeJpeg( + width: number, + height: number, + rgb: { r: number; g: number; b: number }, +): Promise { + return sharp({ create: { width, height, channels: 3, background: rgb } }) + .jpeg({ quality: 85 }) + .toBuffer(); +} + +/** cuid 形状 ID(直种照片用,同 photos.e2e-spec 约定)。 */ +function cuidLike(alias: string): string { + return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`; +} + +describe('batch hide + owner transfer (e2e)', () => { + let cnApp: NestExpressApplication; + let ovApp: NestExpressApplication; + let cnBase: string; + let ovBase: string; + let prismaCn: PrismaClient; + let prismaOv: OvPrismaClient; + let storage: MinioStorageAdapter; + + let ownerAId: string; + let staffBId: string; + let collabId: string; + let adminId: string; + /** 停用系目标账号(⑥ 用例:锁定 / 上游停用 / 待激活)。 */ + let lockedUserId: string; + let disabledUserId: string; + let pendingUserId: string; + let ownerAToken: string; + let staffBToken: string; + let collabToken: string; + let outsiderToken: string; + let adminToken: string; + + let albumAId: string; + let albumBId: string; + let publicTokenA: string; + + /** 真实发布链照片(uploader=ownerA;隐藏主链路 + ov 传播用)。 */ + let p1: string; + let p2: string; + let p3: string; + /** 直种:预隐藏(幂等用例)/ 已删除(不可隐藏态用例)。 */ + let preHiddenId: string; + let deletedId: string; + + function hideBatch(token: string, albumId: string, photoIds: string[]) { + return request(cnBase) + .post(`/v1/albums/${albumId}/photos/hide-batch`) + .set('Authorization', `Bearer ${token}`) + .send({ photoIds }); + } + + function transferAlbum(token: string, albumId: string, body: object) { + return request(cnBase) + .post(`/v1/admin/albums/${albumId}/transfer`) + .set('Authorization', `Bearer ${token}`) + .send(body); + } + + /** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */ + async function uploadAndPublish( + token: string, + clientTaskId: string, + jpeg: Buffer, + ): Promise { + const init = await request(cnBase) + .post('/v1/uploads') + .set('Authorization', `Bearer ${token}`) + .send({ + albumId: albumAId, + clientTaskId, + filename: 'batch-ops.jpg', + bytes: jpeg.length, + contentType: 'image/jpeg', + }); + expect(init.status).toBe(201); + const { uploadId, credential } = init.body as { + uploadId: string; + credential: { url: string; method: string; headers: Record }; + }; + const put = await fetch(credential.url, { + method: credential.method, + headers: credential.headers, + body: new Uint8Array(jpeg), + }); + expect(put.ok).toBe(true); + const done = await request(cnBase) + .post(`/v1/uploads/${uploadId}/complete`) + .set('Authorization', `Bearer ${token}`); + expect(done.status).toBe(200); + expect( + await new PublishService(prismaCn, storage).processPhoto( + init.body.photoId as string, + ), + ).toBe('published'); + return init.body.photoId as string; + } + + /** 直种照片(无变体对象:本 spec 对直种行只断言可见性/版本/事件,不读图)。 */ + async function seedPhoto( + alias: string, + visibility: 'hidden' | 'deleted', + ): Promise { + const photoId = cuidLike(alias); + await prismaCn.photos.create({ + data: { + id: photoId, + albumId: albumAId, + uploaderId: ownerAId, + objectKey: `photo-live/dev/albums/${albumAId}/originals/${photoId}.jpg`, + bytes: 1000, + width: 800, + height: 500, + uploadedAt: new Date(), + publishedAt: visibility === 'hidden' ? new Date() : null, + processingState: 'ready', + visibility, + entityVersion: 2, + }, + }); + return photoId; + } + + /** ov 公开列表全量 id 集(hasMore 分页下循环取尽)。 */ + async function ovPublicPhotoIds(): Promise { + const ids: string[] = []; + let cursor: string | undefined; + for (;;) { + const res = await request(ovBase) + .get(`/v1/public/albums/${publicTokenA}/photos`) + .query(cursor === undefined ? { limit: 60 } : { limit: 60, cursor }); + expect(res.status).toBe(200); + ids.push(...res.body.items.map((p: { photoId: string }) => p.photoId)); + if (res.body.nextCursor === null) { + return ids; + } + cursor = res.body.nextCursor as string; + } + } + + beforeAll(async () => { + expect(SYNC_KEY).not.toBe(''); + await resetDb(); + await resetOvDb(); + + cnApp = await NestFactory.create(AppCnModule, { + rawBody: true, + }); + configureApp(cnApp); + await cnApp.listen(0); + cnBase = await cnApp.getUrl(); + + ovApp = await NestFactory.create(AppOvModule, { + rawBody: true, + }); + ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp)); + configureApp(ovApp); + await ovApp.listen(0); + ovBase = await ovApp.getUrl(); + + prismaCn = new PrismaClient(); + prismaOv = new OvPrismaClient(); + storage = createStorageFromEnv(); + + // 账号:ownerA(相册 A/B 创建者)/ staffB(交接目标)/ collab(协作者)/ + // admin / outsider(外人员工)/ 三个不可交接目标(锁定、上游停用、待激活)。 + const mkUser = ( + account: string, + extra: { role?: string; localLocked?: boolean; sourceStatus?: string; credentialStatus?: string } = {}, + ) => + prismaCn.users.create({ + data: { + account, + role: extra.role ?? 'staff', + passwordHash: 'x', + displayName: account, + ...(extra.localLocked !== undefined ? { localLocked: extra.localLocked } : {}), + ...(extra.sourceStatus !== undefined ? { sourceStatus: extra.sourceStatus } : {}), + ...(extra.credentialStatus !== undefined + ? { credentialStatus: extra.credentialStatus } + : {}), + }, + select: { id: true }, + }); + ownerAId = (await mkUser('bo-owner')).id; + staffBId = (await mkUser('bo-staff-b')).id; + collabId = (await mkUser('bo-collab')).id; + adminId = (await mkUser('bo-admin', { role: 'admin' })).id; + const outsiderId = (await mkUser('bo-outsider')).id; + lockedUserId = (await mkUser('bo-locked', { localLocked: true })).id; + disabledUserId = ( + await mkUser('bo-src-disabled', { sourceStatus: 'disabled' }) + ).id; + pendingUserId = ( + await mkUser('bo-pending-cred', { credentialStatus: 'pending' }) + ).id; + + const mkToken = async (userId: string): Promise => { + const token = randomBytes(32).toString('hex'); + await prismaCn.sessions.create({ + data: { + userId, + tokenHash: hashSessionToken(token), + expiresAt: new Date(Date.now() + 3_600_000), + }, + }); + return token; + }; + ownerAToken = await mkToken(ownerAId); + staffBToken = await mkToken(staffBId); + collabToken = await mkToken(collabId); + outsiderToken = await mkToken(outsiderId); + adminToken = await mkToken(adminId); + + // 相册 A(API 创建:album.updated v1 事件入 outbox,ov 副本链路用)+ + // 成员 staffB/collab(协作者)。 + const albumA = await request(cnBase) + .post('/v1/albums') + .set('Authorization', `Bearer ${ownerAToken}`) + .send({ + translations: [{ locale: 'zh-CN', title: '批量操作相册' }], + defaultLocale: 'zh-CN', + startAt: '2026-10-01T10:00:00.000Z', + }); + expect(albumA.status).toBe(201); + albumAId = albumA.body.id as string; + publicTokenA = albumA.body.publicToken as string; + for (const userId of [staffBId, collabId]) { + const added = await request(cnBase) + .post(`/v1/albums/${albumAId}/members`) + .set('Authorization', `Bearer ${ownerAToken}`) + .send({ userId }); + expect(added.status).toBe(201); + } + + // 相册 B(keep=false 用例;staffB 非成员 → 交接 upsert 建行路径)。 + const albumB = await request(cnBase) + .post('/v1/albums') + .set('Authorization', `Bearer ${ownerAToken}`) + .send({ + translations: [{ locale: 'zh-CN', title: '交接不保留相册' }], + defaultLocale: 'zh-CN', + startAt: '2026-10-02T10:00:00.000Z', + }); + expect(albumB.status).toBe(201); + albumBId = albumB.body.id as string; + + // p1-3:真实上传+发布链(uploader=ownerA)→ 排空,ov 副本就位(公开可见)。 + const shapes = [ + { w: 900, h: 600, rgb: { r: 180, g: 120, b: 60 } }, + { w: 800, h: 500, rgb: { r: 60, g: 130, b: 180 } }, + { w: 700, h: 450, rgb: { r: 120, g: 180, b: 90 } }, + ] as const; + const published: string[] = []; + for (let i = 0; i < shapes.length; i++) { + const s = shapes[i]!; + published.push( + await uploadAndPublish( + ownerAToken, + `bo-p${i + 1}`, + await makeJpeg(s.w, s.h, s.rgb), + ), + ); + } + [p1, p2, p3] = published; + await deliverPendingOnce({ + prisma: prismaCn, + ovApiUrl: ovBase, + internalKey: SYNC_KEY, + }); + + // 直种:预隐藏 + 已删除(隐藏状态机边界用例)。 + preHiddenId = await seedPhoto('boPreHidden', 'hidden'); + deletedId = await seedPhoto('boDeleted', 'deleted'); + }); + + afterAll(async () => { + await cnApp.close(); + await ovApp.close(); + await prismaCn.$disconnect(); + await prismaOv.$disconnect(); + }); + + describe('批量隐藏 hidePhotosBatch', () => { + it('① owner 批量 [p1,p2,p3,ghost] → 200 逐项结果(同序);DB hidden+版本+1;outbox high×3;audit×3', async () => { + const before = await prismaCn.photos.findMany({ + where: { id: { in: [p1, p2, p3] } }, + select: { id: true, entityVersion: true }, + }); + const beforeVersion = new Map(before.map((r) => [r.id, r.entityVersion])); + + const res = await hideBatch(ownerAToken, albumAId, [ + p1, + p2, + p3, + 'no-such-photo', + ]); + expect(res.status).toBe(200); + expect(Object.keys(res.body)).toEqual(['results']); + // 与请求同序逐项对应;成功项无 code 字段。 + expect(res.body.results).toEqual([ + { photoId: p1, ok: true }, + { photoId: p2, ok: true }, + { photoId: p3, ok: true }, + { + photoId: 'no-such-photo', + ok: false, + code: ErrorCodes.PHOTO_NOT_FOUND, + }, + ]); + + const after = await prismaCn.photos.findMany({ + where: { id: { in: [p1, p2, p3] } }, + select: { id: true, visibility: true, entityVersion: true }, + }); + for (const row of after) { + expect(row.visibility).toBe('hidden'); + expect(row.entityVersion).toBe( + (beforeVersion.get(row.id) as number) + 1, + ); + } + + // outbox:photo.hidden×3,高优先级(A16 即刻撤下),载荷最小事实。 + const events = await prismaCn.outbox_events.findMany({ + where: { type: 'photo.hidden', photoId: { in: [p1, p2, p3] } }, + }); + expect(events).toHaveLength(3); + for (const event of events) { + expect(event.priority).toBe('high'); + expect(event.albumId).toBe(albumAId); + expect(event.entityVersion).toBe( + after.find((r) => r.id === event.photoId)?.entityVersion, + ); + expect(event.payload).toMatchObject({ + photoId: event.photoId, + albumId: albumAId, + visibility: 'hidden', + entityVersion: event.entityVersion, + }); + } + + // audit:每个成功项恰一条 photo.hide。 + const audits = await prismaCn.audit_logs.findMany({ + where: { + action: 'photo.hide', + resourceId: { in: [p1, p2, p3] }, + }, + }); + expect(audits).toHaveLength(3); + for (const audit of audits) { + expect(audit.actorId).toBe(ownerAId); + expect(audit.metadata).toMatchObject({ from: 'published', to: 'hidden' }); + } + }); + + it('② [已隐藏, 已删除] → 幂等 ok:true(无新事件/审计/版本)+ ok:false PHOTO_NOT_PUBLIC', async () => { + const preHiddenBefore = await prismaCn.photos.findUniqueOrThrow({ + where: { id: preHiddenId }, + select: { entityVersion: true }, + }); + const deletedBefore = await prismaCn.photos.findUniqueOrThrow({ + where: { id: deletedId }, + select: { entityVersion: true }, + }); + + const res = await hideBatch(ownerAToken, albumAId, [ + preHiddenId, + deletedId, + ]); + expect(res.status).toBe(200); + expect(res.body.results).toEqual([ + { photoId: preHiddenId, ok: true }, + { photoId: deletedId, ok: false, code: ErrorCodes.PHOTO_NOT_PUBLIC }, + ]); + + // 同值幂等:不产生第二次事件/审计,entityVersion 不动(P1 语义)。 + expect( + await prismaCn.outbox_events.count({ + where: { photoId: { in: [preHiddenId, deletedId] } }, + }), + ).toBe(0); + expect( + await prismaCn.audit_logs.count({ + where: { + action: 'photo.hide', + resourceId: { in: [preHiddenId, deletedId] }, + }, + }), + ).toBe(0); + expect( + await prismaCn.photos.findUniqueOrThrow({ + where: { id: preHiddenId }, + select: { entityVersion: true, visibility: true }, + }), + ).toEqual({ entityVersion: preHiddenBefore.entityVersion, visibility: 'hidden' }); + expect( + ( + await prismaCn.photos.findUniqueOrThrow({ + where: { id: deletedId }, + select: { entityVersion: true }, + }) + ).entityVersion, + ).toBe(deletedBefore.entityVersion); + }); + + it('③ 权限与入参:协作者 403;外人员工 404;幽灵相册 404;空/超 200/空串 → 400', async () => { + const forbidden = await hideBatch(collabToken, albumAId, [p1]); + expect(forbidden.status).toBe(403); + expect(forbidden.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const outsider = await hideBatch(outsiderToken, albumAId, [p1]); + expect(outsider.status).toBe(404); + expect(outsider.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + const ghostAlbum = await hideBatch(ownerAToken, 'no-such-album', [p1]); + expect(ghostAlbum.status).toBe(404); + expect(ghostAlbum.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + const empty = await hideBatch(ownerAToken, albumAId, []); + expect(empty.status).toBe(400); + expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const tooMany = await hideBatch( + ownerAToken, + albumAId, + Array.from({ length: 201 }, (_, i) => `photo-${i}`), + ); + expect(tooMany.status).toBe(400); + expect(tooMany.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const blankItem = await hideBatch(ownerAToken, albumAId, [p1, '']); + expect(blankItem.status).toBe(400); + expect(blankItem.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + }); + + it('④ ov 传播:隐藏事件排空后 photos_ov=hidden,ov 公开列表剔除', async () => { + // 排空隐藏事件前:ov 副本仍为 published(beforeAll 已排空发布事件)。 + expect(await ovPublicPhotoIds()).toEqual( + expect.arrayContaining([p1, p2, p3]), + ); + + await deliverPendingOnce({ + prisma: prismaCn, + ovApiUrl: ovBase, + internalKey: SYNC_KEY, + }); + + const replicas = await prismaOv.photos_ov.findMany({ + where: { photoId: { in: [p1, p2, p3] } }, + select: { photoId: true, visibility: true }, + }); + expect(replicas).toHaveLength(3); + for (const replica of replicas) { + expect(replica.visibility).toBe('hidden'); + } + + const ids = await ovPublicPhotoIds(); + expect(ids).not.toContain(p1); + expect(ids).not.toContain(p2); + expect(ids).not.toContain(p3); + }); + }); + + describe('负责人交接 transferAlbum', () => { + it('⑤ admin 转移 A → staffB(keep=true):owner 变更、角色互换、上传人不变、版本+outbox、audit', async () => { + const versionBefore = ( + await prismaCn.albums.findUniqueOrThrow({ + where: { id: albumAId }, + select: { entityVersion: true }, + }) + ).entityVersion; + const uploaderIdsBefore = await prismaCn.photos.findMany({ + where: { albumId: albumAId }, + select: { id: true, uploaderId: true }, + orderBy: { id: 'asc' }, + }); + + const res = await transferAlbum(adminToken, albumAId, { + targetUserId: staffBId, + keepAsCollaborator: true, + }); + expect(res.status).toBe(200); + expect(res.body.id).toBe(albumAId); + expect(res.body.ownerId).toBe(staffBId); + + // 成员角色互换:oldOwner→collaborator、staffB→owner;唯一 owner 行。 + const oldOwnerRow = await prismaCn.album_members.findUnique({ + where: { albumId_userId: { albumId: albumAId, userId: ownerAId } }, + }); + expect(oldOwnerRow?.role).toBe('collaborator'); + expect(oldOwnerRow?.status).toBe('active'); + const newOwnerRow = await prismaCn.album_members.findUnique({ + where: { albumId_userId: { albumId: albumAId, userId: staffBId } }, + }); + expect(newOwnerRow?.role).toBe('owner'); + const ownerRows = await prismaCn.album_members.findMany({ + where: { albumId: albumAId, role: 'owner' }, + }); + expect(ownerRows).toHaveLength(1); + expect(ownerRows[0]?.userId).toBe(staffBId); + + // A17:历史上传人不变(照片行(含已隐藏)uploaderId 全部原样)。 + const uploaderIdsAfter = await prismaCn.photos.findMany({ + where: { albumId: albumAId }, + select: { id: true, uploaderId: true }, + orderBy: { id: 'asc' }, + }); + expect(uploaderIdsAfter).toEqual(uploaderIdsBefore); + + // 版本推进 + outbox album.updated(标准载荷,ov 无需 owner 字段)。 + const albumAfter = await prismaCn.albums.findUniqueOrThrow({ + where: { id: albumAId }, + select: { ownerId: true, entityVersion: true }, + }); + expect(albumAfter.ownerId).toBe(staffBId); + expect(albumAfter.entityVersion).toBe(versionBefore + 1); + const event = await prismaCn.outbox_events.findFirstOrThrow({ + where: { type: 'album.updated', albumId: albumAId }, + orderBy: { entityVersion: 'desc' }, + }); + expect(event.entityVersion).toBe(albumAfter.entityVersion); + + // audit album.transfer {from,to,kept}。 + const audit = await prismaCn.audit_logs.findFirstOrThrow({ + where: { action: 'album.transfer', resourceId: albumAId }, + }); + expect(audit.actorId).toBe(adminId); + expect(audit.metadata).toEqual({ + from: ownerAId, + to: staffBId, + kept: true, + }); + }); + + it('⑥ 权限与目标校验:非 admin 403;幽灵/停用/待激活目标 400;自交接 400;幽灵相册 404', async () => { + // 非 admin:现任 owner(staffB,staff 角色)同样不可交接(组织级治理动作)。 + const byOwner = await transferAlbum(staffBToken, albumAId, { + targetUserId: ownerAId, + keepAsCollaborator: true, + }); + expect(byOwner.status).toBe(403); + expect(byOwner.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const ghostTarget = await transferAlbum(adminToken, albumAId, { + targetUserId: 'u-bo-ghost-user', + keepAsCollaborator: true, + }); + expect(ghostTarget.status).toBe(400); + expect(ghostTarget.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + for (const invalidTarget of [ + lockedUserId, + disabledUserId, + pendingUserId, + ]) { + const res = await transferAlbum(adminToken, albumAId, { + targetUserId: invalidTarget, + keepAsCollaborator: true, + }); + expect(res.status).toBe(400); + expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + } + + // 目标=现任 owner:无迁移语义,400。 + const selfTransfer = await transferAlbum(adminToken, albumBId, { + targetUserId: ownerAId, + keepAsCollaborator: true, + }); + expect(selfTransfer.status).toBe(400); + expect(selfTransfer.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const ghostAlbum = await transferAlbum(adminToken, 'no-such-album', { + targetUserId: staffBId, + keepAsCollaborator: true, + }); + expect(ghostAlbum.status).toBe(404); + expect(ghostAlbum.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + }); + + it('⑦ 交接后:新 owner 管理 200;oldOwner(保留协作者)管理 403、上传 201', async () => { + const newOwnerManage = await request(cnBase) + .patch(`/v1/albums/${albumAId}`) + .set('Authorization', `Bearer ${staffBToken}`) + .send({ downloadEnabled: false }); + expect(newOwnerManage.status).toBe(200); + expect(newOwnerManage.body.ownerId).toBe(staffBId); + expect(newOwnerManage.body.downloadEnabled).toBe(false); + + const oldOwnerManage = await request(cnBase) + .patch(`/v1/albums/${albumAId}`) + .set('Authorization', `Bearer ${ownerAToken}`) + .send({ downloadEnabled: true }); + expect(oldOwnerManage.status).toBe(403); + expect(oldOwnerManage.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + // 保留协作者仍可上传(成员行有效即「已加入的相册」)。 + const upload = await request(cnBase) + .post('/v1/uploads') + .set('Authorization', `Bearer ${ownerAToken}`) + .send({ + albumId: albumAId, + clientTaskId: 'bo-upload-after-transfer', + filename: 'after-transfer.jpg', + bytes: 1000, + contentType: 'image/jpeg', + }); + expect(upload.status).toBe(201); + }); + + it('⑧ keep=false:oldOwner 成员行删除;非成员目标 upsert 为 owner', async () => { + const res = await transferAlbum(adminToken, albumBId, { + targetUserId: staffBId, + keepAsCollaborator: false, + }); + expect(res.status).toBe(200); + expect(res.body.ownerId).toBe(staffBId); + + expect( + await prismaCn.album_members.findUnique({ + where: { albumId_userId: { albumId: albumBId, userId: ownerAId } }, + }), + ).toBeNull(); + const newOwnerRow = await prismaCn.album_members.findUniqueOrThrow({ + where: { albumId_userId: { albumId: albumBId, userId: staffBId } }, + }); + expect(newOwnerRow.role).toBe('owner'); + expect(newOwnerRow.status).toBe('active'); + + const audit = await prismaCn.audit_logs.findFirstOrThrow({ + where: { action: 'album.transfer', resourceId: albumBId }, + }); + expect(audit.metadata).toEqual({ + from: ownerAId, + to: staffBId, + kept: false, + }); + }); + }); +});