// 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; 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 { 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 = 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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); } }