feat(filelib): 彻底删除改与条目可见性同权(ADR-0034,supersede ADR-0031 仅管理员条款)

能删进回收站(MANAGE)的人就能清空;无关者 404(D8)。二次确认与
node.purge 审计不变;BinView 彻底删除按钮对全部可见条目开放。
This commit is contained in:
ymy
2026-07-31 14:01:46 +08:00
parent b1fd2e8f7b
commit beaa92de2e
4 changed files with 57 additions and 22 deletions
+9 -10
View File
@@ -5,7 +5,7 @@
*/
import { onMount } from "svelte";
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 Icon from "./Icon.svelte";
@@ -94,15 +94,14 @@
>
<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}
<!-- 彻底删除与条目可见性同权(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>
+4 -8
View File
@@ -169,19 +169,15 @@ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId
}
/**
* 彻底删除(仅网站管理员):整支硬删。子树经 pathIds 前缀枚举,
* 彻底删除(ADR-0034:与回收站条目同一可见性 —— 管理员或节点直连 MANAGE;
* 能删进回收站的人就能清空)。整支硬删:子树经 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");
}
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
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 node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
const subtree = await tx.fileLibNode.findMany({
where: {
+16 -4
View File
@@ -120,15 +120,15 @@ 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 child = await createNode(treeDeps(), ADMIN, {
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);
expect(removed).toBe(2);
@@ -138,4 +138,16 @@ describe("binService · 彻底删除", () => {
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);
});
});