fix(filelib): 恢复撞名不再死锁,自动改名「(已恢复)」(ADR-0033)

- restore 前查活跃兄弟:撞名则恢复为「原名(已恢复[/ N])」(截断计入
  128 长度预算),同事务落审计并记 renamedFrom;API 返回最终名
- BinView toast 提示改名;测试补撞名/二次恢复用例
This commit is contained in:
ymy
2026-07-31 13:52:23 +08:00
parent f99c8ea4d8
commit 9e38d1e011
5 changed files with 107 additions and 12 deletions
@@ -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).
+9 -2
View File
@@ -36,8 +36,15 @@
async function restore(e: BinEntry): Promise<void> { async function restore(e: BinEntry): Promise<void> {
busyId = e.id; busyId = e.id;
try { try {
await api(`/database/api/bin/${encodeURIComponent(e.id)}/restore`, { method: "POST" }); const r = await api<{ name: string; renamedFrom?: string }>(
toastOk(`已恢复「${e.name}`); `/database/api/bin/${encodeURIComponent(e.id)}/restore`,
{ method: "POST" },
);
toastOk(
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));
+45 -8
View File
@@ -11,7 +11,7 @@
*/ */
import type { PrismaClient } from "@prisma/client"; 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 { 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";
@@ -100,7 +100,7 @@ async function requireBinEntry(
actor: FileLibActor, actor: FileLibActor,
groupIds: readonly string[], groupIds: readonly string[],
nodeId: 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({ const node = await tx.fileLibNode.findFirst({
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } }, 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))) { if (!(await canSeeEntry(tx, deps, actor, groupIds, node.id))) {
throw new FileLibError(404, "node_not_found", "node not found"); 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 interface RestoreResult {
export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<void> { 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<RestoreResult> {
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); 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); 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, { await writeFileLibAudit(tx, {
action: node.kind === "PROJECT" action: node.kind === "PROJECT"
? FILE_LIB_AUDIT_ACTIONS.projectRestore ? FILE_LIB_AUDIT_ACTIONS.projectRestore
@@ -126,8 +162,9 @@ 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: node.name }, detail: { name, ...(renamedFrom !== undefined ? { renamedFrom } : {}) },
}); });
return { name, renamedFrom };
}); });
} }
+1 -2
View File
@@ -25,8 +25,7 @@ export async function registerBinRoutes(app: FastifyInstance, deps: FileLibRoute
if (actor === null) return reply; if (actor === null) return reply;
try { try {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
await restoreBinEntry(svc, actor, id); return await restoreBinEntry(svc, actor, id);
return reply.status(204).send();
} catch (error) { } catch (error) {
return sendRouteError(reply, error); return sendRouteError(reply, error);
} }
+26
View File
@@ -91,6 +91,32 @@ describe("binService · 恢复", () => {
}); });
expect(audits).toHaveLength(1); 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 · 彻底删除", () => { describe("binService · 彻底删除", () => {