From d072e9ec1eb9a92330cbbd3dc8e225a10b7e3d61 Mon Sep 17 00:00:00 2001 From: ymy Date: Fri, 31 Jul 2026 13:27:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(filelib):=20=E8=80=81=E5=B8=88=E7=AB=AF?= =?UTF-8?q?=E5=B7=A6=E6=A0=8F=E5=AF=BC=E8=88=AA:=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E7=AB=99=20+=20=E6=9C=80=E8=BF=91=E6=89=93=E5=BC=80(ADR-0031)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回收站: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 例绿 --- ...1-filelib-recycle-bin-and-recent-visits.md | 58 ++++++ hub/filelib-web/src/lib/BinView.svelte | 103 ++++++++++ .../src/lib/GridLibraryView.svelte | 80 +++++++- hub/filelib-web/src/lib/Icon.svelte | 1 + hub/filelib-web/src/lib/RecentView.svelte | 67 +++++++ hub/filelib-web/src/lib/types.ts | 24 ++- hub/filelib-web/src/routes/app/+page.svelte | 47 ++++- .../migration.sql | 65 +++++++ hub/prisma/schema.prisma | 15 ++ hub/src/database/filelib/audit.ts | 4 + hub/src/database/filelib/binService.ts | 181 ++++++++++++++++++ hub/src/database/filelib/recentService.ts | 106 ++++++++++ hub/src/database/filelib/treeService.ts | 5 +- hub/src/database/routes/binRoutes.ts | 45 +++++ hub/src/database/routes/databaseRoutes.ts | 4 + hub/src/database/routes/recentRoutes.ts | 47 +++++ hub/test/integration/filelib-nav.test.ts | 163 ++++++++++++++++ 17 files changed, 998 insertions(+), 17 deletions(-) create mode 100644 docs/adr/0031-filelib-recycle-bin-and-recent-visits.md create mode 100644 hub/filelib-web/src/lib/BinView.svelte create mode 100644 hub/filelib-web/src/lib/RecentView.svelte create mode 100644 hub/prisma/migrations/20260731050706_filelib_recent_visit/migration.sql create mode 100644 hub/src/database/filelib/binService.ts create mode 100644 hub/src/database/filelib/recentService.ts create mode 100644 hub/src/database/routes/binRoutes.ts create mode 100644 hub/src/database/routes/recentRoutes.ts create mode 100644 hub/test/integration/filelib-nav.test.ts 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/hub/filelib-web/src/lib/BinView.svelte b/hub/filelib-web/src/lib/BinView.svelte new file mode 100644 index 0000000..bc99895 --- /dev/null +++ b/hub/filelib-web/src/lib/BinView.svelte @@ -0,0 +1,103 @@ + + +
+

回收站

