From 11a7ec800413192cd37a3383c3674a93896af919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD?= <3401797899@qq.com> Date: Sun, 26 Jul 2026 18:06:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(database):=20=E5=90=8E=E5=8F=B0=E6=88=90?= =?UTF-8?q?=E5=91=98=E7=BB=84(MemberGroup)=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E5=B5=8C=E5=A5=97=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 4 + ...-member-group-management-and-resolution.md | 153 ++++ hub/.gitignore | 1 + hub/filelib-web/src/lib/BrowserShell.svelte | 32 +- hub/filelib-web/src/lib/GroupAdmin.svelte | 280 +++++++ hub/filelib-web/src/lib/types.ts | 15 + hub/src/database/README.md | 11 +- hub/src/database/filelib/audit.ts | 9 +- hub/src/database/filelib/groupResolver.ts | 14 +- .../database/filelib/memberGroupResolver.ts | 35 + .../database/filelib/memberGroupService.ts | 607 +++++++++++++++ hub/src/database/routes/adminPanels.ts | 688 +++++++++++++++--- hub/src/database/routes/databaseRoutes.ts | 15 +- hub/src/database/routes/filelibRoutes.ts | 25 +- hub/src/database/routes/memberGroupRoutes.ts | 196 +++++ hub/src/database/routes/uiTheme.ts | 17 + hub/test/integration/member-groups.test.ts | 311 ++++++++ 17 files changed, 2289 insertions(+), 124 deletions(-) create mode 100644 docs/adr/0028-member-group-management-and-resolution.md create mode 100644 hub/filelib-web/src/lib/GroupAdmin.svelte create mode 100644 hub/src/database/filelib/memberGroupResolver.ts create mode 100644 hub/src/database/filelib/memberGroupService.ts create mode 100644 hub/src/database/routes/memberGroupRoutes.ts create mode 100644 hub/test/integration/member-groups.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 0bac2d5..0cba565 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -99,3 +99,7 @@ _Avoid_: Cost budget, unlimited run **Emergency Workload Brake**: An audited Platform Administrator control that prevents new agent work for one Organization or the whole platform and may explicitly stop active work during an incident. _Avoid_: Organization deletion, service restart + +**Member Group**: +A global, unlimited-depth, nestable authorization principal managed by the website administrator; a file-library grant on a group applies to that group and its whole descendant subtree, and a user's effective permission collects every group they belong to plus those groups' ancestors (ADR-0028). It stores no folder/project permission itself — only the user→group membership. Global: not owned by any Organization. +_Avoid_: Team (the org-scoped flat grouping), Feishu department diff --git a/docs/adr/0028-member-group-management-and-resolution.md b/docs/adr/0028-member-group-management-and-resolution.md new file mode 100644 index 0000000..17dae92 --- /dev/null +++ b/docs/adr/0028-member-group-management-and-resolution.md @@ -0,0 +1,153 @@ +# ADR 0028: Member Group Management And Resolution + +## Status + +Accepted. + +## Context + +ADR-0020 fixed `Organization` as the tenant root and ADR-0019 pinned the +principal-set permission model. The file library (《文件库-接口契约.md》) computes +effective permission over two principal kinds — `USER` and `GROUP` — and consumes +the group side through a single read-only port, `GroupResolver` +(`resolveMemberGroupIds(userId) → groupIds[]`, contract C2/G2). + +The contract's v0.1 proposal framed the Group system as a *separate HTTP service* +owned by another team, consumed read-only. In practice the schema now carries the +group tables directly in the hub database (`MemberGroup`, `MemberGroupMembership`, +`MemberGroupClosure` — a global, unlimited-depth, closure-backed hierarchy), and +the product requirement is to build **group management in the backend admin**, not +to integrate a foreign service. Until this ADR, nothing read or wrote those tables: +the live `GroupResolver` was a transitional implementation reading flat hub `Team` +membership, and the admin "Group 管理" panel actually managed `Team`. + +This ADR settles the semantics needed to make the `MemberGroup` tables the real, +in-hub group system. + +## Decision + +### Group system is in-hub, not a foreign service + +`MemberGroup` is the platform's global member-group principal. It lives in the hub +database and is managed through the `/database` backend. The contract's "separate +service" framing was an unfrozen v0.1 proposal; the implementation aligns to the +tables that were actually built. The `GroupResolver` port stays — an external +`HUB_GROUP_SERVICE_URL` HTTP implementation remains a supported override — but the +default implementation reads the in-hub `MemberGroup` closure. + +### Authority: website administrator only + +Group create/delete and member add/remove are restricted to the **website +administrator**, defined (consistently with the rest of the file library, D19/C4 +adaptation) as an `OWNER`/`ADMIN` of the silo Organization (`isWebsiteAdmin` in +`filelib/guards.ts`). ADR-0023's `PlatformIdentity` is the future "true" platform +control plane; the file library uniformly uses org OWNER/ADMIN today and this +feature stays consistent with that. Reading groups for the authorization selector +(`/groups/search`) is **not** admin-gated — picking a group to grant is a Manage +holder's ability, not an administrator's. + +### Resolution semantics (the crux) + +`resolveMemberGroupIds(user)` returns the user's **active direct groups ∪ the +active ancestors of those groups**, deduplicated (the closure's depth-0 self row +makes each direct group its own ancestor). This is the single query the permission +engine relies on; equivalently: a grant placed on group G applies to members of G +and of every descendant of G (requirement 3.2 — permission flows down the tree, so +resolution collects up the tree). It is computed **live, never cached** (contract +D4/G4): a membership change is visible on the very next protected request. + +MemberGroup is global (no `organizationId`), so resolution is not org-scoped. + +### Soft delete via `archivedAt`, cascading the subtree + +Delete is soft: `MemberGroup.archivedAt` is a tag. Deleting a group +cascade-soft-deletes its **whole subtree** (walk `MemberGroupClosure` where +`ancestorId = G`, stamp `archivedAt` on each active descendant) — an application +operation, not a DB constraint. Closure and membership rows are **retained**; +resolution and listing filter by `archivedAt`, so an archived group and everything +under it stop contributing to permission at once. + +### Closure maintenance + +The closure is maintained on **create**: insert `(G, G, 0)`, then for a parent `P` +insert `(a.ancestorId, G, a.depth + 1)` for every `a` in +`closure where descendantId = P`. v1 does **not** support reparenting a group +(moving it under a new parent). The schema reserves reparent (closure rebuild plus +the cycle guard "reject a new parent inside the moved subtree"); it is a follow-on. + +### Rename and description edits are in scope; reparent stays out + +A group's `name` and `description` are mutable by the website administrator +(`PATCH /database/api/groups/:id`, audited as `group.update`). This is deliberately +separated from reparent: renaming touches **no** closure row and cannot create a +cycle, so it carries none of the invariant risk that keeps reparent out of v1. The +endpoint therefore **rejects** a `parentId` field outright rather than ignoring it, +so a future reparent cannot arrive silently through this route. Passing an empty +`description` clears it; omitting a field leaves it unchanged. + +### Restore is deliberately asymmetric with delete + +Archived groups stay visible to the administrator (`GET +/database/api/groups?includeArchived=1` returns them carrying `archivedAt`; the +console tags and greys them) and can be restored (`POST +/database/api/groups/:id/restore`, audited as `group.restore`). + +Restore is **not** the mirror image of delete. Delete cascades down the whole +subtree; restore un-archives **the group plus every archived ancestor of it, and +nothing below it**: + +- Restoring the ancestor chain is **mandatory**, not a convenience. An active group + whose parent is archived has no path in the tree, and the `depth` derivation + (closure row count) presumes "an active group's ancestors are active" — the + invariant that cascade-delete establishes. Restoring a node alone would break it. +- The subtree is deliberately **left archived**. A group's descendants may have been + archived for reasons of their own, and one click should not silently re-grant + permission across a whole historical branch. Descendants remain visible in their + archived state and are each restored explicitly. + +Restore takes effect immediately, like every other membership change (D4/G4): the +group resumes contributing permission on the next resolution. + +An archived group is **readable but not writable**. Its membership rows are never +revoked by archiving, so `listMembers` succeeds on an archived group — the console +must be able to show *who was in it* before deciding whether to restore it. Every +mutation, by contrast, still requires an active group (`requireActiveGroup` → 404): +rename, child creation, and member add/remove all reject. The group is inert for +permission purposes and frozen for editing, but not hidden and not forgotten. + +### Member picker reads global users, admin-only + +`GET /database/api/users/search` backs the "add member" picker: it matches `User` +by display name or Feishu open id and is gated to the website administrator, the +same authority that may add members. It widens no existing capability — adding a +member already accepts **any** global user (`resolveUser` does not require an org +membership), so the endpoint only replaces blind id entry with search. It is +deliberately **not** opened to the non-admin authorization-selector audience that +`/groups/search` serves: choosing a group to grant is a Manage-holder action, +whereas enumerating people is not. `excludeGroupId` filters out the target group's +active members so the picker cannot surface a candidate that must 409. + +### Audit is written in-hub + +The contract (C3 §6.3) originally deferred group actions to the foreign Group +service's own audit. With the group system in-hub, group mutations are audited +through the existing file-library sink (`filelib/audit.ts`, same-transaction +`AuditEntry`) under the silo Organization — `MemberGroup` has no `organizationId`, +so the audit row is attributed to the silo org. New actions: `group.create`, +`group.update`, `group.delete`, `group.restore`, `group.member_add`, +`group.member_remove`; new audit object type `group`. + +## Consequences + +- The default `GroupResolver` becomes the in-hub `MemberGroup` closure reader. + `createTeamGroupResolver` is retained but deprecated (no longer wired); existing + flat-Team group grants no longer resolve for the file library. +- Group grants take effect in real time through the existing `effectiveRole` + reducer (P6) with no change to the permission algebra — only the set of group ids + fed to it changes. +- v1 omits reparent; the closure invariants above must hold whenever reparent is + added later (rebuild descendants' ancestor rows, reject cycles). +- Group management is an admin-only surface; the authorization selector is not. +- Numeric limits (max depth, max members) and a hard-delete/restore path remain + follow-on operational decisions; they must not weaken the archived-filter, + admin-authority, or live-resolution invariants fixed here. diff --git a/hub/.gitignore b/hub/.gitignore index 4e09b8a..9830097 100644 --- a/hub/.gitignore +++ b/hub/.gitignore @@ -8,6 +8,7 @@ dist/ .dev-keyring.json .dev-workspaces/ .dev-skills/ +.filelib-repos/ admin-web/node_modules/ admin-web/build/ admin-web/.svelte-kit/ diff --git a/hub/filelib-web/src/lib/BrowserShell.svelte b/hub/filelib-web/src/lib/BrowserShell.svelte index 87d841f..e992058 100644 --- a/hub/filelib-web/src/lib/BrowserShell.svelte +++ b/hub/filelib-web/src/lib/BrowserShell.svelte @@ -7,8 +7,12 @@ import TreeNode from "./TreeNode.svelte"; import NodeDetailPanel from "./NodeDetailPanel.svelte"; import FileEditor from "./FileEditor.svelte"; + import GroupAdmin from "./GroupAdmin.svelte"; import Modal from "./Modal.svelte"; + // 视图切换(仅管理员可见 Group 管理入口;非管理员恒为 library)。 + let view = $state<"library" | "groups">("library"); + let roots = $state(null); let treeError = $state(null); let showCreateRoot = $state(false); @@ -62,7 +66,31 @@ const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase()); -
+
+ {#if $me?.isWebsiteAdmin} + +
+ + +
+ {/if} + +
+ {#if $me?.isWebsiteAdmin && view === "groups"} + + {:else}
{#if showCreateRoot} diff --git a/hub/filelib-web/src/lib/GroupAdmin.svelte b/hub/filelib-web/src/lib/GroupAdmin.svelte new file mode 100644 index 0000000..8eaea46 --- /dev/null +++ b/hub/filelib-web/src/lib/GroupAdmin.svelte @@ -0,0 +1,280 @@ + + +
+ + + + +
+ {#if selected === null} +
从左侧选择一个 Group 查看成员
+ {:else} +
{selected.name}
+ {#if selected.description} +

{selected.description}

+ {:else} +
+ {/if} + +
+
+ + { if (e.key === "Enter") void addMember(); }} + /> +
+ +
+ +
+
+ 成员 {members === null ? "" : `(${members.length})`} +
+ {#if members === null} +
加载中…
+ {:else if membersError} +
{membersError}
+ {:else if members.length === 0} +
暂无成员
+ {:else} + {#each members as m (m.userId)} +
+ {m.displayName} + {m.userId} + +
+ {/each} + {/if} +
+ {/if} +
+
+ +{#if showCreate} + (showCreate = false)}> +
+ + +
+
+ + +
+
+ + +
+
+{/if} diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index 60bfc35..22e5179 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -83,3 +83,18 @@ export interface GroupSearchResult { readonly name: string; readonly breadcrumb: string; } + +/** 成员组(ADR-0028);后端返回扁平列表,前端按 parentId/depth 拼树。 */ +export interface MemberGroupNode { + readonly id: string; + readonly parentId: string | null; + readonly name: string; + readonly description: string | null; + readonly depth: number; + readonly memberCount: number; +} + +export interface MemberGroupMember { + readonly userId: string; + readonly displayName: string; +} diff --git a/hub/src/database/README.md b/hub/src/database/README.md index 6c29dc3..8b927d4 100644 --- a/hub/src/database/README.md +++ b/hub/src/database/README.md @@ -74,8 +74,10 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真 |------|------| | `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()` | | `routes/databaseRoutes.ts` | 登录页/dashboard + 各子路由装配点 | -| `routes/filelibRoutes.ts` | 文件库 树/授权/搜索 API | +| `routes/filelibRoutes.ts` | 文件库 树/授权 API | | `routes/fileRoutes.ts` | 文件库 文件内容/导出 API | +| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0028) | +| `routes/adminPanels.ts` | dashboard「用户管理」(org 成员)/「Group 管理」(MemberGroup 嵌套树)面板 | | `routes/libraryPage.ts` | `/database/library` 文件库浏览页 | | `filelib/` | 文件库领域层(见下) | @@ -98,7 +100,9 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真 | `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) | | `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port | | `filelib/versionStore.ts` | 契约 C1 port + 内存实现(版本团队 npm 包到位后替换) | -| `filelib/groupResolver.ts` | 契约 C2 port + Team 过渡实现 | +| `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0028) | +| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0028) | +| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0028) | | `filelib/groupResolverHttp.ts` | C2 HTTP 实现(HUB_GROUP_SERVICE_URL 启用;失败 → 503) | | `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 | | `filelib/guards.ts` | session → FileLibActor;网站管理员 = org OWNER/ADMIN(D19) | @@ -107,7 +111,8 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真 环境变量: - `HUB_FILELIB_STORAGE_ROOT` — 项目 git 仓库根目录(默认 `./.filelib-repos`) -- `HUB_GROUP_SERVICE_URL` — Group 团队服务地址(C2);未配置时读 hub Team(扁平) +- `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub + MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team) > ⚠️ 开发期注意:当前 VersionStore 是**进程内存**实现,**服务重启后仓库全失**, > 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的 diff --git a/hub/src/database/filelib/audit.ts b/hub/src/database/filelib/audit.ts index f58a92c..fe01d09 100644 --- a/hub/src/database/filelib/audit.ts +++ b/hub/src/database/filelib/audit.ts @@ -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; diff --git a/hub/src/database/filelib/groupResolver.ts b/hub/src/database/filelib/groupResolver.ts index a3aa546..2134476 100644 --- a/hub/src/database/filelib/groupResolver.ts +++ b/hub/src/database/filelib/groupResolver.ts @@ -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, diff --git a/hub/src/database/filelib/memberGroupResolver.ts b/hub/src/database/filelib/memberGroupResolver.ts new file mode 100644 index 0000000..60c2730 --- /dev/null +++ b/hub/src/database/filelib/memberGroupResolver.ts @@ -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))]; + }, + }; +} diff --git a/hub/src/database/filelib/memberGroupService.ts b/hub/src/database/filelib/memberGroupService.ts new file mode 100644 index 0000000..8035d8d --- /dev/null +++ b/hub/src/database/filelib/memberGroupService.ts @@ -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 { + 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 }; + }); +} diff --git a/hub/src/database/routes/adminPanels.ts b/hub/src/database/routes/adminPanels.ts index 6a54981..6196d96 100644 --- a/hub/src/database/routes/adminPanels.ts +++ b/hub/src/database/routes/adminPanels.ts @@ -1,9 +1,11 @@ /** - * 管理员后台「用户管理」「Group 管理」面板(复用 hub 已有 org 管理 API)。 + * 管理员后台「用户管理」「Group 管理」面板。 * - * 用户管理 = org 成员(/api/org/:orgSlug/members); - * Group 管理 = Team(当前 Group 的过渡实现,/api/org/:orgSlug/teams), - * 真 Group 系统落地后此面板改接新 API 即可,文件库授权侧不动。 + * 用户管理 = org 成员(/api/org/:orgSlug/members)。 + * Group 管理 = **MemberGroup**(全局可无限嵌套,ADR-0028),走 /database/api/groups/*。 + * 已从旧的扁平 hub Team 迁过来 —— Team 无父子字段,建不出子组。此面板与 + * filelib-web 的 GroupAdmin.svelte 消费同一套 API,语义一致。 + * 交互:嵌套树(展开/折叠)+ 右键菜单(建子组/建根组/重命名/删除)。 */ function apiBase(orgSlug: string): string { @@ -105,35 +107,110 @@ export function renderUsersPanel(orgSlug: string): string { /* ---------------------------------------------------------------- Group 管理 */ -export function renderGroupsPanel(orgSlug: string): string { +/** + * MemberGroup 嵌套树管理(ADR-0028)。无 orgSlug 参数 —— MemberGroup 是全局主体, + * 不归属任何 Organization,端点也不带 org 段。 + */ +/** 内联 SVG 图标表(24x24 stroke 风格,与 dashboard 侧栏一致)。 */ +const GROUP_ICONS: Record = { + // Group 节点 = 人的集合。**不用文件夹图标** —— Group 不是目录, + // 与文件库的 FOLDER/PROJECT 是两套体系,图标上也不应混淆。两人剪影。 + group: "M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19M10 11.5a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM20 19v-1.5a3.5 3.5 0 0 0-2.6-3.38M15.4 5.22a3.25 3.25 0 0 1 0 6.06", + users: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75", + user: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z", + plus: "M12 5v14M5 12h14", + pencil: "M17 3a2.8 2.8 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3Z", + trash: "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14", + search: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z", + chevron: "m9 18 6-6-6-6", + layers: "m12 2 9 5-9 5-9-5 9-5Zm9 11-9 5-9-5m18 5-9 5-9-5", + clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-14v6l4 2", + minus: "M5 12h14", + // 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。 + archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6", + restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5", +}; + +/** `icon("users", 16)` → 内联 svg 串。 */ +function icon(name: keyof typeof GROUP_ICONS | string, size = 16): string { + const d = GROUP_ICONS[name] ?? ""; + return ``; +} + +export function renderGroupsPanel(): string { return ` -
-
-
-
新建 Group
-
-
-
-
+
+ +
+
+ ${icon("layers", 17)} +
Group 树
+
-
-
Group 列表
-
+
+ ${icon("search", 13)} +
+ +
+
-
-
从左侧选择一个 Group 查看成员
+ + +
+
+
+ ${icon("users", 40)} + 从左侧选择一个 Group 查看成员 +
+
+ `; } diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts index c5b3f46..597fef7 100644 --- a/hub/src/database/routes/databaseRoutes.ts +++ b/hub/src/database/routes/databaseRoutes.ts @@ -26,11 +26,12 @@ import path from "node:path"; import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js"; import { registerFileLibRoutes } from "./filelibRoutes.js"; import { registerFileRoutes } from "./fileRoutes.js"; +import { registerMemberGroupRoutes } from "./memberGroupRoutes.js"; import { registerTeacherApp } from "./teacherApp.js"; import { renderLibraryBrowser } from "./libraryBrowser.js"; import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.js"; import { createInMemoryVersionStore } from "../filelib/versionStore.js"; -import { createTeamGroupResolver } from "../filelib/groupResolver.js"; +import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js"; import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js"; import { createManifestStubAdapter } from "../filelib/exportService.js"; import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js"; @@ -122,7 +123,8 @@ export async function registerDatabaseRoutes( // 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。 // 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换); - // GroupResolver 默认读 hub Team,HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2); + // GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028), + // HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2); // 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。 const siloOrg = await config.prisma.organization.findUnique({ where: { slug: config.siloOrganizationSlug }, @@ -141,13 +143,14 @@ export async function registerDatabaseRoutes( organizationId: siloOrg.id, storageRoot, groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === "" - ? createTeamGroupResolver(config.prisma, siloOrg.id) + ? createMemberGroupResolver(config.prisma) : createHttpGroupResolver({ baseUrl: groupServiceUrl }), versionStore, exportAdapters: [createManifestStubAdapter(versionStore)], }; await registerFileLibRoutes(app, filelibDeps); await registerFileRoutes(app, filelibDeps); + await registerMemberGroupRoutes(app, filelibDeps); await registerTeacherApp(app, { prisma: config.prisma, sessionSecret: config.sessionSecret, @@ -339,7 +342,6 @@ ${pageHead("Database Admin")}
${initial}
-

已登录

${escapeHtml(displayName)}

@@ -368,9 +370,8 @@ ${pageHead("Database Admin")} ${renderUsersPanel(orgSlug)} -