/** * 成员组(MemberGroup)管理服务(ADR-0028)。 * * 语义锚定: * - 全局主体:MemberGroup 无 organizationId,不做租户 scope;审计行挂 silo org * (deps.organizationId)—— MemberGroup 无 orgId,审计沿用文件库 sink(决策4)。 * - 权限门禁:创建/删除/成员增删仅网站管理员(silo org OWNER/ADMIN); * 搜索(授权选择器)不限管理员 —— 选组授权是 Manage 持有者的能力(决策2)。 * - 软删除:archivedAt 打标;删组级联软删整棵子树(闭包 ancestorId=G); * 闭包/成员行保留,list/解析按 archivedAt 过滤(决策4)。 * - 闭包维护:仅 create —— 插 (G,G,0),再对 parent P 插 * (a.ancestorId, G, a.depth+1) for a in closure where descendantId=P。 * v1 不支持 reparent(决策5)。 * * 与 hub Team 不同:成员是全局用户,不要求 org membership;按 userId 或 * User.feishuOpenId(全局 @unique)解析。 */ import type { PrismaClient, Prisma } from "@prisma/client"; import { FileLibError } from "./model.js"; import type { FileLibActor } from "./treeService.js"; import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; export interface MemberGroupServiceDeps { readonly prisma: PrismaClient; /** silo org id —— 仅用于审计归属(MemberGroup 全局无 orgId,决策4)。 */ readonly organizationId: string; } export interface MemberGroupDto { readonly id: string; readonly parentId: string | null; readonly name: string; readonly description: string | null; /** 到根的边数(根 = 0);由闭包行数推导。 */ readonly depth: number; readonly memberCount: number; /** 软删标记(决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。 */ readonly archivedAt: Date | null; } export interface MemberGroupMemberDto { readonly userId: string; readonly displayName: string; readonly feishuOpenId: string; readonly avatarUrl: string | null; /** 加入本组时间(membership.createdAt),用于成员表排序/展示。 */ readonly joinedAt: Date; } /** 成员选择器候选(加成员弹窗搜索用)。 */ export interface UserSearchResult { readonly userId: string; readonly displayName: string; readonly feishuOpenId: string; readonly avatarUrl: string | null; } export interface MemberGroupSearchResult { readonly id: string; readonly name: string; /** 祖先链(根在前,自身在末),用 " / " 连接;无祖先时即自身名。 */ readonly breadcrumb: string; } export interface CreateMemberGroupInput { readonly name: string; readonly description?: string | undefined; readonly parentId?: string | null | undefined; } export interface AddMemberInput { readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined; } /** 改名/改描述(决策6)。字段缺省 = 不动;description 传空串 = 清空。 */ export interface UpdateMemberGroupInput { readonly name?: string | undefined; readonly description?: string | undefined; } type Tx = Prisma.TransactionClient; /* ---------------------------------------------------------------- 内部工具 */ /** 管理门禁:非网站管理员一律 403(决策2)。 */ function requireAdmin(actor: FileLibActor): void { if (!actor.isWebsiteAdmin) { throw new FileLibError(403, "forbidden", "group management requires website administrator"); } } /** 组名校验(Group 域与节点域分开:轻量 trim/非空/长度,不套用节点命名规则)。 */ function normalizeGroupName(raw: string): string { const name = raw.trim(); if (name === "") throw new FileLibError(400, "invalid_request", "group name must not be empty"); if (name.length > 100) throw new FileLibError(400, "invalid_request", "group name too long (max 100)"); return name; } async function requireActiveGroup( client: PrismaClient | Tx, groupId: string, ): Promise<{ readonly id: string; readonly name: string }> { const group = await client.memberGroup.findFirst({ where: { id: groupId, archivedAt: null }, select: { id: true, name: true }, }); if (group === null) throw new FileLibError(404, "group_not_found", "group not found"); return group; } /** 全局用户解析:按 userId,或 User.feishuOpenId(全局 @unique)。不要求 org 成员。 */ async function resolveUser( tx: Tx, input: AddMemberInput, ): Promise<{ readonly id: string; readonly displayName: string; readonly feishuOpenId: string; readonly avatarUrl: string | null; }> { const select = { id: true, displayName: true, feishuOpenId: true, avatarUrl: true } as const; if (input.userId !== undefined && input.userId !== "") { const user = await tx.user.findUnique({ where: { id: input.userId }, select }); if (user === null) throw new FileLibError(404, "user_not_found", `user not found: ${input.userId}`); return user; } if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") { const user = await tx.user.findUnique({ where: { feishuOpenId: input.feishuOpenId }, select, }); if (user === null) throw new FileLibError(404, "user_not_found", `user not found: ${input.feishuOpenId}`); return user; } throw new FileLibError(400, "invalid_request", "userId or feishuOpenId is required"); } /* ---------------------------------------------------------------- 公共操作 */ /** * 创建成员组(建根 / 建子)。仅网站管理员。事务内维护闭包。 * parentId 给定时校验其活跃存在;闭包:插自身 depth0 + 继承 parent 的祖先。 */ export async function createMemberGroup( deps: MemberGroupServiceDeps, actor: FileLibActor, input: CreateMemberGroupInput, ): Promise { requireAdmin(actor); const name = normalizeGroupName(input.name); const description = input.description?.trim() || null; const parentId = input.parentId ?? null; return deps.prisma.$transaction(async (tx) => { let parentClosure: { ancestorId: string; depth: number }[] = []; if (parentId !== null) { const parent = await tx.memberGroup.findFirst({ where: { id: parentId, archivedAt: null }, select: { id: true }, }); if (parent === null) throw new FileLibError(404, "group_not_found", "parent group not found"); parentClosure = await tx.memberGroupClosure.findMany({ where: { descendantId: parentId }, select: { ancestorId: true, depth: true }, }); } const group = await tx.memberGroup.create({ data: { name, parentId, ...(description !== null ? { description } : {}) }, select: { id: true, parentId: true, name: true, description: true }, }); // 闭包维护:自身 depth0,再继承 parent 的每个祖先(depth+1)。 await tx.memberGroupClosure.create({ data: { ancestorId: group.id, descendantId: group.id, depth: 0 }, }); if (parentClosure.length > 0) { await tx.memberGroupClosure.createMany({ data: parentClosure.map((a) => ({ ancestorId: a.ancestorId, descendantId: group.id, depth: a.depth + 1, })), }); } // parent 的闭包行数 = parent.depth + 1 = 新组 depth(闭包不变量)。 const depth = parentClosure.length; await writeFileLibAudit(tx, { action: FILE_LIB_AUDIT_ACTIONS.groupCreate, actorUserId: actor.userId, organizationId: deps.organizationId, objectType: "group", objectId: group.id, objectPath: group.id, detail: { name, parentId }, }); return { id: group.id, parentId: group.parentId, name: group.name, description: group.description, depth, memberCount: 0, archivedAt: null, }; }); } /** * 改名 / 改描述(决策6)。仅网站管理员。**不动 parentId** —— reparent 仍属 v1 * 范围外(决策5),闭包无需维护。字段缺省即不动;description 传 "" 清空。 */ export async function updateMemberGroup( deps: MemberGroupServiceDeps, actor: FileLibActor, groupId: string, input: UpdateMemberGroupInput, ): Promise { requireAdmin(actor); if (input.name === undefined && input.description === undefined) { throw new FileLibError(400, "invalid_request", "name or description is required"); } const name = input.name === undefined ? undefined : normalizeGroupName(input.name); return deps.prisma.$transaction(async (tx) => { await requireActiveGroup(tx, groupId); const group = await tx.memberGroup.update({ where: { id: groupId }, data: { ...(name !== undefined ? { name } : {}), ...(input.description !== undefined ? { description: input.description.trim() || null } : {}), }, select: { id: true, parentId: true, name: true, description: true }, }); // depth 由闭包行数推导(与 listMemberGroups 同一不变量);update 不改闭包。 const closureCount = await tx.memberGroupClosure.count({ where: { descendantId: groupId } }); const memberCount = await tx.memberGroupMembership.count({ where: { groupId, revokedAt: null }, }); await writeFileLibAudit(tx, { action: FILE_LIB_AUDIT_ACTIONS.groupUpdate, actorUserId: actor.userId, organizationId: deps.organizationId, objectType: "group", objectId: group.id, objectPath: group.id, detail: { ...(name !== undefined ? { name } : {}), ...(input.description !== undefined ? { description: group.description } : {}), }, }); return { id: group.id, parentId: group.parentId, name: group.name, description: group.description, depth: closureCount - 1, memberCount, archivedAt: null, // requireActiveGroup 已保证是活跃组 }; }); } /** * 软删除成员组:级联软删整棵子树(闭包 ancestorId=G 的全部活跃 descendant)。 * 闭包/成员行保留;list/解析按 archivedAt 过滤,整支立即停止贡献权限。 */ export async function deleteMemberGroup( deps: MemberGroupServiceDeps, actor: FileLibActor, groupId: string, ): Promise<{ readonly archivedCount: number }> { requireAdmin(actor); return deps.prisma.$transaction(async (tx) => { const group = await requireActiveGroup(tx, groupId); const subtree = await tx.memberGroupClosure.findMany({ where: { ancestorId: groupId }, select: { descendantId: true }, }); const ids = subtree.map((r) => r.descendantId); const now = new Date(); const result = await tx.memberGroup.updateMany({ where: { id: { in: ids }, archivedAt: null }, data: { archivedAt: now }, }); await writeFileLibAudit(tx, { action: FILE_LIB_AUDIT_ACTIONS.groupDelete, actorUserId: actor.userId, organizationId: deps.organizationId, objectType: "group", objectId: group.id, objectPath: group.id, detail: { name: group.name, archivedCount: result.count }, }); return { archivedCount: result.count }; }); } /** * 恢复(取消归档)。仅网站管理员。**与删除不对称**(决策7): * - 删除级联整棵子树;恢复只恢复「该组 + 其全部已归档祖先」,**不动子树**。 * - 恢复祖先链是必须的:活跃组的祖先必须活跃,否则该组在树上无路径、 * depth 推导(闭包行数)与"祖先必活跃"的前提脱节。 * - 子树保持归档、仍可见(带标记),由管理员逐个决定是否恢复 —— 避免一次 * 恢复意外把整支历史组全部重新授权。 * 恢复即刻恢复该组贡献的权限(实时解析,不缓存)。 */ export async function restoreMemberGroup( deps: MemberGroupServiceDeps, actor: FileLibActor, groupId: string, ): Promise<{ readonly restoredCount: number }> { requireAdmin(actor); return deps.prisma.$transaction(async (tx) => { const group = await tx.memberGroup.findUnique({ where: { id: groupId }, select: { id: true, name: true, archivedAt: true }, }); if (group === null) throw new FileLibError(404, "group_not_found", "group not found"); if (group.archivedAt === null) { throw new FileLibError(409, "not_archived", "group is not archived"); } // 自身 + 祖先(闭包 descendantId=G 含 depth0 自身),只挑已归档的解标。 const chain = await tx.memberGroupClosure.findMany({ where: { descendantId: groupId }, select: { ancestorId: true }, }); const ids = chain.map((r) => r.ancestorId); const result = await tx.memberGroup.updateMany({ where: { id: { in: ids }, archivedAt: { not: null } }, data: { archivedAt: null }, }); await writeFileLibAudit(tx, { action: FILE_LIB_AUDIT_ACTIONS.groupRestore, actorUserId: actor.userId, organizationId: deps.organizationId, objectType: "group", objectId: group.id, objectPath: group.id, detail: { name: group.name, restoredCount: result.count }, }); return { restoredCount: result.count }; }); } /** * 组扁平列表(前端自行按 parentId/depth 拼树);仅网站管理员。 * includeArchived=true 时连已归档组一并返回(带 archivedAt 标记),供后台展示/恢复; * 默认只返回活跃组 —— 权限相关的调用方一律走默认。 */ export async function listMemberGroups( deps: MemberGroupServiceDeps, actor: FileLibActor, includeArchived = false, ): Promise { requireAdmin(actor); const groups = await deps.prisma.memberGroup.findMany({ where: includeArchived ? {} : { archivedAt: null }, orderBy: { name: "asc" }, select: { id: true, parentId: true, name: true, description: true, archivedAt: true }, }); if (groups.length === 0) return []; const ids = groups.map((g) => g.id); // depth:每个组的闭包行数(自身 + 祖先)- 1。级联软删保证活跃组的祖先必活跃。 const closure = await deps.prisma.memberGroupClosure.findMany({ where: { descendantId: { in: ids } }, select: { descendantId: true }, }); const closureCount = new Map(); for (const row of closure) { closureCount.set(row.descendantId, (closureCount.get(row.descendantId) ?? 0) + 1); } const counts = await deps.prisma.memberGroupMembership.groupBy({ by: ["groupId"], where: { groupId: { in: ids }, revokedAt: null }, _count: { _all: true }, }); const countByGroup = new Map(counts.map((c) => [c.groupId, c._count._all])); return groups.map((g) => ({ id: g.id, parentId: g.parentId, name: g.name, description: g.description, depth: (closureCount.get(g.id) ?? 1) - 1, memberCount: countByGroup.get(g.id) ?? 0, archivedAt: g.archivedAt, })); } /** * 组成员列表(仅网站管理员)。**已归档组也可读**(决策7):软删是打标,成员行仍在, * 后台需要看得见「这个组曾经有谁」。写操作(add/remove)仍要求活跃组 —— 可读不可改。 */ export async function listMembers( deps: MemberGroupServiceDeps, actor: FileLibActor, groupId: string, ): Promise { requireAdmin(actor); const exists = await deps.prisma.memberGroup.findUnique({ where: { id: groupId }, select: { id: true }, }); if (exists === null) throw new FileLibError(404, "group_not_found", "group not found"); const rows = await deps.prisma.memberGroupMembership.findMany({ where: { groupId, revokedAt: null }, select: { createdAt: true, user: { select: { id: true, displayName: true, feishuOpenId: true, avatarUrl: true } }, }, orderBy: { createdAt: "asc" }, }); return rows.map((r) => ({ userId: r.user.id, displayName: r.user.displayName, feishuOpenId: r.user.feishuOpenId, avatarUrl: r.user.avatarUrl, joinedAt: r.createdAt, })); } /** 加成员(userId 或 feishuOpenId 解析);已是活跃成员 → 409。仅网站管理员。 */ export async function addMember( deps: MemberGroupServiceDeps, actor: FileLibActor, groupId: string, input: AddMemberInput, ): Promise { requireAdmin(actor); return deps.prisma.$transaction(async (tx) => { const group = await requireActiveGroup(tx, groupId); const user = await resolveUser(tx, input); const existing = await tx.memberGroupMembership.findFirst({ where: { groupId: group.id, userId: user.id, revokedAt: null }, select: { id: true }, }); if (existing !== null) { throw new FileLibError(409, "already_member", "user is already a member of this group"); } const created = await tx.memberGroupMembership.create({ data: { groupId: group.id, userId: user.id }, select: { createdAt: true }, }); await writeFileLibAudit(tx, { action: FILE_LIB_AUDIT_ACTIONS.groupMemberAdd, actorUserId: actor.userId, organizationId: deps.organizationId, objectType: "group", objectId: group.id, objectPath: group.id, detail: { userId: user.id }, }); return { userId: user.id, displayName: user.displayName, feishuOpenId: user.feishuOpenId, avatarUrl: user.avatarUrl, joinedAt: created.createdAt, }; }); } /** 移成员(软删 revokedAt);不在组 → 404。仅网站管理员。 */ export async function removeMember( deps: MemberGroupServiceDeps, actor: FileLibActor, groupId: string, userId: string, ): Promise { requireAdmin(actor); await deps.prisma.$transaction(async (tx) => { const group = await requireActiveGroup(tx, groupId); const membership = await tx.memberGroupMembership.findFirst({ where: { groupId: group.id, userId, revokedAt: null }, select: { id: true }, }); if (membership === null) throw new FileLibError(404, "member_not_found", "group member not found"); await tx.memberGroupMembership.update({ where: { id: membership.id }, data: { revokedAt: new Date() }, }); await writeFileLibAudit(tx, { action: FILE_LIB_AUDIT_ACTIONS.groupMemberRemove, actorUserId: actor.userId, organizationId: deps.organizationId, objectType: "group", objectId: group.id, objectPath: group.id, detail: { userId }, }); }); } /** * 成员选择器:按显示名/openId 搜全局用户。**仅网站管理员**(与加成员同权,决策2) * —— 加成员本就能指定任意全局用户(resolveUser 不要求 org 成员),故此端点不扩大 * 已有能力面,只是把"盲敲 id"变成"搜索选择"。 * excludeGroupId 给定时,过滤掉该组的活跃成员(避免选中必然 409 的人)。 */ export async function searchUsers( deps: MemberGroupServiceDeps, actor: FileLibActor, q: string, excludeGroupId?: string, limit = 20, ): Promise { requireAdmin(actor); const keyword = q.trim(); let excludeIds: string[] = []; if (excludeGroupId !== undefined && excludeGroupId !== "") { const rows = await deps.prisma.memberGroupMembership.findMany({ where: { groupId: excludeGroupId, revokedAt: null }, select: { userId: true }, }); excludeIds = rows.map((r) => r.userId); } const users = await deps.prisma.user.findMany({ where: { ...(excludeIds.length > 0 ? { id: { notIn: excludeIds } } : {}), ...(keyword === "" ? {} : { OR: [ { displayName: { contains: keyword, mode: "insensitive" as const } }, { feishuOpenId: { contains: keyword, mode: "insensitive" as const } }, ], }), }, take: limit, orderBy: { displayName: "asc" }, select: { id: true, displayName: true, feishuOpenId: true, avatarUrl: true }, }); return users.map((u) => ({ userId: u.id, displayName: u.displayName, feishuOpenId: u.feishuOpenId, avatarUrl: u.avatarUrl, })); } /** * 授权选择器搜索(契约 C2 /groups/search)。**不限管理员**(决策2)。 * 活跃组按名过滤,breadcrumb 由活跃祖先链按 depth 排序拼成。 */ export async function searchMemberGroups( deps: MemberGroupServiceDeps, q: string, limit = 20, ): Promise { const keyword = q.trim(); const groups = await deps.prisma.memberGroup.findMany({ where: { archivedAt: null, ...(keyword === "" ? {} : { name: { contains: keyword, mode: "insensitive" as const } }), }, take: limit, orderBy: { name: "asc" }, select: { id: true, name: true }, }); if (groups.length === 0) return []; const ids = groups.map((g) => g.id); // 祖先链(仅活跃祖先);depth 越大越靠根。 const closure = await deps.prisma.memberGroupClosure.findMany({ where: { descendantId: { in: ids }, ancestor: { archivedAt: null } }, select: { descendantId: true, ancestorId: true, depth: true }, }); const ancestorIds = [...new Set(closure.map((c) => c.ancestorId))]; const names = await deps.prisma.memberGroup.findMany({ where: { id: { in: ancestorIds } }, select: { id: true, name: true }, }); const nameById = new Map(names.map((n) => [n.id, n.name])); const chainByGroup = new Map(); for (const row of closure) { const arr = chainByGroup.get(row.descendantId) ?? []; arr.push({ ancestorId: row.ancestorId, depth: row.depth }); chainByGroup.set(row.descendantId, arr); } return groups.map((g) => { const chain = (chainByGroup.get(g.id) ?? []).slice().sort((a, b) => b.depth - a.depth); const breadcrumb = chain .map((c) => nameById.get(c.ancestorId) ?? "") .filter((s) => s !== "") .join(" / "); return { id: g.id, name: g.name, breadcrumb: breadcrumb || g.name }; }); }