Files
UHD_Media_Tool/apps/server/src/modules/file-manager/mutate.ts
T
rootandClaude Opus 5.5 12a8caa463 copy(server): 文件管理错误文案按 human-copy 重写
「根」统一叫登记目录;去掉「请…」;H&R 拦截不再露待处理事项 ID,改给下一步动作。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:52:23 +08:00

204 lines
8.0 KiB
TypeScript

import { basename, dirname, extname, join } from "node:path";
import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts";
import { sanitizeSegment } from "@umt/domain";
import type { Principal } from "../../http/auth.ts";
import { lstat, mkdir } from "../../storage/local-fs.ts";
import { isRemotePath, vexists, vlstat, vmkdirp, vrenameNoClobber } from "../../storage/vfs.ts";
import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts";
import { audit, filesChanged } from "./ledger.ts";
import { downloadsFor, isWritableTarget, kindOfName, managedDownloads, sidecarsOf } from "./list.ts";
import { type RecordFile, relocate, relocatePreview, writeRecord } from "./records.ts";
// mkdir / rename:同目录改名不覆盖(moveSafe = link+unlink / lstat+rename),执行后 inode 比对;
// 已登记资产同事务同步 asset_location,目录命中 path_binding 写修订。
const parentRel = (rel: string) => (rel.includes("/") ? dirname(rel) : "");
function assertCleanName(name: string) {
if (sanitizeSegment(name) !== name)
throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(name)}`);
}
export async function mkdirIn(
d: FmDeps,
principal: Principal | undefined,
body: FilesMkdirRequest,
): Promise<FilesMkdirResult> {
const r = await resolveIn(d, body.root, body.path);
if (!(await isWritableTarget(r.def, r.rootReal)))
throw new FilesError("not_writable", `登记目录「${r.def.name}」不可写`);
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`);
assertCleanName(body.name);
await recheck(r.abs, r.rootReal);
const abs = join(r.abs, body.name);
try {
if (r.def.storageId) {
if (await vexists(abs)) throw Object.assign(new Error("exists"), { code: "EEXIST" });
await vmkdirp(abs);
} else await mkdir(abs);
} catch (e) {
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在:${body.name}`);
throw new FilesError("bad_request", `创建目录失败:${errMessage(e)}`);
}
const rel = relOf(r.rootReal, abs);
await audit(d, principal, "mkdir", { root: body.root, path: rel });
filesChanged(d, body.root, [r.rel]);
return { root: body.root, path: rel };
}
interface Pair {
from: string;
to: string;
fromAbs: string;
toAbs: string;
role: "main" | "sidecar";
}
export async function renameEntry(
d: FmDeps,
principal: Principal | undefined,
body: FilesRenameRequest,
): Promise<FilesRenameResult> {
const r = await resolveIn(d, body.root, body.path);
if (r.rel === "") throw new FilesError("bad_request", "登记目录本身不能重命名");
if (!r.def.modifiable) throw new FilesError("not_writable", `登记目录「${r.def.name}」只读,不能重命名`);
assertCleanName(body.newName);
const oldName = basename(r.abs);
if (body.newName === oldName) throw new FilesError("bad_request", "新名称与原名称相同");
const isDir = !!r.lst?.isDirectory() && !r.isSymlink;
// 纳管下载任务的内容(或目录下有):改名会让下载器找不到文件
const hits = downloadsFor(await managedDownloads(d), r.abs);
if (hits.length > 0)
throw new FilesError(
"download_active",
`该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。整理后再改名`,
);
const dirAbs = dirname(r.abs);
const pRel = parentRel(r.rel);
const relIn = (n: string) => (pRel ? `${pRel}/${n}` : n);
const pairs: Pair[] = [
{ from: r.rel, to: relIn(body.newName), fromAbs: r.abs, toAbs: join(dirAbs, body.newName), role: "main" },
];
if (!isDir && body.withSidecars && kindOfName(oldName) === "video") {
const oldStem = oldName.slice(0, oldName.length - extname(oldName).length);
const newStem = body.newName.slice(0, body.newName.length - extname(body.newName).length);
for (const n of await sidecarsOf(r.abs)) {
const nn = `${newStem}${n.slice(oldStem.length)}`;
pairs.push({ from: relIn(n), to: relIn(nn), fromAbs: join(dirAbs, n), toAbs: join(dirAbs, nn), role: "sidecar" });
}
}
// 不覆盖:任一目标已存在 → 409,原文件不动
const taken: string[] = [];
for (const p of pairs) {
const st = await (isRemotePath(p.toAbs) ? vlstat(p.toAbs) : lstat(p.toAbs)).catch((e) =>
errnoOf(e) === "ENOENT" ? null : e,
);
if (st) taken.push(p.to);
}
if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖:${taken.join(", ")}`);
const lib = r.def.libraryRootId;
let locCount = 0;
let bindingIds: string[] = [];
if (lib) {
for (const p of pairs) {
const pv = await relocatePreview(d.ctx.db, lib, p.from, isDir && p.role === "main");
locCount += pv.locations;
bindingIds = [...bindingIds, ...pv.bindingIds];
}
}
const renames = pairs.map((p) => ({ from: p.from, to: p.to, role: p.role }));
if (body.dryRun)
return {
dryRun: true,
renames,
assetLocations: locCount,
bindings: bindingIds.map((id) => ({ oldBindingId: id, newBindingId: null })),
recordId: null,
};
// 执行:逐个 no-clobber 改名;任一失败回滚已改的
const done: Pair[] = [];
const rollback = async () => {
for (const p of done.reverse()) await vrenameNoClobber(p.toAbs, p.fromAbs).catch(() => undefined);
};
for (const p of pairs) {
try {
await recheck(p.fromAbs, r.rootReal);
const remote = isRemotePath(p.fromAbs);
const before = remote ? null : await lstat(p.fromAbs);
const res = await vrenameNoClobber(p.fromAbs, p.toAbs);
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖:${p.to}`);
if (res.status !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`);
// 远端没有 inode:Alist 改名接口成功即算成功(同 MP)
const after = remote ? null : await lstat(p.toAbs);
if (before && after && (after.ino !== before.ino || after.dev !== before.dev))
throw new FilesError("bad_request", `重命名后 inode 不一致,已中止:${p.to}`);
done.push(p);
} catch (e) {
await rollback();
throw e;
}
}
let recordId: string | null = null;
const bindings: FilesRenameResult["bindings"] = [];
let synced = 0;
if (lib) {
try {
await d.ctx.db.transaction().execute(async (trx) => {
const files: RecordFile[] = [];
const entities = new Set<string>();
for (const p of pairs) {
const rr = await relocate(trx, {
fromLib: lib,
fromRel: p.from,
toLib: lib,
toRel: p.to,
isDir: isDir && p.role === "main",
actor: actorOfP(principal),
opKind: "rename",
});
synced += rr.locations.length;
bindings.push(...rr.bindings);
for (const e of rr.mediaEntityIds) entities.add(e);
if (rr.locations.length > 0 || rr.bindings.length > 0)
files.push({
src: p.from,
dst: p.to,
size: 0,
status: "succeeded",
role: p.role === "main" ? "main" : "other",
});
}
if (files.length > 0)
recordId = await writeRecord(d, trx, {
principal,
action: "rename",
mediaEntityId: entities.size === 1 ? [...entities][0]! : null,
sourcePath: r.abs,
targetLibraryRootId: lib,
targetPath: pairs[0]!.to,
plan: { op: "rename", renames, bindings },
files,
});
});
} catch (e) {
// 数据库同步失败:文件改回原名,保持"路径变更与登记同事务"
await rollback();
throw e;
}
}
await audit(d, principal, "rename", {
root: body.root,
renames,
assetLocations: synced,
bindings,
recordId,
});
filesChanged(d, body.root, [pRel]);
return { dryRun: false, renames, assetLocations: synced, bindings, recordId };
}