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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
// services/api/src/albums/albums.module.ts —— 相册模块(Task 14 起步,Task 15
|
// services/api/src/albums/albums.module.ts —— 相册模块(Task 14 起步,Task 15
|
||||||
// 补全,Task 16 增补 PATCH,Task 17 增补成员/分类,P1b Task 3 增补资产上传)。
|
// 补全,Task 16 增补 PATCH,Task 17 增补成员/分类,P1b Task 3 增补资产上传,
|
||||||
// PermissionsService(A02 权限基石)+ AlbumsService/MembersService/
|
// P4a Task 2 增补 admin 交接端点)。PermissionsService(A02 权限基石)+
|
||||||
// CategoriesService/AssetsService 与对应控制器。
|
// AlbumsService/MembersService/CategoriesService/AssetsService 与对应控制器。
|
||||||
// export PermissionsService 供 uploads/photos 等后续模块经模块间 DI 复用同一
|
// export PermissionsService 供 uploads/photos 等后续模块经模块间 DI 复用同一
|
||||||
// 份权限判定逻辑;AlbumsService 等暂无外部消费者不 export。
|
// 份权限判定逻辑;AlbumsService 等暂无外部消费者不 export。
|
||||||
// StoragePort 装配说明(P1b Task 3):UploadsModule 为 PermissionsService 反向
|
// StoragePort 装配说明(P1b Task 3):UploadsModule 为 PermissionsService 反向
|
||||||
// import 本模块,本模块若再 import UploadsModule 取 STORAGE_PORT 会成环;
|
// import 本模块,本模块若再 import UploadsModule 取 STORAGE_PORT 会成环;
|
||||||
// 故直接复用同一 provider 工厂(env 构造、S3Client 懒连接,双实例零成本)。
|
// 故直接复用同一 provider 工厂(env 构造、S3Client 懒连接,双实例零成本)。
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AdminAlbumsController } from './admin-albums.controller';
|
||||||
import { AlbumsController } from './albums.controller';
|
import { AlbumsController } from './albums.controller';
|
||||||
import { AlbumsService } from './albums.service';
|
import { AlbumsService } from './albums.service';
|
||||||
import { AssetsController } from './assets.controller';
|
import { AssetsController } from './assets.controller';
|
||||||
@@ -25,6 +26,7 @@ import { storageProvider } from '../uploads/storage.provider';
|
|||||||
MembersController,
|
MembersController,
|
||||||
CategoriesController,
|
CategoriesController,
|
||||||
AssetsController,
|
AssetsController,
|
||||||
|
AdminAlbumsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PermissionsService,
|
PermissionsService,
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ import {
|
|||||||
keysetWhereDesc,
|
keysetWhereDesc,
|
||||||
} from '../shared/cursor';
|
} from '../shared/cursor';
|
||||||
import { buildAlbumUpdatedPayload } from '../sync/payloads';
|
import { buildAlbumUpdatedPayload } from '../sync/payloads';
|
||||||
import type { AlbumCreateRequestDto, AlbumUpdateRequestDto } from './dto';
|
import type {
|
||||||
|
AlbumCreateRequestDto,
|
||||||
|
AlbumTransferRequestDto,
|
||||||
|
AlbumUpdateRequestDto,
|
||||||
|
} from './dto';
|
||||||
import { PermissionsService } from './permissions.service';
|
import { PermissionsService } from './permissions.service';
|
||||||
import {
|
import {
|
||||||
albumForbidden,
|
albumForbidden,
|
||||||
@@ -440,6 +444,155 @@ export class AlbumsService {
|
|||||||
return album;
|
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)对应的相册。 */
|
/** 相册列表:admin 全量;否则成员行(status=active)对应的相册。 */
|
||||||
async list(
|
async list(
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
|
|||||||
@@ -300,6 +300,23 @@ export class CategoryUpdateRequestDto {
|
|||||||
translations?: CategoryTranslationDto[];
|
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)──
|
// ── 资产(P1b Task 3)──
|
||||||
|
|
||||||
/** 契约 AssetKind:banner(观众页宣传图,PublicAlbum.banner 消费方);cover/logo 建链就绪,展示消费随后续阶段。 */
|
/** 契约 AssetKind:banner(观众页宣传图,PublicAlbum.banner 消费方);cover/logo 建链就绪,展示消费随后续阶段。 */
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
// services/api/src/photos/album-photos.controller.ts —— /albums/{albumId}/photos
|
// services/api/src/photos/album-photos.controller.ts —— /albums/{albumId}/photos
|
||||||
// 管理列表端点(P1b Task 4)。方法名与契约 operationId 一一对应
|
// 管理列表端点(P1b Task 4)+ 批量隐藏(P4a Task 2)。方法名与契约
|
||||||
// (listAlbumPhotos,tag [photos]——路由挂在相册子路径下,域仍属照片管理)。
|
// operationId 一一对应(listAlbumPhotos/hidePhotosBatch,tag [photos]——路由
|
||||||
// 控制器级 SessionGuard:端点需认证。控制器只做认证用户透传 + 查询参数规整
|
// 挂在相册子路径下,域仍属照片管理)。控制器级 SessionGuard:端点需认证。
|
||||||
// (visibility 本域 dto / categoryId 空串归一 / cursor+limit 走 shared/pagination,
|
// 控制器只做认证用户透传 + 查询参数规整(visibility 本域 dto / categoryId
|
||||||
// 同契约 bounds);权限与 HTTP 映射由 PhotosService 抛 ApiException
|
// 空串归一 / cursor+limit 走 shared/pagination,同契约 bounds);权限与 HTTP
|
||||||
// (404 ALBUM_NOT_FOUND / 403 AUTH_FORBIDDEN / 400 VALIDATION_FAILED)。
|
// 映射由 PhotosService 抛 ApiException(404 ALBUM_NOT_FOUND / 403
|
||||||
|
// AUTH_FORBIDDEN / 400 VALIDATION_FAILED)。注意 DTO 值导入(非 import type):
|
||||||
|
// @Body 参数类型经 emitDecoratorMetadata 进入设计期元数据。
|
||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -16,8 +22,12 @@ import {
|
|||||||
import { userOf } from '../albums/albums.controller';
|
import { userOf } from '../albums/albums.controller';
|
||||||
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||||
import { parseCursor, parseLimit } from '../shared/pagination';
|
import { parseCursor, parseLimit } from '../shared/pagination';
|
||||||
import { parseVisibilityFilter } from './dto';
|
import { PhotoHideBatchDto, parseVisibilityFilter } from './dto';
|
||||||
import { PhotosService, type AdminPhotoListResponse } from './photos.service';
|
import {
|
||||||
|
PhotosService,
|
||||||
|
type AdminPhotoListResponse,
|
||||||
|
type BatchHideResult,
|
||||||
|
} from './photos.service';
|
||||||
|
|
||||||
@Controller('albums/:albumId/photos')
|
@Controller('albums/:albumId/photos')
|
||||||
@UseGuards(SessionGuard)
|
@UseGuards(SessionGuard)
|
||||||
@@ -45,4 +55,18 @@ export class AlbumPhotosController {
|
|||||||
limit: parseLimit(limit),
|
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<BatchHideResult> {
|
||||||
|
return this.photosService.hideBatch(userOf(req), albumId, dto.photoIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
// services/api/src/photos/dto.ts —— 照片域入参规整(Task 24 起步:PATCH body;
|
// 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 之外
|
// PhotoVisibilityUpdateRequest.visibility 仅允许 hidden/published(enum 之外
|
||||||
// 任何值 → 400 VALIDATION_FAILED);deleted 只能经 DELETE 进入,不开放 PATCH
|
// 任何值 → 400 VALIDATION_FAILED);deleted 只能经 DELETE 进入,不开放 PATCH
|
||||||
// 直达(单向终态,见 photos.service 状态机)。
|
// 直达(单向终态,见 photos.service 状态机)。
|
||||||
|
// PhotoHideBatchRequest.photoIds:1..200(minItems/maxItems,执行注意「批量
|
||||||
|
// 上限 200/次」),元素非空串(minLength:1)。
|
||||||
import { HttpStatus } from '@nestjs/common';
|
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 { ErrorCodes } from '@photo/contracts';
|
||||||
import { ApiException } from '../api-exception';
|
import { ApiException } from '../api-exception';
|
||||||
|
|
||||||
@@ -17,6 +26,18 @@ export class PhotoVisibilityUpdateDto {
|
|||||||
visibility!: PhotoVisibilityTarget;
|
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 过滤参数的合法值域)。 */
|
/** 契约 PhotoVisibility 全集(管理列表 visibility 过滤参数的合法值域)。 */
|
||||||
export const PHOTO_VISIBILITIES = [
|
export const PHOTO_VISIBILITIES = [
|
||||||
'unpublished',
|
'unpublished',
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
// + audit。幂等前态(同值 PATCH / 重复 DELETE)直接回显当前记录,不产生
|
// + audit。幂等前态(同值 PATCH / 重复 DELETE)直接回显当前记录,不产生
|
||||||
// 第二次事件/审计(entityVersion 不动——副本侧按版本去重的不变量)。
|
// 第二次事件/审计(entityVersion 不动——副本侧按版本去重的不变量)。
|
||||||
import { Injectable } from '@nestjs/common';
|
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 { Prisma } from '@photo/db-cn';
|
||||||
import type { AuthUser } from '../auth/session.guard';
|
import type { AuthUser } from '../auth/session.guard';
|
||||||
import { PermissionsService } from '../albums/permissions.service';
|
import { PermissionsService } from '../albums/permissions.service';
|
||||||
@@ -45,6 +45,8 @@ import {
|
|||||||
|
|
||||||
export type PhotoRecord = components['schemas']['PhotoRecord'];
|
export type PhotoRecord = components['schemas']['PhotoRecord'];
|
||||||
export type AdminPhotoListResponse = components['schemas']['AdminPhotoList'];
|
export type AdminPhotoListResponse = components['schemas']['AdminPhotoList'];
|
||||||
|
export type BatchHideResult = components['schemas']['BatchHideResult'];
|
||||||
|
export type BatchHideItem = components['schemas']['BatchHideItem'];
|
||||||
|
|
||||||
type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
|
type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
|
||||||
|
|
||||||
@@ -195,6 +197,119 @@ export class PhotosService {
|
|||||||
return { items, nextCursor, hasMore: nextCursor !== null };
|
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<BatchHideResult> {
|
||||||
|
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<BatchHideItem> {
|
||||||
|
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 间切换。权限 → 状态机 →
|
* 调整可见性(契约 updatePhoto):hidden↔published 间切换。权限 → 状态机 →
|
||||||
* 条件更新事务(entityVersion+1 + outbox + audit)→ 回读回显。
|
* 条件更新事务(entityVersion+1 + outbox + audit)→ 回读回显。
|
||||||
|
|||||||
@@ -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<Buffer> {
|
||||||
|
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<string> {
|
||||||
|
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<string, string> };
|
||||||
|
};
|
||||||
|
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<string> {
|
||||||
|
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<string[]> {
|
||||||
|
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<NestExpressApplication>(AppCnModule, {
|
||||||
|
rawBody: true,
|
||||||
|
});
|
||||||
|
configureApp(cnApp);
|
||||||
|
await cnApp.listen(0);
|
||||||
|
cnBase = await cnApp.getUrl();
|
||||||
|
|
||||||
|
ovApp = await NestFactory.create<NestExpressApplication>(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<string> => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user