forked from EduCraft/curriculum-project-hub
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:
@@ -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>
|
||||
@@ -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<Role, number> = { VIEW: 1, EDIT: 2, MANAGE: 3 };
|
||||
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 +57,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("");
|
||||
@@ -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<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 {
|
||||
@@ -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<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 +279,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 +288,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 +299,11 @@
|
||||
|
||||
/* ------------------------------------------------------------ 文件操作 */
|
||||
|
||||
function previewFile(f: FileEntry): void {
|
||||
if (projectNode !== null) record(projectNode.id, f.path);
|
||||
selectedFilePath.set(f.path);
|
||||
}
|
||||
|
||||
async function submitNewFile(): Promise<void> {
|
||||
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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
<script lang="ts">
|
||||
/** 老师端。未登录显示登录卡片;登录后是网盘式文件库浏览器。 */
|
||||
/** 老师端。未登录显示登录卡片;登录后是带左栏导航的文件库(ADR-0031)。 */
|
||||
import { onMount } from "svelte";
|
||||
import { me, authChecked } from "$lib/stores.js";
|
||||
import { loadSession } from "$lib/session.js";
|
||||
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);
|
||||
|
||||
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>
|
||||
|
||||
<svelte:head><title>文件库</title></svelte:head>
|
||||
@@ -14,8 +32,29 @@
|
||||
{#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-[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>
|
||||
{: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";
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 { 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user