Files
UHD_Media_Tool/apps/server/src/modules/file-manager/transfer.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

354 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { basename, dirname, join } from "node:path";
import type { FilePlanPair, FilesMoveRequest, FilesTransferResult, OrganizationStatus } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { isRemotePath, vlstat as lstat, vreaddir as readdir } from "../../storage/vfs.ts";
import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts";
import { planImport } from "../acquisition-organize/services/organize.ts";
import { authorizeMoveEntities, PathGuardError, sourceGuard } from "../acquisition-organize/services/organize-guard.ts";
import type { TransferSpec } from "../acquisition-organize/services/organize-transfer.ts";
import {
actorOfP,
FilesError,
type FmDeps,
hashJson,
isAdmin,
type Resolved,
relOf,
resolveIn,
within,
} from "./common.ts";
import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { downloadsFor, isWritableTarget, managedDownloads } from "./list.ts";
import { entitiesUnder, relocatePreview } from "./records.ts";
// 移动/复制到目录(W5-A2):与整理共用 planImport(原样搬运变体 transfer)、冻结计划存储与 plan_stale 校验。
// dryRun:冻结源事实(文件 size/mtime/inode;目录递归文件清单指纹)+ planImport 逐文件前后路径 → planId;
// 执行:校验 planId(未过期、请求一致)、源事实与重算的逐文件路径未变,否则 409 plan_stale;
// 然后写 organization_record 并入队 verifyAndImport(executeRecord:逐文件账本可恢复、fsproxy 挂死隔离、
// 跨卷 Copy→核验→删源、EXDEV 不降级),请求内最多等 FILES_TRANSFER_WAIT_SEC,超时返回 pending。
// move 完成后已登记位置跟随、整目录搬完时路径绑定写修订、源侧空目录删除(finalizeTransfer)。
interface SourceSnap {
root: string;
rel: string;
abs: string;
isDir: boolean;
size: number;
fingerprint: string;
files: { rel: string; size: number }[];
truncated: boolean;
}
async function snapshot(d: FmDeps, r: Resolved, rootId: string): Promise<SourceSnap> {
const st = await lstat(r.abs);
if (!st.isDirectory()) {
const f = { size: Number(st.size), mtimeMs: Number(st.mtimeMs), ino: Number(st.ino) };
return {
root: rootId,
rel: r.rel,
abs: r.abs,
isDir: false,
size: f.size,
fingerprint: hashJson(f),
files: [{ rel: "", size: f.size }],
truncated: false,
};
}
const max = d.ao.ports.options.walkMaxFiles;
const files: { rel: string; size: number; mtimeMs: number }[] = [];
let truncated = false;
const walk = async (dir: string, prefix: string): Promise<void> => {
const names = (await readdir(dir)).sort();
for (const n of names) {
if (files.length >= max) {
truncated = true;
return;
}
const p = join(dir, n);
const s = await lstat(p);
const rel = prefix ? `${prefix}/${n}` : n;
if (s.isDirectory()) await walk(p, rel);
else if (s.isFile()) files.push({ rel, size: Number(s.size), mtimeMs: Number(s.mtimeMs) });
}
};
await walk(r.abs, "");
return {
root: rootId,
rel: r.rel,
abs: r.abs,
isDir: true,
size: files.reduce((a, f) => a + f.size, 0),
fingerprint: hashJson(files),
files: files.map(({ rel, size }) => ({ rel, size })),
truncated,
};
}
const TERMINAL = new Set<OrganizationStatus>(["succeeded", "partial", "failed", "cancelled"]);
export async function transfer(
d: FmDeps,
principal: Principal | undefined,
body: FilesMoveRequest,
): Promise<FilesTransferResult> {
const mode = body.mode;
const t = await resolveIn(d, body.target.root, body.target.path);
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
// 目标:可写的媒体库目录(本地或 Alist),或 Alist 存储根(文件管理的 st: 根,对照 MP 存储内移动 / 复制)
const storageTarget = t.def.kind === "storage";
if ((!t.def.libraryRootId && !storageTarget) || !(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `登记目录「${t.def.name}」不可写,只能选可写的媒体库目录或 Alist 存储`);
const sources: Resolved[] = [];
for (const it of body.items) {
const s = await resolveIn(d, it.root, it.path);
if (s.rel === "") throw new FilesError("bad_request", "登记目录本身不能移动或复制");
if (mode === "move" && !s.def.modifiable)
throw new FilesError("not_writable", `登记目录「${s.def.name}」只读,只能复制`);
if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内:${s.rel}`);
if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中:${s.rel}`);
if (s.isSymlink) throw new FilesError("bad_request", `不能移动符号链接:${s.rel}`);
sources.push(s);
}
const names = sources.map((s) => basename(s.abs));
if (new Set(names).size !== names.length) throw new FilesError("bad_request", "多个源同名,会在目标目录冲突");
const snaps = await Promise.all(sources.map((s, i) => snapshot(d, s, body.items[i]!.root)));
const downloads = await managedDownloads(d);
const hnrBlocks: FilesTransferResult["hnrBlocks"] = [];
if (mode === "move")
for (const s of sources)
for (const h of downloadsFor(downloads, s.abs))
hnrBlocks.push({ taskId: h.taskId, path: s.rel, hnr: h.hnr, status: h.status });
const targetDev = Number((await lstat(t.abs)).dev);
const spec: TransferSpec = {
items: sources.map((s, i) => {
const dst = join(t.abs, names[i]!);
return {
src: s.abs,
dst,
srcLibraryRootId: s.def.libraryRootId,
srcRel: s.def.libraryRootId ? s.rel : null,
dstRel: relOf(t.rootReal, dst),
};
}),
targetLibraryRootId: t.def.libraryRootId,
targetRootPath: t.rootReal,
};
const actorType = principal?.kind === "api_key" ? ("api_key" as const) : ("user" as const);
const importReq = (dryRun: boolean, extra: { taskId?: string; guard?: (p: string) => Promise<string> } = {}) => ({
transfer: { ...spec, ...(extra.guard ? { guard: extra.guard } : {}) },
action: mode,
trigger: "manual" as const,
actorType,
actorId: actorOfP(principal),
dryRun,
...(extra.taskId ? { taskId: extra.taskId } : {}),
});
const planned = await planImport(d.ao, importReq(true));
if (!planned.plan) {
const reason = planned.reason ?? "plan_failed";
throw new FilesError(
reason.startsWith("library_root_offline") ? "root_unavailable" : "bad_request",
`无法生成计划:${reason}`,
);
}
const planFiles = planned.plan.organize.files;
const filePairs = planFiles.map((f) => ({ src: f.src, dst: f.dst }));
const conflictSrc = new Set(planFiles.filter((f) => f.action === "hold").map((f) => f.src));
const under = (item: string, p: string) => p === item || p.startsWith(`${item}/`);
const pairs: FilePlanPair[] = [];
let needBytes = 0;
for (const [i, s] of sources.entries()) {
const dst = spec.items[i]!.dst;
const registered = s.def.libraryRootId
? (await relocatePreview(d.ctx.db, s.def.libraryRootId, s.rel, snaps[i]!.isDir)).locations > 0
: false;
const crossDevice = Number((await lstat(s.abs)).dev) !== targetDev;
if (storageTarget && mode === "move" && registered)
throw new FilesError(
"needs_retire",
`${s.rel} 含已整理的文件,只能移动到媒体库目录;要放到 Alist 存储,改用复制或先从媒体库移除`,
);
pairs.push({
source: { root: body.items[i]!.root, path: s.rel },
target: { root: body.target.root, path: relOf(t.rootReal, dst) },
size: snaps[i]!.size,
conflict: !!(await lstat(dst).catch(() => null)) || [...conflictSrc].some((p) => under(s.abs, p)),
registered,
files: snaps[i]!.files.length,
crossDevice,
});
if (!(mode === "move" && !crossDevice)) needBytes += snaps[i]!.size;
}
const du = isRemotePath(t.abs) ? null : await d.ao.ports.diskUsage(t.abs).catch(() => null);
const freeBytes = du?.status === "ok" ? du.availableBytes : null;
const spaceOk = freeBytes === null ? true : needBytes <= freeBytes;
const truncated = snaps.some((s) => s.truncated) || planned.truncated === true;
const totalBytes = snaps.reduce((a, s) => a + s.size, 0);
const requestHash = hashJson({ items: body.items, target: body.target, mode });
const request = {
sourcePaths: sources.map((s) => s.abs),
action: mode,
...(t.def.libraryRootId ? { targetLibraryRootId: t.def.libraryRootId } : {}),
};
const allowCross = (await d.ctx.settings.get("organize")).allowCrossVolumeMove;
// 跨卷开关只管本地 ↔ 本地;涉及 Alist 的移动一律「传输成功 → 删源」(同 MP)
const crossBlocked =
mode === "move" &&
!allowCross &&
pairs.some((p, i) => p.crossDevice && !isRemotePath(sources[i]!.abs) && !isRemotePath(t.abs));
const warnings: string[] = [];
if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在「设置 → 整理」里开启");
if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`);
if (truncated) warnings.push("源文件数超过上限,分批操作");
const base = { mode, pairs, totalBytes, freeBytes, spaceOk, hnrBlocks, truncated, warnings };
if (body.dryRun) {
const plan = await savePlan(d, "transfer", requestHash, principal, {
request,
sources: snaps.map((s) => ({ abs: s.abs, fingerprint: s.fingerprint })),
files: filePairs,
pairs,
});
return { dryRun: true, planId: plan.id, planExpiresAt: plan.expiresAt.toISOString(), ...base, executed: null };
}
// ── 执行 ──
if (!body.planId) throw new FilesError("bad_request", "执行需要 dryRun 返回的 planId");
const plan = await loadPlan<{ sources: { abs: string; fingerprint: string }[]; files?: typeof filePairs }>(
d,
body.planId,
"transfer",
requestHash,
);
for (const [i, s] of snaps.entries()) {
const frozen = plan.content.sources[i];
if (!frozen || frozen.abs !== s.abs || frozen.fingerprint !== s.fingerprint)
throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单):${s.rel}`);
}
if (plan.content.files && hashJson(plan.content.files) !== hashJson(filePairs))
throw new FilesError("plan_stale", "目标已变化,与预览不一致。重新预览");
if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,分批操作");
if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`);
if (crossBlocked)
throw new FilesError(
"bad_request",
"未开启跨卷移动(settings.organize.allowCrossVolumeMove)。改用复制,或开启后重试",
);
if (mode === "move") {
if (hnrBlocks.length > 0)
throw new FilesError(
"download_active",
`源属于下载任务 ${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")},直接移动会让下载器丢失文件。改用整理,它会按做种义务决定能否移动`,
);
if (!isAdmin(principal)) {
// 每个源按其(目录则为其下)已登记资产求作品集合,逐一核对授权;任一不覆盖 → 整体拒绝并列出未授权项
const perSource: { label: string; entityId: string | null }[] = [];
for (const [i, s] of sources.entries()) {
const ids = await entitiesUnder(d.ctx.db, s.def.libraryRootId, s.rel, snaps[i]!.isDir);
if (ids.length === 0) perSource.push({ label: s.rel, entityId: null });
else for (const id of ids) perSource.push({ label: s.rel, entityId: id });
}
const auth = await authorizeMoveEntities(d.ao, principal, request, perSource);
if (!auth.allowed) {
await audit(d, principal, "move_denied", {
reason: auth.reason,
planId: plan.id,
unauthorized: auth.unauthorized,
});
throw new FilesError("approval_required", auth.reason);
}
}
}
await retirePlan(d, plan.id);
const db = d.ctx.db;
const taskId = await ensureTaskRow(db, {
kind: "verifyAndImport",
idempotencyKey: `files:${plan.id}`,
input: { planId: plan.id, op: "transfer", mode, target: body.target },
subjectType: "organize_request",
triggeredBy: actorOfP(principal),
});
const guard = await sourceGuard(db);
let rec: Awaited<ReturnType<typeof planImport>>;
try {
rec = await planImport(d.ao, importReq(false, { taskId, guard: guard.check }));
} catch (e) {
await markTask(db, taskId, "failed", { error: e instanceof Error ? e.message : String(e) });
if (e instanceof PathGuardError) throw new FilesError("path_escape", e.message);
throw e;
}
if (!rec.recordId || rec.status === "failed") {
await markTask(db, taskId, "failed", { error: rec.reason ?? rec.status });
throw new FilesError("bad_request", `计划不可执行:${rec.reason ?? rec.status}`);
}
const recordId = rec.recordId;
await db.updateTable("organization_record").set({ task_id: taskId }).where("id", "=", recordId).execute();
const { runId } = await d.ctx.engine.enqueue(
"verifyAndImport",
{ recordId, taskId },
{ idempotencyKey: `verifyAndImport:files:${plan.id}`, traceId: taskId },
);
await db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
// 请求内等待执行完成(小操作即时出结果);超时返回 pending,后台继续,可按 recordId 追踪
const deadline = Date.now() + d.options.transferWaitMs;
let status: OrganizationStatus = "running";
for (;;) {
const r = await db
.selectFrom("organization_record")
.select("status")
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
status = r.status;
if (TERMINAL.has(status) || Date.now() >= deadline) break;
await new Promise((res) => setTimeout(res, 150));
}
const pending = !TERMINAL.has(status);
const succeeded: string[] = [];
const failed: { path: string; code: string; message: string }[] = [];
if (!pending) {
const results = await db
.selectFrom("organization_file_result")
.select(["source_path", "status", "error"])
.where("organization_record_id", "=", recordId)
.execute();
for (const s of sources) {
const mine = results.filter((f) => under(s.abs, f.source_path) && f.status !== "skipped");
const bad = mine.find((f) => f.status !== "succeeded");
if (!bad) {
succeeded.push(s.rel);
continue;
}
const err = (bad.error ?? {}) as { code?: string; message?: string };
failed.push({
path: s.rel,
code: err.code === "EEXIST" ? "exists" : (err.code ?? bad.status),
message: `${err.message ?? bad.status}${mine.length > 1 ? `(${mine.filter((f) => f.status === "succeeded").length}/${mine.length} 个文件已完成)` : ""}`,
});
}
}
await audit(d, principal, mode, {
planId: plan.id,
succeeded,
failed,
recordId,
taskId,
pending,
target: body.target,
});
for (const it of body.items) filesChanged(d, it.root, [it.path.includes("/") ? dirname(it.path) : ""]);
filesChanged(d, body.target.root, [t.rel]);
return {
dryRun: false,
planId: plan.id,
planExpiresAt: plan.expiresAt.toISOString(),
...base,
executed: { succeeded, failed, recordId, taskId, status, pending },
};
}