Compare commits

..

22 Commits

Author SHA1 Message Date
ymy 3cfbe55070 Merge branch 'chore/sidebar-240' 2026-07-31 15:06:09 +08:00
ymy 53ef364af2 chore(filelib-web): 老师端左栏宽度对齐管理员后台(240px) 2026-07-31 15:06:08 +08:00
ymy 94dba4e672 Merge branch 'chore/sidebar-wider' 2026-07-31 15:03:00 +08:00
ymy 06aa6f0dfb chore(filelib-web): 左栏加宽168->200px,标题与菜单间加横线 2026-07-31 15:02:59 +08:00
ymy 4fc72541be Merge branch 'chore/app-sidebar-layout' 2026-07-31 14:57:01 +08:00
ymy 672f05c66a chore(filelib-web): 左栏加「教研数据库」标题,用户身份+退出移至栏底,顶栏去掉身份区 2026-07-31 14:57:01 +08:00
ymy e173aa18e0 Merge branch 'chore/grid-icon-size' 2026-07-31 14:51:56 +08:00
ymy e9cecbf071 chore(filelib-web): 放大网格图标(文件夹54->72/项目50->66/文件46->60),列宽118->132 2026-07-31 14:51:55 +08:00
ymy 55f29523a0 Merge branch 'fix/restore-no-suffix' 2026-07-31 14:28:35 +08:00
ymy d9cde19bdf fix(filelib): 恢复保留原名,撞名报清晰错误而非自动加后缀(ADR-0035,supersede ADR-0033)
恢复不再改名;同名兄弟占位时抛 409 name_conflict_on_restore + 人话提示,
操作者自行重命名现有节点或彻底删除旧节点后再恢复。
2026-07-31 14:28:34 +08:00
ymy a463ab48e6 Merge branch 'fix/grid-card-contextmenu' 2026-07-31 14:16:21 +08:00
ymy d39ebed62e fix(filelib-web): 卡片右键补 stopPropagation,不再被背景菜单覆盖(导致看不到删除等节点操作) 2026-07-31 14:16:20 +08:00
ymy 270275c367 Merge branch 'chore/detail-delete-button' 2026-07-31 14:11:27 +08:00
ymy 09338f355a chore(filelib-web): 详情弹窗加删除按钮(MANAGE 专属,进回收站可恢复),删除入口不再只在右键菜单 2026-07-31 14:11:26 +08:00
ymy 8bc5dbf9e2 Merge branch 'feat/purge-follows-manage' 2026-07-31 14:01:47 +08:00
ymy beaa92de2e feat(filelib): 彻底删除改与条目可见性同权(ADR-0034,supersede ADR-0031 仅管理员条款)
能删进回收站(MANAGE)的人就能清空;无关者 404(D8)。二次确认与
node.purge 审计不变;BinView 彻底删除按钮对全部可见条目开放。
2026-07-31 14:01:46 +08:00
ymy b1fd2e8f7b Merge branch 'fix/bin-restore-dedup' 2026-07-31 13:52:24 +08:00
ymy 9e38d1e011 fix(filelib): 恢复撞名不再死锁,自动改名「(已恢复)」(ADR-0033)
- restore 前查活跃兄弟:撞名则恢复为「原名(已恢复[/ N])」(截断计入
  128 长度预算),同事务落审计并记 renamedFrom;API 返回最终名
- BinView toast 提示改名;测试补撞名/二次恢复用例
2026-07-31 13:52:23 +08:00
ymy f99c8ea4d8 Merge branch 'chore/remove-recent-module' 2026-07-31 13:39:19 +08:00
ymy 83a6b012b7 feat(filelib): 移除最近打开模块(ADR-0032,supersede ADR-0031 对应半部)
- FileLibRecentVisit 删表(手写迁移;表当日新建无生产数据)
- recentService/recentRoutes/RecentView 删除;GridLibraryView 埋点与
  navTarget 跳转一并移除;types 清 RecentEntry
