// services/api/src/albums/members.service.ts —— 相册成员管理(Task 17)。 // 契约权威:openapi.yaml tags [members](addAlbumMember/listAlbumMembers/ // removeAlbumMember)。要点: // · 权限顺序与 albums 一致防枚举:canViewAlbum=false → 404;canManageAlbum // =false → 403(协作者可见不可管);admin 视角再由空行兜底 404。 // · 成员行(album_members)是唯一权限来源(Task 14 结论):移除=删行, // 被移除者下一次请求即刻失去可见性(A02,PermissionsService 无缓存)。 // · owner 行不可经本端点产生/删除:邀请 owner 目标 → 400(仅 collaborator // 可加,owner 转移属 P4a 管理员操作);移除 owner → 403。 // · 被邀请账号必须已开通(credentialStatus=active 且 sourceStatus≠disabled): // 邀请属管理动作,明示目标账号有效性不构成成员枚举泄露。 // · 移除幂等:目标成员行不存在仍 204(契约语义);仅真实删除落审计 // (幂等重删不重复记 album.member.remove)。 // · 列表:(addedAt,id) DESC 键集游标(shared/cursor,与相册列表同构); // users 无 relation(扁平外键),二次查询应用层组装 Member。 import { HttpStatus, Injectable } from '@nestjs/common'; import { ErrorCodes } from '@photo/contracts'; import type { components } from '@photo/contracts'; import type { Prisma } from '@photo/db-cn'; import { ApiException } from '../api-exception'; import type { AuthUser } from '../auth/session.guard'; import { PrismaService } from '../prisma/prisma.service'; import { decodeKeysetCursor, encodeKeysetCursor, keysetWhereDesc, } from '../shared/cursor'; import type { MemberAddRequestDto } from './dto'; import { PermissionsService } from './permissions.service'; import { albumForbidden, albumNotFound, isUniqueViolationOn, validationFailed, } from './support'; export type MemberResponse = components['schemas']['Member']; export type MemberListResponse = components['schemas']['MemberList']; @Injectable() export class MembersService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionsService, ) {} /** 添加协作者(契约 addAlbumMember):201 返回 Member。 */ async add( user: AuthUser, albumId: string, dto: MemberAddRequestDto, ): Promise { const { ownerId } = await this.assertManageable(user, albumId); // 目标账号须存在且已开通(pending 凭据/已停用均不可加)。 const target = await this.prisma.users.findUnique({ where: { id: dto.userId }, select: { account: true, displayName: true, credentialStatus: true, sourceStatus: true, }, }); if ( target === null || target.credentialStatus !== 'active' || target.sourceStatus === 'disabled' ) { throw validationFailed('被邀请的用户不存在或不可用(须为已开通账号)'); } // owner 不能经本端点被(重)添加:防重复 owner 行与角色歧义。 if (dto.userId === ownerId) { throw validationFailed('owner 由创建流程产生,不能经由本端点添加'); } const role = dto.role ?? 'collaborator'; try { await this.prisma.$transaction(async (tx) => { await tx.album_members.create({ data: { albumId, userId: dto.userId, role, status: 'active' }, }); await tx.audit_logs.create({ data: { actorId: user.id, action: 'album.member.add', resourceId: albumId, metadata: { userId: dto.userId, role }, }, }); }); } catch (err) { // 并发窗口内重复邀请:唯一索引 @@unique([albumId,userId]) 兜底成契约 409。 if (isUniqueViolationOn(err, 'albumId_userId')) { throw new ApiException( ErrorCodes.MEMBER_CONFLICT, '该用户已是相册成员', HttpStatus.CONFLICT, ); } throw err; } return { userId: dto.userId, account: target.account, displayName: target.displayName, role, status: 'active', }; } /** 成员列表(契约 listAlbumMembers):任何成员/admin 可见;(addedAt,id) DESC。 */ async list( user: AuthUser, albumId: string, cursor: string | undefined, limit: number, ): Promise { if (!(await this.permissions.canViewAlbum(user, albumId))) { throw albumNotFound(); } // admin 对不存在的 id 权限恒真(非 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(); } let decoded: ReturnType = null; if (cursor !== undefined && cursor !== '') { decoded = decodeKeysetCursor(cursor); if (decoded === null) { throw validationFailed('cursor 不合法'); } } const where: Prisma.album_membersWhereInput = { albumId }; if (decoded !== null) { Object.assign(where, keysetWhereDesc('addedAt', 'id', decoded)); } const rows = await this.prisma.album_members.findMany({ where, orderBy: [{ addedAt: 'desc' }, { id: 'desc' }], take: limit + 1, // 多取一条探测 hasMore(同相册列表,免 count 查询) }); const hasMore = rows.length > limit; const page = hasMore ? rows.slice(0, limit) : rows; const users = await this.prisma.users.findMany({ where: { id: { in: page.map((m) => m.userId) } }, select: { id: true, account: true, displayName: true }, }); const byId = new Map(users.map((u) => [u.id, u])); const items = page.flatMap((row) => { const u = byId.get(row.userId); // 用户行缺失(P1a 无用户删除,仅可能来自手工改库):跳过而非报错。 return u === undefined ? [] : [ { userId: row.userId, account: u.account, displayName: u.displayName, role: row.role as MemberResponse['role'], status: row.status as MemberResponse['status'], }, ]; }); const last = page.at(-1); const nextCursor = hasMore && last !== undefined ? encodeKeysetCursor({ sort: last.addedAt.toISOString(), id: last.id, }) : null; return { items, nextCursor, hasMore: nextCursor !== null }; } /** 移除成员(契约 removeAlbumMember):幂等 204;owner 不可移除(403)。 */ async remove( user: AuthUser, albumId: string, targetUserId: string, ): Promise { const { ownerId } = await this.assertManageable(user, albumId); if (targetUserId === ownerId) { throw albumForbidden('不能移除相册创建者(owner 转移属管理员操作)'); } // Task 14:成员行是唯一权限来源,移除=删行(无软删除状态)。 await this.prisma.$transaction(async (tx) => { const removed = await tx.album_members.deleteMany({ where: { albumId, userId: targetUserId }, }); if (removed.count > 0) { // 幂等重删(count=0)不重复落审计。 await tx.audit_logs.create({ data: { actorId: user.id, action: 'album.member.remove', resourceId: albumId, metadata: { userId: targetUserId }, }, }); } }); } /** 404/403 顺序映射 + admin 空相册兜底(增删均为管理动作,共用入口)。 */ private async assertManageable( user: AuthUser, albumId: string, ): Promise<{ ownerId: string }> { if (!(await this.permissions.canViewAlbum(user, albumId))) { throw albumNotFound(); } if (!(await this.permissions.canManageAlbum(user, albumId))) { throw albumForbidden('仅创建者或管理员可管理成员'); } const album = await this.prisma.albums.findUnique({ where: { id: albumId }, select: { ownerId: true }, }); if (album === null) { // admin 对不存在的 id 权限恒真,由空值兜底同码 404。 throw albumNotFound(); } return album; } }