feat(filelib): 老师端左栏导航:回收站 + 最近打开(ADR-0031)

- 回收站:listBin(祖先全活跃的已删顶点;管理员/直连 MANAGE 可见)、
  restore(与 D15 对称只清本节点,落审计)、purge(仅管理员,pathIds 枚举
  子树按深度降序分批硬删,绕过 self-FK RESTRICT)
- 最近打开:FileLibRecentVisit 表(filePath='' 兜底 PG 唯一索引),客户端
  成功打开后上报(VIEW 门禁,upsert 刷新),列表 20 条,D8/D15 可见性过滤
- 前端:/app 左栏(文件库/最近打开/回收站);RecentView/BinView;
  GridLibraryView 埋点 + navTarget 跳转(breadcrumb 建栈,role 已捎带)
- 测试:filelib-nav 集成 4 例;全套 79 例绿
This commit is contained in:
ymy
2026-07-31 13:27:04 +08:00
parent 04fa383286
commit d072e9ec1e
17 changed files with 998 additions and 17 deletions
@@ -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.
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
/**
* 回收站(ADR-0031):祖先全活跃的已删节点顶;恢复只清本节点 deletedAt;
* 彻底删除(整支硬删)仅网站管理员,二次确认。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { me, toastErr, toastOk } from "./stores.js";
import type { BinEntry } from "./types.js";
import Icon from "./Icon.svelte";
let entries = $state<BinEntry[] | null>(null);
let error = $state<string | null>(null);
let busyId = $state<string | null>(null);
async function load(): Promise<void> {
try {
const r = await api<{ entries: BinEntry[] }>("/database/api/bin");
entries = r.entries;
error = null;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
onMount(load);
function fmt(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
async function restore(e: BinEntry): Promise<void> {
busyId = e.id;
try {
await api(`/database/api/bin/${encodeURIComponent(e.id)}/restore`, { method: "POST" });
toastOk(`已恢复「${e.name}」`);
await load();
} catch (err) {
toastErr(err instanceof Error ? err.message : String(err));
} finally {
busyId = null;
}
}
async function purge(e: BinEntry): Promise<void> {
if (!confirm(`彻底删除「${e.name}」及其全部内容?此操作不可恢复。`)) return;
if (!confirm(`再次确认:整支硬删,含子文件夹/项目/文件与授权记录。继续?`)) return;
busyId = e.id;
try {
const r = await api<{ removed: number }>(`/database/api/bin/${encodeURIComponent(e.id)}`, { method: "DELETE" });
toastOk(`已彻底删除(${r.removed} 个节点)`);
await load();
} catch (err) {
toastErr(err instanceof Error ? err.message : String(err));
} finally {
busyId = null;
}
}
</script>
<div class="flex-1 overflow-y-auto px-6 py-5">
<h1 class="mb-4 text-[15px] font-semibold text-ink">回收站</h1>
{#if error !== null}
<div class="py-8 text-center text-[13px] text-danger">{error}</div>
{:else if entries === null}
<div class="quiet py-8 text-center">加载中…</div>
{:else if entries.length === 0}
<div class="quiet py-8 text-center">回收站是空的</div>
{:else}
<div class="panel !p-2">
{#each entries as e (e.id)}
<div class="flex items-center gap-3 rounded-lg px-3 py-2">
<span class="flex text-ink-3"><Icon name={e.kind === "FOLDER" ? "folder" : "layers"} size={15} /></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px] text-ink">{e.name}</span>
<span class="quiet block">删除于 {fmt(e.deletedAt)}</span>
</span>
<button
class="btn btn-sm disabled:opacity-50"
onclick={() => void restore(e)}
disabled={busyId === e.id}
>
<Icon name="restore" size={12} /> 恢复
</button>
{#if $me?.isWebsiteAdmin}
<button
class="btn btn-sm btn-danger disabled:opacity-50"
onclick={() => void purge(e)}
disabled={busyId === e.id}
>
<Icon name="trash" size={12} /> 彻底删除
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
+70 -10
View File
@@ -23,14 +23,23 @@
import GrantsPanel from "./GrantsPanel.svelte"; import GrantsPanel from "./GrantsPanel.svelte";
import OverviewPanel from "./OverviewPanel.svelte"; import OverviewPanel from "./OverviewPanel.svelte";
/** 最近打开上报的导航目标(ADR-0031):父组件传入后,本组件跳到对应节点并清除。 */
export interface NavTarget {
readonly nodeId: string;
readonly filePath?: string | undefined;
}
let { navTarget = null, onnavigated }: { navTarget?: NavTarget | null; onnavigated?: () => void } = $props();
const RANK: Record<Role, number> = { VIEW: 1, EDIT: 2, MANAGE: 3 }; const RANK: Record<Role, number> = { VIEW: 1, EDIT: 2, MANAGE: 3 };
const atLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min]; const atLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min];
type View = "nodes" | "files"; type View = "nodes" | "files";
type StackItem = Pick<NodeChild, "id" | "name" | "kind" | "role">;
let view = $state<View>("nodes"); let view = $state<View>("nodes");
/** 下钻栈(均为 FOLDER;根层为空栈)。 */ /** 下钻栈(均为 FOLDER;根层为空栈)。 */
let stack = $state<NodeChild[]>([]); let stack = $state<StackItem[]>([]);
let children = $state<NodeChild[] | null>(null); let children = $state<NodeChild[] | null>(null);
let nodesError = $state<string | null>(null); let nodesError = $state<string | null>(null);
@@ -48,7 +57,7 @@
let createParentId = $state<string | null>(null); let createParentId = $state<string | null>(null);
let formName = $state(""); let formName = $state("");
let formDesc = $state(""); let formDesc = $state("");
let renameTarget = $state<NodeChild | null>(null); let renameTarget = $state<StackItem | null>(null);
let detailNode = $state<NodeDetail | null>(null); let detailNode = $state<NodeDetail | null>(null);
let newPath = $state(""); let newPath = $state("");
let newContent = $state(""); let newContent = $state("");
@@ -98,6 +107,14 @@
onMount(loadChildren); onMount(loadChildren);
/** 最近打开上报(ADR-0031):fire-and-forget,失败静默,不阻塞浏览。 */
function record(nodeId: string, filePath?: string): void {
void api("/database/api/recent", {
method: "POST",
body: { nodeId, ...(filePath !== undefined ? { filePath } : {}) },
}).catch(() => undefined);
}
function refresh(): void { function refresh(): void {
selected = null; selected = null;
menu = null; menu = null;
@@ -107,8 +124,9 @@
/* ------------------------------------------------------------ 导航 */ /* ------------------------------------------------------------ 导航 */
function openNode(n: NodeChild): void { function openNode(n: StackItem): void {
selected = null; selected = null;
record(n.id);
if (n.kind === "FOLDER") { if (n.kind === "FOLDER") {
stack = [...stack, n]; stack = [...stack, n];
void loadChildren(); void loadChildren();
@@ -156,6 +174,43 @@
void loadChildren(); void loadChildren();
} }
/** 跳到任意节点(最近打开入口):breadcrumb 建栈,FOLDER 进子层,PROJECT 进文件视图。 */
async function navigateTo(target: NavTarget): Promise<void> {
try {
const r = await api<{ breadcrumb: Array<{ id: string | null; name: string | null; kind: "FOLDER" | "PROJECT"; role: Role | null }> }>(
`/database/api/nodes/${target.nodeId}/breadcrumb`,
);
const visible = r.breadcrumb.filter(
(e): e is { id: string; name: string; kind: "FOLDER" | "PROJECT"; role: Role | null } =>
e.id !== null && e.name !== null,
);
if (visible.length === 0) return;
const self = visible[visible.length - 1]!;
selected = null;
if (self.kind === "FOLDER") {
view = "nodes";
projectNode = null;
clearSelectedFile();
stack = visible.map((e) => ({ id: e.id, name: e.name, kind: e.kind, role: e.role ?? "VIEW" }));
await loadChildren();
} else {
stack = visible.slice(0, -1).map((e) => ({ id: e.id, name: e.name, kind: e.kind, role: e.role ?? "VIEW" }));
projectNode = await fetchDetail(self.id);
view = "files";
await loadFiles();
if (target.filePath !== undefined) selectedFilePath.set(target.filePath);
}
} catch (e) {
toastErr(errText(e));
}
}
$effect(() => {
if (navTarget === null) return;
const t = navTarget;
void navigateTo(t).finally(() => onnavigated?.());
});
/* ------------------------------------------------------------ 节点操作 */ /* ------------------------------------------------------------ 节点操作 */
function openCreate(kind: "FOLDER" | "PROJECT", parentId: string | null): void { function openCreate(kind: "FOLDER" | "PROJECT", parentId: string | null): void {
@@ -190,7 +245,7 @@
} }
} }
function openRename(n: NodeChild): void { function openRename(n: StackItem): void {
renameTarget = n; renameTarget = n;
formName = n.name; formName = n.name;
modal = "rename"; modal = "rename";
@@ -213,7 +268,7 @@
} }
} }
async function removeNode(n: NodeChild): Promise<void> { async function removeNode(n: StackItem): Promise<void> {
if (!confirm(`删除「${n.name}」?软删除后不可见。`)) return; if (!confirm(`删除「${n.name}」?软删除后不可见。`)) return;
try { try {
await api(`/database/api/nodes/${n.id}`, { method: "DELETE" }); await api(`/database/api/nodes/${n.id}`, { method: "DELETE" });
@@ -224,7 +279,7 @@
} }
} }
async function openGrants(n: NodeChild): Promise<void> { async function openGrants(n: StackItem): Promise<void> {
try { try {
detailNode = await fetchDetail(n.id); detailNode = await fetchDetail(n.id);
modal = "grants"; modal = "grants";
@@ -233,7 +288,7 @@
} }
} }
async function openDetail(n: NodeChild): Promise<void> { async function openDetail(n: StackItem): Promise<void> {
try { try {
detailNode = await fetchDetail(n.id); detailNode = await fetchDetail(n.id);
modal = "detail"; modal = "detail";
@@ -244,6 +299,11 @@
/* ------------------------------------------------------------ 文件操作 */ /* ------------------------------------------------------------ 文件操作 */
function previewFile(f: FileEntry): void {
if (projectNode !== null) record(projectNode.id, f.path);
selectedFilePath.set(f.path);
}
async function submitNewFile(): Promise<void> { async function submitNewFile(): Promise<void> {
if (projectNode === null) return; if (projectNode === null) return;
const path = newPath.trim(); const path = newPath.trim();
@@ -285,7 +345,7 @@
/* ------------------------------------------------------------ 右键菜单 */ /* ------------------------------------------------------------ 右键菜单 */
function nodeMenuItems(n: NodeChild): MenuItem[] { function nodeMenuItems(n: StackItem): MenuItem[] {
const items: MenuItem[] = [{ label: "打开", icon: "chevron", onclick: () => openNode(n) }]; const items: MenuItem[] = [{ label: "打开", icon: "chevron", onclick: () => openNode(n) }];
if (n.kind === "FOLDER" && atLeast(n.role, "EDIT")) { if (n.kind === "FOLDER" && atLeast(n.role, "EDIT")) {
items.push({ label: "新建子文件夹", icon: "plus", onclick: () => openCreate("FOLDER", n.id) }); items.push({ label: "新建子文件夹", icon: "plus", onclick: () => openCreate("FOLDER", n.id) });
@@ -305,7 +365,7 @@
function fileMenuItems(f: FileEntry): MenuItem[] { function fileMenuItems(f: FileEntry): MenuItem[] {
const items: MenuItem[] = [ const items: MenuItem[] = [
{ label: "打开预览", icon: "chevron", onclick: () => selectedFilePath.set(f.path) }, { label: "打开预览", icon: "chevron", onclick: () => previewFile(f) },
{ {
label: "下载", label: "下载",
icon: "download", icon: "download",
@@ -441,7 +501,7 @@
meta="{f.size} B" meta="{f.size} B"
selected={selected === f.path} selected={selected === f.path}
onselect={() => (selected = f.path)} onselect={() => (selected = f.path)}
onopen={() => selectedFilePath.set(f.path)} onopen={() => previewFile(f)}
oncontextmenu={(x, y) => (menu = { x, y, items: fileMenuItems(f) })} oncontextmenu={(x, y) => (menu = { x, y, items: fileMenuItems(f) })}
/> />
{/each} {/each}
+1
View File
@@ -21,6 +21,7 @@
arrowLeft: "M19 12H5m0 0 6 6m-6-6 6-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", 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", 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。 // 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6", 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", restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5",
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
/**
* 最近打开(ADR-0031):本人最近 20 条,点击跳回文件库对应位置
* (经 onopen 把导航目标交给外层,由 GridLibraryView 建栈跳转)。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import type { RecentEntry } from "./types.js";
import Icon from "./Icon.svelte";
let { onopen }: { onopen: (target: { nodeId: string; filePath?: string }) => void } = $props();
let entries = $state<RecentEntry[] | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
try {
const r = await api<{ entries: RecentEntry[] }>("/database/api/recent");
entries = r.entries;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
});
function fmt(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
function iconOf(e: RecentEntry): "folder" | "layers" | "chevron" {
if (e.filePath !== "") return "chevron";
return e.kind === "FOLDER" ? "folder" : "layers";
}
</script>
<div class="flex-1 overflow-y-auto px-6 py-5">
<h1 class="mb-4 text-[15px] font-semibold text-ink">最近打开</h1>
{#if error !== null}
<div class="py-8 text-center text-[13px] text-danger">{error}</div>
{:else if entries === null}
<div class="quiet py-8 text-center">加载中…</div>
{:else if entries.length === 0}
<div class="quiet py-8 text-center">还没有访问记录 · 去文件库逛逛</div>
{:else}
<div class="panel !p-2">
{#each entries as e (e.nodeId + "/" + e.filePath)}
<button
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition hover:bg-hover"
onclick={() => onopen({ nodeId: e.nodeId, ...(e.filePath !== "" ? { filePath: e.filePath } : {}) })}
>
<span class="flex text-ink-3"><Icon name={iconOf(e)} size={15} /></span>
<span class="min-w-0 flex-1">
<span class="block truncate text-[13px] text-ink">{e.name}</span>
{#if e.filePath !== ""}
<span class="block truncate font-mono text-[11px] text-ink-3">{e.filePath}</span>
{/if}
</span>
<span class="quiet shrink-0">{fmt(e.openedAt)}</span>
</button>
{/each}
</div>
{/if}
</div>
+22 -2
View File
@@ -18,6 +18,8 @@ export interface BreadcrumbEntry {
readonly id: string | null; readonly id: string | null;
readonly name: string | null; readonly name: string | null;
readonly kind: NodeKind; readonly kind: NodeKind;
/** 该节点对调用者的 effective role;无 View 为 null。 */
readonly role: Role | null;
} }
export interface NodeDetail { export interface NodeDetail {
@@ -130,9 +132,27 @@ export interface UserSearchResult {
readonly avatarUrl: string | null; readonly avatarUrl: string | null;
} }
/** 最近打开条目(GET /database/api/recent)。 */
export interface RecentEntry {
readonly nodeId: string;
readonly kind: NodeKind;
readonly name: string;
/** "" = 节点本身;非空 = 项目内文件路径。 */
readonly filePath: string;
readonly openedAt: string;
}
/** 回收站条目(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)。 */ /** 管理后台概览统计(GET /database/api/stats)。 */
export interface DashboardStats { export interface DashboardStats { readonly folders: number;
readonly folders: number;
readonly projects: number; readonly projects: number;
readonly files: number; readonly files: number;
readonly grants: number; readonly grants: number;
+43 -4
View File
@@ -1,12 +1,30 @@
<script lang="ts"> <script lang="ts">
/** 老师端。未登录显示登录卡片;登录后是网盘式文件库浏览器。 */ /** 老师端。未登录显示登录卡片;登录后是带左栏导航的文件库(ADR-0031)。 */
import { onMount } from "svelte"; import { onMount } from "svelte";
import { me, authChecked } from "$lib/stores.js"; import { me, authChecked } from "$lib/stores.js";
import { loadSession } from "$lib/session.js"; import { loadSession } from "$lib/session.js";
import LoginView from "$lib/LoginView.svelte"; import LoginView from "$lib/LoginView.svelte";
import GridLibraryView from "$lib/GridLibraryView.svelte"; import GridLibraryView, { type NavTarget } from "$lib/GridLibraryView.svelte";
import RecentView from "$lib/RecentView.svelte";
import BinView from "$lib/BinView.svelte";
import Icon from "$lib/Icon.svelte";
onMount(loadSession); onMount(loadSession);
type View = "library" | "recent" | "bin";
let view = $state<View>("library");
let navTarget = $state<NavTarget | null>(null);
function openFromRecent(target: NavTarget): void {
navTarget = target;
view = "library";
}
const tabs: ReadonlyArray<readonly [View, string, "layers" | "clock" | "trash"]> = [
["library", "文件库", "layers"],
["recent", "最近打开", "clock"],
["bin", "回收站", "trash"],
];
</script> </script>
<svelte:head><title>文件库</title></svelte:head> <svelte:head><title>文件库</title></svelte:head>
@@ -14,8 +32,29 @@
{#if !$authChecked} {#if !$authChecked}
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div> <div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
{:else if $me} {:else if $me}
<div class="flex h-full flex-col"> <div class="flex h-full">
<GridLibraryView /> <!-- 左栏导航(ADR-0031) -->
<nav class="flex w-[168px] shrink-0 flex-col gap-0.5 border-r border-line-soft bg-sidebar px-2.5 py-3.5">
{#each tabs as [id, label, icon] (id)}
<button
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[13px] transition {view === id
? 'bg-selected font-semibold text-ink'
: 'text-ink-2 hover:bg-hover'}"
onclick={() => (view = id)}
>
<span class="text-ink-3"><Icon name={icon} size={15} /></span>
{label}
</button>
{/each}
</nav>
{#if view === "library"}
<GridLibraryView {navTarget} onnavigated={() => (navTarget = null)} />
{:else if view === "recent"}
<RecentView onopen={openFromRecent} />
{:else}
<BinView />
{/if}
</div> </div>
{:else} {:else}
<LoginView /> <LoginView />
@@ -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";
+15
View File
@@ -1110,3 +1110,18 @@ model FileLibExportJob {
@@index([organizationId, status]) @@index([organizationId, status])
} }
/// ADR-0031:最近打开。客户端在成功打开后上报;filePath="" 表示节点本身
/// (文件夹/项目),非空表示项目内文件预览(PG 唯一索引视 NULL 互不相同,
/// 故用空串而非 null)。名称读取时 join FileLibNode 实时取,不做冗余。
model FileLibRecentVisit {
id String @id @default(cuid())
organizationId String
userId String
nodeId String
filePath String @default("")
openedAt DateTime
@@unique([organizationId, userId, nodeId, filePath])
@@index([organizationId, userId, openedAt])
}
+4
View File
@@ -19,6 +19,10 @@ export const FILE_LIB_AUDIT_ACTIONS = {
projectRename: "project.rename", projectRename: "project.rename",
projectMove: "project.move", projectMove: "project.move",
projectDelete: "project.delete", projectDelete: "project.delete",
// ADR-0031:回收站。restore 与 delete 对称(都只动本节点);purge 是整支硬删。
folderRestore: "folder.restore",
projectRestore: "project.restore",
nodePurge: "node.purge",
permissionGrant: "permission.grant", permissionGrant: "permission.grant",
permissionUpdate: "permission.update", permissionUpdate: "permission.update",
permissionRevoke: "permission.revoke", permissionRevoke: "permission.revoke",
+181
View File
@@ -0,0 +1,181 @@
/**
* 回收站(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 } 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<PrismaClient, "fileLibGrant">,
deps: BinDeps,
actor: FileLibActor,
groupIds: readonly string[],
nodeId: string,
): Promise<boolean> {
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<readonly BinEntryDto[]> {
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<string>();
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 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, kind: node.kind, name: node.name, pathIds: node.pathIds };
}
/** 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。 */
export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<void> {
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
await deps.prisma.$transaction(async (tx) => {
const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
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 },
});
});
}
/**
* 彻底删除(仅网站管理员):整支硬删。子树经 pathIds 前缀枚举,
* 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT,
* 父行必须晚于全部子孙行删除。
*/
export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(404, "node_not_found", "node not found");
}
return deps.prisma.$transaction(async (tx) => {
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");
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 };
});
}
+106
View File
@@ -0,0 +1,106 @@
/**
* 最近打开(ADR-0031)。
*
* 记录:客户端在成功打开后上报;node 需 VIEW(D8,无权即 404 不泄露);
* upsert 语义 —— 重复打开只刷新 openedAt。不写审计(按用户的读模型,
* 非权限敏感写)。
* 列表:本人最近 20 条,openedAt 倒序;节点已删或**任一祖先已删**的条目
* 过滤掉(D8/D15 可见性在每个面都成立);名称 join FileLibNode 实时取。
*/
import type { PrismaClient } from "@prisma/client";
import { requireAccessInTx, type AccessDeps, type FileLibActor } from "./treeService.js";
export type RecentDeps = AccessDeps & { readonly prisma: PrismaClient };
export interface RecentEntryDto {
readonly nodeId: string;
readonly kind: "FOLDER" | "PROJECT";
readonly name: string;
/** "" = 节点本身;非空 = 项目内文件路径。 */
readonly filePath: string;
readonly openedAt: Date;
}
const RECENT_LIMIT = 20;
export async function recordVisit(
deps: RecentDeps,
actor: FileLibActor,
nodeId: string,
filePath: string | undefined,
): Promise<void> {
const path = filePath ?? "";
await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, nodeId, "VIEW"));
await deps.prisma.fileLibRecentVisit.upsert({
where: {
organizationId_userId_nodeId_filePath: {
organizationId: deps.organizationId,
userId: actor.userId,
nodeId,
filePath: path,
},
},
update: { openedAt: new Date() },
create: {
organizationId: deps.organizationId,
userId: actor.userId,
nodeId,
filePath: path,
openedAt: new Date(),
},
});
}
export async function listRecent(deps: RecentDeps, actor: FileLibActor): Promise<readonly RecentEntryDto[]> {
// 可见性过滤会丢弃一部分,超取再截断。
const rows = await deps.prisma.fileLibRecentVisit.findMany({
where: { organizationId: deps.organizationId, userId: actor.userId },
orderBy: { openedAt: "desc" },
take: RECENT_LIMIT * 3,
});
if (rows.length === 0) return [];
const nodeIds = [...new Set(rows.map((r) => r.nodeId))];
const nodes = await deps.prisma.fileLibNode.findMany({
where: { id: { in: nodeIds } },
select: { id: true, kind: true, name: true, pathIds: true, deletedAt: true },
});
const byId = new Map(nodes.map((n) => [n.id, n]));
// 祖先活跃性:收集所有节点的祖先段,查已删集合。
const ancestorIds = new Set<string>();
for (const n of nodes) {
for (const s of n.pathIds.split("/").filter((x) => x !== "" && x !== n.id)) ancestorIds.add(s);
}
const deletedAncestorIds = new Set(
ancestorIds.size === 0
? []
: (
await deps.prisma.fileLibNode.findMany({
where: { id: { in: [...ancestorIds] }, deletedAt: { not: null } },
select: { id: true },
})
).map((r) => r.id),
);
const out: RecentEntryDto[] = [];
for (const row of rows) {
if (out.length >= RECENT_LIMIT) break;
const node = byId.get(row.nodeId);
if (node === undefined || node.deletedAt !== null) continue;
const hidden = node.pathIds
.split("/")
.filter((s) => s !== "" && s !== node.id)
.some((s) => deletedAncestorIds.has(s));
if (hidden) continue;
out.push({
nodeId: node.id,
kind: node.kind,
name: node.name,
filePath: row.filePath,
openedAt: row.openedAt,
});
}
return out;
}
+4 -1
View File
@@ -443,10 +443,12 @@ export async function getEffectiveRole(
export interface BreadcrumbEntry { export interface BreadcrumbEntry {
readonly depth: number; readonly depth: number;
/** D17:无 View 的祖先 id/name 为 null(不泄露)。 */ /** D17:无 View 的祖先 id/name 为 null(不泄露)。 */
readonly id: string | null; readonly id: string | null;
readonly name: string | null; readonly name: string | null;
readonly kind: "FOLDER" | "PROJECT"; readonly kind: "FOLDER" | "PROJECT";
/** 该节点对调用者的 effective role;无 View 为 null。 */
readonly role: FileLibRole | null;
} }
/** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */ /** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */
@@ -484,6 +486,7 @@ export async function breadcrumb(
id: visible ? current.id : null, id: visible ? current.id : null,
name: visible ? current.name : null, name: visible ? current.name : null,
kind: current.kind, kind: current.kind,
role,
}; };
}); });
}); });
+45
View File
@@ -0,0 +1,45 @@
/**
* /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<void> {
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 };
await restoreBinEntry(svc, actor, id);
return reply.status(204).send();
} 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);
}
});
}
@@ -28,6 +28,8 @@ import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
import { registerFileLibRoutes } from "./filelibRoutes.js"; import { registerFileLibRoutes } from "./filelibRoutes.js";
import { registerFileRoutes } from "./fileRoutes.js"; import { registerFileRoutes } from "./fileRoutes.js";
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js"; import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
import { registerBinRoutes } from "./binRoutes.js";
import { registerRecentRoutes } from "./recentRoutes.js";
import { registerTeacherApp } from "./teacherApp.js"; import { registerTeacherApp } from "./teacherApp.js";
import { createInMemoryVersionStore } from "../filelib/versionStore.js"; import { createInMemoryVersionStore } from "../filelib/versionStore.js";
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js"; import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
@@ -161,6 +163,8 @@ export async function registerDatabaseRoutes(
await registerFileLibRoutes(app, filelibDeps); await registerFileLibRoutes(app, filelibDeps);
await registerFileRoutes(app, filelibDeps); await registerFileRoutes(app, filelibDeps);
await registerMemberGroupRoutes(app, filelibDeps); await registerMemberGroupRoutes(app, filelibDeps);
await registerBinRoutes(app, filelibDeps);
await registerRecentRoutes(app, filelibDeps);
await registerTeacherApp(app, { await registerTeacherApp(app, {
prisma: config.prisma, prisma: config.prisma,
sessionSecret: config.sessionSecret, sessionSecret: config.sessionSecret,
+47
View File
@@ -0,0 +1,47 @@
/**
* /database/api/recent 最近打开端点(ADR-0031)。
* 约定:绝对路径;actorOrNull 前置;业务全走 recentService;错误统一 sendRouteError。
*/
import type { FastifyInstance } from "fastify";
import { listRecent, recordVisit } from "../filelib/recentService.js";
import { FileLibError } from "../filelib/model.js";
import {
actorOrNull,
bodyObject,
optionalString,
requireString,
sendRouteError,
type FileLibRouteDeps,
} from "../filelib/routeShared.js";
export async function registerRecentRoutes(app: FastifyInstance, deps: FileLibRouteDeps): Promise<void> {
const svc = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver };
app.get("/database/api/recent", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
return { entries: await listRecent(svc, actor) };
} catch (error) {
return sendRouteError(reply, error);
}
});
app.post("/database/api/recent", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
try {
const body = bodyObject(request.body);
const nodeId = requireString(body, "nodeId");
const filePath = optionalString(body, "filePath");
if (filePath !== undefined && filePath.trim() === "") {
throw new FileLibError(400, "invalid_request", "filePath must be non-empty when present");
}
await recordVisit(svc, actor, nodeId, filePath);
return reply.status(204).send();
} catch (error) {
return sendRouteError(reply, error);
}
});
}
+163
View File
@@ -0,0 +1,163 @@
/**
* 回收站 + 最近打开集成测试(真实 Postgres,ADR-0031)。
* 覆盖:bin 列出(祖先全活跃顶点/直连 MANAGE 可见性/管理员)、restore 对称语义
* 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)、recent 上报 VIEW 门禁 /
* upsert 刷新 / 删除与祖先删除的可见性过滤。
* 运行前提:本地 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,
getEffectiveRole,
type FileLibActor,
type TreeServiceDeps,
} from "../../src/database/filelib/treeService.js";
import { listBin, purgeBinEntry, restoreBinEntry, type BinDeps } from "../../src/database/filelib/binService.js";
import { listRecent, recordVisit, type RecentDeps } from "../../src/database/filelib/recentService.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"] }),
};
}
function recentDeps(): RecentDeps {
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);
});
});
describe("binService · 彻底删除", () => {
it("仅管理员;整支硬删(含子孙/授权),落 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: "EDIT" }],
});
await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶
await expect(purgeBinEntry(binDeps(), ALICE, 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);
});
});
describe("recentService · 最近打开", () => {
it("上报需 VIEW(404);upsert 刷新 openedAt;删除/祖先删除的条目被过滤", async () => {
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
const proj = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "PROJECT", name: "TH-141" });
const secret = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "秘密" });
// bob 对 secret 无 VIEW → 404
await expect(recordVisit(recentDeps(), BOB, secret.id, undefined)).rejects.toMatchObject({ statusCode: 404 });
// admin 上报:root、proj、proj 内文件
await recordVisit(recentDeps(), ADMIN, root.id, undefined);
await recordVisit(recentDeps(), ADMIN, proj.id, undefined);
await recordVisit(recentDeps(), ADMIN, proj.id, "讲义/第一章.md");
let entries = await listRecent(recentDeps(), ADMIN);
expect(entries).toHaveLength(3);
expect(entries.map((e) => e.filePath)).toContain("讲义/第一章.md");
// upsert:重复打开 root 只刷新,不新增
await recordVisit(recentDeps(), ADMIN, root.id, undefined);
entries = await listRecent(recentDeps(), ADMIN);
expect(entries).toHaveLength(3);
expect(entries[0]!.nodeId).toBe(root.id); // 最新在前
// 删祖先 → 整支条目消失
await softDeleteNode(treeDeps(), ADMIN, root.id);
entries = await listRecent(recentDeps(), ADMIN);
expect(entries).toEqual([]);
// 恢复后重新可见
await restoreBinEntry(binDeps(), ADMIN, root.id);
entries = await listRecent(recentDeps(), ADMIN);
expect(entries).toHaveLength(3);
expect(await getEffectiveRole(treeDeps(), ADMIN, proj.id)).toBe("MANAGE");
});
});