- 左栏保留 文件库/回收站(ADR-0031 回收站半部不受影响)
- breadcrumb 的 role 字段保留(独立可用的增量字段)
2026-07-31 13:39:18 +08:00
ymy 475b397ca8 Merge branch 'feat/filelib-nav-rail' 2026-07-31 13:27:04 +08:00
ymy d072e9ec1e 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 例绿
2026-07-31 13:27:04 +08:00
20 changed files with 859 additions and 39 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.
@@ -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.
@@ -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).
+28
View File
@@ -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.
@@ -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.
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
/**
* 回收站(ADR-0031):祖先全活跃的已删节点顶;恢复只清本节点 deletedAt;
* 彻底删除(整支硬删)仅网站管理员,二次确认。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { 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 {
const r = await api<{ name: string }>(
`/database/api/bin/${encodeURIComponent(e.id)}/restore`,
{ method: "POST" },
);
toastOk(`已恢复「${r.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>
<!-- 彻底删除与条目可见性同权(ADR-0034):能在回收站看到,就能清空 -->
<button
class="btn btn-sm btn-danger disabled:opacity-50"
onclick={() => void purge(e)}
disabled={busyId === e.id}
>
<Icon name="trash" size={12} /> 彻底删除
</button>
</div>
{/each}
</div>
{/if}
</div>
+5 -5
View File
@@ -28,16 +28,16 @@
: 'hover:bg-hover'}"
onclick={onselect}
ondblclick={onopen}
oncontextmenu={(e) => { e.preventDefault(); oncontextmenu(e.clientX, e.clientY); }}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); oncontextmenu(e.clientX, e.clientY); }}
title={name}
>
<span class="flex h-14 w-14 items-center justify-center">
<span class="flex h-20 w-20 items-center justify-center">
{#if kind === "FOLDER"}
<svg width="54" height="54" viewBox="0 0 24 24" fill="#f5c94a" stroke="#d9a92b" stroke-width="0.6" stroke-linejoin="round"><path d="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" /></svg>
<svg width="72" height="72" viewBox="0 0 24 24" fill="#f5c94a" stroke="#d9a92b" stroke-width="0.6" stroke-linejoin="round"><path d="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" /></svg>
{:else if kind === "PROJECT"}
<svg width="50" height="50" viewBox="0 0 24 24" fill="#e8f0ea" stroke="#4a6741" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
<svg width="66" height="66" viewBox="0 0 24 24" fill="#e8f0ea" stroke="#4a6741" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
{:else}
<svg width="46" height="46" viewBox="0 0 24 24" fill="#ffffff" stroke="#9c9b96" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z" /><path d="M14 3v5h5" /><path d="M9 13h6M9 17h6" /></svg>
<svg width="60" height="60" viewBox="0 0 24 24" fill="#ffffff" stroke="#9c9b96" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Z" /><path d="M14 3v5h5" /><path d="M9 13h6M9 17h6" /></svg>
{/if}
</span>
<span class="line-clamp-2 w-full break-all text-center text-[12.5px] leading-snug text-ink">{name}</span>
+18 -25
View File
@@ -11,7 +11,6 @@
import { onMount } from "svelte";
import { api } from "./api.js";
import { me, toastErr, toastOk } from "./stores.js";
import { logout } from "./session.js";
import { selectedFilePath, clearSelectedFile } from "./browser.js";
import { ROLE_LABEL } from "./labels.js";
import type { FileEntry, NodeChild, NodeDetail, Role } from "./types.js";
@@ -27,10 +26,11 @@
const atLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min];
type View = "nodes" | "files";
type StackItem = Pick<NodeChild, "id" | "name" | "kind" | "role">;
let view = $state<View>("nodes");
/** 下钻栈(均为 FOLDER;根层为空栈)。 */
let stack = $state<NodeChild[]>([]);
let stack = $state<StackItem[]>([]);
let children = $state<NodeChild[] | null>(null);
let nodesError = $state<string | null>(null);
@@ -48,7 +48,7 @@
let createParentId = $state<string | null>(null);
let formName = $state("");
let formDesc = $state("");
let renameTarget = $state<NodeChild | null>(null);
let renameTarget = $state<StackItem | null>(null);
let detailNode = $state<NodeDetail | null>(null);
let newPath = $state("");
let newContent = $state("");
@@ -60,7 +60,6 @@
currentFolder === null ? ($me?.isWebsiteAdmin ?? false) : atLeast(currentFolder.role, "EDIT"),
);
const projectCanEdit = $derived(projectNode !== null && projectNode.role !== "VIEW");
const initial = $derived(($me?.displayName ?? $me?.userId ?? "U").slice(0, 1).toUpperCase());
/* ------------------------------------------------------------ 数据加载 */
@@ -107,7 +106,7 @@
/* ------------------------------------------------------------ 导航 */
function openNode(n: NodeChild): void {
function openNode(n: StackItem): void {
selected = null;
if (n.kind === "FOLDER") {
stack = [...stack, n];
@@ -190,7 +189,7 @@
}
}
function openRename(n: NodeChild): void {
function openRename(n: StackItem): void {
renameTarget = n;
formName = n.name;
modal = "rename";
@@ -213,7 +212,7 @@
}
}
async function removeNode(n: NodeChild): Promise<void> {
async function removeNode(n: StackItem): Promise<void> {
if (!confirm(`删除「${n.name}」?软删除后不可见。`)) return;
try {
await api(`/database/api/nodes/${n.id}`, { method: "DELETE" });
@@ -224,7 +223,7 @@
}
}
async function openGrants(n: NodeChild): Promise<void> {
async function openGrants(n: StackItem): Promise<void> {
try {
detailNode = await fetchDetail(n.id);
modal = "grants";
@@ -233,7 +232,7 @@
}
}
async function openDetail(n: NodeChild): Promise<void> {
async function openDetail(n: StackItem): Promise<void> {
try {
detailNode = await fetchDetail(n.id);
modal = "detail";
@@ -244,6 +243,10 @@
/* ------------------------------------------------------------ 文件操作 */
function previewFile(f: FileEntry): void {
selectedFilePath.set(f.path);
}
async function submitNewFile(): Promise<void> {
if (projectNode === null) return;
const path = newPath.trim();
@@ -285,7 +288,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 +308,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",
@@ -382,16 +385,6 @@
</button>
{/if}
<button class="btn btn-sm" onclick={refresh} title="刷新"><Icon name="refresh" size={13} /></button>
<div class="ml-1 flex shrink-0 items-center gap-2 border-l border-line-soft pl-3">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</span>
<span class="max-w-[120px] truncate text-[12.5px] text-ink">{$me?.displayName ?? $me?.userId ?? ""}</span>
<button
class="rounded-lg border border-line-soft px-2 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink"
onclick={logout}
title="退出登录"
>退出</button>
</div>
</header>
<!-- 主体:网格 + 文件预览栏 -->
@@ -411,7 +404,7 @@
{currentFolder === null ? "空文件库" : "空文件夹"}{canCreateHere ? " · 右键或点上方按钮新建" : ""}
</div>
{:else}
<div class="grid grid-cols-[repeat(auto-fill,minmax(118px,1fr))] gap-x-2 gap-y-4">
<div class="grid grid-cols-[repeat(auto-fill,minmax(132px,1fr))] gap-x-2 gap-y-4">
{#each children as n (n.id)}
<GridCard
kind={n.kind}
@@ -433,7 +426,7 @@
{:else if files.length === 0}
<div class="quiet py-10 text-center">空仓库{projectCanEdit ? " · 右键或点上方按钮新建文件" : ""}</div>
{:else}
<div class="grid grid-cols-[repeat(auto-fill,minmax(118px,1fr))] gap-x-2 gap-y-4">
<div class="grid grid-cols-[repeat(auto-fill,minmax(132px,1fr))] gap-x-2 gap-y-4">
{#each files as f (f.path)}
<GridCard
kind="FILE"
@@ -441,7 +434,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}
@@ -510,7 +503,7 @@
{#if modal === "detail" && detailNode}
<Modal maxW={680} title="详情 · {detailNode.name}" onclose={() => (modal = null)}>
<OverviewPanel node={detailNode} />
<OverviewPanel node={detailNode} ondeleted={() => { modal = null; refresh(); }} />
</Modal>
{/if}
+1
View File
@@ -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",
+29 -1
View File
@@ -6,15 +6,32 @@
import type { ExportJob, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte";
let { node }: { node: NodeDetail } = $props();
let { node, ondeleted }: { node: NodeDetail; ondeleted?: () => void } = $props();
let showEditDesc = $state(false);
let descDraft = $state("");
let exportJob = $state<ExportJob | null>(null);
let deleting = $state(false);
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
const canManage = $derived(node.role === "MANAGE");
const roleLabel = $derived(ROLE_LABEL[node.role]);
/** 删除(进回收站,可恢复;ADR-0031)。MANAGE 专属,与右键菜单同语义。 */
async function deleteNode(): Promise<void> {
if (!confirm(`删除「${node.name}」?移入回收站,可在回收站恢复。`)) return;
deleting = true;
try {
await api(`/database/api/nodes/${node.id}`, { method: "DELETE" });
toastOk("已移入回收站");
ondeleted?.();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
} finally {
deleting = false;
}
}
$effect(() => {
void node.id;
exportJob = null;
@@ -113,6 +130,17 @@
{/if}
</div>
{/if}
{#if canManage}
<div class="my-4 border-t border-line-soft"></div>
<div class="flex items-center justify-between">
<span class="quiet">删除后移入回收站,可恢复</span>
<button class="btn btn-danger disabled:opacity-50" onclick={deleteNode} disabled={deleting}>
{deleting ? "删除中…" : `删除此${node.kind === "PROJECT" ? "项目" : "文件夹"}`}
</button>
</div>
{/if}
</div>
{#if showEditDesc}
+12 -2
View File
@@ -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,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;
+54 -5
View File
@@ -1,21 +1,70 @@
<script lang="ts">
/** 老师端。未登录显示登录卡片;登录后是网盘式文件库浏览器。 */
/** 老师端。未登录显示登录卡片;登录后是带左栏导航的文件库(ADR-0031)。 */
import { onMount } from "svelte";
import { me, authChecked } from "$lib/stores.js";
import { loadSession } from "$lib/session.js";
import { loadSession, logout } from "$lib/session.js";
import LoginView from "$lib/LoginView.svelte";
import GridLibraryView from "$lib/GridLibraryView.svelte";
import BinView from "$lib/BinView.svelte";
import Icon from "$lib/Icon.svelte";
onMount(loadSession);
type View = "library" | "bin";
let view = $state<View>("library");
const tabs: ReadonlyArray<readonly [View, string, "layers" | "trash"]> = [
["library", "文件库", "layers"],
["bin", "回收站", "trash"],
];
const initial = $derived(($me?.displayName ?? $me?.userId ?? "U").slice(0, 1).toUpperCase());
</script>
<svelte:head><title>文件</title></svelte:head>
<svelte:head><title>教研数据</title></svelte:head>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
{:else if $me}
<div class="flex h-full flex-col">
<GridLibraryView />
<div class="flex h-full">
<!-- 左栏导航(ADR-0031) -->
<nav class="flex w-[240px] shrink-0 flex-col border-r border-line-soft bg-sidebar px-3 py-4">
<!-- 标题 -->
<div class="mb-3 px-1 text-[15px] font-bold text-ink">教研数据库</div>
<div class="mb-2 border-t border-line-soft"></div>
<!-- 导航项 -->
<div class="flex flex-1 flex-col gap-0.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}
</div>
<!-- 底部:用户身份 + 退出 -->
<div class="mt-auto flex items-center gap-2 border-t border-line-soft pt-3">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</span>
<span class="min-w-0 flex-1 truncate text-[12.5px] text-ink">{$me?.displayName ?? $me?.userId ?? ""}</span>
<button
class="rounded-lg border border-line-soft px-2 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink"
onclick={logout}
title="退出登录"
>退出</button>
</div>
</nav>
{#if view === "library"}
<GridLibraryView />
{:else}
<BinView />
{/if}
</div>
{:else}
<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";
@@ -0,0 +1,2 @@
-- ADR-0032:最近打开模块移除,删表(今日新建,无生产数据)。
DROP TABLE "FileLibRecentVisit";
+4
View File
@@ -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",
+201
View File
@@ -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<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 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<RestoreResult> {
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 };
});
}
+4 -1
View File
@@ -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,
};
});
});
+44
View File
@@ -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<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 };
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);
}
});
}
@@ -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 { createInMemoryVersionStore } from "../filelib/versionStore.js";
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
@@ -161,6 +162,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,
+151
View File
@@ -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);
});
});