feat(database): 后台成员组(MemberGroup)管理与嵌套解析

This commit is contained in:
2026-07-26 18:06:18 +08:00
parent 2f2ece1a3a
commit 11a7ec8004
17 changed files with 2289 additions and 124 deletions
+4
View File
@@ -99,3 +99,7 @@ _Avoid_: Cost budget, unlimited run
**Emergency Workload Brake**: **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. 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 _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
@@ -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.
+1
View File
@@ -8,6 +8,7 @@ dist/
.dev-keyring.json .dev-keyring.json
.dev-workspaces/ .dev-workspaces/
.dev-skills/ .dev-skills/
.filelib-repos/
admin-web/node_modules/ admin-web/node_modules/
admin-web/build/ admin-web/build/
admin-web/.svelte-kit/ admin-web/.svelte-kit/
+31 -1
View File
@@ -7,8 +7,12 @@
import TreeNode from "./TreeNode.svelte"; import TreeNode from "./TreeNode.svelte";
import NodeDetailPanel from "./NodeDetailPanel.svelte"; import NodeDetailPanel from "./NodeDetailPanel.svelte";
import FileEditor from "./FileEditor.svelte"; import FileEditor from "./FileEditor.svelte";
import GroupAdmin from "./GroupAdmin.svelte";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
// 视图切换(仅管理员可见 Group 管理入口;非管理员恒为 library)。
let view = $state<"library" | "groups">("library");
let roots = $state<NodeChild[] | null>(null); let roots = $state<NodeChild[] | null>(null);
let treeError = $state<string | null>(null); let treeError = $state<string | null>(null);
let showCreateRoot = $state(false); let showCreateRoot = $state(false);
@@ -62,7 +66,31 @@
const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase()); const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase());
</script> </script>
<div class="flex h-full"> <div class="flex h-full flex-col">
{#if $me?.isWebsiteAdmin}
<!-- 视图切换(仅管理员) -->
<div class="flex shrink-0 items-center gap-1 border-b border-line-soft bg-sidebar px-3 py-1.5">
<button
class="rounded-lg px-3 py-1 text-[12.5px] font-medium transition"
class:bg-selected={view === "library"}
class:text-ink={view === "library"}
class:text-ink-3={view !== "library"}
onclick={() => (view = "library")}
>文件库</button>
<button
class="rounded-lg px-3 py-1 text-[12.5px] font-medium transition"
class:bg-selected={view === "groups"}
class:text-ink={view === "groups"}
class:text-ink-3={view !== "groups"}
onclick={() => (view = "groups")}
>Group 管理</button>
</div>
{/if}
<div class="flex min-h-0 flex-1">
{#if $me?.isWebsiteAdmin && view === "groups"}
<GroupAdmin />
{:else}
<!-- 侧栏 --> <!-- 侧栏 -->
<aside class="flex w-[300px] shrink-0 flex-col border-r border-line-soft bg-sidebar"> <aside class="flex w-[300px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5"> <div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5">
@@ -114,6 +142,8 @@
/> />
</section> </section>
{/if} {/if}
{/if}
</div>
</div> </div>
{#if showCreateRoot} {#if showCreateRoot}
+280
View File
@@ -0,0 +1,280 @@
<script lang="ts">
import { onMount } from "svelte";
import { api } from "./api.js";
import { toastErr, toastOk } from "./stores.js";
import type { MemberGroupNode, MemberGroupMember } from "./types.js";
import Modal from "./Modal.svelte";
// 后端返回活跃组扁平列表(ADR-0028);前端按 parentId/depth 拼成有序树。
let groups = $state<MemberGroupNode[] | null>(null);
let listError = $state<string | null>(null);
let selectedId = $state<string | null>(null);
let members = $state<MemberGroupMember[] | null>(null);
let membersError = $state<string | null>(null);
// 新建组弹窗:parentId=null 建根,否则建子。
let showCreate = $state(false);
let createParentId = $state<string | null>(null);
let createParentName = $state<string | null>(null);
let newName = $state("");
let newDesc = $state("");
let addValue = $state("");
const selected = $derived(groups?.find((g) => g.id === selectedId) ?? null);
/** 扁平列表按 parentId 排成先根遍历顺序(每项自带 depth,渲染时缩进)。 */
const ordered = $derived.by(() => {
if (groups === null) return [];
const byParent = new Map<string | null, MemberGroupNode[]>();
for (const g of groups) {
const arr = byParent.get(g.parentId) ?? [];
arr.push(g);
byParent.set(g.parentId, arr);
}
for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name));
const out: MemberGroupNode[] = [];
const walk = (parentId: string | null): void => {
for (const g of byParent.get(parentId) ?? []) {
out.push(g);
walk(g.id);
}
};
walk(null);
// 兜底:孤儿(父已不在活跃列表,理论上级联软删不会出现)也列出。
const seen = new Set(out.map((g) => g.id));
for (const g of groups) if (!seen.has(g.id)) out.push(g);
return out;
});
async function loadGroups(): Promise<void> {
try {
const r = await api<{ groups: MemberGroupNode[] }>("/database/api/groups");
groups = r.groups;
listError = null;
if (selectedId !== null && !groups.some((g) => g.id === selectedId)) {
selectedId = null;
members = null;
}
} catch (e) {
listError = e instanceof Error ? e.message : String(e);
}
}
async function loadMembers(): Promise<void> {
if (selectedId === null) return;
members = null;
membersError = null;
try {
const r = await api<{ members: MemberGroupMember[] }>(
`/database/api/groups/${encodeURIComponent(selectedId)}/members`,
);
members = r.members;
} catch (e) {
membersError = e instanceof Error ? e.message : String(e);
}
}
onMount(loadGroups);
function select(id: string): void {
selectedId = id;
void loadMembers();
}
function openCreate(parent: MemberGroupNode | null): void {
createParentId = parent?.id ?? null;
createParentName = parent?.name ?? null;
newName = "";
newDesc = "";
showCreate = true;
}
async function createGroup(): Promise<void> {
const name = newName.trim();
if (name === "") return;
try {
await api("/database/api/groups", {
method: "POST",
body: {
name,
parentId: createParentId,
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
},
});
toastOk("已创建成员组");
showCreate = false;
await loadGroups();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function deleteGroup(g: MemberGroupNode): Promise<void> {
if (!confirm(`删除「${g.name}」?其整棵子树将一并归档,相关授权立即失效。`)) return;
try {
const r = await api<{ archivedCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}`,
{ method: "DELETE" },
);
toastOk(`已归档 ${r.archivedCount} 个组`);
if (selectedId === g.id) { selectedId = null; members = null; }
await loadGroups();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function addMember(): Promise<void> {
const v = addValue.trim();
if (v === "" || selectedId === null) return;
try {
// ou_ 开头当飞书 openId,其余当 userId(与 hub 现有习惯一致)。
const body = v.startsWith("ou_") ? { feishuOpenId: v } : { userId: v };
await api(`/database/api/groups/${encodeURIComponent(selectedId)}/members`, {
method: "POST",
body,
});
addValue = "";
toastOk("已添加成员");
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function removeMember(userId: string): Promise<void> {
if (selectedId === null) return;
try {
await api(
`/database/api/groups/${encodeURIComponent(selectedId)}/members/${encodeURIComponent(userId)}`,
{ method: "DELETE" },
);
toastOk("已移除成员");
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
<div class="flex h-full">
<!-- 左栏:嵌套组树 -->
<aside class="flex w-[340px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5">
<span class="text-[15px] font-semibold text-ink">Group 管理</span>
<button
class="rounded-lg border border-line bg-panel px-2.5 py-1 text-[11.5px] font-medium text-ink transition hover:bg-hover"
onclick={() => openCreate(null)}
>
+ 根 Group
</button>
</div>
<div class="flex-1 overflow-y-auto px-2 py-2 text-[13px]">
{#if groups === null}
<div class="px-3 py-6 text-center text-xs text-ink-3">加载中…</div>
{:else if listError}
<div class="px-3 py-6 text-center text-xs text-danger">{listError}</div>
{:else if ordered.length === 0}
<div class="px-3 py-6 text-center text-xs text-ink-3">暂无成员组 · 点上方「+ 根 Group」开始</div>
{:else}
{#each ordered as g (g.id)}
<div
class="group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 transition hover:bg-hover"
class:bg-selected={selectedId === g.id}
style="padding-left: {8 + g.depth * 16}px"
>
<button class="flex min-w-0 flex-1 items-center gap-2 text-left" onclick={() => select(g.id)}>
<span class="truncate font-medium text-ink">{g.name}</span>
<span class="shrink-0 text-[11px] text-ink-3">{g.memberCount}</span>
</button>
<button
class="shrink-0 rounded px-1.5 py-0.5 text-[15px] leading-none text-ink-3 opacity-0 transition hover:bg-panel hover:text-ink group-hover:opacity-100"
title="在此下新建子 Group"
onclick={() => openCreate(g)}
>+</button>
<button
class="shrink-0 rounded px-1.5 py-0.5 text-[11px] leading-none text-ink-3 opacity-0 transition hover:text-danger group-hover:opacity-100"
title="删除(级联归档子树)"
onclick={() => deleteGroup(g)}
>删除</button>
</div>
{/each}
{/if}
</div>
</aside>
<!-- 右栏:成员管理 -->
<main class="flex-1 overflow-y-auto p-6">
{#if selected === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">从左侧选择一个 Group 查看成员</div>
{:else}
<div class="mb-1 text-[17px] font-semibold text-ink">{selected.name}</div>
{#if selected.description}
<p class="mb-4 text-[12.5px] text-ink-2">{selected.description}</p>
{:else}
<div class="mb-4"></div>
{/if}
<div class="mb-4 flex items-end gap-2">
<div class="flex-1">
<label class="mb-1 block text-[11.5px] text-ink-3" for="add-member">添加成员(userId 或飞书 openId)</label>
<input
id="add-member"
class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent"
bind:value={addValue}
placeholder="userId ou_ 开头的 openId"
onkeydown={(e) => { if (e.key === "Enter") void addMember(); }}
/>
</div>
<button
class="rounded-lg bg-accent px-3.5 py-2 text-[12.5px] font-medium text-white transition hover:bg-accent-hover"
onclick={addMember}
>添加</button>
</div>
<div class="rounded-xl border border-line-soft bg-panel">
<div class="border-b border-line-soft px-4 py-2.5 text-[12.5px] font-semibold text-ink">
成员 {members === null ? "" : `(${members.length})`}
</div>
{#if members === null}
<div class="px-4 py-6 text-center text-xs text-ink-3">加载中…</div>
{:else if membersError}
<div class="px-4 py-6 text-center text-xs text-danger">{membersError}</div>
{:else if members.length === 0}
<div class="px-4 py-6 text-center text-xs text-ink-3">暂无成员</div>
{:else}
{#each members as m (m.userId)}
<div class="flex items-center gap-3 border-b border-line-soft px-4 py-2.5 text-[13px] last:border-b-0">
<span class="text-ink">{m.displayName}</span>
<span class="truncate font-mono text-[11px] text-ink-3">{m.userId}</span>
<button
class="ml-auto shrink-0 rounded px-1.5 py-0.5 text-[11.5px] text-ink-3 transition hover:text-danger"
onclick={() => removeMember(m.userId)}
>移除</button>
</div>
{/each}
{/if}
</div>
{/if}
</main>
</div>
{#if showCreate}
<Modal title={createParentName === null ? "新建根 Group" : `在「${createParentName}」下新建子 Group`} onclose={() => (showCreate = false)}>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="g-name">名称</label>
<input id="g-name" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newName} placeholder="例如:物理教研组" />
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="g-desc">描述(可选)</label>
<textarea id="g-desc" rows="2" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newDesc} placeholder="一句话说明…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreate = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={createGroup}>创建</button>
</div>
</Modal>
{/if}
+15
View File
@@ -83,3 +83,18 @@ export interface GroupSearchResult {
readonly name: string; readonly name: string;
readonly breadcrumb: 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;
}
+8 -3
View File
@@ -74,8 +74,10 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|------|------| |------|------|
| `plugin.ts` | 模块对外入口,`hub.ts``registerDatabasePlugin()` | | `plugin.ts` | 模块对外入口,`hub.ts``registerDatabasePlugin()` |
| `routes/databaseRoutes.ts` | 登录页/dashboard + 各子路由装配点 | | `routes/databaseRoutes.ts` | 登录页/dashboard + 各子路由装配点 |
| `routes/filelibRoutes.ts` | 文件库 树/授权/搜索 API | | `routes/filelibRoutes.ts` | 文件库 树/授权 API |
| `routes/fileRoutes.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` 文件库浏览页 | | `routes/libraryPage.ts` | `/database/library` 文件库浏览页 |
| `filelib/` | 文件库领域层(见下) | | `filelib/` | 文件库领域层(见下) |
@@ -98,7 +100,9 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
| `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) | | `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) |
| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port | | `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port |
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(版本团队 npm 包到位后替换) | | `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/groupResolverHttp.ts` | C2 HTTP 实现(HUB_GROUP_SERVICE_URL 启用;失败 → 503) |
| `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 | | `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 |
| `filelib/guards.ts` | session → FileLibActor;网站管理员 = org OWNER/ADMIN(D19) | | `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_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 是**进程内存**实现,**服务重启后仓库全失**, > ⚠️ 开发期注意:当前 VersionStore 是**进程内存**实现,**服务重启后仓库全失**,
> 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的 > 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的
+8 -1
View File
@@ -32,9 +32,16 @@ export const FILE_LIB_AUDIT_ACTIONS = {
fileConflictDetected: "file.conflict_detected", fileConflictDetected: "file.conflict_detected",
exportRun: "export.run", exportRun: "export.run",
adminForceAdjust: "admin.force_adjust", 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; } as const;
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job"; export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job" | "group";
export interface FileLibAuditEntry { export interface FileLibAuditEntry {
readonly action: string; readonly action: string;
+9 -5
View File
@@ -2,8 +2,10 @@
* GroupResolver port(契约 C2)。 * GroupResolver port(契约 C2)。
* *
* 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。 * 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。
* Group 系统(需求系统二:全局、无限嵌套)由别的团队交付;调用方只依赖此 * ADR-0028 起,默认实现是 in-hub 的 MemberGroup 闭包读取器
* port,真身到位后替换实现,不换调用点。 * (`createMemberGroupResolver`,见 memberGroupResolver.ts);
* `HUB_GROUP_SERVICE_URL` 配置后切外部 HTTP 实现(groupResolverHttp.ts)。
* 调用方只依赖此 port,不换调用点。
*/ */
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
@@ -13,9 +15,11 @@ export interface GroupResolver {
} }
/** /**
* 过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。 * @deprecated ADR-0028:成员组已内置为 in-hub MemberGroup,默认 resolver 改为
* 需求 3.2 的祖先递归语义在嵌套 Group 落地前无从谈起;此实现保证权限引擎 * `createMemberGroupResolver`。此扁平 Team 过渡实现不再接线,保留仅为历史参照
* 的 Group 通路今天就是真的,而不是 mock * (以及潜在的迁移对照),新代码不要使用
*
* 旧过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。
*/ */
export function createTeamGroupResolver( export function createTeamGroupResolver(
prisma: PrismaClient, 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 };
});
}
+604 -84
View File
@@ -1,9 +1,11 @@
/** /**
* 管理员后台「用户管理」「Group 管理」面板(复用 hub 已有 org 管理 API) * 管理员后台「用户管理」「Group 管理」面板。
* *
* 用户管理 = org 成员(/api/org/:orgSlug/members); * 用户管理 = org 成员(/api/org/:orgSlug/members)
* Group 管理 = Team(当前 Group 的过渡实现,/api/org/:orgSlug/teams), * Group 管理 = **MemberGroup**(全局可无限嵌套,ADR-0028),走 /database/api/groups/*。
* 真 Group 系统落地后此面板改接新 API 即可,文件库授权侧不动。 * 已从旧的扁平 hub Team 迁过来 —— Team 无父子字段,建不出子组。此面板与
* filelib-web 的 GroupAdmin.svelte 消费同一套 API,语义一致。
* 交互:嵌套树(展开/折叠)+ 右键菜单(建子组/建根组/重命名/删除)。
*/ */
function apiBase(orgSlug: string): string { function apiBase(orgSlug: string): string {
@@ -105,35 +107,110 @@ export function renderUsersPanel(orgSlug: string): string {
/* ---------------------------------------------------------------- Group 管理 */ /* ---------------------------------------------------------------- Group 管理 */
export function renderGroupsPanel(orgSlug: string): string { /**
* MemberGroup 嵌套树管理(ADR-0028)。无 orgSlug 参数 —— MemberGroup 是全局主体,
* 不归属任何 Organization,端点也不带 org 段。
*/
/** 内联 SVG 图标表(24x24 stroke 风格,与 dashboard 侧栏一致)。 */
const GROUP_ICONS: Record<string, string> = {
// 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 `<svg style="width:${size}px;height:${size}px;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
}
export function renderGroupsPanel(): string {
return ` return `
<div id="groups-root" style="display:flex;gap:14px;align-items:flex-start"> <div id="groups-root" style="display:flex;gap:14px;align-items:stretch;height:100%;min-height:0">
<div style="width:340px;flex-shrink:0"> <!-- 左:组树 -->
<div class="panel" style="margin-bottom:14px"> <div class="panel" style="width:326px;flex-shrink:0;display:flex;flex-direction:column;min-height:0;padding:14px">
<div class="section-title">新建 Group</div> <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
<div class="form-row"><label class="form-label">标识(slug)</label><input id="g-slug" class="input" placeholder="physics-dept"/></div> <span style="display:flex;color:var(--accent)">${icon("layers", 17)}</span>
<div class="form-row"><label class="form-label">名称</label><input id="g-name" class="input" placeholder="物理教研组"/></div> <div class="section-title" style="margin:0;flex:1">Group 树</div>
<div class="form-row"><label class="form-label">描述(可选)</label><input id="g-desc" class="input" placeholder="一句话说明"/></div> <button id="g-new-root" class="btn" style="font-size:11.5px;padding:4px 9px;display:inline-flex;align-items:center;gap:4px">
<div style="text-align:right"><button id="g-create" class="btn btn-primary">创建</button></div> ${icon("plus", 13)} 根组
</button>
</div> </div>
<div class="panel"> <div style="position:relative;margin-bottom:8px">
<div class="section-title">Group 列表</div> <span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">${icon("search", 13)}</span>
<div id="g-list"></div> <input id="g-filter" class="input" placeholder="过滤组名…" style="padding-left:28px;font-size:12.5px"/>
</div> </div>
<label class="switch" style="margin-bottom:9px;font-size:11.5px;color:var(--text-3)">
<input type="checkbox" id="g-show-archived"/>
<span></span>
显示已删除的组
</label>
<div id="g-tree" style="flex:1;overflow-y:auto;margin:0 -6px;min-height:0"></div>
<div id="g-tree-foot" class="section-note" style="border-top:1px solid var(--border-soft);margin-top:8px;padding-top:8px"></div>
</div> </div>
<div class="panel" style="flex:1;min-height:200px">
<div id="g-detail" class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div> <!-- 右:成员表 -->
<div class="panel" style="flex:1;min-width:0;display:flex;flex-direction:column;min-height:0;padding:0">
<div id="g-detail" style="display:flex;flex-direction:column;min-height:0;flex:1">
<div class="quiet" style="margin:auto;padding:28px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px">
<span style="color:var(--border);display:flex">${icon("users", 40)}</span>
从左侧选择一个 Group 查看成员
</div>
</div>
</div> </div>
</div> </div>
<div id="g-menu" class="hidden" style="position:fixed;z-index:60;min-width:184px;background:var(--panel);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-pop);padding:5px;font-size:13px"></div>
<script> <script>
(function () { (function () {
const BASE = "${apiBase(orgSlug)}"; const treeEl = document.getElementById("g-tree");
const listEl = document.getElementById("g-list");
const detailEl = document.getElementById("g-detail"); const detailEl = document.getElementById("g-detail");
const menuEl = document.getElementById("g-menu");
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
let selected = null;
const ICONS = ${JSON.stringify(GROUP_ICONS)};
function ico(name, size) {
return '<svg style="width:' + (size || 16) + 'px;height:' + (size || 16) + 'px;flex-shrink:0" viewBox="0 0 24 24"' +
' fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="' + (ICONS[name] || "") + '"/></svg>';
}
/** 头像:有 avatarUrl 用图,否则首字母色块。 */
function avatar(m, size) {
const s = size || 28;
if (m.avatarUrl) {
return '<img src="' + esc(m.avatarUrl) + '" alt="" style="width:' + s + 'px;height:' + s +
'px;border-radius:50%;object-fit:cover;flex-shrink:0"/>';
}
const ch = esc((m.displayName || m.userId || "?").slice(0, 1).toUpperCase());
return '<span style="width:' + s + 'px;height:' + s + 'px;border-radius:50%;flex-shrink:0;background:var(--accent);' +
'color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:' + Math.round(s * 0.42) +
'px;font-weight:600">' + ch + "</span>";
}
function fmtDate(iso) {
try { return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" }); }
catch { return String(iso || ""); }
}
let groups = []; // 后端返回的活跃组扁平列表
let selected = null; // { id, name }
const collapsed = new Set(); // 折叠的组 id(默认全展开)
let filterText = ""; // 组名过滤(命中项的祖先链一并保留)
let showArchived = false; // 是否列出已归档(软删)的组
async function req(path, opts = {}) { async function req(path, opts = {}) {
const res = await fetch(BASE + path, { const res = await fetch("/database/api/groups" + path, {
credentials: "same-origin", credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined, headers: opts.body ? { "Content-Type": "application/json" } : undefined,
method: opts.method ?? "GET", method: opts.method ?? "GET",
@@ -145,83 +222,526 @@ export function renderGroupsPanel(orgSlug: string): string {
if (!res.ok) throw new Error((data && data.error && data.error.message) || res.statusText); if (!res.ok) throw new Error((data && data.error && data.error.message) || res.statusText);
return data; return data;
} }
async function loadList() {
const { teams } = await req("/teams"); /* ---------------- 树渲染 ---------------- */
listEl.innerHTML = teams.length === 0
? '<div class="quiet" style="text-align:center;padding:12px">暂无 Group</div>' /** 扁平列表 → 先根遍历顺序;折叠的子树整段跳过。过滤时命中项的祖先链保留。 */
: teams.map((t) => function orderedVisible() {
'<div class="g-team" data-id="' + esc(t.id) + '" data-name="' + esc(t.name) + '" style="display:flex;align-items:center;gap:8px;padding:8px 6px;border-radius:8px;cursor:pointer">' + const byParent = new Map();
'<span style="flex:1"><b>' + esc(t.name) + '</b> <span class="file-meta">' + esc(t.slug) + "</span></span>" + const byId = new Map();
'<button class="link-danger" data-archive="' + esc(t.id) + '" style="font-size:11px">归档</button>' + for (const g of groups) {
"</div>").join(""); byId.set(g.id, g);
listEl.querySelectorAll(".g-team").forEach((el) => { const arr = byParent.get(g.parentId) || [];
arr.push(g);
byParent.set(g.parentId, arr);
}
for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
// 过滤:命中集 = 名字命中的组 ∪ 其全部祖先(否则命中的深层组无路径可展示)。
let keep = null;
const q = filterText.trim().toLowerCase();
if (q !== "") {
keep = new Set();
for (const g of groups) {
if (!g.name.toLowerCase().includes(q)) continue;
let cur = g;
while (cur) { keep.add(cur.id); cur = cur.parentId ? byId.get(cur.parentId) : null; }
}
}
const out = [];
(function walk(parentId) {
for (const g of byParent.get(parentId) || []) {
if (keep !== null && !keep.has(g.id)) continue;
const kids = (byParent.get(g.id) || []).filter((k) => keep === null || keep.has(k.id));
out.push({ g, hasKids: kids.length > 0, hit: q === "" || g.name.toLowerCase().includes(q) });
// 过滤态下强制展开(否则命中项被折叠祖先藏住)。
if (keep !== null || !collapsed.has(g.id)) walk(g.id);
}
})(null);
// 兜底:父不在活跃列表的孤儿(级联软删理论上不产生)也列出,避免"看不见"。
const seen = new Set(out.map((r) => r.g.id));
for (const g of groups) {
if (seen.has(g.id)) continue;
if (keep !== null && !keep.has(g.id)) continue;
out.push({ g, hasKids: false, hit: true });
}
return out;
}
function renderTree() {
const footEl = document.getElementById("g-tree-foot");
if (groups.length === 0) {
treeEl.innerHTML = '<div class="quiet" style="text-align:center;padding:22px 12px;display:flex;flex-direction:column;align-items:center;gap:8px">' +
'<span style="color:var(--border);display:flex">' + ico("layers", 30) + "</span>" +
"暂无成员组 · 点上方「根组」开始</div>";
footEl.textContent = "";
return;
}
const rows = orderedVisible();
if (rows.length === 0) {
treeEl.innerHTML = '<div class="quiet" style="text-align:center;padding:22px 12px">无匹配的组</div>';
} else {
treeEl.innerHTML = rows.map(({ g, hasKids, hit }) => {
const isSel = selected && selected.id === g.id;
const caret = hasKids
? '<span data-caret="' + esc(g.id) + '" style="display:inline-flex;width:15px;justify-content:center;cursor:pointer;' +
'color:var(--text-3);transition:transform .12s;' +
(collapsed.has(g.id) && filterText.trim() === "" ? "" : "transform:rotate(90deg)") + '">' +
ico("chevron", 13) + "</span>"
: '<span style="display:inline-block;width:15px"></span>';
const arch = !!g.archivedAt;
return '<div class="g-node" data-id="' + esc(g.id) + '" data-name="' + esc(g.name) + '"' +
' data-archived="' + (arch ? "1" : "") + '"' +
' style="display:flex;align-items:center;gap:6px;padding:6px 8px 6px ' + (8 + g.depth * 15) + 'px;' +
'border-radius:8px;cursor:pointer;user-select:none;' + (isSel ? "background:var(--selected)" : "") +
(hit ? "" : ";opacity:.5") + '">' +
caret +
'<span style="display:flex;color:' + (arch ? "var(--text-3)" : (isSel ? "var(--accent)" : "var(--text-3)")) + '">' +
ico(arch ? "archive" : "group", 15) + "</span>" +
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' +
(arch ? ";color:var(--text-3);text-decoration:line-through" : "") + '">' + esc(g.name) + "</span>" +
'<span class="tag" style="flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:10.5px' +
(arch ? ";opacity:.7" : "") + '">' + ico("user", 10) + g.memberCount + "</span>" +
(arch ? '<span class="tag" style="flex-shrink:0;font-size:10px;opacity:.85">已删除</span>' : "") +
"</div>";
}).join("");
}
const active = groups.filter((g) => !g.archivedAt);
const archivedN = groups.length - active.length;
const totalMembers = active.reduce((n, g) => n + g.memberCount, 0);
footEl.textContent = active.length + " 个活跃组 · " + totalMembers + " 条成员关系" +
(archivedN > 0 ? " · " + archivedN + " 个已删除" : "");
treeEl.querySelectorAll(".g-node").forEach((el) => {
el.onclick = (e) => { el.onclick = (e) => {
if (e.target.closest("button")) return; if (e.target.closest("[data-caret]")) return; // 折叠三角不触发选中
selected = { id: el.dataset.id, name: el.dataset.name }; selectGroup({ id: el.dataset.id, name: el.dataset.name });
listEl.querySelectorAll(".g-team").forEach((x) => x.style.background = "");
el.style.background = "var(--selected)";
loadMembers(); loadMembers();
}; };
el.oncontextmenu = (e) => {
e.preventDefault();
openMenu(e.clientX, e.clientY, {
id: el.dataset.id,
name: el.dataset.name,
archived: el.dataset.archived === "1",
});
};
}); });
listEl.querySelectorAll("button[data-archive]").forEach((btn) => { treeEl.querySelectorAll("[data-caret]").forEach((el) => {
btn.onclick = async () => { el.onclick = (e) => {
if (!confirm("归档该 Group?其成员授权将失效。")) return; e.stopPropagation();
try { await req("/teams/" + encodeURIComponent(btn.dataset.archive) + "/archive", { method: "POST" }); selected = null; detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>'; loadList(); } const id = el.dataset.caret;
catch (e) { alert(e.message); } if (collapsed.has(id)) collapsed.delete(id); else collapsed.add(id);
renderTree();
}; };
}); });
} }
async function loadMembers() {
if (!selected) return; /* ---------------- 右键菜单 ---------------- */
detailEl.innerHTML = '<div class="quiet" style="padding:12px;text-align:center">加载中…</div>';
const { members } = await req("/teams/" + encodeURIComponent(selected.id) + "/members"); function closeMenu() { menuEl.classList.add("hidden"); }
detailEl.innerHTML = document.addEventListener("click", closeMenu);
'<div class="section-title">' + esc(selected.name) + " · 成员(" + members.length + ")</div>" + document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenu(); });
'<table class="list"><tbody>' + // 树空白处右键 = 建根组
(members.length === 0 treeEl.oncontextmenu = (e) => {
? '<tr><td class="quiet" style="text-align:center;padding:14px">暂无成员</td></tr>' if (e.target.closest(".g-node")) return;
: members.map((m) => e.preventDefault();
"<tr><td>" + esc(m.displayName || m.userId) + '</td><td class="file-meta">' + esc(m.userId) + "</td>" + openMenu(e.clientX, e.clientY, null);
'<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '">移除</button></td></tr>').join("")) + };
"</tbody></table>" +
'<div class="divider"></div>' + function openMenu(x, y, target) {
'<div class="section-title">添加成员</div>' + // 已归档组:只给「恢复」—— 归档态下不允许建子组/加成员/改名(后端亦 404 兜底)。
'<div class="inline-form">' + const items = target === null
'<input id="g-add-openid" class="input" placeholder="用户 openId 或 userId"/>' + ? [{ label: "新建根 Group", ic: "plus", fn: () => createDialog(null, null) }]
'<button id="g-add" class="btn btn-primary">添加</button>' + : target.archived
"</div>"; ? [
detailEl.querySelectorAll("button[data-revoke]").forEach((btn) => { { label: "查看成员(只读)", ic: "users", fn: () => { selectGroup(target); loadMembers(); } },
btn.onclick = async () => { { label: "恢复此 Group", ic: "restore", fn: () => restoreGroup(target) },
try { await req("/teams/" + encodeURIComponent(selected.id) + "/members/" + encodeURIComponent(btn.dataset.revoke) + "/revoke", { method: "POST" }); loadMembers(); } { sep: true },
catch (e) { alert(e.message); } { label: "新建根 Group", ic: "layers", fn: () => createDialog(null, null) },
}; ]
: [
{ label: "新建子 Group", ic: "plus", fn: () => createDialog(target.id, target.name) },
{ label: "添加成员", ic: "user", fn: () => { selectGroup(target); addMemberDialog(); } },
{ label: "重命名 / 改描述", ic: "pencil", fn: () => renameDialog(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => createDialog(null, null) },
{ label: "删除(级联子树)", ic: "trash", danger: true, fn: () => removeGroup(target) },
];
menuEl.innerHTML = items.map((it, i) =>
it.sep
? '<div style="height:1px;background:var(--border-soft);margin:4px 6px"></div>'
: '<div data-mi="' + i + '" style="display:flex;align-items:center;gap:8px;padding:7px 11px;border-radius:6px;cursor:pointer' +
(it.danger ? ";color:var(--danger)" : "") + '">' +
'<span style="display:flex;opacity:.75">' + ico(it.ic, 14) + "</span>" + esc(it.label) + "</div>").join("");
menuEl.querySelectorAll("[data-mi]").forEach((el) => {
el.onmouseenter = () => { el.style.background = "var(--hover)"; };
el.onmouseleave = () => { el.style.background = ""; };
el.onclick = (e) => { e.stopPropagation(); closeMenu(); items[Number(el.dataset.mi)].fn(); };
}); });
detailEl.querySelector("#g-add").onclick = async () => { menuEl.classList.remove("hidden");
const v = detailEl.querySelector("#g-add-openid").value.trim(); // 贴边翻转,避免菜单溢出视口。
if (!v) return alert("请填写用户 id"); const r = menuEl.getBoundingClientRect();
menuEl.style.left = Math.min(x, window.innerWidth - r.width - 8) + "px";
menuEl.style.top = Math.min(y, window.innerHeight - r.height - 8) + "px";
}
/* ---------------- 弹窗:建组 / 改名 ---------------- */
function modal(html, width) {
const mask = document.createElement("div");
mask.className = "modal-mask";
mask.innerHTML = '<div class="modal-card"' +
(width ? ' style="max-width:' + width + 'px"' : "") + ">" + html + "</div>";
mask.onclick = (e) => { if (e.target === mask) mask.remove(); };
document.body.appendChild(mask);
const input = mask.querySelector("input");
if (input) input.focus();
return mask;
}
function createDialog(parentId, parentName) {
const m = modal(
'<div class="modal-title">' + (parentId ? "在「" + esc(parentName) + "」下新建子 Group" : "新建根 Group") + "</div>" +
'<div class="form-row"><label class="form-label">名称</label><input id="gc-name" class="input" placeholder="例如:物理教研组"/></div>' +
'<div class="form-row"><label class="form-label">描述(可选)</label><input id="gc-desc" class="input" placeholder="一句话说明"/></div>' +
'<div class="modal-actions"><button class="btn" data-x>取消</button><button class="btn btn-primary" data-ok>创建</button></div>'
);
m.querySelector("[data-x]").onclick = () => m.remove();
const submit = async () => {
const name = m.querySelector("#gc-name").value.trim();
if (!name) return alert("名称必填");
const description = m.querySelector("#gc-desc").value.trim();
try { try {
await req("/teams/" + encodeURIComponent(selected.id) + "/members", { await req("", { method: "POST", body: { name, parentId, ...(description ? { description } : {}) } });
method: "POST", m.remove();
body: v.startsWith("ou_") ? { feishuOpenId: v } : { userId: v }, if (parentId) collapsed.delete(parentId); // 建完自动展开父节点
}); await loadTree();
loadMembers();
} catch (e) { alert(e.message); } } catch (e) { alert(e.message); }
}; };
m.querySelector("[data-ok]").onclick = submit;
m.querySelector("#gc-name").onkeydown = (e) => { if (e.key === "Enter") submit(); };
} }
document.getElementById("g-create").onclick = async () => {
const slug = document.getElementById("g-slug").value.trim(); function renameDialog(target) {
const name = document.getElementById("g-name").value.trim(); const cur = groups.find((g) => g.id === target.id);
const description = document.getElementById("g-desc").value.trim(); const m = modal(
if (!slug || !name) return alert("slug 和名称必填"); '<div class="modal-title">重命名 / 改描述</div>' +
'<div class="form-row"><label class="form-label">名称</label><input id="gr-name" class="input" value="' + esc(target.name) + '"/></div>' +
'<div class="form-row"><label class="form-label">描述</label><input id="gr-desc" class="input" value="' +
esc((cur && cur.description) || "") + '" placeholder="留空则清除描述"/></div>' +
'<div class="modal-actions"><button class="btn" data-x>取消</button><button class="btn btn-primary" data-ok>保存</button></div>'
);
m.querySelector("[data-x]").onclick = () => m.remove();
const submit = async () => {
const name = m.querySelector("#gr-name").value.trim();
if (!name) return alert("名称必填");
try {
// description 总是回传(含空串)——空串即清除描述。
await req("/" + encodeURIComponent(target.id), {
method: "PATCH",
body: { name, description: m.querySelector("#gr-desc").value.trim() },
});
m.remove();
if (selected && selected.id === target.id) selected.name = name;
await loadTree();
if (selected && selected.id === target.id) loadMembers();
} catch (e) { alert(e.message); }
};
m.querySelector("[data-ok]").onclick = submit;
m.querySelector("#gr-name").onkeydown = (e) => { if (e.key === "Enter") submit(); };
}
async function restoreGroup(target) {
// 恢复语义与删除不对称(决策7):只回该组 + 已归档祖先链,子树仍归档。
if (!confirm("恢复「" + target.name + "」?\\n\\n其已删除的上级会一并恢复(否则它在树上无路径);" +
"子组保持删除状态,需各自恢复。恢复后该组的授权立即重新生效。")) return;
try { try {
await req("/teams", { method: "POST", body: { slug, name, ...(description ? { description } : {}) } }); const r = await req("/" + encodeURIComponent(target.id) + "/restore", { method: "POST" });
document.getElementById("g-slug").value = ""; await loadTree();
document.getElementById("g-name").value = ""; if (selected && selected.id === target.id) loadMembers();
document.getElementById("g-desc").value = ""; alert("已恢复 " + r.restoredCount + " 个组");
loadList();
} catch (e) { alert(e.message); } } catch (e) { alert(e.message); }
}
async function removeGroup(target) {
if (!confirm("删除「" + target.name + "」?\\n\\n软删除:整棵子树一并标记删除,相关授权立即失效," +
"但数据保留 —— 可在左侧打开「显示已删除的组」后恢复。")) return;
try {
const r = await req("/" + encodeURIComponent(target.id), { method: "DELETE" });
if (selected && selected.id === target.id) {
selected = null;
detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>';
}
await loadTree();
alert("已删除 " + r.archivedCount + " 个组(软删除,可恢复)");
} catch (e) { alert(e.message); }
}
/* ---------------- 成员面板 ---------------- */
let members = []; // 当前组的成员
let memberFilter = ""; // 成员表内过滤(显示名/userId/openId)
function selectGroup(target) {
selected = { id: target.id, name: target.name };
memberFilter = "";
renderTree();
}
/** 组头部 + 成员表(表格列:成员 / userId / 飞书 openId / 加入时间 / 操作)。 */
function renderMembers() {
if (!selected) return;
const g = groups.find((x) => x.id === selected.id);
const q = memberFilter.trim().toLowerCase();
const shown = q === ""
? members
: members.filter((m) =>
(m.displayName || "").toLowerCase().includes(q) ||
m.userId.toLowerCase().includes(q) ||
(m.feishuOpenId || "").toLowerCase().includes(q));
// 面包屑:祖先链(根在前)。
const byId = new Map(groups.map((x) => [x.id, x]));
const chain = [];
for (let cur = g; cur; cur = cur.parentId ? byId.get(cur.parentId) : null) chain.unshift(cur);
const crumb = chain.map((c, i) =>
(i > 0 ? '<span style="color:var(--text-3);margin:0 5px">/</span>' : "") +
(i === chain.length - 1
? '<span style="color:var(--text);font-weight:500">' + esc(c.name) + "</span>"
: '<span style="color:var(--text-3)">' + esc(c.name) + "</span>")).join("");
const isArchived = !!(g && g.archivedAt);
detailEl.innerHTML =
// 归档横幅:软删除是"打标",数据仍在,只是不再贡献权限。
(isArchived
? '<div style="display:flex;align-items:center;gap:9px;padding:10px 18px;flex-shrink:0;' +
'background:var(--hover);border-bottom:1px solid var(--border-soft);font-size:12.5px">' +
'<span style="display:flex;color:var(--text-3)">' + ico("archive", 15) + "</span>" +
'<span style="flex:1">此 Group 已删除于 ' +
esc(fmtDate(g.archivedAt)) + " · 成员只读,不再授予任何权限</span>" +
'<button id="gm-restore" class="btn" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
ico("restore", 13) + "恢复</button>" +
"</div>"
: "") +
// 头部
'<div style="padding:16px 18px 12px;border-bottom:1px solid var(--border-soft);flex-shrink:0">' +
'<div style="font-size:12px;margin-bottom:6px">' + crumb + "</div>" +
'<div style="display:flex;align-items:center;gap:10px">' +
'<span style="display:flex;color:' + (isArchived ? "var(--text-3)" : "var(--accent)") + '">' +
ico(isArchived ? "archive" : "group", 20) + "</span>" +
'<div style="flex:1;min-width:0">' +
'<div style="font-size:16px;font-weight:600;color:' + (isArchived ? "var(--text-3)" : "var(--text)") + '">' +
esc(selected.name) + "</div>" +
(g && g.description
? '<div class="section-note" style="margin-top:2px">' + esc(g.description) + "</div>"
: '<div class="section-note" style="margin-top:2px;opacity:.6">无描述</div>') +
"</div>" +
// 归档态不给改名/加成员入口(后端 requireActiveGroup 亦 404 兜底)。
(isArchived
? ""
: '<button id="gm-rename" class="btn" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
ico("pencil", 13) + "编辑</button>" +
'<button id="gm-add" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
ico("plus", 13) + "添加成员</button>") +
"</div>" +
// 统计条
'<div style="display:flex;gap:16px;margin-top:12px;font-size:12px;color:var(--text-3)">' +
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("user", 12) + members.length + " 名成员</span>" +
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("layers", 12) + "层级 " + ((g && g.depth) || 0) + "</span>" +
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("group", 12) +
groups.filter((x) => x.parentId === selected.id).length + " 个子组</span>" +
"</div>" +
"</div>" +
// 成员表工具条
'<div style="padding:10px 18px;flex-shrink:0;display:flex;align-items:center;gap:10px">' +
'<div style="position:relative;flex:1;max-width:280px">' +
'<span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">' +
ico("search", 13) + "</span>" +
'<input id="gm-filter" class="input" placeholder="搜索成员…" style="padding-left:28px;font-size:12.5px"' +
' value="' + esc(memberFilter) + '"/>' +
"</div>" +
'<span class="file-meta">' + (q === "" ? "" : shown.length + " / " + members.length) + "</span>" +
"</div>" +
// 成员表
'<div style="flex:1;overflow-y:auto;padding:0 18px 18px;min-height:0">' +
(members.length === 0
? '<div class="quiet" style="text-align:center;padding:36px 12px;display:flex;flex-direction:column;align-items:center;gap:10px">' +
'<span style="color:var(--border);display:flex">' + ico("users", 34) + "</span>" +
(isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」") + "</div>"
: shown.length === 0
? '<div class="quiet" style="text-align:center;padding:30px 12px">无匹配成员</div>'
: '<table class="list" style="width:100%">' +
"<thead><tr>" +
"<th>成员</th><th>userId</th><th>飞书 openId</th><th>加入时间</th>" +
(isArchived ? "" : "<th style=\\"text-align:right\\">操作</th>") +
"</tr></thead><tbody>" +
shown.map((m) =>
"<tr>" +
'<td><span style="display:inline-flex;align-items:center;gap:9px">' + avatar(m, 28) +
'<span style="font-weight:500">' + esc(m.displayName || "(未命名)") + "</span></span></td>" +
'<td class="file-meta">' + esc(m.userId) + "</td>" +
'<td class="file-meta">' + esc(m.feishuOpenId || "—") + "</td>" +
'<td class="file-meta">' + esc(fmtDate(m.joinedAt)) + "</td>" +
(isArchived
? ""
: '<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '"' +
' style="display:inline-flex;align-items:center;gap:4px">' + ico("minus", 12) + "移除</button></td>") +
"</tr>").join("") +
"</tbody></table>") +
"</div>";
// 归档态下这些按钮不渲染,故逐个判空。
const addBtn = detailEl.querySelector("#gm-add");
if (addBtn) addBtn.onclick = addMemberDialog;
const renameBtn = detailEl.querySelector("#gm-rename");
if (renameBtn) renameBtn.onclick = () => renameDialog(selected);
const restoreBtn = detailEl.querySelector("#gm-restore");
if (restoreBtn) {
restoreBtn.onclick = () => restoreGroup({ id: selected.id, name: selected.name, archived: true });
}
const f = detailEl.querySelector("#gm-filter");
if (f) {
f.oninput = () => { memberFilter = f.value; renderMembers(); detailEl.querySelector("#gm-filter").focus(); };
}
detailEl.querySelectorAll("button[data-revoke]").forEach((btn) => {
btn.onclick = async () => {
const uid = btn.dataset.revoke;
const who = (members.find((m) => m.userId === uid) || {}).displayName || uid;
if (!confirm("将「" + who + "」从「" + selected.name + "」移除?")) return;
try {
await req("/" + encodeURIComponent(selected.id) + "/members/" + encodeURIComponent(uid), { method: "DELETE" });
await Promise.all([loadTree(), loadMembers()]);
} catch (e) { alert(e.message); }
};
});
}
async function loadMembers() {
if (!selected) return;
// 归档组的成员照样拉取展示(只读)—— 软删是打标,成员行仍在库里。
detailEl.innerHTML = '<div class="quiet" style="margin:auto;padding:18px;text-align:center">加载中…</div>';
try {
const r = await req("/" + encodeURIComponent(selected.id) + "/members");
members = r.members;
renderMembers();
} catch (e) {
detailEl.innerHTML = '<div style="color:var(--danger);padding:16px">' + esc(e.message) + "</div>";
}
}
/* ---------------- 添加成员弹窗(搜索选择) ---------------- */
function addMemberDialog() {
if (!selected) return;
const m = modal(
'<div class="modal-title" style="display:flex;align-items:center;gap:7px">' +
'<span style="display:flex;color:var(--accent)">' + ico("user", 16) + "</span>" +
"添加成员到「" + esc(selected.name) + "」</div>" +
'<div style="position:relative;margin-bottom:10px">' +
'<span style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">' +
ico("search", 14) + "</span>" +
'<input id="ga-q" class="input" placeholder="搜索用户显示名 / 飞书 openId…" style="padding-left:30px"/>' +
"</div>" +
'<div id="ga-list" style="max-height:290px;overflow-y:auto;margin:0 -4px"></div>' +
'<div class="section-note" id="ga-note" style="margin-top:10px">已在本组的用户不会出现在结果里</div>' +
'<div class="modal-actions"><button class="btn" data-x>关闭</button></div>',
520
);
m.querySelector("[data-x]").onclick = () => m.remove();
const qEl = m.querySelector("#ga-q");
const listEl = m.querySelector("#ga-list");
const noteEl = m.querySelector("#ga-note");
let seq = 0;
async function search() {
const mine = ++seq;
listEl.innerHTML = '<div class="quiet" style="padding:16px;text-align:center">搜索中…</div>';
try {
const url = "/database/api/users/search?q=" + encodeURIComponent(qEl.value.trim()) +
"&excludeGroupId=" + encodeURIComponent(selected.id);
const res = await fetch(url, { credentials: "same-origin" });
if (res.status === 401) { location.href = "/database/admin"; return; }
const data = await res.json();
if (mine !== seq) return; // 丢弃过期响应
if (!res.ok) throw new Error((data.error && data.error.message) || res.statusText);
render(data.users);
} catch (e) {
if (mine === seq) listEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>";
}
}
function render(users) {
if (users.length === 0) {
listEl.innerHTML = '<div class="quiet" style="padding:20px;text-align:center">无匹配用户</div>';
return;
}
listEl.innerHTML = users.map((u, i) =>
'<div data-ui="' + i + '" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer">' +
avatar(u, 30) +
'<span style="flex:1;min-width:0">' +
'<span style="display:block;font-size:13px;font-weight:500;color:var(--text)">' + esc(u.displayName || "(未命名)") + "</span>" +
'<span class="file-meta" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
esc(u.feishuOpenId) + "</span>" +
"</span>" +
'<span class="btn" style="flex-shrink:0;font-size:11.5px;padding:3px 9px;display:inline-flex;align-items:center;gap:4px">' +
ico("plus", 12) + "添加</span>" +
"</div>").join("");
listEl.querySelectorAll("[data-ui]").forEach((el) => {
el.onmouseenter = () => { el.style.background = "var(--hover)"; };
el.onmouseleave = () => { el.style.background = ""; };
el.onclick = async () => {
const u = users[Number(el.dataset.ui)];
el.style.pointerEvents = "none";
el.style.opacity = ".5";
try {
await req("/" + encodeURIComponent(selected.id) + "/members", {
method: "POST", body: { userId: u.userId },
});
noteEl.textContent = "已添加 " + (u.displayName || u.userId);
noteEl.style.color = "var(--accent)";
await Promise.all([loadTree(), loadMembers()]);
await search(); // 刷新候选(已加的会被排除)
} catch (e) {
alert(e.message);
el.style.pointerEvents = "";
el.style.opacity = "";
}
};
});
}
let timer = null;
qEl.oninput = () => { clearTimeout(timer); timer = setTimeout(search, 200); };
search(); // 打开即列出候选
}
/* ---------------- 初始化 ---------------- */
async function loadTree() {
const r = await req(showArchived ? "?includeArchived=1" : "");
groups = r.groups;
// 选中项可能已被级联归档 → 清空右栏(showArchived 下归档组仍在列表里,不清)。
if (selected && !groups.some((g) => g.id === selected.id)) {
selected = null;
detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>';
}
renderTree();
}
document.getElementById("g-new-root").onclick = () => createDialog(null, null);
const filterEl = document.getElementById("g-filter");
filterEl.oninput = () => { filterText = filterEl.value; renderTree(); };
const archEl = document.getElementById("g-show-archived");
archEl.onchange = () => {
showArchived = archEl.checked;
loadTree().catch((e) => alert(e.message));
}; };
loadList().catch((e) => { listEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>"; }); loadTree().catch((e) => {
treeEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>";
});
})(); })();
</script>`; </script>`;
} }
+8 -7
View File
@@ -26,11 +26,12 @@ import path from "node:path";
import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js"; import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js";
import { registerFileLibRoutes } from "./filelibRoutes.js"; import { registerFileLibRoutes } from "./filelibRoutes.js";
import { registerFileRoutes } from "./fileRoutes.js"; import { registerFileRoutes } from "./fileRoutes.js";
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
import { registerTeacherApp } from "./teacherApp.js"; import { registerTeacherApp } from "./teacherApp.js";
import { renderLibraryBrowser } from "./libraryBrowser.js"; import { renderLibraryBrowser } from "./libraryBrowser.js";
import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.js"; import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.js";
import { createInMemoryVersionStore } from "../filelib/versionStore.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 { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
import { createManifestStubAdapter } from "../filelib/exportService.js"; import { createManifestStubAdapter } from "../filelib/exportService.js";
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js"; import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
@@ -122,7 +123,8 @@ export async function registerDatabaseRoutes(
// 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。 // 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。
// 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换); // 依赖装配: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,真导出工具到位后替换)。 // 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
const siloOrg = await config.prisma.organization.findUnique({ const siloOrg = await config.prisma.organization.findUnique({
where: { slug: config.siloOrganizationSlug }, where: { slug: config.siloOrganizationSlug },
@@ -141,13 +143,14 @@ export async function registerDatabaseRoutes(
organizationId: siloOrg.id, organizationId: siloOrg.id,
storageRoot, storageRoot,
groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === "" groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === ""
? createTeamGroupResolver(config.prisma, siloOrg.id) ? createMemberGroupResolver(config.prisma)
: createHttpGroupResolver({ baseUrl: groupServiceUrl }), : createHttpGroupResolver({ baseUrl: groupServiceUrl }),
versionStore, versionStore,
exportAdapters: [createManifestStubAdapter(versionStore)], exportAdapters: [createManifestStubAdapter(versionStore)],
}; };
await registerFileLibRoutes(app, filelibDeps); await registerFileLibRoutes(app, filelibDeps);
await registerFileRoutes(app, filelibDeps); await registerFileRoutes(app, filelibDeps);
await registerMemberGroupRoutes(app, filelibDeps);
await registerTeacherApp(app, { await registerTeacherApp(app, {
prisma: config.prisma, prisma: config.prisma,
sessionSecret: config.sessionSecret, sessionSecret: config.sessionSecret,
@@ -339,7 +342,6 @@ ${pageHead("Database Admin")}
<div style="margin:10px;padding:10px 12px;border-top:1px solid var(--border-soft);display:flex;align-items:center;gap:9px"> <div style="margin:10px;padding:10px 12px;border-top:1px solid var(--border-soft);display:flex;align-items:center;gap:9px">
<div style="width:26px;height:26px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;flex-shrink:0">${initial}</div> <div style="width:26px;height:26px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;flex-shrink:0">${initial}</div>
<div style="min-width:0;flex:1"> <div style="min-width:0;flex:1">
<p style="font-size:10.5px;color:var(--text-3)">已登录</p>
<p style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--text)">${escapeHtml(displayName)}</p> <p style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--text)">${escapeHtml(displayName)}</p>
</div> </div>
<button id="logout" class="btn" style="padding:3px 10px;font-size:11px">退出</button> <button id="logout" class="btn" style="padding:3px 10px;font-size:11px">退出</button>
@@ -368,9 +370,8 @@ ${pageHead("Database Admin")}
${renderUsersPanel(orgSlug)} ${renderUsersPanel(orgSlug)}
</section> </section>
<section id="tab-groups" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px"> <section id="tab-groups" class="admin-tab-section" style="display:none;flex:1;overflow:hidden;padding:20px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:16px">Group 管理</h1> ${renderGroupsPanel()}
${renderGroupsPanel(orgSlug)}
</section> </section>
<section id="tab-search" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px"> <section id="tab-search" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
+2 -23
View File
@@ -261,29 +261,8 @@ export async function registerFileLibRoutes(
} }
}); });
/* ------------------------------------------------------------ Group 搜索(过渡) */ // Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
// 过渡实现:读 hub Team(C2 /groups/search 由 Group 团队交付后切换)。
app.get("/database/api/groups/search", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const q = ((request.query as { q?: string }).q ?? "").trim();
const teams = await deps.prisma.team.findMany({
where: {
organizationId: deps.organizationId,
archivedAt: null,
...(q === "" ? {} : { name: { contains: q, mode: "insensitive" as const } }),
},
take: 20,
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return { groups: teams.map((t) => ({ id: t.id, name: t.name, breadcrumb: t.name })) };
} catch (error) {
return sendRouteError(reply, error);
}
});
} }
function parseGrants(raw: unknown): InitialGrant[] | undefined { function parseGrants(raw: unknown): InitialGrant[] | undefined {
@@ -0,0 +1,196 @@
/**
* /database/api/groups/* 成员组管理端点(ADR-0028)。
* 约定:绝对路径;actorOrNull 前置 fail closed;业务全走 memberGroupService;
* 错误统一 sendRouteError。
*
* 管理端点(CRUD + 成员)由 service 层门禁到网站管理员;搜索端点不限管理员
* (授权选择器是 Manage 持有者的能力,决策2)。
*/
import type { FastifyInstance } from "fastify";
import {
addMember,
createMemberGroup,
deleteMemberGroup,
listMemberGroups,
listMembers,
removeMember,
restoreMemberGroup,
searchMemberGroups,
searchUsers,
updateMemberGroup,
} from "../filelib/memberGroupService.js";
import { FileLibError } from "../filelib/model.js";
import {
actorOrNull,
bodyObject,
optionalString,
requireString,
sendRouteError,
type FileLibRouteDeps,
} from "../filelib/routeShared.js";
export async function registerMemberGroupRoutes(
app: FastifyInstance,
deps: FileLibRouteDeps,
): Promise<void> {
const svc = { prisma: deps.prisma, organizationId: deps.organizationId };
/* ------------------------------------------------------------ 搜索(授权选择器) */
// 契约 C2 /groups/search:活跃组 + breadcrumb。**非管理员可调**(决策2)。
// 注:必须先于 "/database/api/groups" 之类的段前缀之外单独成路径,Fastify
// 静态路由不会 shadow,顺序无关;此处与其它端点平级注册。
app.get("/database/api/groups/search", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const q = (request.query as { q?: string }).q ?? "";
return { groups: await searchMemberGroups(svc, q) };
} catch (error) {
return sendRouteError(reply, error);
}
});
// 成员选择器:搜全局用户。仅管理员(service 层门禁)。
// excludeGroupId 过滤掉该组已有活跃成员。
app.get("/database/api/users/search", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const query = request.query as { q?: string; excludeGroupId?: string };
return { users: await searchUsers(svc, actor, query.q ?? "", query.excludeGroupId) };
} catch (error) {
return sendRouteError(reply, error);
}
});
/* ------------------------------------------------------------ 组 CRUD */
// includeArchived=1 时连已归档组一并返回(带 archivedAt),供后台展示/恢复。
app.get("/database/api/groups", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const raw = (request.query as { includeArchived?: string }).includeArchived;
const includeArchived = raw === "1" || raw === "true";
return { groups: await listMemberGroups(svc, actor, includeArchived) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.post("/database/api/groups", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const body = bodyObject(request.body);
const parentIdRaw = body["parentId"];
if (parentIdRaw !== undefined && parentIdRaw !== null && typeof parentIdRaw !== "string") {
throw new FileLibError(400, "invalid_request", "parentId must be a string or null");
}
const group = await createMemberGroup(svc, actor, {
name: requireString(body, "name"),
description: optionalString(body, "description"),
parentId: parentIdRaw === undefined ? null : parentIdRaw,
});
return reply.status(201).send({ group });
} catch (error) {
return sendRouteError(reply, error);
}
});
// 改名 / 改描述(决策6)。不接受 parentId —— reparent 仍不在 v1(决策5)。
app.patch("/database/api/groups/:id", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
if (body["parentId"] !== undefined) {
throw new FileLibError(400, "invalid_request", "reparent is not supported (ADR-0028)");
}
// description 需区分"未传"(不动)与 ""(清空),故不用 optionalString
// (它把 "" 也归为 undefined)。
const descRaw = body["description"];
if (descRaw !== undefined && descRaw !== null && typeof descRaw !== "string") {
throw new FileLibError(400, "invalid_request", "description must be a string");
}
const group = await updateMemberGroup(svc, actor, id, {
name: optionalString(body, "name"),
description: descRaw === undefined || descRaw === null ? undefined : descRaw,
});
return { group };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/groups/:id", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const result = await deleteMemberGroup(svc, actor, id);
return { archivedCount: result.archivedCount };
} catch (error) {
return sendRouteError(reply, error);
}
});
// 恢复(取消归档)。与删除不对称:只恢复该组 + 已归档祖先链,不动子树(决策7)。
app.post("/database/api/groups/:id/restore", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const result = await restoreMemberGroup(svc, actor, id);
return { restoredCount: result.restoredCount };
} catch (error) {
return sendRouteError(reply, error);
}
});
/* ------------------------------------------------------------ 成员 */
app.get("/database/api/groups/:id/members", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
return { members: await listMembers(svc, actor, id) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.post("/database/api/groups/:id/members", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id } = request.params as { id: string };
const body = bodyObject(request.body);
const userId = optionalString(body, "userId");
const feishuOpenId = optionalString(body, "feishuOpenId");
if (userId === undefined && feishuOpenId === undefined) {
throw new FileLibError(400, "invalid_request", "userId or feishuOpenId is required");
}
const member = await addMember(svc, actor, id, { userId, feishuOpenId });
return reply.status(201).send({ member });
} catch (error) {
return sendRouteError(reply, error);
}
});
app.delete("/database/api/groups/:id/members/:userId", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const { id, userId } = request.params as { id: string; userId: string };
await removeMember(svc, actor, id, userId);
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
}
+17
View File
@@ -135,4 +135,21 @@ export const UI_THEME_CSS = `
.file-meta { font-size: 11px; color: var(--text-3); font-family: var(--mono); } .file-meta { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
.link-danger { color: var(--danger); font-size: 12.5px; background: none; border: none; cursor: pointer; padding: 0; } .link-danger { color: var(--danger); font-size: 12.5px; background: none; border: none; cursor: pointer; padding: 0; }
.link-danger:hover { text-decoration: underline; } .link-danger:hover { text-decoration: underline; }
/* 开关(toggle)。用法:<label class="switch"><input type="checkbox"><span></span>文字</label>
真正的 checkbox 藏在下面 —— 保留键盘可达与 :checked 语义,不做 div 假开关。 */
.switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
.switch > input { position: absolute; opacity: 0; width: 0; height: 0; }
.switch > span {
position: relative; flex-shrink: 0; width: 30px; height: 17px; border-radius: 999px;
background: var(--border); transition: background .16s;
}
.switch > span::after {
content: ""; position: absolute; top: 2px; left: 2px; width: 13px; height: 13px;
border-radius: 50%; background: #fff; transition: transform .16s;
box-shadow: 0 1px 2px rgba(0,0,0,.25);
}
.switch > input:checked + span { background: var(--accent); }
.switch > input:checked + span::after { transform: translateX(13px); }
.switch > input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
`; `;
+311
View File
@@ -0,0 +1,311 @@
/**
* 成员组(MemberGroup)集成测试(真实 Postgres)。ADR-0028。
* 覆盖:嵌套创建 + 闭包维护、解析(直接组 ∪ 活跃祖先)、祖先授权递归传递
* (3.2)、级联软删 + 实时失效、非管理员 403、成员增删幂等/重加、搜索 breadcrumb。
* 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。
*/
import { beforeEach, describe, expect, it } from "vitest";
import { prisma, resetDb, DEFAULT_ORG_ID } from "./helpers.js";
import {
addMember,
createMemberGroup,
deleteMemberGroup,
listMemberGroups,
listMembers,
removeMember,
searchMemberGroups,
searchUsers,
updateMemberGroup,
type MemberGroupServiceDeps,
} from "../../src/database/filelib/memberGroupService.js";
import { createMemberGroupResolver } from "../../src/database/filelib/memberGroupResolver.js";
import {
createNode,
getEffectiveRole,
type FileLibActor,
type TreeServiceDeps,
} from "../../src/database/filelib/treeService.js";
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
const ADMIN: FileLibActor = { userId: "u_admin", isWebsiteAdmin: true };
const ALICE: FileLibActor = { userId: "u_alice", isWebsiteAdmin: false };
function svc(): MemberGroupServiceDeps {
return { prisma, organizationId: DEFAULT_ORG_ID };
}
/** treeService deps 用真实 MemberGroup 解析器,串起「组授权 → 成员生效」全链路。 */
function treeDeps(): TreeServiceDeps {
return {
prisma,
groupResolver: createMemberGroupResolver(prisma),
versionStore: createInMemoryVersionStore(),
organizationId: DEFAULT_ORG_ID,
storageRoot: "/tmp/member-groups-test",
};
}
async function seedUsers(): Promise<void> {
for (const [id, openId] of [["u_admin", "ou_admin"], ["u_alice", "ou_alice"], ["u_bob", "ou_bob"]] as const) {
await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } });
}
}
/** 建嵌套链 A>B>C,返回三者 id。 */
async function seedChain(): Promise<{ a: string; b: string; c: string }> {
const a = await createMemberGroup(svc(), ADMIN, { name: "A" });
const b = await createMemberGroup(svc(), ADMIN, { name: "B", parentId: a.id });
const c = await createMemberGroup(svc(), ADMIN, { name: "C", parentId: b.id });
return { a: a.id, b: b.id, c: c.id };
}
beforeEach(async () => {
await resetDb();
await seedUsers();
});
describe("memberGroupService · 创建与闭包", () => {
it("建根 depth0;建子继承祖先闭包,depth 递增", async () => {
const { a, b, c } = await seedChain();
// 闭包不变量:C 有 (A,C,2)/(B,C,1)/(C,C,0) 三行。
const closureC = await prisma.memberGroupClosure.findMany({
where: { descendantId: c },
orderBy: { depth: "asc" },
});
expect(closureC.map((r) => [r.ancestorId, r.depth])).toEqual([
[c, 0], [b, 1], [a, 2],
]);
const groups = await listMemberGroups(svc(), ADMIN);
const byId = new Map(groups.map((g) => [g.id, g]));
expect(byId.get(a)?.depth).toBe(0);
expect(byId.get(b)?.depth).toBe(1);
expect(byId.get(c)?.depth).toBe(2);
expect(byId.get(b)?.parentId).toBe(a);
});
it("父组不存在/已归档 → 404", async () => {
await expect(createMemberGroup(svc(), ADMIN, { name: "X", parentId: "nope" }))
.rejects.toMatchObject({ statusCode: 404 });
});
});
describe("memberGroupResolver · 解析(直接组 活跃祖先)", () => {
it("成员在 C → 解析得 {C,B,A};无所属 → 空", async () => {
const { a, b, c } = await seedChain();
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
const resolver = createMemberGroupResolver(prisma);
const ids = await resolver.resolveMemberGroupIds("u_alice");
expect([...ids].sort()).toEqual([a, b, c].sort());
expect(await resolver.resolveMemberGroupIds("u_bob")).toEqual([]);
});
});
describe("memberGroupService · 3.2 祖先授权递归传递", () => {
it("给祖先组 A 授文件夹权限 → C 的成员经 effectiveRole 拿到该权限", async () => {
const { a, c } = await seedChain();
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
// ADMIN 建根文件夹,授权给"祖先组 A"。ALICE 只在 C,靠祖先方向解析命中 A。
const folder = await createNode(treeDeps(), ADMIN, {
parentId: null, kind: "FOLDER", name: "共享",
grants: [{ principalType: "GROUP", principalId: a, role: "EDIT" }],
});
expect(await getEffectiveRole(treeDeps(), ALICE, folder.id)).toBe("EDIT");
});
});
describe("memberGroupService · 改名/改描述(决策6)", () => {
it("改名不动闭包:depth/parentId/子树关系全保持", async () => {
const { a, b, c } = await seedChain();
const before = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] });
const updated = await updateMemberGroup(svc(), ADMIN, b, { name: "B2", description: "改后" });
expect(updated.name).toBe("B2");
expect(updated.description).toBe("改后");
expect(updated.parentId).toBe(a);
expect(updated.depth).toBe(1);
// 闭包逐行未变 —— rename 不碰层级(ADR-0028 决策6 的核心不变量)。
const after = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] });
expect(after).toEqual(before);
// C 仍在 B 之下,depth 不变。
const byId = new Map((await listMemberGroups(svc(), ADMIN)).map((g) => [g.id, g]));
expect(byId.get(c)?.depth).toBe(2);
expect(byId.get(c)?.parentId).toBe(b);
});
it("空描述清空;字段缺省则不动;两者皆缺 → 400", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G", description: "原描述" });
expect((await updateMemberGroup(svc(), ADMIN, g.id, { description: "" })).description).toBeNull();
// 只传 name → 描述保持(此时已是 null)。
const renamed = await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" });
expect(renamed.name).toBe("G2");
expect(renamed.description).toBeNull();
await expect(updateMemberGroup(svc(), ADMIN, g.id, {})).rejects.toMatchObject({ statusCode: 400 });
});
it("空名 → 400;已归档组 → 404;非管理员 → 403", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: " " })).rejects.toMatchObject({ statusCode: 400 });
await expect(updateMemberGroup(svc(), ALICE, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 403 });
await deleteMemberGroup(svc(), ADMIN, g.id);
await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 404 });
});
it("改名写 group.update 审计", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" });
const actions = (await prisma.auditEntry.findMany({
where: { organizationId: DEFAULT_ORG_ID },
select: { action: true },
orderBy: { createdAt: "asc" },
})).map((e) => e.action);
expect(actions).toEqual(["group.create", "group.update"]);
});
});
describe("memberGroupService · 级联软删 + 实时失效", () => {
it("软删 B → B、C 从 list/解析消失,经此支的权限立即失效", async () => {
const { a, b, c } = await seedChain();
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
const folder = await createNode(treeDeps(), ADMIN, {
parentId: null, kind: "FOLDER", name: "共享",
grants: [{ principalType: "GROUP", principalId: a, role: "EDIT" }],
});
expect(await getEffectiveRole(treeDeps(), ALICE, folder.id)).toBe("EDIT");
const { archivedCount } = await deleteMemberGroup(svc(), ADMIN, b);
expect(archivedCount).toBe(2); // B + C
const remaining = (await listMemberGroups(svc(), ADMIN)).map((g) => g.id);
expect(remaining).toEqual([a]);
// C 已归档 → ALICE 的直接组失效 → 解析空 → 对该文件夹不再可见(D8 → 404)。
expect(await createMemberGroupResolver(prisma).resolveMemberGroupIds("u_alice")).toEqual([]);
await expect(getEffectiveRole(treeDeps(), ALICE, folder.id)).rejects.toMatchObject({ statusCode: 404 });
});
});
describe("memberGroupService · 成员增删", () => {
it("重复添加 → 409;移除后可重新添加", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
await expect(addMember(svc(), ADMIN, g.id, { userId: "u_alice" }))
.rejects.toMatchObject({ statusCode: 409 });
await removeMember(svc(), ADMIN, g.id, "u_alice");
expect(await listMembers(svc(), ADMIN, g.id)).toHaveLength(0);
// 重加(revokedAt 软删允许 @@unique([groupId,userId,revokedAt]) 下的新活跃行)。
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
expect(await listMembers(svc(), ADMIN, g.id)).toHaveLength(1);
});
it("按飞书 openId 解析成员", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
const m = await addMember(svc(), ADMIN, g.id, { feishuOpenId: "ou_bob" });
expect(m.userId).toBe("u_bob");
});
it("移除不存在成员 → 404", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await expect(removeMember(svc(), ADMIN, g.id, "u_alice"))
.rejects.toMatchObject({ statusCode: 404 });
});
});
describe("memberGroupService · 管理门禁(决策2)", () => {
it("非管理员调 CRUD/成员 → 403;搜索不限管理员", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await expect(createMemberGroup(svc(), ALICE, { name: "X" })).rejects.toMatchObject({ statusCode: 403 });
await expect(deleteMemberGroup(svc(), ALICE, g.id)).rejects.toMatchObject({ statusCode: 403 });
await expect(listMemberGroups(svc(), ALICE)).rejects.toMatchObject({ statusCode: 403 });
await expect(listMembers(svc(), ALICE, g.id)).rejects.toMatchObject({ statusCode: 403 });
await expect(addMember(svc(), ALICE, g.id, { userId: "u_bob" })).rejects.toMatchObject({ statusCode: 403 });
await expect(removeMember(svc(), ALICE, g.id, "u_bob")).rejects.toMatchObject({ statusCode: 403 });
// 搜索:非管理员可调(授权选择器)。
const results = await searchMemberGroups(svc(), "G");
expect(results.map((r) => r.id)).toContain(g.id);
});
});
describe("memberGroupService · 成员选择器 searchUsers", () => {
it("按显示名/openId 搜;excludeGroupId 排除已在组成员;仅管理员", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
// 空 q 列出全部(3 个 seed 用户)。
expect((await searchUsers(svc(), ADMIN, "")).length).toBe(3);
// 按 displayName 命中(seed 的 displayName 即 id)。
expect((await searchUsers(svc(), ADMIN, "alice")).map((u) => u.userId)).toEqual(["u_alice"]);
// 按 openId 命中。
expect((await searchUsers(svc(), ADMIN, "ou_bob")).map((u) => u.userId)).toEqual(["u_bob"]);
// 已在组的人被排除 —— 避免选中必然 409 的候选。
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
const ids = (await searchUsers(svc(), ADMIN, "", g.id)).map((u) => u.userId);
expect(ids).not.toContain("u_alice");
expect(ids).toContain("u_bob");
// 移除后重新成为候选(revokedAt 软删)。
await removeMember(svc(), ADMIN, g.id, "u_alice");
expect((await searchUsers(svc(), ADMIN, "", g.id)).map((u) => u.userId)).toContain("u_alice");
await expect(searchUsers(svc(), ALICE, "")).rejects.toMatchObject({ statusCode: 403 });
});
});
describe("memberGroupService · 成员表字段", () => {
it("listMembers 返回 openId/avatar/joinedAt(供成员表列展示)", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
const [m] = await listMembers(svc(), ADMIN, g.id);
expect(m).toMatchObject({
userId: "u_alice",
displayName: "u_alice",
feishuOpenId: "ou_alice",
avatarUrl: null,
});
expect(m?.joinedAt).toBeInstanceOf(Date);
});
});
describe("memberGroupService · 搜索 breadcrumb", () => {
it("breadcrumb 由活跃祖先链按 depth 拼(根在前)", async () => {
const { c } = await seedChain();
const results = await searchMemberGroups(svc(), "C");
const hit = results.find((r) => r.id === c);
expect(hit?.breadcrumb).toBe("A / B / C");
});
it("归档组不出现在搜索(G3)", async () => {
const { a, b } = await seedChain();
await deleteMemberGroup(svc(), ADMIN, b); // 归档 B、C
const ids = (await searchMemberGroups(svc(), "")).map((r) => r.id);
expect(ids).toContain(a);
expect(ids).not.toContain(b);
});
});
describe("memberGroupService · 审计(C3/决策4)", () => {
it("建组/加成员/删组写 AuditEntry(挂 silo org)", async () => {
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
await removeMember(svc(), ADMIN, g.id, "u_alice");
await deleteMemberGroup(svc(), ADMIN, g.id);
const actions = (await prisma.auditEntry.findMany({
where: { organizationId: DEFAULT_ORG_ID },
select: { action: true },
orderBy: { createdAt: "asc" },
})).map((e) => e.action);
expect(actions).toEqual([
"group.create", "group.member_add", "group.member_remove", "group.delete",
]);
});
});