forked from EduCraft/curriculum-project-hub
fix(filelib): 恢复保留原名,撞名报清晰错误而非自动加后缀(ADR-0035,supersede ADR-0033)
恢复不再改名;同名兄弟占位时抛 409 name_conflict_on_restore + 人话提示, 操作者自行重命名现有节点或彻底删除旧节点后再恢复。
This commit is contained in:
@@ -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.
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,9 +149,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, ...(renamedFrom !== undefined ? { renamedFrom } : {}) },
|
detail: { name: node.name },
|
||||||
});
|
});
|
||||||
return { name, renamedFrom };
|
return { name: node.name };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,30 +93,27 @@ 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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user