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:
rootandClaude Opus 5.5 committed 2026-09-27 23:53:50 +08:00
1 parent 3641ab9573
commit 36aa99b8aa
25 files changed
+1766 -271

No files matched your search

+81 -13
View File
@@ -34,6 +34,7 @@ import {
} from "./common.ts";
import { audit, consumeApproval, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { bindingsAt, downloadsFor, isDownloading, managedDownloads } from "./list.ts";
import { type RecordFile, writeRecord } from "./records.ts";
// 删除(原则 5):默认进回收站(同卷 <根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>,O(1) rename;
// 不可写或跨卷时复制到 DATA_DIR/trash,超过上限拒绝)。引用检查:纳管做种 / 已登记资产 / 路径绑定 / 根本身。
@@ -172,7 +173,7 @@ export async function deleteFiles(
if (!isAdmin(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话");
}
const downloads = await managedDownloads(d);
const results = [];
const results: Awaited<ReturnType<typeof checkItem>>[] = [];
for (const it of body.items) results.push(await checkItem(d, body, it, downloads));
const checks = results.map((r) => r.check);
const requestHash = hashJson({
@@ -221,30 +222,38 @@ export async function deleteFiles(
const deleted: string[] = [];
const failed: FilesDeleteResult["failed"] = [];
const done: typeof results = [];
// 整理记录的文件级结果(删除/回收站也进整理记录页可追溯)
const recFiles: RecordFile[] = [];
for (const res of results) {
const { check, ctx } = res;
const abs = ctx.abs!;
const inner = check.isDir ? await filesUnder(abs) : null;
try {
await recheck(abs, ctx.rootReal!);
let where: string | null = null;
if (body.mode === "permanent") {
await rm(abs, { recursive: true, force: false });
deleted.push(check.path);
} else {
const row = await moveToTrash(d, principal, check, ctx, expiresAt);
trashed.push(toTrashItem(row));
where = row.trash_path;
}
recFiles.push(...recordFilesOf(abs, where, check.size, inner, "succeeded"));
done.push(res);
} catch (e) {
failed.push({
const f = {
path: check.path,
code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"),
message: errMessage(e),
});
};
failed.push(f);
recFiles.push({ src: abs, dst: null, size: check.size, status: "failed", error: `${f.code}: ${f.message}` });
}
}
// 资产退役 / 解除绑定(只对已实际删除/移入回收站的项)
await d.ctx.db.transaction().execute(async (trx) => {
// 资产退役 / 解除绑定(只对已实际删除/移入回收站的项)+ 整理记录
const recordId = await d.ctx.db.transaction().execute(async (trx) => {
const locIds = done.flatMap((r) => r.check.assets.map((a) => a.locationId));
if (locIds.length > 0) {
await trx.updateTable("asset_location").set({ status: "removed" }).where("id", "in", locIds).execute();
@@ -281,6 +290,28 @@ export async function deleteFiles(
.set({ status: "superseded", note: `files.delete by ${actorOfP(principal)}` })
.where("id", "in", bindIds)
.execute();
if (recFiles.length === 0) return null;
const libs = [...new Set(results.map((r) => r.ctx.libraryRootId))];
const entities = [...new Set(done.flatMap((r) => r.check.assets.map((a) => a.mediaEntityId)))];
return writeRecord(d, trx, {
principal,
action: body.mode === "permanent" ? "delete" : "trash",
mediaEntityId: entities.length === 1 ? entities[0]! : null,
sourcePath: results.length === 1 ? results[0]!.ctx.abs! : dirname(results[0]!.ctx.abs!),
targetLibraryRootId: null,
targetPath: null,
plan: {
op: "delete",
mode: body.mode,
planId,
libraryRootId: libs.length === 1 ? libs[0] : null,
items: checks.map((c) => ({ root: c.root, path: c.path, isDir: c.isDir, size: c.size })),
trashed: trashed.map((t) => ({ id: t.id, trashPath: t.trashPath })),
retiredLocations: done.flatMap((r) => r.check.assets.map((a) => a.locationId)),
unboundBindings: bindIds,
},
files: recFiles,
});
});
await audit(d, principal, body.mode === "permanent" ? "delete_permanent" : "delete", {
@@ -289,6 +320,7 @@ export async function deleteFiles(
trashed: trashed.map((t) => t.id),
deleted,
failed,
recordId,
});
for (const c of checks) filesChanged(d, c.root, [c.path.includes("/") ? dirname(c.path) : ""]);
return {
@@ -300,10 +332,28 @@ export async function deleteFiles(
trashed,
deleted,
failed,
recordId: null,
recordId,
};
}
/** 目录下的文件(相对路径);超过上限返回 null(整理记录只记目录本身一行) */
const RECORD_FILES_MAX = 2000;
async function filesUnder(abs: string): Promise<string[] | null> {
const { files } = await sizeOf(abs, RECORD_FILES_MAX + 1).catch(() => ({ files: [] as string[] }));
return files.length > RECORD_FILES_MAX ? null : files;
}
function recordFilesOf(
abs: string,
where: string | null,
size: number,
inner: string[] | null,
status: RecordFile["status"],
): RecordFile[] {
if (!inner || inner.length === 0) return [{ src: abs, dst: where, size, status }];
return inner.map((f) => ({ src: join(abs, f), dst: where ? join(where, f) : null, size: 0, status }));
}
async function copyTree(src: string, dst: string, files: string[], hash: "size" | "sha256" | "sample") {
const st = await lstat(src);
if (!st.isDirectory()) {
@@ -423,6 +473,8 @@ export async function restoreTrash(
): Promise<FilesTrashRestoreResult> {
const restored: FilesTrashRestoreResult["restored"] = [];
const failed: FilesTrashRestoreResult["failed"] = [];
const recFiles: RecordFile[] = [];
const libs = new Set<string | null>();
const target = body.target ? await resolveIn(d, body.target.root, body.target.path) : null;
if (target && (!target.lst?.isDirectory() || target.isSymlink))
throw new FilesError("bad_request", "还原目标必须是已存在的目录");
@@ -465,18 +517,34 @@ export async function restoreTrash(
}
await d.ctx.db.deleteFrom("trash_item").where("id", "=", id).execute();
const rel = dst === rootReal ? "" : dst.slice(rootReal.length + 1);
libs.add(target ? target.def.libraryRootId : row.library_root_id);
recFiles.push({ src: row.trash_path, dst: rel, size: Number(row.size_bytes), status: "succeeded" });
restored.push({ id, root: rootId as FilesTrashRestoreResult["restored"][number]["root"], path: rel });
filesChanged(d, rootId, [rel.includes("/") ? dirname(rel) : ""]);
} catch (e) {
failed.push({
id,
code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"),
message: errMessage(e),
});
const f = { id, code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"), message: errMessage(e) };
failed.push(f);
recFiles.push({ src: `trash:${id}`, dst: null, size: 0, status: "failed", error: `${f.code}: ${f.message}` });
}
}
await audit(d, principal, "trash_restore", { restored, failed });
return { restored, failed };
const lib = libs.size === 1 ? [...libs][0]! : null;
const recordId =
restored.length > 0
? await d.ctx.db.transaction().execute((trx) =>
writeRecord(d, trx, {
principal,
action: "restore",
mediaEntityId: null,
sourcePath: recFiles.find((f) => f.status === "succeeded")!.src,
targetLibraryRootId: lib,
targetPath: restored.length === 1 ? restored[0]!.path : null,
plan: { op: "trash_restore", ids: body.ids, target: body.target ?? null, restored },
files: recFiles,
}),
)
: null;
await audit(d, principal, "trash_restore", { restored, failed, recordId });
return { restored, failed, recordId };
}
/** 清除:先删行(复核仍存在)再删文件;ids 缺省 = 只清过期项 */