forked from EduCraft/curriculum-project-hub
feat(database): 后台成员组(MemberGroup)管理与嵌套解析
This commit is contained in:
@@ -32,9 +32,16 @@ export const FILE_LIB_AUDIT_ACTIONS = {
|
||||
fileConflictDetected: "file.conflict_detected",
|
||||
exportRun: "export.run",
|
||||
adminForceAdjust: "admin.force_adjust",
|
||||
// ADR-0028:成员组内置进 hub,组动作在本地审计(契约 C3 §6.3 原委托外部 Group 服务)。
|
||||
groupCreate: "group.create",
|
||||
groupUpdate: "group.update",
|
||||
groupDelete: "group.delete",
|
||||
groupRestore: "group.restore",
|
||||
groupMemberAdd: "group.member_add",
|
||||
groupMemberRemove: "group.member_remove",
|
||||
} as const;
|
||||
|
||||
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job";
|
||||
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job" | "group";
|
||||
|
||||
export interface FileLibAuditEntry {
|
||||
readonly action: string;
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* GroupResolver port(契约 C2)。
|
||||
*
|
||||
* 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。
|
||||
* Group 系统(需求系统二:全局、无限嵌套)由别的团队交付;调用方只依赖此
|
||||
* port,真身到位后替换实现,不换调用点。
|
||||
* ADR-0028 起,默认实现是 in-hub 的 MemberGroup 闭包读取器
|
||||
* (`createMemberGroupResolver`,见 memberGroupResolver.ts);
|
||||
* `HUB_GROUP_SERVICE_URL` 配置后切外部 HTTP 实现(groupResolverHttp.ts)。
|
||||
* 调用方只依赖此 port,不换调用点。
|
||||
*/
|
||||
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
@@ -13,9 +15,11 @@ export interface GroupResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* 过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。
|
||||
* 需求 3.2 的祖先递归语义在嵌套 Group 落地前无从谈起;此实现保证权限引擎
|
||||
* 的 Group 通路今天就是真的,而不是 mock。
|
||||
* @deprecated ADR-0028:成员组已内置为 in-hub MemberGroup,默认 resolver 改为
|
||||
* `createMemberGroupResolver`。此扁平 Team 过渡实现不再接线,保留仅为历史参照
|
||||
* (以及潜在的迁移对照),新代码不要使用。
|
||||
*
|
||||
* 旧过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。
|
||||
*/
|
||||
export function createTeamGroupResolver(
|
||||
prisma: PrismaClient,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 默认 GroupResolver 实现:读 in-hub MemberGroup 闭包(ADR-0028)。
|
||||
*
|
||||
* resolveMemberGroupIds(user) = 用户**活跃直接组 ∪ 这些组的活跃祖先**,去重
|
||||
* (闭包 depth0 自身行令每个直接组也是自己的祖先)。等价于:授权放在组 G 上,
|
||||
* G 及其全部子孙的成员都命中(需求 3.2 权限沿树向下 → 解析沿树向上收集)。
|
||||
*
|
||||
* 实时、不缓存(契约 D4/G4):成员变更在下一次受保护请求即可见。
|
||||
* MemberGroup 全局(无 organizationId),解析不做 org scope。
|
||||
* 两条 Prisma 查询,不用裸 SQL(与 treeService 风格一致)。
|
||||
*/
|
||||
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { GroupResolver } from "./groupResolver.js";
|
||||
|
||||
export function createMemberGroupResolver(prisma: PrismaClient): GroupResolver {
|
||||
return {
|
||||
async resolveMemberGroupIds(userId) {
|
||||
// 1) 活跃直接组:成员未撤销 + 组未归档。
|
||||
const direct = await prisma.memberGroupMembership.findMany({
|
||||
where: { userId, revokedAt: null, group: { archivedAt: null } },
|
||||
select: { groupId: true },
|
||||
});
|
||||
if (direct.length === 0) return [];
|
||||
const directIds = direct.map((m) => m.groupId);
|
||||
|
||||
// 2) 经闭包取活跃祖先(含 depth0 自身);祖先组须未归档。
|
||||
const ancestors = await prisma.memberGroupClosure.findMany({
|
||||
where: { descendantId: { in: directIds }, ancestor: { archivedAt: null } },
|
||||
select: { ancestorId: true },
|
||||
});
|
||||
return [...new Set(ancestors.map((a) => a.ancestorId))];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* 成员组(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<MemberGroupDto> {
|
||||
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<MemberGroupDto> {
|
||||
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<readonly MemberGroupDto[]> {
|
||||
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<string, number>();
|
||||
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<readonly MemberGroupMemberDto[]> {
|
||||
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<MemberGroupMemberDto> {
|
||||
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<void> {
|
||||
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<readonly UserSearchResult[]> {
|
||||
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<readonly MemberGroupSearchResult[]> {
|
||||
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<string, { ancestorId: string; depth: number }[]>();
|
||||
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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user