diff --git a/.gitignore b/.gitignore
index 08f5590..633ed63 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,5 @@ node_modules/
# OS / editor
.DS_Store
+
+.omo/
diff --git a/docs/adr/0030-project-grants-always-live.md b/docs/adr/0030-project-grants-always-live.md
new file mode 100644
index 0000000..8e17408
--- /dev/null
+++ b/docs/adr/0030-project-grants-always-live.md
@@ -0,0 +1,43 @@
+# ADR 0030: Project Grants Are Always Live; The Independent-Permission Toggle Is Removed
+
+## Status
+
+Accepted. Supersedes the file-library contract rule **D11 / P5** (《文件库-接口契约.md》,
+since deleted; recoverable from git history) which introduced the per-project
+"独立权限" (independent permission) switch.
+
+## Context
+
+D11 gave each PROJECT a toggle (`FileLibProjectSettings.independentPermissionsEnabled`,
+default off). While off, project-level non-creator grants were **frozen** — present in
+`FileLibGrant` but excluded from `effectiveRole`; ancestor-chain grants and the creator's
+auto-grant were unaffected. The intent was to support two workflows: "project follows the
+folder's ACL" (off) vs "project has its own ACL" (on).
+
+In practice the toggle surprised operators twice: grants appeared to "not work" until
+someone found and flipped a per-project switch buried in the 概览 tab, and the frozen state
+was indistinguishable from missing grants in the UI. The product decision is that
+project-level grants should simply always be live.
+
+## Decision
+
+- **Project-level grants always participate in `effectiveRole`.** The freeze branch in
+ `hub/src/database/filelib/permission.ts` is deleted; `EffectiveRoleInput` no longer
+ carries `independentPermissionsEnabled`.
+- **The toggle surface is removed end-to-end**: `PUT /database/api/projects/:id/independent-permission`,
+ `grantService.setIndependentPermission`, the `independentPermission` field in the node
+ detail DTO, and the 概览 tab switch in `filelib-web`.
+- **`FileLibProjectSettings` becomes vestigial.** The table stays (existing rows are
+ ignored, no data migration); new projects no longer get a default row. It may be dropped
+ in a future migration once nothing references it.
+- Audit action vocabulary `independent_enable` / `independent_disable` is retained for
+ reading historical audit entries; no new entries are produced.
+
+Behavior change for existing deployments: projects whose toggle was off now have their
+project-level grants effective immediately — this is the intended effect of the decision.
+
+## Consequences
+
+- Permission semantics shrink to the single P6 rule: `effective = max(grants on self ∪
+ ancestors for user ∪ resolved groups)`, no exceptions by node kind.
+- One less state dimension in tests and in the admin UI.
diff --git a/docs/adr/0031-filelib-recycle-bin-and-recent-visits.md b/docs/adr/0031-filelib-recycle-bin-and-recent-visits.md
new file mode 100644
index 0000000..53ce452
--- /dev/null
+++ b/docs/adr/0031-filelib-recycle-bin-and-recent-visits.md
@@ -0,0 +1,58 @@
+# ADR 0031: File Library Recycle Bin And Recent-Visit Tracking
+
+## Status
+
+Accepted.
+
+## Context
+
+The teacher app (`/app`) gains a left navigation rail with three entries: 文件库 /
+最近打开 / 回收站. Two of them need semantics that no prior decision covers:
+
+- **回收站 (recycle bin)**: D15 defined soft delete (mark `deletedAt` on the node only;
+ a node is invisible when any ancestor is deleted) but never defined listing, restore,
+ or permanent deletion.
+- **最近打开 (recent visits)**: nothing tracks opens.
+
+## Decision
+
+### Recycle bin
+
+- **List**: shows nodes with `deletedAt != null` whose **ancestors are all active**
+ (the topmost deleted node per branch; descendants of a deleted node are represented
+ by it and not listed separately).
+- **Visibility/auth**: a bin entry is visible to (a) the website administrator, or
+ (b) any actor holding an active MANAGE grant **on the deleted node itself**
+ (grants stay live through soft delete, so this is a plain grant query — no chain
+ walk, no inheritance; the bin is a management surface, not a browsing surface).
+- **Restore** clears `deletedAt` on that node only (D15 symmetry: delete marks one
+ node, restore unmarks one node). The subtree becomes visible again immediately.
+ Same auth as the list entry. Audited (`folder_restore` / `project_restore`).
+- **Permanent delete (彻底删除)** is **website-administrator only**: hard-deletes the
+ node **and its whole subtree** (descendants enumerated via the `pathIds` materialized
+ path, deleted deepest-first because the self-FK is `ON DELETE RESTRICT`), in one
+ transaction, with one audit entry (`node_purge`, detail carries removed count).
+ Grants/settings/export-jobs cascade. There is no recovery; the UI must confirm
+ explicitly.
+
+### Recent visits
+
+- **Model**: `FileLibRecentVisit(organizationId, userId, nodeId, filePath, openedAt)`,
+ unique on `(organizationId, userId, nodeId, filePath)` with `filePath` defaulting to
+ `""` (Postgres unique indexes treat NULLs as distinct). `filePath = ""` means the
+ visit is the node itself (drill into folder/project); non-empty means a file preview
+ inside that project.
+- **Recording is client-driven**: the teacher app POSTs after a successful open
+ (folder drill, project open, file preview). The endpoint requires VIEW on the node
+ (D8: no VIEW → 404, leaking nothing). Upsert semantics: re-opening refreshes
+ `openedAt`. No audit entries — this is a per-user read model, not a权限-sensitive
+ mutation.
+- **List**: the actor's own most recent 20, `openedAt` desc. Entries whose node is
+ deleted **or has any deleted ancestor** are filtered out (D8/D15 visibility holds
+ on every surface). Names are read live from `FileLibNode` (no denormalization).
+
+## Consequences
+
+- No change to existing permission algebra; both features are additive surfaces.
+- The bin deliberately does not offer per-owner bins or inherited-MANAGE visibility —
+ if real usage demands it, that is a new decision.
diff --git a/docs/adr/0032-remove-recent-visit-module.md b/docs/adr/0032-remove-recent-visit-module.md
new file mode 100644
index 0000000..6cd24a3
--- /dev/null
+++ b/docs/adr/0032-remove-recent-visit-module.md
@@ -0,0 +1,30 @@
+# ADR 0032: Remove The Recent-Visit Module
+
+## Status
+
+Accepted. **Supersedes the "Recent visits" half of ADR-0031** (the recycle-bin half
+is unaffected and remains in force).
+
+## Context
+
+ADR-0031 (same day) introduced 最近打开: a `FileLibRecentVisit` table, client-driven
+visit recording, and a rail entry in the teacher app. After seeing it live, the product
+call is that the module is not wanted — it adds a tracking surface, a table, and rail
+noise without a compelling teacher workflow behind it.
+
+## Decision
+
+The recent-visit module is removed end-to-end:
+
+- `FileLibRecentVisit` is dropped (hand-written migration
+ `20260731090000_drop_filelib_recent_visit`; the table was created the same day and
+ held no production data).
+- `recentService` / `recentRoutes` (`/database/api/recent`) and the `RecentView`
+ component are deleted; the rail in `/app` keeps only 文件库 / 回收站.
+- `GridLibraryView` visit recording and the `navTarget` navigation entry go with it.
+- The `role` field added to breadcrumb entries for ADR-0031 is **kept** — it is a
+ cheap, additive field on an existing API and independent of the removed module.
+
+If recent-visit tracking comes back as a requirement, it is a new decision (and
+should then define why client-driven tracking is worth its surface) rather than a
+revival of this one.
diff --git a/docs/adr/0033-restore-deduplicates-name.md b/docs/adr/0033-restore-deduplicates-name.md
new file mode 100644
index 0000000..579f12c
--- /dev/null
+++ b/docs/adr/0033-restore-deduplicates-name.md
@@ -0,0 +1,26 @@
+# ADR 0033: Restore De-Duplicates The Node Name On Sibling Conflict
+
+## Status
+
+Accepted.
+
+## Context
+
+ADR-0031 defined restore as "clear `deletedAt` on that node only". It did not cover
+the case where a same-name sibling was created **after** the deletion: D14's partial
+unique index (active siblings, case-insensitive) then rejects the restore with a 409
+`conflict`, leaving the entry permanently stuck in the bin — unrecoverable for
+non-admin users (who cannot purge) and cryptic for admins.
+
+## Decision
+
+Restore never fails on a name conflict. Before clearing `deletedAt`, the service
+checks active siblings; if the node's name is taken, it restores as
+`原名(已恢复)`, then `原名(已恢复 2)`, …, first free key wins (suffix is included
+in the `NODE_NAME_MAX_LENGTH` budget by truncating the base). The rename is part of
+the same transaction and is recorded in the restore audit entry as
+`{ name, renamedFrom }`. The API returns the final name so the UI can tell the user.
+
+Rationale: the bin's purpose is recovery; a restore that can deadlock on naming is a
+trap, not a safeguard. Users who care about the name can rename afterwards (they have
+MANAGE by definition of bin visibility).
diff --git a/docs/adr/0034-purge-follows-manage.md b/docs/adr/0034-purge-follows-manage.md
new file mode 100644
index 0000000..e2f1bcb
--- /dev/null
+++ b/docs/adr/0034-purge-follows-manage.md
@@ -0,0 +1,28 @@
+# ADR 0034: Permanent Delete Follows MANAGE, Not Website Administrator
+
+## Status
+
+Accepted. **Supersedes one clause of ADR-0031**: "Permanent delete (彻底删除) is
+website-administrator only".
+
+## Context
+
+ADR-0031 gated 彻底删除 to the website administrator as a high-risk-operation
+precaution. The product call is that this is inconsistent with the rest of the
+permission model: soft delete already requires only MANAGE on the node, and a
+MANAGE holder who can delete a node into the bin should also be able to purge it —
+the authority that grants deletion grants destruction. Admin-only purge strands
+non-admin managers with bins they cannot empty.
+
+## Decision
+
+Permanent delete uses **the same visibility rule as the bin entry itself**: website
+administrator, or an actor with an active MANAGE grant on the deleted node (direct
+grant, USER or resolved GROUP). Anyone else gets 404 (D8). The double confirmation
+in the UI and the `node.purge` audit entry are unchanged.
+
+## Consequences
+
+- Purge auth = restore auth = bin-entry visibility: one rule, three surfaces.
+- The operation remains irreversible and audited; no new capability is granted to
+ anyone who could not already delete the node (soft) and see it in the bin.
diff --git a/docs/adr/0035-restore-keeps-original-name.md b/docs/adr/0035-restore-keeps-original-name.md
new file mode 100644
index 0000000..a16c689
--- /dev/null
+++ b/docs/adr/0035-restore-keeps-original-name.md
@@ -0,0 +1,20 @@
+# ADR 0035: Restore Keeps The Original Name; Conflict Is A Clear Error
+
+## Status
+
+Accepted. **Supersedes ADR-0033** (restore de-duplicates the node name on sibling
+conflict).
+
+## Context
+
+ADR-0033 made restore auto-rename to `原名(已恢复)` on sibling name conflict so
+restore never fails. In practice the suffix is unwanted noise - operators expect the
+original name back and prefer to resolve conflicts themselves.
+
+## Decision
+
+Restore clears `deletedAt` and keeps the node's **original name**. If an active
+sibling now occupies the same name (D14 partial unique index), the service throws a
+`409 name_conflict_on_restore` with a human-readable message ("同名节点已存在,请先
+重命名现有节点再恢复") - no silent renaming, no suffix. The restore audit records
+the original name only.
diff --git a/hub/filelib-web/src/lib/BinView.svelte b/hub/filelib-web/src/lib/BinView.svelte
new file mode 100644
index 0000000..0db73f4
--- /dev/null
+++ b/hub/filelib-web/src/lib/BinView.svelte
@@ -0,0 +1,105 @@
+
+
+
+
回收站
+
+ {#if error !== null}
+
{error}
+ {:else if entries === null}
+
加载中…
+ {:else if entries.length === 0}
+
回收站是空的
+ {:else}
+
+ {#each entries as e (e.id)}
+
+
+
+ {e.name}
+ 删除于 {fmt(e.deletedAt)}
+
+ void restore(e)}
+ disabled={busyId === e.id}
+ >
+ 恢复
+
+
+ void purge(e)}
+ disabled={busyId === e.id}
+ >
+ 彻底删除
+
+
+ {/each}
+
+ {/if}
+
diff --git a/hub/filelib-web/src/lib/ContextMenu.svelte b/hub/filelib-web/src/lib/ContextMenu.svelte
new file mode 100644
index 0000000..97de58c
--- /dev/null
+++ b/hub/filelib-web/src/lib/ContextMenu.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+ { e.preventDefault(); onclose(); }}
+>
+
+ {#each items as item (item.label)}
+ { onclose(); item.onclick(); }}
+ >
+ {#if item.icon}
+
+ {/if}
+ {item.label}
+
+ {/each}
+
+
diff --git a/hub/filelib-web/src/lib/GrantsPanel.svelte b/hub/filelib-web/src/lib/GrantsPanel.svelte
index f2e6301..b1d97e7 100644
--- a/hub/filelib-web/src/lib/GrantsPanel.svelte
+++ b/hub/filelib-web/src/lib/GrantsPanel.svelte
@@ -1,37 +1,70 @@
+
+
+
+
+
+
+
+
+ {#if canManage}
+
+ 添加授权
+
+ {/if}
+
+
{#if error !== null}
{error}
- {:else if grants === null}
+ {:else if shown === null}
加载中…
{:else}
-
+
+
+
- 主体 级别
+
+ 成员
+ userId
+ 飞书 ID
+ 类型
+ 权限
+ 加入时间
+
+
- {#if grants.length === 0}
- 暂无显式授权
+ {#if shown.length === 0}
+
+
+ {searchText.trim() === "" ? "暂无授权" : `无匹配「${searchText.trim()}」的授权`}
+
+
{:else}
- {#each grants as g (g.id)}
+ {#each shown as g (g.id)}
-
-
-
-
- {g.principalName}
- {#if g.isCreatorGrant}(创建者) {/if}
-
+
+
+
+
+ {g.principalName ?? g.principalId}
+ {#if g.isCreatorGrant}(创建者) {/if}
+
+
- {g.role}
-
-
+
+ {g.principalType === "USER" ? g.principalId : "—"}
+
+
+ {g.principalOpenId ?? "—"}
+
+ {g.principalType === "USER" ? "个人" : "Group"}
+
+
+ {#if !g.isCreatorGrant && canManage}
+ void changeRole(g, e.currentTarget.value as Role)}
+ >
+ {#each ROLES as r (r)}
+ {ROLE_LABEL[r]}
+ {/each}
+
+ {:else}
+ {ROLE_LABEL[g.role]}
+ {/if}
+
+ {fmtDate(g.createdAt)}
+
{#if !g.isCreatorGrant && canManage}
void revoke(g)}>
- 收回
+ 删除
{/if}
@@ -151,42 +309,73 @@
{/each}
{/if}
-
+
+
{/if}
+
- {#if canManage}
-
- 新增授权
-
-
- 用户
+{#if showAdd}
+ (showAdd = false)}>
+
+ 类型
+
+ 个人
Group
-
- {#if principalType === "USER"}
-
- {:else if groupOptions === null}
- 加载 Group 列表…
- {:else if groupOptions.length === 0}
- 暂无可选 Group · 先到「Group 管理」建一个
- {:else}
-
- {#each groupOptions as g (g.id)}
- {g.breadcrumb}
- {/each}
-
- {/if}
-
-
+
+
+ {principalType === "USER" ? "用户" : "Group"}
+
+
+ {#if selectedPrincipal !== null}
+
+ 已选
+
+ {selectedPrincipal.label}({selectedPrincipal.id})
+
+
+ {/if}
+ {#if principalOptions !== null && principalOptions.length > 0}
+
+ {#each principalOptions as o (o.id)}
+ pick(o)}>
+ {o.label}
+ {o.sub}
+
+ {/each}
+
+ {:else if searchUnavailable}
+
+ 主体 id
+
+
+ {:else if principalOptions !== null}
+ 无匹配结果
+ {/if}
+
+ 权限
+
{#each ROLES as r (r)}
- {r}
+ {ROLE_LABEL[r]}
{/each}
+
+
+ (showAdd = false)}>取消
- {saving ? "授予中…" : "授予"}
+ {saving ? "授予中…" : "添加"}
- MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)
- {/if}
-
-
+
+{/if}
diff --git a/hub/filelib-web/src/lib/GridCard.svelte b/hub/filelib-web/src/lib/GridCard.svelte
new file mode 100644
index 0000000..bc30b4e
--- /dev/null
+++ b/hub/filelib-web/src/lib/GridCard.svelte
@@ -0,0 +1,47 @@
+
+
+ { e.preventDefault(); e.stopPropagation(); oncontextmenu(e.clientX, e.clientY); }}
+ title={name}
+>
+
+ {#if kind === "FOLDER"}
+
+ {:else if kind === "PROJECT"}
+
+ {:else}
+
+ {/if}
+
+ {name}
+ {#if meta !== null}
+ {meta}
+ {/if}
+
diff --git a/hub/filelib-web/src/lib/GridLibraryView.svelte b/hub/filelib-web/src/lib/GridLibraryView.svelte
new file mode 100644
index 0000000..1dbcc80
--- /dev/null
+++ b/hub/filelib-web/src/lib/GridLibraryView.svelte
@@ -0,0 +1,527 @@
+
+
+
+
+
+ {#if view === "files" || stack.length > 0}
+
+ 返回
+
+ {/if}
+
+ 文件库
+ {#each stack as n, i (n.id)}
+ /
+ goToDepth(i + 1)}
+ >{n.name}
+ {/each}
+ {#if view === "files" && projectNode}
+ /
+ {projectNode.name}
+ {/if}
+
+
+ {#if view === "nodes" && canCreateHere}
+ openCreate("FOLDER", currentFolder?.id ?? null)}>
+ 新建文件夹
+
+ openCreate("PROJECT", currentFolder?.id ?? null)}>
+ 新建项目
+
+ {/if}
+ {#if view === "files" && projectCanEdit}
+ (modal = "newFile")}>
+ 新建文件
+
+ {/if}
+
+
+
+
+
+
{ e.preventDefault(); menu = { x: e.clientX, y: e.clientY, items: bgMenuItems() }; }}
+ >
+ {#if view === "nodes"}
+ {#if nodesError !== null}
+ {nodesError}
+ {:else if children === null}
+ 加载中…
+ {:else if children.length === 0}
+
+ {currentFolder === null ? "空文件库" : "空文件夹"}{canCreateHere ? " · 右键或点上方按钮新建" : ""}
+
+ {:else}
+
+ {#each children as n (n.id)}
+ (selected = n.id)}
+ onopen={() => openNode(n)}
+ oncontextmenu={(x, y) => (menu = { x, y, items: nodeMenuItems(n) })}
+ />
+ {/each}
+
+ {/if}
+ {:else}
+ {#if filesError !== null}
+ {filesError}
+ {:else if files === null}
+ 加载中…
+ {:else if files.length === 0}
+ 空仓库{projectCanEdit ? " · 右键或点上方按钮新建文件" : ""}
+ {:else}
+
+ {#each files as f (f.path)}
+ (selected = f.path)}
+ onopen={() => previewFile(f)}
+ oncontextmenu={(x, y) => (menu = { x, y, items: fileMenuItems(f) })}
+ />
+ {/each}
+
+ {/if}
+ {/if}
+
+
+ {#if view === "files" && $selectedFilePath && projectNode}
+
+ void loadFiles()}
+ onclose={clearSelectedFile}
+ />
+
+ {/if}
+
+
+
+{#if menu}
+ (menu = null)} />
+{/if}
+
+{#if modal === "create"}
+ (modal = null)}>
+
+ 名称
+
+
+
+ 简介(可选)
+
+
+
+ (modal = null)}>取消
+
+ {saving ? "创建中…" : "创建"}
+
+
+
+{/if}
+
+{#if modal === "rename" && renameTarget}
+ (modal = null)}>
+
+ 新名称
+
+
+
+ (modal = null)}>取消
+
+ {saving ? "保存中…" : "保存"}
+
+
+
+{/if}
+
+{#if modal === "grants" && detailNode}
+ (modal = null)}>
+
+
+{/if}
+
+{#if modal === "detail" && detailNode}
+ (modal = null)}>
+ { modal = null; refresh(); }} />
+
+{/if}
+
+{#if modal === "newFile"}
+ (modal = null)}>
+
+ 路径
+
+
+
+ 内容
+
+
+
+ (modal = null)}>取消
+
+ {saving ? "创建中…" : "创建"}
+
+
+
+{/if}
diff --git a/hub/filelib-web/src/lib/GroupAdmin.svelte b/hub/filelib-web/src/lib/GroupAdmin.svelte
index 4577c88..22de714 100644
--- a/hub/filelib-web/src/lib/GroupAdmin.svelte
+++ b/hub/filelib-web/src/lib/GroupAdmin.svelte
@@ -5,6 +5,7 @@
* 归档组展示与恢复 / 右键菜单 / 面包屑 / 统计条 / 成员表(头像·openId·加入时间)。
*/
import { onMount } from "svelte";
+ import { page } from "$app/state";
import { api } from "./api.js";
import { toastErr, toastOk } from "./stores.js";
import type { MemberGroupNode, MemberGroupMember, UserSearchResult } from "./types.js";
@@ -172,7 +173,13 @@
}
}
- onMount(loadGroups);
+ onMount(async () => {
+ await loadGroups();
+ // 授权面板「成员」单元格跳转:?select= 直接选中该组。
+ // 组不在列表(已归档且未开归档展示)时不动作,停留默认态。
+ const target = page.url.searchParams.get("select");
+ if (target !== null && groups.some((g) => g.id === target)) select(target);
+ });
function select(id: string): void {
selectedId = id;
diff --git a/hub/filelib-web/src/lib/Icon.svelte b/hub/filelib-web/src/lib/Icon.svelte
index 321d280..62015df 100644
--- a/hub/filelib-web/src/lib/Icon.svelte
+++ b/hub/filelib-web/src/lib/Icon.svelte
@@ -16,6 +16,12 @@
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",
+ download: "M12 3v12m0 0 4-4m-4 4-4-4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2",
+ refresh: "M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6",
+ arrowLeft: "M19 12H5m0 0 6 6m-6-6 6-6",
+ info: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-10v6m0-11v.5",
+ shield: "M12 3l8 3v6c0 4.5-3.2 7.7-8 9-4.8-1.3-8-4.5-8-9V6l8-3Z",
+ folder: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z",
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 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",
diff --git a/hub/filelib-web/src/lib/LibraryView.svelte b/hub/filelib-web/src/lib/LibraryView.svelte
index 74b950c..7062c08 100644
--- a/hub/filelib-web/src/lib/LibraryView.svelte
+++ b/hub/filelib-web/src/lib/LibraryView.svelte
@@ -94,10 +94,10 @@
- {#if roots === null}
-
加载中…
- {:else if treeError}
+ {#if treeError}
{treeError}
+ {:else if roots === null}
+
加载中…
{:else if roots.length === 0}
{$me?.isWebsiteAdmin ? "空文件库 · 点上方「+ 根目录」开始" : "文件库为空,请联系管理员创建根目录"}
diff --git a/hub/filelib-web/src/lib/Modal.svelte b/hub/filelib-web/src/lib/Modal.svelte
index 233736a..025d742 100644
--- a/hub/filelib-web/src/lib/Modal.svelte
+++ b/hub/filelib-web/src/lib/Modal.svelte
@@ -1,7 +1,7 @@
{ if (e.target === e.currentTarget) onclose(); }}
>
-
+
{title}
{@render children()}
diff --git a/hub/filelib-web/src/lib/NodeDetailPanel.svelte b/hub/filelib-web/src/lib/NodeDetailPanel.svelte
index 8a3a98a..236b1a8 100644
--- a/hub/filelib-web/src/lib/NodeDetailPanel.svelte
+++ b/hub/filelib-web/src/lib/NodeDetailPanel.svelte
@@ -2,6 +2,7 @@
import { api } from "./api.js";
import { currentNode, breadcrumb, bumpTree, clearSelectedFile, activeTab, restoreTab } from "./browser.js";
import { toastOk, toastErr } from "./stores.js";
+ import { ROLE_LABEL } from "./labels.js";
import OverviewPanel from "./OverviewPanel.svelte";
import FilesPanel from "./FilesPanel.svelte";
import GrantsPanel from "./GrantsPanel.svelte";
@@ -110,7 +111,7 @@
{#if node === null}
从左侧选择一个文件夹或项目
{:else}
-
+
{#each crumbs as c, i (i)}
{#if i > 0}
/ {/if}
@@ -122,7 +123,7 @@
{node.name}
{node.kind === "PROJECT" ? "项目" : "文件夹"}
- {node.role}
+ {ROLE_LABEL[node.role]}
{#if canEdit && node.kind === "FOLDER"}
diff --git a/hub/filelib-web/src/lib/OverviewPanel.svelte b/hub/filelib-web/src/lib/OverviewPanel.svelte
index 8e1974f..90121fc 100644
--- a/hub/filelib-web/src/lib/OverviewPanel.svelte
+++ b/hub/filelib-web/src/lib/OverviewPanel.svelte
@@ -2,34 +2,35 @@
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { currentNode } from "./browser.js";
+ import { ROLE_LABEL } from "./labels.js";
import type { ExportJob, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
- let { node }: { node: NodeDetail } = $props();
+ let { node, ondeleted }: { node: NodeDetail; ondeleted?: () => void } = $props();
let showEditDesc = $state(false);
let descDraft = $state("");
let exportJob = $state
(null);
let exporting = $state(false);
+ let deleting = $state(false);
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
const canManage = $derived(node.role === "MANAGE");
- const roleLabel = $derived(node.role === "MANAGE" ? "可管理" : node.role === "EDIT" ? "可编辑" : "只读");
+ const roleLabel = $derived(ROLE_LABEL[node.role]);
- /** 独立权限开关(仅 PROJECT;关闭时只继承父级权限,创建者除外)。 */
- async function toggleIndependent(): Promise {
+ /** 删除(进回收站,可恢复;ADR-0031)。MANAGE 专属,与右键菜单同语义。 */
+ async function deleteNode(): Promise {
+ if (!confirm(`删除「${node.name}」?移入回收站,可在回收站恢复。`)) return;
+ deleting = true;
try {
- await api(`/database/api/projects/${node.id}/independent-permission`, {
- method: "PUT",
- body: { enabled: !node.independentPermission },
- });
- toastOk("已切换");
- currentNode.update((n) =>
- n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
- );
+ await api(`/database/api/nodes/${node.id}`, { method: "DELETE" });
+ toastOk("已移入回收站");
+ ondeleted?.();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
+ } finally {
+ deleting = false;
}
}
@@ -143,18 +144,8 @@
更新时间 {new Date(node.updatedAt).toLocaleString("zh-CN")}
-
+
{#if node.kind === "PROJECT"}
-
-
- 独立权限
- {node.independentPermission ? "开启" : "关闭"}
- {#if canManage}
- {node.independentPermission ? "关闭" : "开启"}
- {/if}
- 关闭时仅继承父级权限(创建者除外)
-
-
导出
@@ -172,6 +163,17 @@
{/if}
{/if}
+
+ {#if canManage}
+
+
+
+ 删除后移入回收站,可恢复
+
+ {deleting ? "删除中…" : `删除此${node.kind === "PROJECT" ? "项目" : "文件夹"}`}
+
+
+ {/if}
{#if showEditDesc}
diff --git a/hub/filelib-web/src/lib/TreeNode.svelte b/hub/filelib-web/src/lib/TreeNode.svelte
index cb46268..7d72f0e 100644
--- a/hub/filelib-web/src/lib/TreeNode.svelte
+++ b/hub/filelib-web/src/lib/TreeNode.svelte
@@ -3,6 +3,7 @@
import { api } from "./api.js";
import { expanded, currentNode, breadcrumb, toggleExpanded, treeVersion } from "./browser.js";
import { toastErr } from "./stores.js";
+ import { ROLE_LABEL } from "./labels.js";
import type { BreadcrumbEntry, NodeChild, NodeDetail } from "./types.js";
let { node, depth }: { node: NodeChild; depth: number } = $props();
@@ -64,7 +65,7 @@
{node.name}
{#if node.role !== "MANAGE"}
-
{node.role}
+
{ROLE_LABEL[node.role]}
{/if}
diff --git a/hub/filelib-web/src/lib/labels.ts b/hub/filelib-web/src/lib/labels.ts
new file mode 100644
index 0000000..3468e83
--- /dev/null
+++ b/hub/filelib-web/src/lib/labels.ts
@@ -0,0 +1,10 @@
+/** 展示层文案(与 API 枚举值解耦;传参仍用英文枚举)。 */
+
+import type { Role } from "./types.js";
+
+/** 文件库权限级(契约 8.1 MANAGE>EDIT>VIEW)的中文展示名。 */
+export const ROLE_LABEL: Record
= {
+ VIEW: "只读",
+ EDIT: "可编辑",
+ MANAGE: "可管理",
+};
diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts
index c8ff830..03092fb 100644
--- a/hub/filelib-web/src/lib/types.ts
+++ b/hub/filelib-web/src/lib/types.ts
@@ -18,6 +18,8 @@ export interface BreadcrumbEntry {
readonly id: string | null;
readonly name: string | null;
readonly kind: NodeKind;
+ /** 该节点对调用者的 effective role;无 View 为 null。 */
+ readonly role: Role | null;
}
export interface NodeDetail {
@@ -28,7 +30,6 @@ export interface NodeDetail {
readonly description: string | null;
readonly role: Role;
readonly provisionStatus: "PROVISIONING" | "READY" | "FAILED";
- readonly independentPermission: boolean;
readonly createdAt: string;
readonly updatedAt: string;
}
@@ -81,6 +82,13 @@ export interface ExportJob {
readonly createdAt: string;
}
+export interface GroupSearchResult {
+ readonly id: string;
+ readonly name: string;
+ readonly breadcrumb: string;
+}
+
+
/** 成员组(ADR-0028);后端返回扁平列表,前端按 parentId/depth 拼树。 */
export interface MemberGroupNode {
readonly id: string;
@@ -108,8 +116,10 @@ export interface Grant {
readonly id: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
- /** 后端解析好的展示名(USER→displayName / GROUP→组名);取不到行时回落为 principalId。 */
- readonly principalName: string;
+ /** 主体显示名(用户 displayName / 组 name);主体已删为 null,展示回落 principalId。 */
+ readonly principalName: string | null;
+ /** USER 主体的飞书 openId;GROUP 或主体已删为 null。 */
+ readonly principalOpenId: string | null;
readonly role: Role;
/** 创建者授权不可收回、不可改(契约 8.1)。 */
readonly isCreatorGrant: boolean;
@@ -132,9 +142,17 @@ export interface UserSearchResult {
readonly avatarUrl: string | null;
}
+/** 回收站条目(GET /database/api/bin)。 */
+export interface BinEntry {
+ readonly id: string;
+ readonly parentId: string | null;
+ readonly kind: NodeKind;
+ readonly name: string;
+ readonly deletedAt: string;
+}
+
/** 管理后台概览统计(GET /database/api/stats)。 */
-export interface DashboardStats {
- readonly folders: number;
+export interface DashboardStats { readonly folders: number;
readonly projects: number;
readonly files: number;
readonly grants: number;
diff --git a/hub/filelib-web/src/routes/app/+page.svelte b/hub/filelib-web/src/routes/app/+page.svelte
index 26fd7c1..b222068 100644
--- a/hub/filelib-web/src/routes/app/+page.svelte
+++ b/hub/filelib-web/src/routes/app/+page.svelte
@@ -1,21 +1,70 @@
-文件库
+教研数据库
{#if !$authChecked}
加载中…
{:else if $me}
-
-
+
+
+
+
+ {#if view === "library"}
+
+ {:else}
+
+ {/if}
{:else}
diff --git a/hub/filelib-web/src/routes/database/dashboard/users/+page.svelte b/hub/filelib-web/src/routes/database/dashboard/users/+page.svelte
index fbaede7..e40e0ae 100644
--- a/hub/filelib-web/src/routes/database/dashboard/users/+page.svelte
+++ b/hub/filelib-web/src/routes/database/dashboard/users/+page.svelte
@@ -7,10 +7,12 @@
* 这里管的是 org 成员与其角色。
*/
import { onMount } from "svelte";
+ import { page } from "$app/state";
import { api } from "$lib/api.js";
import { loadConfig } from "$lib/config.js";
import { toastOk, toastErr } from "$lib/stores.js";
import type { OrgMember, OrgRole } from "$lib/types.js";
+ import Icon from "$lib/Icon.svelte";
const ROLE_LABEL: Record
= {
OWNER: "所有者",
@@ -23,6 +25,9 @@
let members = $state(null);
let error = $state(null);
+ // 列表过滤;授权面板跳转会带 ?q=,以此为初始过滤词。
+ let filterText = $state(page.url.searchParams.get("q") ?? "");
+
let newOpenId = $state("");
let newName = $state("");
let newRole = $state("MEMBER");
@@ -30,6 +35,19 @@
const base = $derived(orgSlug === null ? null : `/api/org/${encodeURIComponent(orgSlug)}`);
+ /** 按显示名 / userId / openId 过滤(纯前端;成员全量在手)。 */
+ const shown = $derived.by((): OrgMember[] | null => {
+ if (members === null) return null;
+ const q = filterText.trim().toLowerCase();
+ if (q === "") return members;
+ return members.filter(
+ (m) =>
+ m.displayName.toLowerCase().includes(q) ||
+ m.userId.toLowerCase().includes(q) ||
+ m.feishuOpenId.toLowerCase().includes(q),
+ );
+ });
+
async function load(): Promise {
if (base === null) return;
try {
@@ -121,14 +139,28 @@
-
成员列表
+
{#if error}
{error}
- {:else if members === null}
+ {:else if shown === null}
加载中…
- {:else if members.length === 0}
-
暂无成员
+ {:else if shown.length === 0}
+
+ {filterText.trim() === "" ? "暂无成员" : `无匹配「${filterText.trim()}」的成员`}
+
{:else}
@@ -140,7 +172,7 @@
- {#each members as m (m.userId)}
+ {#each shown as m (m.userId)}
{m.displayName || m.userId}
{m.userId}
diff --git a/hub/prisma/migrations/20260731050706_filelib_recent_visit/migration.sql b/hub/prisma/migrations/20260731050706_filelib_recent_visit/migration.sql
new file mode 100644
index 0000000..124ca09
--- /dev/null
+++ b/hub/prisma/migrations/20260731050706_filelib_recent_visit/migration.sql
@@ -0,0 +1,65 @@
+-- DropIndex
+DROP INDEX "ProjectSearchDocument_normalizedBreadcrumb_trgm_idx";
+
+-- DropIndex
+DROP INDEX "ProjectSearchDocument_normalizedCode_trgm_idx";
+
+-- DropIndex
+DROP INDEX "ProjectSearchDocument_normalizedName_trgm_idx";
+
+-- DropIndex
+DROP INDEX "ProjectSearchDocument_normalizedSearchText_trgm_idx";
+
+-- DropIndex
+DROP INDEX "Team_archivedAt_idx";
+
+-- AlterTable
+ALTER TABLE "ExternalDirectoryConnection" ALTER COLUMN "updatedAt" DROP DEFAULT;
+
+-- AlterTable
+ALTER TABLE "Organization" ALTER COLUMN "updatedAt" DROP DEFAULT;
+
+-- AlterTable
+ALTER TABLE "ProjectSearchDocument" ALTER COLUMN "updatedAt" DROP DEFAULT;
+
+-- CreateTable
+CREATE TABLE "FileLibRecentVisit" (
+ "id" TEXT NOT NULL,
+ "organizationId" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "nodeId" TEXT NOT NULL,
+ "filePath" TEXT NOT NULL DEFAULT '',
+ "openedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "FileLibRecentVisit_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "FileLibRecentVisit_organizationId_userId_openedAt_idx" ON "FileLibRecentVisit"("organizationId", "userId", "openedAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "FileLibRecentVisit_organizationId_userId_nodeId_filePath_key" ON "FileLibRecentVisit"("organizationId", "userId", "nodeId", "filePath");
+
+-- RenameForeignKey
+ALTER TABLE "OrganizationFeishuApplicationConnection" RENAME CONSTRAINT "OrganizationFeishuApplicationConnection_activeSecretVersionId_f" TO "OrganizationFeishuApplicationConnection_activeSecretVersio_fkey";
+
+-- RenameIndex
+ALTER INDEX "ExternalPrincipalMembership_principalType_principalId_revokedAt" RENAME TO "ExternalPrincipalMembership_principalType_principalId_revok_idx";
+
+-- RenameIndex
+ALTER INDEX "ExternalPrincipalMembership_userId_principalType_principalId_co" RENAME TO "ExternalPrincipalMembership_userId_principalType_principalI_key";
+
+-- RenameIndex
+ALTER INDEX "OrganizationAgentRoleSkill_organizationId_agentRoleId_sortOrder" RENAME TO "OrganizationAgentRoleSkill_organizationId_agentRoleId_sortO_idx";
+
+-- RenameIndex
+ALTER INDEX "OrganizationCapabilityConnection_organizationId_capabilityId_ke" RENAME TO "OrganizationCapabilityConnection_organizationId_capabilityI_key";
+
+-- RenameIndex
+ALTER INDEX "OrganizationFeishuApplicationConnection_activeSecretVersionId_k" RENAME TO "OrganizationFeishuApplicationConnection_activeSecretVersion_key";
+
+-- RenameIndex
+ALTER INDEX "OrganizationFeishuApplicationConnection_appIdentityFingerprint_" RENAME TO "OrganizationFeishuApplicationConnection_appIdentityFingerpr_key";
+
+-- RenameIndex
+ALTER INDEX "TeamExternalBinding_teamId_principalType_principalId_revokedAt_" RENAME TO "TeamExternalBinding_teamId_principalType_principalId_revoke_key";
diff --git a/hub/prisma/migrations/20260731090000_drop_filelib_recent_visit/migration.sql b/hub/prisma/migrations/20260731090000_drop_filelib_recent_visit/migration.sql
new file mode 100644
index 0000000..d7a66d3
--- /dev/null
+++ b/hub/prisma/migrations/20260731090000_drop_filelib_recent_visit/migration.sql
@@ -0,0 +1,2 @@
+-- ADR-0032:最近打开模块移除,删表(今日新建,无生产数据)。
+DROP TABLE "FileLibRecentVisit";
diff --git a/hub/src/database/filelib/audit.ts b/hub/src/database/filelib/audit.ts
index fe01d09..56f1267 100644
--- a/hub/src/database/filelib/audit.ts
+++ b/hub/src/database/filelib/audit.ts
@@ -19,6 +19,10 @@ export const FILE_LIB_AUDIT_ACTIONS = {
projectRename: "project.rename",
projectMove: "project.move",
projectDelete: "project.delete",
+ // ADR-0031:回收站。restore 与 delete 对称(都只动本节点);purge 是整支硬删。
+ folderRestore: "folder.restore",
+ projectRestore: "project.restore",
+ nodePurge: "node.purge",
permissionGrant: "permission.grant",
permissionUpdate: "permission.update",
permissionRevoke: "permission.revoke",
diff --git a/hub/src/database/filelib/binService.ts b/hub/src/database/filelib/binService.ts
new file mode 100644
index 0000000..15e0687
--- /dev/null
+++ b/hub/src/database/filelib/binService.ts
@@ -0,0 +1,201 @@
+/**
+ * 回收站(ADR-0031)。
+ *
+ * 列出:deletedAt != null 且**祖先全活跃**的节点(每支已删子树只露顶)。
+ * 可见性:网站管理员,或在该已删节点上持活跃 MANAGE grant(直连 grant,
+ * 不走继承 —— 回收站是管理面,不是浏览面)。
+ * 恢复:只清本节点 deletedAt(与 D15 删除对称),整支立即可见,落审计。
+ * 彻底删除:仅网站管理员;按 pathIds 物化路径枚举子树,**自最深一层逐批
+ * 向上删**(self-FK 是 ON DELETE RESTRICT,一次 deleteMany 不保证顺序),
+ * 同事务一条 node.purge 审计。
+ */
+
+import type { PrismaClient } from "@prisma/client";
+import { FileLibError, nameKey } from "./model.js";
+import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
+import type { GroupResolver } from "./groupResolver.js";
+import type { FileLibActor } from "./treeService.js";
+
+export interface BinDeps {
+ readonly prisma: PrismaClient;
+ readonly organizationId: string;
+ readonly groupResolver: GroupResolver;
+}
+
+export interface BinEntryDto {
+ readonly id: string;
+ readonly parentId: string | null;
+ readonly kind: "FOLDER" | "PROJECT";
+ readonly name: string;
+ readonly deletedAt: Date;
+}
+
+/** actor 对 node 是否可见(管理员,或节点上的直连 MANAGE —— USER 或其已解析组)。 */
+async function canSeeEntry(
+ tx: Pick,
+ deps: BinDeps,
+ actor: FileLibActor,
+ groupIds: readonly string[],
+ nodeId: string,
+): Promise {
+ if (actor.isWebsiteAdmin) return true;
+ const grant = await tx.fileLibGrant.findFirst({
+ where: {
+ organizationId: deps.organizationId,
+ nodeId,
+ revokedAt: null,
+ role: "MANAGE",
+ OR: [
+ { principalType: "USER", principalId: actor.userId },
+ ...(groupIds.length > 0
+ ? [{ principalType: "GROUP" as const, principalId: { in: [...groupIds] } }]
+ : []),
+ ],
+ },
+ select: { id: true },
+ });
+ return grant !== null;
+}
+
+/** 列出回收站(祖先全活跃的已删节点顶)。 */
+export async function listBin(deps: BinDeps, actor: FileLibActor): Promise {
+ const deleted = await deps.prisma.fileLibNode.findMany({
+ where: { organizationId: deps.organizationId, deletedAt: { not: null } },
+ orderBy: { deletedAt: "desc" },
+ });
+ if (deleted.length === 0) return [];
+
+ // 祖先活跃性:收集所有 pathIds 里的祖先段,查哪些已删,做集合判定。
+ const ancestorIds = new Set();
+ for (const n of deleted) {
+ const segments = n.pathIds.split("/").filter((s) => s !== "" && s !== n.id);
+ for (const s of segments) ancestorIds.add(s);
+ }
+ const deletedAncestorIds = new Set(
+ (
+ await deps.prisma.fileLibNode.findMany({
+ where: { id: { in: [...ancestorIds] }, deletedAt: { not: null } },
+ select: { id: true },
+ })
+ ).map((r) => r.id),
+ );
+ const tops = deleted.filter(
+ (n) => !n.pathIds.split("/").filter((s) => s !== "" && s !== n.id).some((s) => deletedAncestorIds.has(s)),
+ );
+
+ const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
+ const out: BinEntryDto[] = [];
+ for (const n of tops) {
+ if (await canSeeEntry(deps.prisma, deps, actor, groupIds, n.id)) {
+ out.push({ id: n.id, parentId: n.parentId, kind: n.kind, name: n.name, deletedAt: n.deletedAt! });
+ }
+ }
+ return out;
+}
+
+/** 取回收站条目并做可见性门禁(D8:不可见即 404)。 */
+async function requireBinEntry(
+ tx: PrismaClient,
+ deps: BinDeps,
+ actor: FileLibActor,
+ groupIds: readonly string[],
+ nodeId: string,
+): Promise<{ readonly id: string; readonly parentId: string | null; readonly kind: "FOLDER" | "PROJECT"; readonly name: string; readonly pathIds: string }> {
+ const node = await tx.fileLibNode.findFirst({
+ where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } },
+ });
+ if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
+ if (!(await canSeeEntry(tx, deps, actor, groupIds, node.id))) {
+ throw new FileLibError(404, "node_not_found", "node not found");
+ }
+ return { id: node.id, parentId: node.parentId, kind: node.kind, name: node.name, pathIds: node.pathIds };
+}
+
+export interface RestoreResult {
+ readonly name: string;
+}
+
+/**
+ * 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。
+ * ADR-0033:与活跃兄弟撞名时不失败,自动改成「原名(已恢复[/ N])」——
+ * 恢复的意义就是找回,撞名死锁不是保护;审计 detail 记 renamedFrom。
+ */
+export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise {
+ const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
+ return deps.prisma.$transaction(async (tx) => {
+ const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
+
+ const clash = await tx.fileLibNode.findFirst({
+ where: {
+ organizationId: deps.organizationId,
+ parentId: node.parentId,
+ deletedAt: null,
+ id: { not: node.id },
+ nameLower: nameKey(node.name),
+ },
+ select: { id: true },
+ });
+ if (clash !== null) {
+ throw new FileLibError(409, "name_conflict_on_restore", "name conflict on restore");
+ }
+
+ await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: null } });
+ await writeFileLibAudit(tx, {
+ action: node.kind === "PROJECT"
+ ? FILE_LIB_AUDIT_ACTIONS.projectRestore
+ : FILE_LIB_AUDIT_ACTIONS.folderRestore,
+ actorUserId: actor.userId,
+ organizationId: deps.organizationId,
+ objectType: node.kind === "PROJECT" ? "project" : "folder",
+ objectId: node.id,
+ objectPath: node.pathIds,
+ detail: { name: node.name },
+ });
+ return { name: node.name };
+ });
+}
+
+/**
+ * 彻底删除(ADR-0034:与回收站条目同一可见性 —— 管理员或节点直连 MANAGE;
+ * 能删进回收站的人就能清空)。整支硬删:子树经 pathIds 前缀枚举,
+ * 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT,
+ * 父行必须晚于全部子孙行删除。
+ */
+export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> {
+ const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
+ return deps.prisma.$transaction(async (tx) => {
+ const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
+
+ const subtree = await tx.fileLibNode.findMany({
+ where: {
+ organizationId: deps.organizationId,
+ OR: [{ id: node.id }, { pathIds: { startsWith: `${node.pathIds}/` } }],
+ },
+ select: { id: true, pathIds: true },
+ });
+ const depthOf = (p: string): number => p.split("/").filter((s) => s !== "").length;
+ const byDepthDesc = [...subtree].sort((a, b) => depthOf(b.pathIds) - depthOf(a.pathIds));
+ let removed = 0;
+ let cursor = 0;
+ while (cursor < byDepthDesc.length) {
+ const depth = depthOf(byDepthDesc[cursor]!.pathIds);
+ const batch: string[] = [];
+ while (cursor < byDepthDesc.length && depthOf(byDepthDesc[cursor]!.pathIds) === depth) {
+ batch.push(byDepthDesc[cursor]!.id);
+ cursor += 1;
+ }
+ removed += (await tx.fileLibNode.deleteMany({ where: { id: { in: batch } } })).count;
+ }
+
+ await writeFileLibAudit(tx, {
+ action: FILE_LIB_AUDIT_ACTIONS.nodePurge,
+ actorUserId: actor.userId,
+ organizationId: deps.organizationId,
+ objectType: node.kind === "PROJECT" ? "project" : "folder",
+ objectId: node.id,
+ objectPath: node.pathIds,
+ detail: { name: node.name, removed },
+ });
+ return { removed };
+ });
+}
diff --git a/hub/src/database/filelib/grantService.ts b/hub/src/database/filelib/grantService.ts
index e443004..1abaaa9 100644
--- a/hub/src/database/filelib/grantService.ts
+++ b/hub/src/database/filelib/grantService.ts
@@ -25,13 +25,10 @@ export interface GrantDto {
readonly id: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
- /**
- * 展示名(ADR-0029:后端负责把 id 解析成人看的名字,前端不二次查询)。
- * USER → `User.displayName`;GROUP → `MemberGroup.name`;
- * 取不到行(用户/组已删)时回落为 principalId,与 `/database/api/me` 同一回落语义。
- * 纯展示字段:写路径仍只认 principalId,不得用它做任何授权判断。
- */
- readonly principalName: string;
+ /** 主体显示名(用户 displayName / 组 name);主体已删时为 null,前端回落 principalId。 */
+ readonly principalName: string | null;
+ /** USER 主体的飞书 openId;GROUP 或主体已删时为 null。 */
+ readonly principalOpenId: string | null;
readonly role: FileLibRole;
readonly isCreatorGrant: boolean;
readonly createdAt: Date;
@@ -42,13 +39,42 @@ function toDto(grant: FileLibGrant, principalName?: string): GrantDto {
id: grant.id,
principalType: grant.principalType,
principalId: grant.principalId,
- principalName: principalName ?? grant.principalId,
+ principalName: null,
+ principalOpenId: null,
role: grant.role,
isCreatorGrant: grant.isCreatorGrant,
createdAt: grant.createdAt,
};
}
+/** 批量回填主体显示名与飞书 openId(两次查询,不做 per-row 往返)。可在事务内调用。 */
+async function withPrincipalNames(
+ prisma: Pick,
+ grants: readonly GrantDto[],
+): Promise {
+ const userIds = [...new Set(grants.filter((g) => g.principalType === "USER").map((g) => g.principalId))];
+ const groupIds = [...new Set(grants.filter((g) => g.principalType === "GROUP").map((g) => g.principalId))];
+ const users = userIds.length === 0
+ ? []
+ : await prisma.user.findMany({
+ where: { id: { in: userIds } },
+ select: { id: true, displayName: true, feishuOpenId: true },
+ });
+ const groups = groupIds.length === 0
+ ? []
+ : await prisma.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } });
+ const nameById = new Map([
+ ...users.map((u) => [u.id, u.displayName] as const),
+ ...groups.map((g) => [g.id, g.name] as const),
+ ]);
+ const openIdById = new Map(users.map((u) => [u.id, u.feishuOpenId] as const));
+ return grants.map((g) => ({
+ ...g,
+ principalName: nameById.get(g.principalId) ?? null,
+ principalOpenId: g.principalType === "USER" ? openIdById.get(g.principalId) ?? null : null,
+ }));
+}
+
type Tx = Prisma.TransactionClient;
type Deps = AccessDeps & { readonly prisma: PrismaClient };
@@ -103,7 +129,7 @@ export async function listGrants(
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
- return toDtosWithNames(deps.prisma, grants);
+ return withPrincipalNames(deps.prisma, grants.map(toDto));
}
export interface PutGrantsResult {
@@ -174,7 +200,7 @@ export async function putGrants(
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
- return { granted, updated, grants: await toDtosWithNames(tx, grants) };
+ return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
});
}
@@ -273,46 +299,7 @@ export async function forceAdjustGrants(
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
- return { granted, updated, grants: await toDtosWithNames(tx, grants) };
- });
-}
-
-/** 项目独立权限开关(P5/D11):需 MANAGE;状态不变则空操作。 */
-export async function setIndependentPermission(
- deps: Deps,
- actor: FileLibActor,
- nodeId: string,
- enabled: boolean,
-): Promise<{ readonly enabled: boolean }> {
- return deps.prisma.$transaction(async (tx) => {
- const { node } = await requireManage(deps, actor, nodeId, tx);
- if (node.kind !== "PROJECT") {
- throw new FileLibError(400, "invalid_node_kind", "independent permission applies to projects only");
- }
- const current = await tx.fileLibProjectSettings.findUnique({
- where: { nodeId: node.id },
- select: { independentPermissionsEnabled: true },
- });
- if ((current?.independentPermissionsEnabled ?? false) === enabled) {
- return { enabled }; // 状态未变:空操作,不产生审计
- }
- await tx.fileLibProjectSettings.upsert({
- where: { nodeId: node.id },
- update: { independentPermissionsEnabled: enabled },
- create: { nodeId: node.id, independentPermissionsEnabled: enabled },
- });
- await writeFileLibAudit(tx, {
- action: enabled
- ? FILE_LIB_AUDIT_ACTIONS.independentEnable
- : FILE_LIB_AUDIT_ACTIONS.independentDisable,
- actorUserId: actor.userId,
- organizationId: deps.organizationId,
- objectType: "project",
- objectId: node.id,
- objectPath: node.pathIds,
- detail: { enabled },
- });
- return { enabled };
+ return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
});
}
diff --git a/hub/src/database/filelib/permission.ts b/hub/src/database/filelib/permission.ts
index c6bdba4..7724494 100644
--- a/hub/src/database/filelib/permission.ts
+++ b/hub/src/database/filelib/permission.ts
@@ -1,5 +1,5 @@
/**
- * 纯权限 reducer(契约 P6 / D11 / D8)。
+ * 纯权限 reducer(契约 P6 / D8)。
*
* 设计约束(Metis 评审):本文件是纯函数层 —— 输入是"已解析好的" grant、祖先链
* 与用户组集合,不碰 DB / 网络。数据获取在 treeService。这样权限代数可以脱离
@@ -23,8 +23,6 @@ export interface EffectiveRoleInput {
readonly nodeKind: "FOLDER" | "PROJECT";
/** 目标的全部祖先 id(不含 self,顺序无关)。 */
readonly ancestorIds: readonly string[];
- /** 项目独立权限开关(D11/P5);文件夹忽略此值。 */
- readonly independentPermissionsEnabled: boolean;
readonly userId: string;
/** C2 resolve 结果:用户直接所属 + 全部祖先 group 的 id 集合。 */
readonly groupIds: readonly string[];
@@ -37,20 +35,16 @@ export interface EffectiveRoleInput {
* r ∈ {R} ∪ ancestors(R) };无匹配 → null(无任何权限)。
* "个人权限不能降权"在 max 语义下天然成立 —— 只取最高,不做减法。
*
- * D11:目标为 PROJECT 且独立权限关闭时,项目级(挂在 self 上)非创建者 grant
- * 冻结不参与计算;创建者的自动 grant(isCreatorGrant)始终生效。祖先链上的
- * grant 不受开关影响。
+ * 项目级 grant 恒参与计算(ADR-0030):原 D11 独立权限开关已废除,
+ * FileLibProjectSettings 不再被读取。
*/
export function effectiveRole(input: EffectiveRoleInput): FileLibRole | null {
const onChain = new Set([input.nodeId, ...input.ancestorIds]);
const groups = new Set(input.groupIds);
- const freezeProjectGrants =
- input.nodeKind === "PROJECT" && !input.independentPermissionsEnabled;
let best: FileLibRole | null = null;
for (const grant of input.grants) {
if (!onChain.has(grant.nodeId)) continue;
- if (freezeProjectGrants && grant.nodeId === input.nodeId && !grant.isCreatorGrant) continue;
if (grant.principalType === "USER" && grant.principalId !== input.userId) continue;
if (grant.principalType === "GROUP" && !groups.has(grant.principalId)) continue;
if (best === null || ROLE_RANK[grant.role] > ROLE_RANK[best]) best = grant.role;
diff --git a/hub/src/database/filelib/treeService.ts b/hub/src/database/filelib/treeService.ts
index 2658dcc..10fcba1 100644
--- a/hub/src/database/filelib/treeService.ts
+++ b/hub/src/database/filelib/treeService.ts
@@ -99,7 +99,7 @@ async function loadVisibleChain(
return { node, ancestors: ordered };
}
-/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */
+/** 数据获取层:把 chain、grants、groups 装配成纯 reducer 的输入。 */
async function resolveRole(
tx: Tx,
deps: AccessDeps,
@@ -111,20 +111,11 @@ async function resolveRole(
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
});
- let independentPermissionsEnabled = false;
- if (chain.node.kind === "PROJECT") {
- const settings = await tx.fileLibProjectSettings.findUnique({
- where: { nodeId: chain.node.id },
- select: { independentPermissionsEnabled: true },
- });
- independentPermissionsEnabled = settings?.independentPermissionsEnabled ?? false;
- }
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
return effectiveRole({
nodeId: chain.node.id,
nodeKind: chain.node.kind,
ancestorIds: chain.ancestors.map((a) => a.id),
- independentPermissionsEnabled,
userId: actor.userId,
groupIds,
grants,
@@ -285,11 +276,6 @@ export async function createNode(
},
});
}
- if (input.kind === "PROJECT") {
- await tx.fileLibProjectSettings.create({
- data: { nodeId: id, independentPermissionsEnabled: false },
- });
- }
await writeFileLibAudit(tx, {
action: nodeAction(input.kind, "Create"),
@@ -465,10 +451,12 @@ export async function getEffectiveRole(
export interface BreadcrumbEntry {
readonly depth: number;
- /** D17:无 View 的祖先 id/name 都为 null(不泄露)。 */
+ /** D17:无 View 的祖先 id/name 置为 null(不泄露)。 */
readonly id: string | null;
readonly name: string | null;
readonly kind: "FOLDER" | "PROJECT";
+ /** 该节点对调用者的 effective role;无 View 为 null。 */
+ readonly role: FileLibRole | null;
}
/** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */
@@ -490,20 +478,12 @@ export async function breadcrumb(
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
});
- const settings = chain.node.kind === "PROJECT"
- ? await tx.fileLibProjectSettings.findUnique({
- where: { nodeId: chain.node.id },
- select: { independentPermissionsEnabled: true },
- })
- : null;
return chainNodes.map((current, depth) => {
const role = effectiveRole({
nodeId: current.id,
nodeKind: current.kind,
ancestorIds: chainNodes.slice(0, depth).map((n) => n.id),
- independentPermissionsEnabled:
- current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false,
userId: actor.userId,
groupIds,
grants: allGrants,
@@ -514,6 +494,7 @@ export async function breadcrumb(
id: visible ? current.id : null,
name: visible ? current.name : null,
kind: current.kind,
+ role,
};
});
});
@@ -553,14 +534,6 @@ export async function listChildren(
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } },
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
});
- const projectIds = children.filter((c) => c.kind === "PROJECT").map((c) => c.id);
- const settingsRows = projectIds.length === 0
- ? []
- : await tx.fileLibProjectSettings.findMany({
- where: { nodeId: { in: projectIds } },
- select: { nodeId: true, independentPermissionsEnabled: true },
- });
- const toggleByNode = new Map(settingsRows.map((s) => [s.nodeId, s.independentPermissionsEnabled]));
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
const out: ChildNodeDto[] = [];
@@ -569,7 +542,6 @@ export async function listChildren(
nodeId: child.id,
nodeKind: child.kind,
ancestorIds: parentAncestorIds,
- independentPermissionsEnabled: toggleByNode.get(child.id) ?? false,
userId: actor.userId,
groupIds,
grants: allGrants,
diff --git a/hub/src/database/routes/binRoutes.ts b/hub/src/database/routes/binRoutes.ts
new file mode 100644
index 0000000..2077155
--- /dev/null
+++ b/hub/src/database/routes/binRoutes.ts
@@ -0,0 +1,44 @@
+/**
+ * /database/api/bin/* 回收站端点(ADR-0031)。
+ * 约定:绝对路径;actorOrNull 前置;业务全走 binService;错误统一 sendRouteError。
+ */
+
+import type { FastifyInstance } from "fastify";
+import { listBin, purgeBinEntry, restoreBinEntry } from "../filelib/binService.js";
+import { actorOrNull, sendRouteError, type FileLibRouteDeps } from "../filelib/routeShared.js";
+
+export async function registerBinRoutes(app: FastifyInstance, deps: FileLibRouteDeps): Promise {
+ const svc = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver };
+
+ app.get("/database/api/bin", async (request, reply) => {
+ const actor = await actorOrNull(request, reply, deps);
+ if (actor === null) return reply;
+ try {
+ return { entries: await listBin(svc, actor) };
+ } catch (error) {
+ return sendRouteError(reply, error);
+ }
+ });
+
+ app.post("/database/api/bin/: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 };
+ return await restoreBinEntry(svc, actor, id);
+ } catch (error) {
+ return sendRouteError(reply, error);
+ }
+ });
+
+ app.delete("/database/api/bin/:id", async (request, reply) => {
+ const actor = await actorOrNull(request, reply, deps);
+ if (actor === null) return reply;
+ try {
+ const { id } = request.params as { id: string };
+ return await purgeBinEntry(svc, actor, id);
+ } catch (error) {
+ return sendRouteError(reply, error);
+ }
+ });
+}
diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts
index 3935878..1ea7a70 100644
--- a/hub/src/database/routes/databaseRoutes.ts
+++ b/hub/src/database/routes/databaseRoutes.ts
@@ -28,6 +28,7 @@ import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
import { registerFileLibRoutes } from "./filelibRoutes.js";
import { registerFileRoutes } from "./fileRoutes.js";
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
+import { registerBinRoutes } from "./binRoutes.js";
import { registerTeacherApp } from "./teacherApp.js";
import { createGitVersionStore } from "../filelib/gitVersionStore.js";
import { resolveMaxFileBytes } from "../filelib/fileService.js";
@@ -166,6 +167,7 @@ export async function registerDatabaseRoutes(
await registerFileLibRoutes(app, filelibDeps);
await registerFileRoutes(app, filelibDeps);
await registerMemberGroupRoutes(app, filelibDeps);
+ await registerBinRoutes(app, filelibDeps);
await registerTeacherApp(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
diff --git a/hub/src/database/routes/filelibRoutes.ts b/hub/src/database/routes/filelibRoutes.ts
index 39cd339..9c6a2ac 100644
--- a/hub/src/database/routes/filelibRoutes.ts
+++ b/hub/src/database/routes/filelibRoutes.ts
@@ -21,7 +21,6 @@ import {
listGrants,
putGrants,
revokeGrant,
- setIndependentPermission,
} from "../filelib/grantService.js";
import { FileLibError } from "../filelib/model.js";
import {
@@ -114,9 +113,6 @@ export async function registerFileLibRoutes(
where: { id, organizationId: deps.organizationId },
});
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
- const settings = node.kind === "PROJECT"
- ? await deps.prisma.fileLibProjectSettings.findUnique({ where: { nodeId: node.id } })
- : null;
return {
node: {
id: node.id,
@@ -126,7 +122,6 @@ export async function registerFileLibRoutes(
description: node.description,
role,
provisionStatus: node.provisionStatus,
- independentPermission: settings?.independentPermissionsEnabled ?? false,
createdAt: node.createdAt,
updatedAt: node.updatedAt,
},
@@ -258,21 +253,6 @@ export async function registerFileLibRoutes(
}
});
- app.put("/database/api/projects/:id/independent-permission", 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 (typeof body["enabled"] !== "boolean") {
- throw new FileLibError(400, "invalid_request", "enabled must be a boolean");
- }
- return await setIndependentPermission(grantDeps, actor, id, body["enabled"]);
- } catch (error) {
- return sendRouteError(reply, error);
- }
- });
-
// Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
}
diff --git a/hub/test/integration/filelib-nav.test.ts b/hub/test/integration/filelib-nav.test.ts
new file mode 100644
index 0000000..5272981
--- /dev/null
+++ b/hub/test/integration/filelib-nav.test.ts
@@ -0,0 +1,151 @@
+/**
+ * 回收站集成测试(真实 Postgres,ADR-0031;最近打开已由 ADR-0032 移除)。
+ * 覆盖:bin 列出(祖先全活跃顶点/直连 MANAGE 可见性/管理员)、restore 对称语义
+ * 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)。
+ * 运行前提:本地 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 {
+ createNode,
+ softDeleteNode,
+ listChildren,
+ renameNode,
+ type FileLibActor,
+ type TreeServiceDeps,
+} from "../../src/database/filelib/treeService.js";
+import { listBin, purgeBinEntry, restoreBinEntry, type BinDeps } from "../../src/database/filelib/binService.js";
+import { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js";
+import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
+import { FILE_LIB_AUDIT_ACTIONS } from "../../src/database/filelib/audit.js";
+
+const ADMIN: FileLibActor = { userId: "u_admin", isWebsiteAdmin: true };
+const ALICE: FileLibActor = { userId: "u_alice", isWebsiteAdmin: false };
+const BOB: FileLibActor = { userId: "u_bob", isWebsiteAdmin: false };
+
+function treeDeps(): TreeServiceDeps {
+ return {
+ prisma,
+ groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }),
+ versionStore: createInMemoryVersionStore(),
+ organizationId: DEFAULT_ORG_ID,
+ storageRoot: "/tmp/filelib-test",
+ };
+}
+
+function binDeps(): BinDeps {
+ return {
+ prisma,
+ organizationId: DEFAULT_ORG_ID,
+ groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }),
+ };
+}
+
+beforeEach(async () => {
+ await resetDb();
+ 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 } });
+ }
+});
+
+describe("binService · 列出与可见性", () => {
+ it("只露每支已删子树的顶;管理员全见,直连 MANAGE 可见,无关者不见", async () => {
+ const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
+ const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
+ await createNode(treeDeps(), ADMIN, { parentId: child.id, kind: "PROJECT", name: "TH-141" });
+ // alice 在 child 上直连 MANAGE。
+ const own = await createNode(treeDeps(), ADMIN, {
+ parentId: null, kind: "PROJECT", name: "alice 项目",
+ grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
+ });
+
+ await softDeleteNode(treeDeps(), ADMIN, child.id); // 删中间层:child 是顶,孙项目不单列
+ await softDeleteNode(treeDeps(), ADMIN, own.id);
+
+ const adminBin = await listBin(binDeps(), ADMIN);
+ expect(adminBin.map((e) => e.name).sort()).toEqual(["alice 项目", "必修一"]);
+
+ const aliceBin = await listBin(binDeps(), ALICE);
+ expect(aliceBin.map((e) => e.name)).toEqual(["alice 项目"]); // 只见自己 MANAGE 的
+
+ const bobBin = await listBin(binDeps(), BOB);
+ expect(bobBin).toEqual([]);
+
+ void root;
+ });
+});
+
+describe("binService · 恢复", () => {
+ it("restore 只清本节点:整支立即可见,落 folder.restore 审计;无权者 404", async () => {
+ const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
+ const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
+ await softDeleteNode(treeDeps(), ADMIN, child.id);
+
+ await expect(restoreBinEntry(binDeps(), BOB, child.id)).rejects.toMatchObject({ statusCode: 404 });
+ await restoreBinEntry(binDeps(), ADMIN, child.id);
+
+ const visible = await listChildren(treeDeps(), ADMIN, root.id);
+ expect(visible.map((c) => c.name)).toContain("必修一");
+
+ const audits = await prisma.auditEntry.findMany({
+ where: { action: FILE_LIB_AUDIT_ACTIONS.folderRestore },
+ });
+ expect(audits).toHaveLength(1);
+ });
+
+ it("ADR-0035:撞名时恢复报 name_conflict_on_restore(不自动改名),清名后可恢复", async () => {
+ const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
+ const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
+ await softDeleteNode(treeDeps(), ADMIN, child.id);
+ // 删除后同名新建 -> 活跃兄弟占了名字
+ await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
+
+ await expect(restoreBinEntry(binDeps(), ADMIN, child.id)).rejects.toMatchObject({
+ statusCode: 409,
+ code: "name_conflict_on_restore",
+ });
+
+ // 改名现有节点后恢复 -> 成功,保留原名
+ const active = (await listChildren(treeDeps(), ADMIN, root.id)).find((c) => c.name === "必修一")!;
+ await renameNode(treeDeps(), ADMIN, active.id, "必修一(新)");
+ const result = await restoreBinEntry(binDeps(), ADMIN, child.id);
+ expect(result.name).toBe("必修一");
+ expect(result.renamedFrom).toBeUndefined();
+
+ const visible = await listChildren(treeDeps(), ADMIN, root.id);
+ expect(visible.map((c) => c.name).sort()).toEqual(["必修一", "必修一(新)"]);
+ });
+});
+
+describe("binService · 彻底删除", () => {
+ it("ADR-0034:与条目可见性同权 —— 直连 MANAGE 可清空,无关者 404;整支硬删 + node.purge 审计", async () => {
+ const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
+ const child = await createNode(treeDeps(), ADMIN, {
+ parentId: root.id, kind: "PROJECT", name: "TH-141",
+ grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
+ });
+ await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶;alice 在 root 上无直连 MANAGE
+
+ await expect(purgeBinEntry(binDeps(), BOB, root.id)).rejects.toMatchObject({ statusCode: 404 });
+ const { removed } = await purgeBinEntry(binDeps(), ADMIN, root.id);
+ expect(removed).toBe(2);
+
+ expect(await prisma.fileLibNode.count({ where: { id: { in: [root.id, child.id] } } })).toBe(0);
+ expect(await prisma.fileLibGrant.count({ where: { nodeId: child.id } })).toBe(0);
+
+ const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.nodePurge } });
+ expect(audits).toHaveLength(1);
+ });
+
+ it("ADR-0034:非管理员的直连 MANAGE 持有者也能彻底删除", async () => {
+ const own = await createNode(treeDeps(), ADMIN, {
+ parentId: null, kind: "PROJECT", name: "alice 项目",
+ grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
+ });
+ await softDeleteNode(treeDeps(), ADMIN, own.id);
+
+ const { removed } = await purgeBinEntry(binDeps(), ALICE, own.id);
+ expect(removed).toBe(1);
+ expect(await prisma.fileLibNode.count({ where: { id: own.id } })).toBe(0);
+ });
+});
diff --git a/hub/test/integration/filelib-tree.test.ts b/hub/test/integration/filelib-tree.test.ts
index 93d8621..b2605af 100644
--- a/hub/test/integration/filelib-tree.test.ts
+++ b/hub/test/integration/filelib-tree.test.ts
@@ -80,20 +80,15 @@ describe("treeService · 创建规则", () => {
});
});
-describe("treeService · D11 独立权限开关", () => {
- it("关闭时项目级非创建者 grant 冻结,创建者仍 MANAGE", async () => {
+describe("treeService · 项目级 grant 恒生效(ADR-0030)", () => {
+ it("项目级非创建者 grant 创建即生效,创建者仍 MANAGE", async () => {
const project = await createNode(deps(), ADMIN, {
parentId: null, kind: "PROJECT", name: "TH-141",
grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }],
});
- await expect(getEffectiveRole(deps(), ALICE, project.id))
- .rejects.toMatchObject({ statusCode: 404 }); // 冻结 = 无权限 = D8 不可见
+ // 无开关、无冻结:alice 的项目级 EDIT 立即可见。
+ expect(await getEffectiveRole(deps(), ALICE, project.id)).toBe("EDIT");
expect(await getEffectiveRole(deps(), ADMIN, project.id)).toBe("MANAGE");
- await prisma.fileLibProjectSettings.update({
- where: { nodeId: project.id },
- data: { independentPermissionsEnabled: true },
- });
- expect(await getEffectiveRole(deps(), ALICE, project.id)).toBe("EDIT"); // 恢复
});
});
diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts
index aca8e73..d69715b 100644
--- a/hub/test/integration/helpers.ts
+++ b/hub/test/integration/helpers.ts
@@ -45,6 +45,12 @@ export async function resetDb(): Promise {
// two tables have no FK to Project and must be cleared explicitly.
prisma.permissionGrant.deleteMany(),
prisma.permissionSettings.deleteMany(),
+ // MemberGroup is global (ADR-0028): no FK to the org/user roots, so the
+ // cascade above never reaches it. Clear explicitly — closure/membership
+ // first (they FK into MemberGroup), groups last.
+ prisma.memberGroupClosure.deleteMany(),
+ prisma.memberGroupMembership.deleteMany(),
+ prisma.memberGroup.deleteMany(),
prisma.user.deleteMany(),
prisma.organization.deleteMany(),
]);
diff --git a/hub/test/unit/filelib-permission.test.ts b/hub/test/unit/filelib-permission.test.ts
index c54069e..102cde7 100644
--- a/hub/test/unit/filelib-permission.test.ts
+++ b/hub/test/unit/filelib-permission.test.ts
@@ -1,6 +1,6 @@
/**
- * 纯权限 reducer 单测(契约 P6 / D11 / 2.3)。
- * 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限/toggle 冻结;
+ * 纯权限 reducer 单测(契约 P6 / 2.3)。
+ * 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限;
* 外加确定性随机化不变量(单调性:任何可用 grant 都不超过 effective)。
*/
import { describe, expect, it } from "vitest";
@@ -10,7 +10,6 @@ const base: EffectiveRoleInput = {
nodeId: "N",
nodeKind: "FOLDER",
ancestorIds: ["A", "R"], // N ⊂ A ⊂ R
- independentPermissionsEnabled: false,
userId: "u1",
groupIds: ["g1"],
grants: [],
@@ -75,33 +74,28 @@ describe("effectiveRole · 契约 P6 矩阵", () => {
});
});
-describe("effectiveRole · D11 独立权限开关", () => {
+describe("effectiveRole · 项目级 grant 恒生效(ADR-0030)", () => {
const project: EffectiveRoleInput = { ...base, nodeKind: "PROJECT", nodeId: "P" };
- it("开关关闭:项目级非创建者 grant 冻结", () => {
+ it("项目级非创建者 grant 直接参与(无开关、无冻结)", () => {
const grants = [grant({ nodeId: "P", role: "EDIT" })];
- expect(effectiveRole({ ...project, grants })).toBeNull();
+ expect(effectiveRole({ ...project, grants })).toBe("EDIT");
});
- it("开关关闭:创建者 grant 仍生效", () => {
+ it("创建者 grant 照常生效", () => {
const grants = [grant({ nodeId: "P", role: "MANAGE", isCreatorGrant: true })];
expect(effectiveRole({ ...project, grants })).toBe("MANAGE");
});
- it("开关关闭:祖先链 grant 不受影响", () => {
+ it("项目级与祖先链 grant 同取 max", () => {
const grants = [
- grant({ nodeId: "P", role: "MANAGE" }), // 冻结
- grant({ nodeId: "A", role: "VIEW" }), // 生效
+ grant({ nodeId: "P", role: "VIEW" }),
+ grant({ nodeId: "A", role: "EDIT" }),
];
- expect(effectiveRole({ ...project, grants })).toBe("VIEW");
+ expect(effectiveRole({ ...project, grants })).toBe("EDIT");
});
- it("开关开启:项目级 grant 恢复参与", () => {
- const grants = [grant({ nodeId: "P", role: "EDIT" })];
- expect(effectiveRole({ ...project, independentPermissionsEnabled: true, grants })).toBe("EDIT");
- });
-
- it("文件夹忽略开关(self grant 照常参与)", () => {
+ it("文件夹与项目语义一致(self grant 照常参与)", () => {
const grants = [grant({ nodeId: "N", role: "EDIT" })];
expect(effectiveRole({ ...base, grants })).toBe("EDIT");
});