diff --git a/docs/adr/0033-restore-deduplicates-name.md b/docs/adr/0033-restore-deduplicates-name.md new file mode 100644 index 0000000..579f12c --- /dev/null +++ b/docs/adr/0033-restore-deduplicates-name.md @@ -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). diff --git a/hub/filelib-web/src/lib/BinView.svelte b/hub/filelib-web/src/lib/BinView.svelte index bc99895..91732c6 100644 --- a/hub/filelib-web/src/lib/BinView.svelte +++ b/hub/filelib-web/src/lib/BinView.svelte @@ -36,8 +36,15 @@ async function restore(e: BinEntry): Promise { busyId = e.id; try { - await api(`/database/api/bin/${encodeURIComponent(e.id)}/restore`, { method: "POST" }); - toastOk(`已恢复「${e.name}」`); + const r = await api<{ name: string; renamedFrom?: string }>( + `/database/api/bin/${encodeURIComponent(e.id)}/restore`, + { method: "POST" }, + ); + toastOk( + r.renamedFrom !== undefined && r.renamedFrom !== r.name + ? `已恢复为「${r.name}」(原名与现有节点冲突)` + : `已恢复「${r.name}」`, + ); await load(); } catch (err) { toastErr(err instanceof Error ? err.message : String(err)); diff --git a/hub/src/database/filelib/binService.ts b/hub/src/database/filelib/binService.ts index 2cb20cc..dc702df 100644 --- a/hub/src/database/filelib/binService.ts +++ b/hub/src/database/filelib/binService.ts @@ -11,7 +11,7 @@ */ import type { PrismaClient } from "@prisma/client"; -import { FileLibError } from "./model.js"; +import { FileLibError, nameKey, NODE_NAME_MAX_LENGTH } from "./model.js"; import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; import type { GroupResolver } from "./groupResolver.js"; import type { FileLibActor } from "./treeService.js"; @@ -100,7 +100,7 @@ async function requireBinEntry( actor: FileLibActor, groupIds: readonly string[], nodeId: string, -): Promise<{ readonly id: string; readonly kind: "FOLDER" | "PROJECT"; readonly name: string; readonly pathIds: 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 } }, }); @@ -108,15 +108,51 @@ async function requireBinEntry( 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 }; + return { id: node.id, parentId: node.parentId, kind: node.kind, name: node.name, pathIds: node.pathIds }; } -/** 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。 */ -export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise { +export interface RestoreResult { + readonly name: string; + readonly renamedFrom?: string | undefined; +} + +/** + * 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。 + * ADR-0033:与活跃兄弟撞名时不失败,自动改成「原名(已恢复[/ N])」—— + * 恢复的意义就是找回,撞名死锁不是保护;审计 detail 记 renamedFrom。 + */ +export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise { const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); - await deps.prisma.$transaction(async (tx) => { + return 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 } }); + + const siblings = await tx.fileLibNode.findMany({ + where: { + organizationId: deps.organizationId, + parentId: node.parentId, + deletedAt: null, + id: { not: node.id }, + }, + select: { nameLower: true }, + }); + const taken = new Set(siblings.map((s) => s.nameLower)); + + 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({ + where: { id: node.id }, + data: { deletedAt: null, name, nameLower: nameKey(name) }, + }); await writeFileLibAudit(tx, { action: node.kind === "PROJECT" ? FILE_LIB_AUDIT_ACTIONS.projectRestore @@ -126,8 +162,9 @@ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId objectType: node.kind === "PROJECT" ? "project" : "folder", objectId: node.id, objectPath: node.pathIds, - detail: { name: node.name }, + detail: { name, ...(renamedFrom !== undefined ? { renamedFrom } : {}) }, }); + return { name, renamedFrom }; }); } diff --git a/hub/src/database/routes/binRoutes.ts b/hub/src/database/routes/binRoutes.ts index 402503a..2077155 100644 --- a/hub/src/database/routes/binRoutes.ts +++ b/hub/src/database/routes/binRoutes.ts @@ -25,8 +25,7 @@ export async function registerBinRoutes(app: FastifyInstance, deps: FileLibRoute if (actor === null) return reply; try { const { id } = request.params as { id: string }; - await restoreBinEntry(svc, actor, id); - return reply.status(204).send(); + return await restoreBinEntry(svc, actor, id); } catch (error) { return sendRouteError(reply, error); } diff --git a/hub/test/integration/filelib-nav.test.ts b/hub/test/integration/filelib-nav.test.ts index c28d434..c66299b 100644 --- a/hub/test/integration/filelib-nav.test.ts +++ b/hub/test/integration/filelib-nav.test.ts @@ -91,6 +91,32 @@ describe("binService · 恢复", () => { }); expect(audits).toHaveLength(1); }); + + it("ADR-0033:与活跃兄弟撞名时恢复为「(已恢复)」,不死锁;审计记 renamedFrom", 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: "必修一" }); + + const result = await restoreBinEntry(binDeps(), ADMIN, child.id); + expect(result.renamedFrom).toBe("必修一"); + expect(result.name).toBe("必修一(已恢复)"); + + const visible = await listChildren(treeDeps(), ADMIN, root.id); + 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 · 彻底删除", () => {