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
|
||||
// 补全,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 建链就绪,展示消费随后续阶段。 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)→ 回读回显。
|
||||
|
||||
Reference in New Issue
Block a user