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
@@ -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<BatchHideResult> {
return this.photosService.hideBatch(userOf(req), albumId, dto.photoIds);
}
}
+23 -2
View File
@@ -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',
+116 -1
View File
@@ -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<Prisma.photosDefaultArgs>;
@@ -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<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 间切换。权限 → 状态机 →
* 条件更新事务(entityVersion+1 + outbox + audit)→ 回读回显。