diff --git a/docs/adr/0035-restore-keeps-original-name.md b/docs/adr/0035-restore-keeps-original-name.md new file mode 100644 index 0000000..a16c689 --- /dev/null +++ b/docs/adr/0035-restore-keeps-original-name.md @@ -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. diff --git a/hub/filelib-web/src/lib/BinView.svelte b/hub/filelib-web/src/lib/BinView.svelte index a74b71b..0db73f4 100644 --- a/hub/filelib-web/src/lib/BinView.svelte +++ b/hub/filelib-web/src/lib/BinView.svelte @@ -36,15 +36,11 @@ async function restore(e: BinEntry): Promise { busyId = e.id; try { - const r = await api<{ name: string; renamedFrom?: string }>( + const r = await api<{ name: string }>( `/database/api/bin/${encodeURIComponent(e.id)}/restore`, { method: "POST" }, ); - toastOk( - r.renamedFrom !== undefined && r.renamedFrom !== r.name - ? `已恢复为「${r.name}」(原名与现有节点冲突)` - : `已恢复「${r.name}」`, - ); + toastOk(`已恢复「${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 7aaadd2..15e0687 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, nameKey, NODE_NAME_MAX_LENGTH } from "./model.js"; +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"; @@ -113,7 +113,6 @@ async function requireBinEntry( export interface RestoreResult { 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) => { const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId); - const siblings = await tx.fileLibNode.findMany({ + const clash = await tx.fileLibNode.findFirst({ where: { organizationId: deps.organizationId, parentId: node.parentId, deletedAt: null, id: { not: node.id }, + nameLower: nameKey(node.name), }, - select: { nameLower: true }, + select: { id: 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))); + 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, name, nameLower: nameKey(name) }, - }); + await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: null } }); await writeFileLibAudit(tx, { action: node.kind === "PROJECT" ? FILE_LIB_AUDIT_ACTIONS.projectRestore @@ -162,9 +149,9 @@ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId objectType: node.kind === "PROJECT" ? "project" : "folder", objectId: node.id, objectPath: node.pathIds, - detail: { name, ...(renamedFrom !== undefined ? { renamedFrom } : {}) }, + detail: { name: node.name }, }); - return { name, renamedFrom }; + return { name: node.name }; }); } diff --git a/hub/test/integration/filelib-nav.test.ts b/hub/test/integration/filelib-nav.test.ts index f8de9fd..5272981 100644 --- a/hub/test/integration/filelib-nav.test.ts +++ b/hub/test/integration/filelib-nav.test.ts @@ -10,6 +10,7 @@ import { createNode, softDeleteNode, listChildren, + renameNode, type FileLibActor, type TreeServiceDeps, } from "../../src/database/filelib/treeService.js"; @@ -92,30 +93,27 @@ describe("binService · 恢复", () => { 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 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.renamedFrom).toBe("必修一"); - expect(result.name).toBe("必修一(已恢复)"); + expect(result.name).toBe("必修一"); + expect(result.renamedFrom).toBeUndefined(); 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); + expect(visible.map((c) => c.name).sort()).toEqual(["必修一", "必修一(新)"]); }); });