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
@@ -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 缺省 = 只清过期项 */
|
||||
|
||||
Reference in new issue
Block a user