feat(files): W5-A2 move/copy via planImport, organize overrides, delete records, download
- /files/move|copy go through planImport's new `transfer` variant (keep names, directory expanded to per-file plan) → organization_record + verifyAndImport/executeRecord: per-file ledger resumable, fsproxy, cross-volume directory move = per-file copy→verify→delete then remove empty source dirs; shared plan store + plan_stale; asset_location follows per file, path_binding revised when a whole directory moved (finalizeTransfer); request waits up to FILES_TRANSFER_WAIT_SEC, else executed.pending - /files/organize per-item overrides take effect: season/episodes (domain planOrganize overrides), contentUnitId coverage, targetDir (no binding), operation, slotId (satisfied after verify); warnings only for inapplicable overrides - delete/trash/restore write organization_record (action trash|delete|restore, file-level results; migration 0026) - GET /files/download (admin session, nfo/subtitle/image, size cap, streamed, audited) - GET /files/records?ids|planIds for the organize page - fs race errors map to 503/404/403/400 instead of 500 - runbook updated Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3641ab9573
commit
36aa99b8aa
25 files changed
+1766
-271
No files matched your search
@@ -1,29 +1,32 @@
|
||||
import { lstat, mkdir, readdir, stat } from "node:fs/promises";
|
||||
import { lstat, readdir, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { copyWithVerify, crossDeviceMove, moveSafe } from "@umt/adapters";
|
||||
import type { FilePlanPair, FilesMoveRequest, FilesTransferResult } from "@umt/contracts";
|
||||
import type { FilePlanPair, FilesMoveRequest, FilesTransferResult, OrganizationStatus } from "@umt/contracts";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { authorizeMove } from "../acquisition-organize/services/organize-guard.ts";
|
||||
import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts";
|
||||
import { planImport } from "../acquisition-organize/services/organize.ts";
|
||||
import { authorizeMove, PathGuardError, sourceGuard } from "../acquisition-organize/services/organize-guard.ts";
|
||||
import type { TransferSpec } from "../acquisition-organize/services/organize-transfer.ts";
|
||||
import {
|
||||
actorOfP,
|
||||
errMessage,
|
||||
FilesError,
|
||||
type FmDeps,
|
||||
hashJson,
|
||||
isAdmin,
|
||||
type Resolved,
|
||||
recheck,
|
||||
relOf,
|
||||
resolveIn,
|
||||
within,
|
||||
} from "./common.ts";
|
||||
import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
|
||||
import { downloadsFor, isWritableTarget, managedDownloads } from "./list.ts";
|
||||
import { type RecordFile, relocate, relocatePreview, writeRecord } from "./records.ts";
|
||||
import { relocatePreview } from "./records.ts";
|
||||
|
||||
// 移动/复制到目录:dryRun 冻结源事实(文件 size/mtime/inode;目录递归文件清单指纹)→ planId;
|
||||
// 执行校验 planId(未过期、请求一致)与源事实未变,否则 409 plan_stale。
|
||||
// 搬运原语与整理流程相同(adapters moveSafe / crossDeviceMove / copyWithVerify:no-clobber、跨卷 Copy→核验→删源、EXDEV 不降级)。
|
||||
// 移动/复制到目录(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;
|
||||
@@ -81,6 +84,8 @@ async function snapshot(d: FmDeps, r: Resolved, rootId: string): Promise<SourceS
|
||||
};
|
||||
}
|
||||
|
||||
const TERMINAL = new Set<OrganizationStatus>(["succeeded", "partial", "failed", "cancelled"]);
|
||||
|
||||
export async function transfer(
|
||||
d: FmDeps,
|
||||
principal: Principal | undefined,
|
||||
@@ -89,7 +94,7 @@ export async function transfer(
|
||||
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", "目标必须是已存在的目录");
|
||||
if (!(await isWritableTarget(t.def, t.rootReal)))
|
||||
if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal)))
|
||||
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写(仅 managed 库根可作为目标)`);
|
||||
|
||||
const sources: Resolved[] = [];
|
||||
@@ -115,60 +120,94 @@ export async function transfer(
|
||||
hnrBlocks.push({ taskId: h.taskId, path: s.rel, hnr: h.hnr, status: h.status });
|
||||
|
||||
const targetDev = Number((await stat(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 = join(t.abs, names[i]!);
|
||||
const conflict = !!(await lstat(dst).catch(() => null));
|
||||
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;
|
||||
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,
|
||||
conflict: !!(await lstat(dst).catch(() => null)) || [...conflictSrc].some((p) => under(s.abs, p)),
|
||||
registered,
|
||||
files: snaps[i]!.files.length,
|
||||
crossDevice,
|
||||
});
|
||||
const sameDev = Number((await lstat(s.abs)).dev) === targetDev;
|
||||
if (!(mode === "move" && sameDev)) needBytes += snaps[i]!.size;
|
||||
if (!(mode === "move" && !crossDevice)) needBytes += snaps[i]!.size;
|
||||
}
|
||||
const du = 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);
|
||||
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,
|
||||
targetLibraryRootId: t.def.libraryRootId ?? undefined,
|
||||
targetLibraryRootId: t.def.libraryRootId,
|
||||
};
|
||||
const allowCross = (await d.ctx.settings.get("organize")).allowCrossVolumeMove;
|
||||
const crossBlocked = mode === "move" && !allowCross && pairs.some((p) => p.crossDevice);
|
||||
const warnings: string[] = [];
|
||||
if (crossBlocked) warnings.push("跨卷移动未开启(设置 → 整理 → 允许跨卷 Move),执行会被拒绝;可改用复制");
|
||||
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(),
|
||||
mode,
|
||||
pairs,
|
||||
totalBytes,
|
||||
freeBytes,
|
||||
spaceOk,
|
||||
hnrBlocks,
|
||||
truncated,
|
||||
executed: null,
|
||||
};
|
||||
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 }[] }>(
|
||||
const plan = await loadPlan<{ sources: { abs: string; fingerprint: string }[]; files?: typeof filePairs }>(
|
||||
d,
|
||||
body.planId,
|
||||
"transfer",
|
||||
@@ -179,8 +218,15 @@ export async function transfer(
|
||||
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(
|
||||
@@ -204,100 +250,89 @@ export async function transfer(
|
||||
}
|
||||
await retirePlan(d, plan.id);
|
||||
|
||||
const hash = d.ao.ports.options.verifyHash;
|
||||
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 }[] = [];
|
||||
const recFiles: RecordFile[] = [];
|
||||
const moved: { s: Resolved; dstRel: string; isDir: boolean }[] = [];
|
||||
for (const [i, s] of sources.entries()) {
|
||||
const pair = pairs[i]!;
|
||||
const dst = join(t.abs, names[i]!);
|
||||
const fail = (code: string, message: string) => {
|
||||
failed.push({ path: s.rel, code, message });
|
||||
recFiles.push({ src: s.abs, dst: pair.target.path, size: pair.size, status: "failed", error: message });
|
||||
};
|
||||
try {
|
||||
await recheck(s.abs, s.rootReal);
|
||||
if (await lstat(dst).catch(() => null)) {
|
||||
fail("exists", "目标已存在,不覆盖");
|
||||
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 isDir = snaps[i]!.isDir;
|
||||
if (mode === "move") {
|
||||
const r = await moveSafe(s.abs, dst);
|
||||
if (r.status === "exists") {
|
||||
fail("exists", "目标已存在,不覆盖");
|
||||
continue;
|
||||
}
|
||||
if (r.status === "exdev") {
|
||||
if (isDir) {
|
||||
fail("exdev_dir", "跨卷移动目录暂不支持(W5),请先复制再删除,或逐个文件移动");
|
||||
continue;
|
||||
}
|
||||
const x = await crossDeviceMove(s.abs, dst, { hash });
|
||||
if (x.status !== "completed") {
|
||||
fail(x.status, `跨卷移动未完成(${x.status}):${JSON.stringify(x.steps.at(-1) ?? {})}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
moved.push({ s, dstRel: pair.target.path, isDir });
|
||||
} else if (isDir) {
|
||||
for (const f of snaps[i]!.files) {
|
||||
await copyWithVerify(join(s.abs, f.rel), join(dst, f.rel), { hash, createParents: true });
|
||||
}
|
||||
await mkdir(dst, { recursive: true });
|
||||
} else {
|
||||
await copyWithVerify(s.abs, dst, { hash });
|
||||
}
|
||||
succeeded.push(s.rel);
|
||||
recFiles.push({ src: s.abs, dst: pair.target.path, size: pair.size, status: "succeeded" });
|
||||
} catch (e) {
|
||||
fail(e instanceof FilesError ? e.reason : "io_error", errMessage(e));
|
||||
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} 个文件已完成)` : ""}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const recordId = await d.ctx.db.transaction().execute(async (trx) => {
|
||||
const entities = new Set<string>();
|
||||
if (mode === "move" && t.def.libraryRootId) {
|
||||
for (const m of moved) {
|
||||
if (!m.s.def.libraryRootId) continue;
|
||||
const rr = await relocate(trx, {
|
||||
fromLib: m.s.def.libraryRootId,
|
||||
fromRel: m.s.rel,
|
||||
toLib: t.def.libraryRootId,
|
||||
toRel: m.dstRel,
|
||||
isDir: m.isDir,
|
||||
actor: actorOfP(principal),
|
||||
opKind: "move",
|
||||
});
|
||||
for (const e of rr.mediaEntityIds) entities.add(e);
|
||||
}
|
||||
}
|
||||
return writeRecord(d, trx, {
|
||||
principal,
|
||||
action: mode === "move" ? "relocate" : "copy",
|
||||
mediaEntityId: entities.size === 1 ? [...entities][0]! : null,
|
||||
sourcePath: sources.length === 1 ? sources[0]!.abs : dirname(sources[0]!.abs),
|
||||
targetLibraryRootId: t.def.libraryRootId,
|
||||
targetPath: t.rel,
|
||||
plan: { op: "transfer", mode, planId: plan.id, pairs },
|
||||
files: recFiles,
|
||||
});
|
||||
await audit(d, principal, mode, {
|
||||
planId: plan.id,
|
||||
succeeded,
|
||||
failed,
|
||||
recordId,
|
||||
taskId,
|
||||
pending,
|
||||
target: body.target,
|
||||
});
|
||||
await audit(d, principal, mode, { planId: plan.id, succeeded, failed, recordId, 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(),
|
||||
mode,
|
||||
pairs,
|
||||
totalBytes,
|
||||
freeBytes,
|
||||
spaceOk,
|
||||
hnrBlocks,
|
||||
truncated,
|
||||
executed: { succeeded, failed, recordId },
|
||||
...base,
|
||||
executed: { succeeded, failed, recordId, taskId, status, pending },
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user