// 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。 // 每次真实状态迁移:条件更新(WHERE visibility=期望前态,并发下恰一人胜出) // + entityVersion 自增 + 事务内 outbox 事件(P2 副本按 entityVersion 单调消费) // + audit。幂等前态(同值 PATCH / 重复 DELETE)直接回显当前记录,不产生 // 第二次事件/审计(entityVersion 不动——副本侧按版本去重的不变量)。 import { Injectable } from '@nestjs/common'; import 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'; 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 type { PhotoVisibility, PhotoVisibilityTarget } from './dto'; import { photoForbidden, photoNotFound, visibilityTransitionFailed, } from './support'; export type PhotoRecord = components['schemas']['PhotoRecord']; export type AdminPhotoListResponse = components['schemas']['AdminPhotoList']; 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 { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, ) {} /** * 管理照片列表(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)。 * * 未完成上传(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; cursor?: string; limit: number; }, ): Promise { if (!(await this.permissions.canViewAlbum(user, albumId))) { throw albumNotFound(); } if (!(await this.permissions.canManageAlbum(user, albumId))) { throw albumForbidden('仅创建者或管理员可访问管理列表'); } // admin 对不存在 id 的 canView/canManage 恒真(非 admin 的可见成员行本身 // 蕴含相册存在),空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。 const albumExists = await this.prisma.albums.findUnique({ where: { id: albumId }, select: { id: true }, }); if (albumExists === null) { 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 } : {}), }; 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])); const items = page.flatMap((row) => { const uploader = userById.get(row.uploaderId); // 用户行缺失(P1a 无用户删除,仅可能来自手工改库):跳过而非 5xx(同 // 成员列表先例,单条脏数据不拖垮整页)。 return uploader === undefined ? [] : [ { ...toPhotoRecord(row), uploader: { userId: uploader.id, account: uploader.account, displayName: uploader.displayName, }, }, ]; }); 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 }; } /** * 调整可见性(契约 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, ); } return toPhotoRecord(updatedRow); } }