feat(api): batch hide with per-item results and owner transfer

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 <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 16:36:34 +08:00
parent 8e3e19a7d3
commit a125fcab1a
8 changed files with 1085 additions and 15 deletions
@@ -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<AlbumResponse> {
return this.albumsService.transfer(userOf(req), albumId, dto);
}
}
+5 -3
View File
@@ -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,
+154 -1
View File
@@ -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<AlbumResponse> {
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,
+17
View File
@@ -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 建链就绪,展示消费随后续阶段。 */