+ + {#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)} + + + {#if $me?.isWebsiteAdmin} + + {/if} +
+ {/each} +
+ {/if} +
diff --git a/hub/filelib-web/src/lib/GridLibraryView.svelte b/hub/filelib-web/src/lib/GridLibraryView.svelte index 4e91a70..3e064bf 100644 --- a/hub/filelib-web/src/lib/GridLibraryView.svelte +++ b/hub/filelib-web/src/lib/GridLibraryView.svelte @@ -23,14 +23,23 @@ import GrantsPanel from "./GrantsPanel.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 = { VIEW: 1, EDIT: 2, MANAGE: 3 }; const atLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min]; type View = "nodes" | "files"; + type StackItem = Pick; let view = $state("nodes"); /** 下钻栈(均为 FOLDER;根层为空栈)。 */ - let stack = $state([]); + let stack = $state([]); let children = $state(null); let nodesError = $state(null); @@ -48,7 +57,7 @@ let createParentId = $state(null); let formName = $state(""); let formDesc = $state(""); - let renameTarget = $state(null); + let renameTarget = $state(null); let detailNode = $state(null); let newPath = $state(""); let newContent = $state(""); @@ -98,6 +107,14 @@ 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 { selected = null; menu = null; @@ -107,8 +124,9 @@ /* ------------------------------------------------------------ 导航 */ - function openNode(n: NodeChild): void { + function openNode(n: StackItem): void { selected = null; + record(n.id); if (n.kind === "FOLDER") { stack = [...stack, n]; void loadChildren(); @@ -156,6 +174,43 @@ void loadChildren(); } + /** 跳到任意节点(最近打开入口):breadcrumb 建栈,FOLDER 进子层,PROJECT 进文件视图。 */ + async function navigateTo(target: NavTarget): Promise { + 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 { @@ -190,7 +245,7 @@ } } - function openRename(n: NodeChild): void { + function openRename(n: StackItem): void { renameTarget = n; formName = n.name; modal = "rename"; @@ -213,7 +268,7 @@ } } - async function removeNode(n: NodeChild): Promise { + async function removeNode(n: StackItem): Promise { if (!confirm(`删除「${n.name}」?软删除后不可见。`)) return; try { await api(`/database/api/nodes/${n.id}`, { method: "DELETE" }); @@ -224,7 +279,7 @@ } } - async function openGrants(n: NodeChild): Promise { + async function openGrants(n: StackItem): Promise { try { detailNode = await fetchDetail(n.id); modal = "grants"; @@ -233,7 +288,7 @@ } } - async function openDetail(n: NodeChild): Promise { + async function openDetail(n: StackItem): Promise { try { detailNode = await fetchDetail(n.id); 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 { if (projectNode === null) return; 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) }]; if (n.kind === "FOLDER" && atLeast(n.role, "EDIT")) { items.push({ label: "新建子文件夹", icon: "plus", onclick: () => openCreate("FOLDER", n.id) }); @@ -305,7 +365,7 @@ function fileMenuItems(f: FileEntry): MenuItem[] { const items: MenuItem[] = [ - { label: "打开预览", icon: "chevron", onclick: () => selectedFilePath.set(f.path) }, + { label: "打开预览", icon: "chevron", onclick: () => previewFile(f) }, { label: "下载", icon: "download", @@ -441,7 +501,7 @@ meta="{f.size} B" selected={selected === f.path} onselect={() => (selected = f.path)} - onopen={() => selectedFilePath.set(f.path)} + onopen={() => previewFile(f)} oncontextmenu={(x, y) => (menu = { x, y, items: fileMenuItems(f) })} /> {/each} diff --git a/hub/filelib-web/src/lib/Icon.svelte b/hub/filelib-web/src/lib/Icon.svelte index ce7c8ec..39aaff9 100644 --- a/hub/filelib-web/src/lib/Icon.svelte +++ b/hub/filelib-web/src/lib/Icon.svelte @@ -21,6 +21,7 @@ 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/RecentView.svelte b/hub/filelib-web/src/lib/RecentView.svelte new file mode 100644 index 0000000..7f2e33d --- /dev/null +++ b/hub/filelib-web/src/lib/RecentView.svelte @@ -0,0 +1,67 @@ + + +
+

最近打开

+ + {#if error !== null} +
{error}
+ {:else if entries === null} +
加载中…
+ {:else if entries.length === 0} +
还没有访问记录 · 去文件库逛逛
+ {:else} +
+ {#each entries as e (e.nodeId + "/" + e.filePath)} + + {/each} +
+ {/if} +
diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index 74f472b..29a0e8f 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 { @@ -130,9 +132,27 @@ export interface UserSearchResult { 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)。 */ -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 30bb9bf..ac106b0 100644 --- a/hub/filelib-web/src/routes/app/+page.svelte +++ b/hub/filelib-web/src/routes/app/+page.svelte @@ -1,12 +1,30 @@ 文件库 @@ -14,8 +32,29 @@ {#if !$authChecked}
加载中…
{:else if $me} -
- +
+ + + + {#if view === "library"} + (navTarget = null)} /> + {:else if view === "recent"} + + {:else} + + {/if}
{:else} 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/schema.prisma b/hub/prisma/schema.prisma index 0779fa9..ffd6828 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -1110,3 +1110,18 @@ model FileLibExportJob { @@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]) +} 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..2cb20cc --- /dev/null +++ b/hub/src/database/filelib/binService.ts @@ -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, + 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 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 { + 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 }; + }); +} diff --git a/hub/src/database/filelib/recentService.ts b/hub/src/database/filelib/recentService.ts new file mode 100644 index 0000000..c494d94 --- /dev/null +++ b/hub/src/database/filelib/recentService.ts @@ -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 { + 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 { + // 可见性过滤会丢弃一部分,超取再截断。 + 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(); + 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; +} diff --git a/hub/src/database/filelib/treeService.ts b/hub/src/database/filelib/treeService.ts index 10322b4..3827cd1 100644 --- a/hub/src/database/filelib/treeService.ts +++ b/hub/src/database/filelib/treeService.ts @@ -443,10 +443,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 只留占位。 */ @@ -484,6 +486,7 @@ export async function breadcrumb( id: visible ? current.id : null, name: visible ? current.name : null, kind: current.kind, + role, }; }); }); diff --git a/hub/src/database/routes/binRoutes.ts b/hub/src/database/routes/binRoutes.ts new file mode 100644 index 0000000..402503a --- /dev/null +++ b/hub/src/database/routes/binRoutes.ts @@ -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 { + 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); + } + }); +} diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts index 9cfaf35..11b1ccb 100644 --- a/hub/src/database/routes/databaseRoutes.ts +++ b/hub/src/database/routes/databaseRoutes.ts @@ -28,6 +28,8 @@ 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 { registerRecentRoutes } from "./recentRoutes.js"; import { registerTeacherApp } from "./teacherApp.js"; import { createInMemoryVersionStore } from "../filelib/versionStore.js"; import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js"; @@ -161,6 +163,8 @@ export async function registerDatabaseRoutes( await registerFileLibRoutes(app, filelibDeps); await registerFileRoutes(app, filelibDeps); await registerMemberGroupRoutes(app, filelibDeps); + await registerBinRoutes(app, filelibDeps); + await registerRecentRoutes(app, filelibDeps); await registerTeacherApp(app, { prisma: config.prisma, sessionSecret: config.sessionSecret, diff --git a/hub/src/database/routes/recentRoutes.ts b/hub/src/database/routes/recentRoutes.ts new file mode 100644 index 0000000..ebd6479 --- /dev/null +++ b/hub/src/database/routes/recentRoutes.ts @@ -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 { + 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); + } + }); +} diff --git a/hub/test/integration/filelib-nav.test.ts b/hub/test/integration/filelib-nav.test.ts new file mode 100644 index 0000000..926da4e --- /dev/null +++ b/hub/test/integration/filelib-nav.test.ts @@ -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"); + }); +});