forked from bai/curriculum-project-hub
Compare commits
16 Commits
b1fd2e8f7b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cfbe55070 | |||
| 53ef364af2 | |||
| 94dba4e672 | |||
| 06aa6f0dfb | |||
| 4fc72541be | |||
| 672f05c66a | |||
| e173aa18e0 | |||
| e9cecbf071 | |||
| 55f29523a0 | |||
| d9cde19bdf | |||
| a463ab48e6 | |||
| d39ebed62e | |||
| 270275c367 | |||
| 09338f355a | |||
| 8bc5dbf9e2 | |||
| beaa92de2e |
@@ -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.
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import { me, toastErr, toastOk } from "./stores.js";
|
import { toastErr, toastOk } from "./stores.js";
|
||||||
import type { BinEntry } from "./types.js";
|
import type { BinEntry } from "./types.js";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
@@ -36,15 +36,11 @@
|
|||||||
async function restore(e: BinEntry): Promise<void> {
|
async function restore(e: BinEntry): Promise<void> {
|
||||||
busyId = e.id;
|
busyId = e.id;
|
||||||
try {
|
try {
|
||||||
const r = await api<{ name: string; renamedFrom?: string }>(
|
const r = await api<{ name: string }>(
|
||||||
`/database/api/bin/${encodeURIComponent(e.id)}/restore`,
|
`/database/api/bin/${encodeURIComponent(e.id)}/restore`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
);
|
);
|
||||||
toastOk(
|
toastOk(`已恢复「${r.name}」`);
|
||||||
r.renamedFrom !== undefined && r.renamedFrom !== r.name
|
|
||||||
? `已恢复为「${r.name}」(原名与现有节点冲突)`
|
|
||||||
: `已恢复「${r.name}」`,
|
|
||||||
);
|
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastErr(err instanceof Error ? err.message : String(err));
|
toastErr(err instanceof Error ? err.message : String(err));
|
||||||
@@ -94,15 +90,14 @@
|
|||||||
>
|
>
|
||||||
<Icon name="restore" size={12} /> 恢复
|
<Icon name="restore" size={12} /> 恢复
|
||||||
</button>
|
</button>
|
||||||
{#if $me?.isWebsiteAdmin}
|
<!-- 彻底删除与条目可见性同权(ADR-0034):能在回收站看到,就能清空 -->
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-danger disabled:opacity-50"
|
class="btn btn-sm btn-danger disabled:opacity-50"
|
||||||
onclick={() => void purge(e)}
|
onclick={() => void purge(e)}
|
||||||
disabled={busyId === e.id}
|
disabled={busyId === e.id}
|
||||||
>
|
>
|
||||||
<Icon name="trash" size={12} /> 彻底删除
|
<Icon name="trash" size={12} /> 彻底删除
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,16 +28,16 @@
|
|||||||
: 'hover:bg-hover'}"
|
: 'hover:bg-hover'}"
|
||||||
onclick={onselect}
|
onclick={onselect}
|
||||||
ondblclick={onopen}
|
ondblclick={onopen}
|
||||||
oncontextmenu={(e) => { e.preventDefault(); oncontextmenu(e.clientX, e.clientY); }}
|
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); oncontextmenu(e.clientX, e.clientY); }}
|
||||||
title={name}
|
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"}
|
{#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"}
|
{: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}
|
{: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}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="line-clamp-2 w-full break-all text-center text-[12.5px] leading-snug text-ink">{name}</span>
|
<span class="line-clamp-2 w-full break-all text-center text-[12.5px] leading-snug text-ink">{name}</span>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import { me, toastErr, toastOk } from "./stores.js";
|
import { me, toastErr, toastOk } from "./stores.js";
|
||||||
import { logout } from "./session.js";
|
|
||||||
import { selectedFilePath, clearSelectedFile } from "./browser.js";
|
import { selectedFilePath, clearSelectedFile } from "./browser.js";
|
||||||
import { ROLE_LABEL } from "./labels.js";
|
import { ROLE_LABEL } from "./labels.js";
|
||||||
import type { FileEntry, NodeChild, NodeDetail, Role } from "./types.js";
|
import type { FileEntry, NodeChild, NodeDetail, Role } from "./types.js";
|
||||||
@@ -61,7 +60,6 @@
|
|||||||
currentFolder === null ? ($me?.isWebsiteAdmin ?? false) : atLeast(currentFolder.role, "EDIT"),
|
currentFolder === null ? ($me?.isWebsiteAdmin ?? false) : atLeast(currentFolder.role, "EDIT"),
|
||||||
);
|
);
|
||||||
const projectCanEdit = $derived(projectNode !== null && projectNode.role !== "VIEW");
|
const projectCanEdit = $derived(projectNode !== null && projectNode.role !== "VIEW");
|
||||||
const initial = $derived(($me?.displayName ?? $me?.userId ?? "U").slice(0, 1).toUpperCase());
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 数据加载 */
|
/* ------------------------------------------------------------ 数据加载 */
|
||||||
|
|
||||||
@@ -387,16 +385,6 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="btn btn-sm" onclick={refresh} title="刷新"><Icon name="refresh" size={13} /></button>
|
<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>
|
</header>
|
||||||
|
|
||||||
<!-- 主体:网格 + 文件预览栏 -->
|
<!-- 主体:网格 + 文件预览栏 -->
|
||||||
@@ -416,7 +404,7 @@
|
|||||||
{currentFolder === null ? "空文件库" : "空文件夹"}{canCreateHere ? " · 右键或点上方按钮新建" : ""}
|
{currentFolder === null ? "空文件库" : "空文件夹"}{canCreateHere ? " · 右键或点上方按钮新建" : ""}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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)}
|
{#each children as n (n.id)}
|
||||||
<GridCard
|
<GridCard
|
||||||
kind={n.kind}
|
kind={n.kind}
|
||||||
@@ -438,7 +426,7 @@
|
|||||||
{:else if files.length === 0}
|
{:else if files.length === 0}
|
||||||
<div class="quiet py-10 text-center">空仓库{projectCanEdit ? " · 右键或点上方按钮新建文件" : ""}</div>
|
<div class="quiet py-10 text-center">空仓库{projectCanEdit ? " · 右键或点上方按钮新建文件" : ""}</div>
|
||||||
{:else}
|
{: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)}
|
{#each files as f (f.path)}
|
||||||
<GridCard
|
<GridCard
|
||||||
kind="FILE"
|
kind="FILE"
|
||||||
@@ -515,7 +503,7 @@
|
|||||||
|
|
||||||
{#if modal === "detail" && detailNode}
|
{#if modal === "detail" && detailNode}
|
||||||
<Modal maxW={680} title="详情 · {detailNode.name}" onclose={() => (modal = null)}>
|
<Modal maxW={680} title="详情 · {detailNode.name}" onclose={() => (modal = null)}>
|
||||||
<OverviewPanel node={detailNode} />
|
<OverviewPanel node={detailNode} ondeleted={() => { modal = null; refresh(); }} />
|
||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,32 @@
|
|||||||
import type { ExportJob, NodeDetail } from "./types.js";
|
import type { ExportJob, NodeDetail } from "./types.js";
|
||||||
import Modal from "./Modal.svelte";
|
import Modal from "./Modal.svelte";
|
||||||
|
|
||||||
let { node }: { node: NodeDetail } = $props();
|
let { node, ondeleted }: { node: NodeDetail; ondeleted?: () => void } = $props();
|
||||||
|
|
||||||
let showEditDesc = $state(false);
|
let showEditDesc = $state(false);
|
||||||
let descDraft = $state("");
|
let descDraft = $state("");
|
||||||
let exportJob = $state<ExportJob | null>(null);
|
let exportJob = $state<ExportJob | null>(null);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
|
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
|
||||||
|
const canManage = $derived(node.role === "MANAGE");
|
||||||
const roleLabel = $derived(ROLE_LABEL[node.role]);
|
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(() => {
|
$effect(() => {
|
||||||
void node.id;
|
void node.id;
|
||||||
exportJob = null;
|
exportJob = null;
|
||||||
@@ -113,6 +130,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
|
|
||||||
{#if showEditDesc}
|
{#if showEditDesc}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/** 老师端。未登录显示登录卡片;登录后是带左栏导航的文件库(ADR-0031)。 */
|
/** 老师端。未登录显示登录卡片;登录后是带左栏导航的文件库(ADR-0031)。 */
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { me, authChecked } from "$lib/stores.js";
|
import { me, authChecked } from "$lib/stores.js";
|
||||||
import { loadSession } from "$lib/session.js";
|
import { loadSession, logout } from "$lib/session.js";
|
||||||
import LoginView from "$lib/LoginView.svelte";
|
import LoginView from "$lib/LoginView.svelte";
|
||||||
import GridLibraryView from "$lib/GridLibraryView.svelte";
|
import GridLibraryView from "$lib/GridLibraryView.svelte";
|
||||||
import BinView from "$lib/BinView.svelte";
|
import BinView from "$lib/BinView.svelte";
|
||||||
@@ -17,27 +17,47 @@
|
|||||||
["library", "文件库", "layers"],
|
["library", "文件库", "layers"],
|
||||||
["bin", "回收站", "trash"],
|
["bin", "回收站", "trash"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const initial = $derived(($me?.displayName ?? $me?.userId ?? "U").slice(0, 1).toUpperCase());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>文件库</title></svelte:head>
|
<svelte:head><title>教研数据库</title></svelte:head>
|
||||||
|
|
||||||
{#if !$authChecked}
|
{#if !$authChecked}
|
||||||
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
|
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
|
||||||
{:else if $me}
|
{:else if $me}
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<!-- 左栏导航(ADR-0031) -->
|
<!-- 左栏导航(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">
|
<nav class="flex w-[240px] shrink-0 flex-col border-r border-line-soft bg-sidebar px-3 py-4">
|
||||||
{#each tabs as [id, label, icon] (id)}
|
<!-- 标题 -->
|
||||||
|
<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
|
<button
|
||||||
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[13px] transition {view === id
|
class="rounded-lg border border-line-soft px-2 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink"
|
||||||
? 'bg-selected font-semibold text-ink'
|
onclick={logout}
|
||||||
: 'text-ink-2 hover:bg-hover'}"
|
title="退出登录"
|
||||||
onclick={() => (view = id)}
|
>退出</button>
|
||||||
>
|
</div>
|
||||||
<span class="text-ink-3"><Icon name={icon} size={15} /></span>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{#if view === "library"}
|
{#if view === "library"}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import { FileLibError, nameKey, NODE_NAME_MAX_LENGTH } from "./model.js";
|
import { FileLibError, nameKey } from "./model.js";
|
||||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||||
import type { GroupResolver } from "./groupResolver.js";
|
import type { GroupResolver } from "./groupResolver.js";
|
||||||
import type { FileLibActor } from "./treeService.js";
|
import type { FileLibActor } from "./treeService.js";
|
||||||
@@ -113,7 +113,6 @@ async function requireBinEntry(
|
|||||||
|
|
||||||
export interface RestoreResult {
|
export interface RestoreResult {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly renamedFrom?: string | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,33 +125,21 @@ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId
|
|||||||
return deps.prisma.$transaction(async (tx) => {
|
return deps.prisma.$transaction(async (tx) => {
|
||||||
const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
|
const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
|
||||||
|
|
||||||
const siblings = await tx.fileLibNode.findMany({
|
const clash = await tx.fileLibNode.findFirst({
|
||||||
where: {
|
where: {
|
||||||
organizationId: deps.organizationId,
|
organizationId: deps.organizationId,
|
||||||
parentId: node.parentId,
|
parentId: node.parentId,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
id: { not: node.id },
|
id: { not: node.id },
|
||||||
|
nameLower: nameKey(node.name),
|
||||||
},
|
},
|
||||||
select: { nameLower: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
const taken = new Set(siblings.map((s) => s.nameLower));
|
if (clash !== null) {
|
||||||
|
throw new FileLibError(409, "name_conflict_on_restore", "name conflict on restore");
|
||||||
let name = node.name;
|
|
||||||
let renamedFrom: string | undefined;
|
|
||||||
if (taken.has(nameKey(name))) {
|
|
||||||
renamedFrom = node.name;
|
|
||||||
let n = 1;
|
|
||||||
do {
|
|
||||||
const suffix = n === 1 ? "(已恢复)" : `(已恢复 ${n})`;
|
|
||||||
name = node.name.slice(0, NODE_NAME_MAX_LENGTH - suffix.length) + suffix;
|
|
||||||
n += 1;
|
|
||||||
} while (taken.has(nameKey(name)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.fileLibNode.update({
|
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: null } });
|
||||||
where: { id: node.id },
|
|
||||||
data: { deletedAt: null, name, nameLower: nameKey(name) },
|
|
||||||
});
|
|
||||||
await writeFileLibAudit(tx, {
|
await writeFileLibAudit(tx, {
|
||||||
action: node.kind === "PROJECT"
|
action: node.kind === "PROJECT"
|
||||||
? FILE_LIB_AUDIT_ACTIONS.projectRestore
|
? FILE_LIB_AUDIT_ACTIONS.projectRestore
|
||||||
@@ -162,26 +149,22 @@ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId
|
|||||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||||
objectId: node.id,
|
objectId: node.id,
|
||||||
objectPath: node.pathIds,
|
objectPath: node.pathIds,
|
||||||
detail: { name, ...(renamedFrom !== undefined ? { renamedFrom } : {}) },
|
detail: { name: node.name },
|
||||||
});
|
});
|
||||||
return { name, renamedFrom };
|
return { name: node.name };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 彻底删除(仅网站管理员):整支硬删。子树经 pathIds 前缀枚举,
|
* 彻底删除(ADR-0034:与回收站条目同一可见性 —— 管理员或节点直连 MANAGE;
|
||||||
|
* 能删进回收站的人就能清空)。整支硬删:子树经 pathIds 前缀枚举,
|
||||||
* 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT,
|
* 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT,
|
||||||
* 父行必须晚于全部子孙行删除。
|
* 父行必须晚于全部子孙行删除。
|
||||||
*/
|
*/
|
||||||
export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> {
|
export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> {
|
||||||
if (!actor.isWebsiteAdmin) {
|
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||||
throw new FileLibError(404, "node_not_found", "node not found");
|
|
||||||
}
|
|
||||||
return deps.prisma.$transaction(async (tx) => {
|
return deps.prisma.$transaction(async (tx) => {
|
||||||
const node = await tx.fileLibNode.findFirst({
|
const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
|
||||||
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({
|
const subtree = await tx.fileLibNode.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
createNode,
|
createNode,
|
||||||
softDeleteNode,
|
softDeleteNode,
|
||||||
listChildren,
|
listChildren,
|
||||||
|
renameNode,
|
||||||
type FileLibActor,
|
type FileLibActor,
|
||||||
type TreeServiceDeps,
|
type TreeServiceDeps,
|
||||||
} from "../../src/database/filelib/treeService.js";
|
} from "../../src/database/filelib/treeService.js";
|
||||||
@@ -92,43 +93,40 @@ describe("binService · 恢复", () => {
|
|||||||
expect(audits).toHaveLength(1);
|
expect(audits).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ADR-0033:与活跃兄弟撞名时恢复为「(已恢复)」,不死锁;审计记 renamedFrom", async () => {
|
it("ADR-0035:撞名时恢复报 name_conflict_on_restore(不自动改名),清名后可恢复", async () => {
|
||||||
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
||||||
const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
|
const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
|
||||||
await softDeleteNode(treeDeps(), ADMIN, child.id);
|
await softDeleteNode(treeDeps(), ADMIN, child.id);
|
||||||
// 删除后同名新建 → 活跃兄弟占了名字
|
// 删除后同名新建 -> 活跃兄弟占了名字
|
||||||
await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" });
|
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);
|
const result = await restoreBinEntry(binDeps(), ADMIN, child.id);
|
||||||
expect(result.renamedFrom).toBe("必修一");
|
expect(result.name).toBe("必修一");
|
||||||
expect(result.name).toBe("必修一(已恢复)");
|
expect(result.renamedFrom).toBeUndefined();
|
||||||
|
|
||||||
const visible = await listChildren(treeDeps(), ADMIN, root.id);
|
const visible = await listChildren(treeDeps(), ADMIN, root.id);
|
||||||
expect(visible.map((c) => c.name).sort()).toEqual(["必修一", "必修一(已恢复)"]);
|
expect(visible.map((c) => c.name).sort()).toEqual(["必修一", "必修一(新)"]);
|
||||||
|
|
||||||
// 再删再恢复:名字已是去重后的「(已恢复)」,不再冲突 → 保持,不二次改名
|
|
||||||
await softDeleteNode(treeDeps(), ADMIN, child.id);
|
|
||||||
const second = await restoreBinEntry(binDeps(), ADMIN, child.id);
|
|
||||||
expect(second.name).toBe("必修一(已恢复)");
|
|
||||||
expect(second.renamedFrom).toBeUndefined();
|
|
||||||
|
|
||||||
const audits = await prisma.auditEntry.findMany({
|
|
||||||
where: { action: FILE_LIB_AUDIT_ACTIONS.folderRestore },
|
|
||||||
});
|
|
||||||
expect(audits).toHaveLength(2);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("binService · 彻底删除", () => {
|
describe("binService · 彻底删除", () => {
|
||||||
it("仅管理员;整支硬删(含子孙/授权),落 node.purge 审计", async () => {
|
it("ADR-0034:与条目可见性同权 —— 直连 MANAGE 可清空,无关者 404;整支硬删 + node.purge 审计", async () => {
|
||||||
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
||||||
const child = await createNode(treeDeps(), ADMIN, {
|
const child = await createNode(treeDeps(), ADMIN, {
|
||||||
parentId: root.id, kind: "PROJECT", name: "TH-141",
|
parentId: root.id, kind: "PROJECT", name: "TH-141",
|
||||||
grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }],
|
grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }],
|
||||||
});
|
});
|
||||||
await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶
|
await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶;alice 在 root 上无直连 MANAGE
|
||||||
|
|
||||||
await expect(purgeBinEntry(binDeps(), ALICE, root.id)).rejects.toMatchObject({ statusCode: 404 });
|
await expect(purgeBinEntry(binDeps(), BOB, root.id)).rejects.toMatchObject({ statusCode: 404 });
|
||||||
const { removed } = await purgeBinEntry(binDeps(), ADMIN, root.id);
|
const { removed } = await purgeBinEntry(binDeps(), ADMIN, root.id);
|
||||||
expect(removed).toBe(2);
|
expect(removed).toBe(2);
|
||||||
|
|
||||||
@@ -138,4 +136,16 @@ describe("binService · 彻底删除", () => {
|
|||||||
const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.nodePurge } });
|
const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.nodePurge } });
|
||||||
expect(audits).toHaveLength(1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user