Files
photo-live/services/api/src/photos/photos.service.ts
T
浅念 8e674a902e feat(albums): soft-delete albums (admin+owner, hidden & irreversible, both app entries)
契约 deleteAlbum(DELETE /albums/{albumId} → 204 幂等)+ status enum 'deleted':
- 服务端:softDelete(canView 404→canManage 403 防枚举;乐观锁 claim +
  outbox album.updated priority=high + audit album.delete);列表/详情/更新/
  成员/资产/分类/水印/照片/展示位绑定全部 deleted→404;公开端 cn
  (status!=='active')与 ov(isAlbumVisibleOv)天然屏蔽;ov 副本经
  album.updated 载荷 status 直投自动下线
- Admin:列表表格操作「删除」+ 详情页头「删除相册」(确认含不可恢复说明)
- App:操作面板「删除相册」(红字、二次确认、成功退回列表并刷新)
- 验证:API 56/56、App 397/397(analyze 0)、admin typecheck/lint/test/build 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 10:13:08 +08:00

611 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// services/api/src/photos/photos.service.ts —— 照片可见性生命周期(Task 24:
// updatePhoto 隐藏/恢复发布、deletePhoto 软删除)+ 管理照片列表(P1b Task 4:
// listAlbumPhotos,全状态可见 + uploader 脱敏内嵌 + (uploadedAt,id) 键集游标)。
// 契约权威:openapi.yaml tags [photos]。状态机(visibility,docs §5.5「对外可见性」):
// unpublished ──(worker 发布事务, Task 21)──▶ published
// published ◀──────(manager)──────▶ hidden 双向,管理者可来回
// unpublished/published/hidden ──(manager)──▶ deleted 单向终态(软删除)
// · unpublished →published:发布是处理流水线(变体+outbox photo.published)
// 的产物,PATCH 不承担 —— 400(状态机拒绝,走完上传/处理链路自然发布)。
// · deleted 出态一律 400:P1a 判定 deleted 为员工侧终态,恢复/重发布仅
// P4a 管理员物理清除流程的相邻能力(重新上传即新照片新 ID)。
// · 恢复发布(hidden→published)P1a 默认仅相册创建者/公司管理员:隐藏是
// 单人可做的轻操作,重新进入公开面是运营决策——从严默认,需求侧(§2
// 「管理自己照片」边界)最终裁决前不放宽。已登记为待定需求决策。
// 权限(§2 矩阵,A02 逐次校验):canManagePhoto = admin 全部 / 相册 owner 全部 /
// 协作者仅自己上传。404/403 映射:照片不存在或相册不可见(非成员)→ 404
// PHOTO_NOT_FOUND(防枚举);可见但无管理权(协作者对他人照片)→ 403。
// UI-01 增补:listAlbumPhotos scope=own(协作者显式查询自己上传,服务端过滤
// uploaderId,成员资格逐请求检查——沿用 canViewAlbum 逐次直查口径)+ 条目
// preview(受权限管理的 thumb/browse 短期签名 URL;变体按页一次批量查询,
// 逐张裁决:无 thumb/browse 变体、已删除 → null;隐藏 → 仅 admin/owner;
// 无权签发不改变 PhotoRecord 本身——记录可见性与图片可见性分离)。
// 每次真实状态迁移:条件更新(WHERE visibility=期望前态,并发下恰一人胜出)
// + entityVersion 自增 + 事务内 outbox 事件(P2 副本按 entityVersion 单调消费)
// + audit。幂等前态(同值 PATCH / 重复 DELETE)直接回显当前记录,不产生
// 第二次事件/审计(entityVersion 不动——副本侧按版本去重的不变量)。
// P4a Task 5 检查点:可见性真实迁移(transition/hideBatch)提交后评估用量
// 阈值告警(deleted 不计用量——软删除可能解除告警;hidden 仍计)。评估在
// 自开事务执行且失败只记日志,绝不影响可见性操作本身(见 evaluateUsageSafe)。
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ErrorCodes, type components } from '@photo/contracts';
import { evaluateUsageSafe } from '@photo/db-cn';
import type { Prisma } from '@photo/db-cn';
import type { StoragePort } from '@photo/storage';
import type { AuthUser } from '../auth/session.guard';
import { PermissionsService } from '../albums/permissions.service';
import {
albumForbidden,
albumNotFound,
validationFailed,
} from '../albums/support';
import { PrismaService } from '../prisma/prisma.service';
import {
decodeKeysetCursor,
encodeKeysetCursor,
keysetWhereDesc,
} from '../shared/cursor';
import { buildPhotoPublishedPayload } from '../sync/payloads';
import { STORAGE_PORT } from '../uploads/storage.provider';
import { signedUrlTtlSeconds } from '../uploads/support';
import type {
AlbumPhotoScope,
PhotoVisibility,
PhotoVisibilityTarget,
} from './dto';
import {
photoForbidden,
photoNotFound,
visibilityTransitionFailed,
} from './support';
export type PhotoRecord = components['schemas']['PhotoRecord'];
export type AdminPhotoListResponse = components['schemas']['AdminPhotoList'];
export type BatchHideResult = components['schemas']['BatchHideResult'];
export type BatchHideItem = components['schemas']['BatchHideItem'];
export type PhotoPreviewResponse = components['schemas']['PhotoPreview'];
type SignedUrlResponse = components['schemas']['SignedUrl'];
type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
function toPhotoRecord(row: PhotoRow): PhotoRecord {
return {
photoId: row.id,
albumId: row.albumId,
categoryId: row.categoryId,
uploaderId: row.uploaderId,
visibility: row.visibility as PhotoRecord['visibility'],
processingState: row.processingState as PhotoRecord['processingState'],
bytes: row.bytes,
width: row.width,
height: row.height,
shotAt: row.shotAt === null ? null : row.shotAt.toISOString(),
// 契约 uploadedAt 必返 string;真实链路(complete)恒写入。?? 兜底仅收窄
// 类型(直种的脏行),不构成可观察行为。
uploadedAt: (row.uploadedAt ?? new Date(0)).toISOString(),
publishedAt: row.publishedAt === null ? null : row.publishedAt.toISOString(),
likeCount: row.likeCount,
};
}
@Injectable()
export class PhotosService {
private readonly logger = new Logger(PhotosService.name);
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
) {}
/**
* 管理照片列表(P1b Task 4,契约 listAlbumPhotos):区别于公开列表——全部
* 可见性状态可见(unpublished/published/hidden/deleted),条目内嵌 uploader
* 脱敏信息(account/displayName,无凭据字段)。权限顺序防枚举(同 members):
* canViewAlbum=false → 404 ALBUM_NOT_FOUND;可见但 canManageAlbum=false
* (协作者)→ 403 AUTH_FORBIDDEN;admin 对幽灵 id 权限恒真,空相册兜底 404。
* 排序 (uploadedAt,id) DESC 键集游标(shared/cursor)。
*
* scope(UI-01):缺省/'all'=上述既有行为(仅 owner/admin,协作者 403 不
* 变);'own'=显式查询自己上传——任何有效成员可用(canViewAlbum 逐请求
* 直查成员行:被移除后下一次请求即刻 404),服务端追加 uploaderId 过滤;
* admin/owner 同样可用 own(过滤为自己上传的历史)。preview(UI-01)逐张
* 裁决见 previewOf。
*
* 未完成上传(pending_upload、uploadedAt NULL)不入列表:uploadedAt 既是契约
* 必返字段又是键集排序键——NULL 行既无法以契约形状返回(toPhotoRecord 的
* new Date(0) 兜底会伪造 1970 时间轴),也无法参与键集谓词(SQL 对 NULL 的
* 比较恒 UNKNOWN,跨页会被静默跳过)。进行中的会话由 uploads 历史端点表达。
*/
async listAlbumPhotos(
user: AuthUser,
albumId: string,
query: {
visibility?: PhotoVisibility;
categoryId?: string;
scope?: AlbumPhotoScope;
cursor?: string;
limit: number;
},
): Promise<AdminPhotoListResponse> {
const scope: AlbumPhotoScope = query.scope ?? 'all';
if (!(await this.permissions.canViewAlbum(user, albumId))) {
throw albumNotFound();
}
// manager=admin/owner:全量可见 + 隐藏照片可签发预览。scope=all(缺省)
// 仅 manager(协作者 403,既有行为不变);scope=own 任何有效成员可用。
const manager = await this.permissions.canManageAlbum(user, albumId);
if (scope === 'all' && !manager) {
throw albumForbidden('仅创建者或管理员可访问管理列表');
}
// admin 对不存在 id 的 canView/canManage 恒真(非 admin 的可见成员行本身
// 蕴含相册存在),空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。
const albumExists = await this.prisma.albums.findUnique({
where: { id: albumId },
select: { id: true, status: true },
});
if (albumExists === null || albumExists.status === 'deleted') {
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
throw albumNotFound();
}
// categoryId:须为本相册分类;幽灵/跨册同码 400(同公开列表语义,不区分
// 两种失败,防探测)。
if (query.categoryId !== undefined) {
const category = await this.prisma.categories.findUnique({
where: { id: query.categoryId },
select: { albumId: true },
});
if (category === null || category.albumId !== albumId) {
throw validationFailed('categoryId 不属于该相册');
}
}
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
if (query.cursor !== undefined && query.cursor !== '') {
decoded = decodeKeysetCursor(query.cursor);
if (decoded === null) {
throw validationFailed('cursor 不合法');
}
}
const where: Prisma.photosWhereInput = {
albumId,
uploadedAt: { not: null },
...(query.visibility !== undefined
? { visibility: query.visibility }
: {}),
...(query.categoryId !== undefined
? { categoryId: query.categoryId }
: {}),
// scope=own:服务端过滤(客户端传 uploaderId 不可信),与 canViewAlbum
// 同请求内裁决——权限撤销后下一次请求即刻失效。
...(scope === 'own' ? { uploaderId: user.id } : {}),
};
if (decoded !== null) {
Object.assign(where, keysetWhereDesc('uploadedAt', 'id', decoded));
}
const rows = await this.prisma.photos.findMany({
where,
orderBy: [{ uploadedAt: 'desc' }, { id: 'desc' }],
take: query.limit + 1, // 多取一条探测 hasMore(同相册/成员列表)
});
const hasMore = rows.length > query.limit;
const page = hasMore ? rows.slice(0, query.limit) : rows;
// users 无 relation(扁平外键):二次查询应用层组装 uploader 脱敏信息。
const users =
page.length === 0
? []
: await this.prisma.users.findMany({
where: { id: { in: page.map((p) => p.uploaderId) } },
select: { id: true, account: true, displayName: true },
});
const userById = new Map(users.map((u) => [u.id, u]));
// UI-01 preview:本页变体一次批量查询(photoId IN 页内 id 集,杜绝逐张
// N+1);裁决在 previewOf(presign 为 SigV4 本地计算,无网络/查询放大)。
const variantRows =
page.length === 0
? []
: await this.prisma.photo_variants.findMany({
where: { photoId: { in: page.map((p) => p.id) } },
select: { photoId: true, variant: true, objectKey: true },
});
const variantKeysByPhoto = new Map<
string,
{ thumb?: string; browse?: string }
>();
for (const row of variantRows) {
const bucket = variantKeysByPhoto.get(row.photoId) ?? {};
if (row.variant === 'thumb') {
bucket.thumb = row.objectKey;
} else if (row.variant === 'browse') {
bucket.browse = row.objectKey;
}
variantKeysByPhoto.set(row.photoId, bucket);
}
const items: AdminPhotoListResponse['items'] = [];
for (const row of page) {
const uploader = userById.get(row.uploaderId);
// 用户行缺失(P1a 无用户删除,仅可能来自手工改库):跳过而非 5xx(同
// 成员列表先例,单条脏数据不拖垮整页)。
if (uploader === undefined) {
continue;
}
items.push({
...toPhotoRecord(row),
uploader: {
userId: uploader.id,
account: uploader.account,
displayName: uploader.displayName,
},
preview: await this.previewOf(
row,
variantKeysByPhoto.get(row.id),
manager,
),
});
}
const last = page.at(-1);
const nextCursor =
hasMore && last !== undefined
? encodeKeysetCursor({
// where 已排除 NULL:断言收窄供类型系统(键集取页末行 DB 位置)。
sort: (last.uploadedAt ?? new Date(0)).toISOString(),
id: last.id,
})
: null;
return { items, nextCursor, hasMore: nextCursor !== null };
}
/**
* UI-01 受权限管理的预览裁决:thumb 与 browse 变体齐备且未删除才签发;
* 隐藏照片仅 manager(admin/owner)签发——协作者即便自己上传(scope=own
* 可见记录)也不获得隐藏图 URL(「隐藏是运营动作」,与恢复发布同一权限
* 口径)。无变体(处理未完成/失败)与已删除返回 null,PhotoRecord 本身
* 不受影响(记录可见性与图片可见性分离)。签名短期有效,过期经列表重取。
*/
private async previewOf(
row: PhotoRow,
keys: { thumb?: string; browse?: string } | undefined,
manager: boolean,
): Promise<PhotoPreviewResponse | null> {
if (keys?.thumb === undefined || keys?.browse === undefined) {
return null;
}
if (row.visibility === 'deleted') {
return null;
}
if (row.visibility === 'hidden' && !manager) {
return null;
}
return {
thumb: await this.presign(keys.thumb),
browse: await this.presign(keys.browse),
};
}
/** 现签短期 GET 凭证(TTL 惰性读取;SigV4 本地计算无网络副作用)。 */
private async presign(objectKey: string): Promise<SignedUrlResponse> {
const ttl = signedUrlTtlSeconds();
const url = await this.storage.presignGet(objectKey, ttl);
return { url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() };
}
/**
* 批量隐藏(契约 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, status: true },
});
if (albumExists === null || albumExists.status === 'deleted') {
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
throw albumNotFound();
}
const results: BatchHideItem[] = [];
for (const photoId of photoIds) {
results.push(await this.hideOne(user, albumId, photoId));
}
// P4a Task 5 检查点:批量后评估一次(逐项不重复评估——隐藏不改存储,
// 检查点统一挂在可见性动作完成后;失败只记日志)。
await evaluateUsageSafe(this.prisma, (err) =>
this.logger.warn(
{
err: err instanceof Error ? err.message : String(err),
albumId,
},
'用量告警评估失败(批量隐藏后)',
),
);
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)→ 回读回显。
*/
async updateVisibility(
user: AuthUser,
photoId: string,
target: PhotoVisibilityTarget,
): Promise<PhotoRecord> {
const photo = await this.locateManagedPhoto(user, photoId);
// 幂等前态:目标即当前态——无迁移无事件,直接回显(entityVersion 不动)。
if (photo.visibility === target) {
return toPhotoRecord(photo);
}
if (target === 'hidden') {
// 仅 published → hidden:隐藏语义是「从公开面撤下」,unpublished 尚未
// 公开、deleted 已终态,均不可达。
if (photo.visibility !== 'published') {
throw visibilityTransitionFailed(photo.visibility, target);
}
return this.transition(user, photo, 'hidden');
}
// target === 'published':仅 hidden → published(恢复发布)。unpublished 的
// 发布属 worker 处理链路;deleted 终态。
if (photo.visibility !== 'hidden') {
throw visibilityTransitionFailed(photo.visibility, target);
}
// 恢复发布 P1a 从严:仅相册创建者/公司管理员(待定需求决策,文件头注)。
if (!(await this.permissions.canManageAlbum(user, photo.albumId))) {
throw photoForbidden('仅创建者或管理员可恢复发布');
}
return this.transition(user, photo, 'published');
}
/**
* 软删除(契约 deletePhoto):任意非 deleted 前态单向进入 deleted。行/对象/
* 变体全部保留(物理清除属 P4a 运维流程);公开端点即刻不可见(读侧只认
* published)。重复删除幂等 204,不再发事件。
*/
async remove(user: AuthUser, photoId: string): Promise<void> {
const photo = await this.locateManagedPhoto(user, photoId);
if (photo.visibility === 'deleted') {
return;
}
await this.transition(user, photo, 'deleted');
}
/**
* 照片定位 + 权限映射:不存在 → 404;相册不可见(非成员)→ 同码 404(防
* id 枚举);可见但无管理权 → 403。返回照片行(状态机裁决用)。
*/
private async locateManagedPhoto(
user: AuthUser,
photoId: string,
): Promise<PhotoRow> {
const photo = await this.prisma.photos.findUnique({
where: { id: photoId },
});
if (photo === null) {
throw photoNotFound();
}
if (!(await this.permissions.canViewAlbum(user, photo.albumId))) {
throw photoNotFound();
}
if (!(await this.permissions.canManagePhoto(user, photoId))) {
throw photoForbidden();
}
return photo;
}
/**
* 状态迁移事务:条件更新 WHERE visibility=前态(并发下败方 count=0 → 重读
* 后按「已达目标态」幂等回显或 400 状态冲突)+ outbox(photo.hidden/
* photo.published/photo.deleted,entityVersion=迁移后版本)+ audit
* (photo.hide/photo.publish/photo.delete)。恢复发布的 outbox 载荷复用契约
* PhotoPublishedPayload 全集(P2 副本一张照片只需一种 published 载荷形状);
* hidden/deleted 载荷 P1a 无契约消费者,落最小事实 {photoId,albumId,
* visibility,entityVersion}。publishedAt 不随恢复发布改写——隐藏是即时
* 撤下,时间轴(列表排序依据)保持原发布时刻。
*/
private async transition(
user: AuthUser,
photo: PhotoRow,
target: 'hidden' | 'published' | 'deleted',
): Promise<PhotoRecord> {
const nextVersion = photo.entityVersion + 1;
let updatedRow: PhotoRow | null = null;
await this.prisma.$transaction(async (tx) => {
const race = await tx.photos.updateMany({
where: { id: photo.id, visibility: photo.visibility },
data: { visibility: target, entityVersion: { increment: 1 } },
});
if (race.count === 0) {
// 并发败方:不猜并发结果,交给外层重读裁决。
return;
}
if (target === 'published') {
const variants = await tx.photo_variants.findMany({
where: { photoId: photo.id },
});
// 载荷构建与 bootstrap/reconcile 快照共用同一出口(sync/payloads.ts),
// 保证两类事件逐字节同形;post-publish 不变量破坏时 5xx 回滚整个迁移。
const payload = buildPhotoPublishedPayload(photo, variants, nextVersion);
await tx.outbox_events.create({
data: {
type: 'photo.published',
albumId: photo.albumId,
photoId: photo.id,
entityVersion: nextVersion,
// P2 Task 3:恢复发布为常规优先级(显式声明,不依赖列默认)。
priority: 'normal',
// 接口类型无隐式索引签名,Prisma InputJsonValue 需显式收窄
//(同 worker publish.service 先例)。
payload: payload as unknown as Prisma.InputJsonValue,
},
});
} else {
await tx.outbox_events.create({
data: {
type: target === 'hidden' ? 'photo.hidden' : 'photo.deleted',
albumId: photo.albumId,
photoId: photo.id,
entityVersion: nextVersion,
// P2 Task 3:隐藏/删除是「即刻从公开面撤下」的墓碑类事件——高优先级,
// 发送器批次内优先投递(worker 首次发布的 photo.published 走列默认
// normal,无需改动)。
priority: 'high',
payload: {
photoId: photo.id,
albumId: photo.albumId,
visibility: target,
entityVersion: nextVersion,
},
},
});
}
await tx.audit_logs.create({
data: {
actorId: user.id,
action:
target === 'hidden'
? 'photo.hide'
: target === 'published'
? 'photo.publish'
: 'photo.delete',
resourceId: photo.id,
metadata: { from: photo.visibility, to: target },
},
});
updatedRow = {
...photo,
visibility: target,
entityVersion: nextVersion,
};
});
if (updatedRow === null) {
// 条件更新败北:重读当前态——已达目标态按幂等回显,否则 400 状态冲突
// (客户端可凭 PhotoRecord.visibility 重新裁决)。
const current = await this.prisma.photos.findUnique({
where: { id: photo.id },
});
if (current !== null && current.visibility === target) {
return toPhotoRecord(current);
}
throw visibilityTransitionFailed(
current?.visibility ?? photo.visibility,
target,
);
}
// P4a Task 5 检查点:真实迁移提交后评估(deleted 出存储口径、恢复发布回
// 计数口径都可能翻转告警状态);上方败方/幂等路径无状态变化,不评估。
await evaluateUsageSafe(this.prisma, (err) =>
this.logger.warn(
{
err: err instanceof Error ? err.message : String(err),
photoId: photo.id,
target,
},
'用量告警评估失败(可见性迁移后)',
),
);
return toPhotoRecord(updatedRow);
}
}