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)} + + + + +
+ {/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 @@ + + + + + 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} - + +
+
- + + + + + + + + + - {#if grants.length === 0} - + {#if shown.length === 0} + + + {:else} - {#each grants as g (g.id)} + {#each shown as g (g.id)} - - - + + + + + @@ -151,42 +309,73 @@ {/each} {/if} -
主体级别
成员userId飞书 ID类型权限加入时间
暂无显式授权
+ {searchText.trim() === "" ? "暂无授权" : `无匹配「${searchText.trim()}」的授权`} +
- - - - {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} + + {:else} + {ROLE_LABEL[g.role]} + {/if} + {fmtDate(g.createdAt)} {#if !g.isCreatorGrant && canManage} {/if}
+ +
{/if} + - {#if canManage} -
-
新增授权
-
- + - - {#if principalType === "USER"} - - {:else if groupOptions === null} - 加载 Group 列表… - {:else if groupOptions.length === 0} - 暂无可选 Group · 先到「Group 管理」建一个 - {:else} - - {/if} - - +
+ {#if selectedPrincipal !== null} +
+ 已选 + + {selectedPrincipal.label}({selectedPrincipal.id}) + +
+ {/if} + {#if principalOptions !== null && principalOptions.length > 0} +
+ {#each principalOptions as o (o.id)} + + {/each} +
+ {:else if searchUnavailable} +
+ + +
+ {:else if principalOptions !== null} +
无匹配结果
+ {/if} +
+ + +
+
+
-
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 @@ + + + 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} + + + {#if view === "nodes" && canCreateHere} + + + {/if} + {#if view === "files" && projectCanEdit} + + {/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)}> +
+ + +
+
+ + +
+
+ + +
+
+{/if} + +{#if modal === "rename" && renameTarget} + (modal = null)}> +
+ + +
+
+ + +
+
+{/if} + +{#if modal === "grants" && detailNode} + (modal = null)}> + + +{/if} + +{#if modal === "detail" && detailNode} + (modal = null)}> + { modal = null; refresh(); }} /> + +{/if} + +{#if modal === "newFile"} + (modal = null)}> +
+ + +
+
+ + +
+
+ + +
+
+{/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} - - {/if} - 关闭时仅继承父级权限(创建者除外) -
-
导出
@@ -172,6 +163,17 @@ {/if}
{/if} + + {#if canManage} +
+ +
+ 删除后移入回收站,可恢复 + +
+ {/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)} 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"); });
{m.displayName || m.userId} {m.userId}