8e674a902e
契约 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>
611 lines
26 KiB
TypeScript
611 lines
26 KiB
TypeScript
// 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);
|
||
}
|
||
}
|