feat(organize): record filters, stats and delete with files (HIS-1)
List query gains kind/since/organizeOnly and wildcard/hash search; new GET /organization-records/stats and POST /organization-records/delete (optional source/library files to trash via files delete). Public record adds posterPath, sizeBytes and download summary. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f60254e265
commit
51e82f1cd9
8 files changed
+850
-17
No files matched your search
@@ -0,0 +1,278 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import { basename, isAbsolute, join } from "node:path";
|
||||
import type {
|
||||
FileRef,
|
||||
FilesDeleteResult,
|
||||
OrganizationDeleteBlock,
|
||||
OrganizationDeleteRequest,
|
||||
OrganizationDeleteResult,
|
||||
} from "@umt/contracts";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { FilesError, type FmDeps, isAdmin, loadRootDefs, type RootDef, within } from "./common.ts";
|
||||
import { audit } from "./ledger.ts";
|
||||
import { deleteFiles } from "./trash.ts";
|
||||
|
||||
// HIS-1 删除整理记录(对照 MP DELETE /history/transfer?deletesrc&deletedest):
|
||||
// 可选把源文件 / 媒体库里的文件一并移入回收站(走文件管理的删除:做种义务、已入库文件、绑定目录的检查都照旧),
|
||||
// 下载器里的任务不动。整条记录的文件有任何一个不能删,这条记录就不删(不留半截)。
|
||||
// 只有复制 / 移动 / 硬链接这三类整理记录有"源文件 / 媒体库文件";改名、删除、还原类记录只删记录本身。
|
||||
|
||||
const FILE_ACTIONS = new Set(["copy", "move", "hardlink"]);
|
||||
|
||||
const BLOCK_TEXT: Record<OrganizationDeleteBlock, string> = {
|
||||
running: "还在整理,等结束后再删",
|
||||
not_found: "记录不存在",
|
||||
download_active: "下载还没完成或做种时间不够",
|
||||
needs_unbind: "是作品的绑定目录",
|
||||
is_root: "在只读的媒体库目录里",
|
||||
root_unavailable: "目录不可访问或未挂载",
|
||||
escape: "路径指向登记目录之外",
|
||||
outside_roots: "文件不在登记目录里",
|
||||
failed: "删除失败",
|
||||
};
|
||||
|
||||
type Ref = FileRef & { abs: string };
|
||||
type Plan = {
|
||||
id: string;
|
||||
blocked: OrganizationDeleteBlock | null;
|
||||
message: string | null;
|
||||
sources: string[];
|
||||
targets: string[];
|
||||
downloadTaskId: string | null;
|
||||
};
|
||||
|
||||
const exists = (abs: string) =>
|
||||
lstat(abs).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
async function planRecords(
|
||||
d: FmDeps,
|
||||
body: OrganizationDeleteRequest,
|
||||
): Promise<{ plans: Plan[]; refs: Map<string, Ref> }> {
|
||||
const db = d.ctx.db;
|
||||
const ids = [...new Set(body.ids)];
|
||||
const recs = await db
|
||||
.selectFrom("organization_record")
|
||||
.select(["id", "status", "action", "source_path_view", "download_task_id"])
|
||||
.where("id", "in", ids)
|
||||
.execute();
|
||||
const files =
|
||||
body.deleteSource || body.deleteTarget
|
||||
? await db
|
||||
.selectFrom("organization_file_result as f")
|
||||
.leftJoin("library_root as lr", "lr.id", "f.target_library_root_id")
|
||||
.select([
|
||||
"f.organization_record_id",
|
||||
"f.status",
|
||||
"f.source_path",
|
||||
"f.target_path",
|
||||
"lr.local_path as root_path",
|
||||
])
|
||||
.where("f.organization_record_id", "in", ids)
|
||||
.orderBy("f.ordinal")
|
||||
.execute()
|
||||
: [];
|
||||
const defs: RootDef[] = (await loadRootDefs(db)).sort((a, b) => b.hostPath.length - a.hostPath.length);
|
||||
const refs = new Map<string, Ref>();
|
||||
const toRef = (abs: string): Ref | null => {
|
||||
const def = defs.find((x) => within(abs, x.hostPath));
|
||||
return def ? { root: def.id, path: abs === def.hostPath ? "" : abs.slice(def.hostPath.length + 1), abs } : null;
|
||||
};
|
||||
|
||||
const plans: Plan[] = [];
|
||||
for (const id of ids) {
|
||||
const r = recs.find((x) => x.id === id);
|
||||
const plan: Plan = {
|
||||
id,
|
||||
blocked: null,
|
||||
message: null,
|
||||
sources: [],
|
||||
targets: [],
|
||||
downloadTaskId: r?.download_task_id ?? null,
|
||||
};
|
||||
plans.push(plan);
|
||||
if (!r) {
|
||||
plan.blocked = "not_found";
|
||||
continue;
|
||||
}
|
||||
if (r.status === "queued" || r.status === "running") {
|
||||
plan.blocked = "running";
|
||||
continue;
|
||||
}
|
||||
if (!FILE_ACTIONS.has(r.action)) continue;
|
||||
const mine = files.filter((f) => f.organization_record_id === id);
|
||||
const want: { abs: string; side: "sources" | "targets" }[] = [];
|
||||
if (body.deleteSource && r.source_path_view === "local")
|
||||
for (const f of mine) want.push({ abs: f.source_path, side: "sources" });
|
||||
if (body.deleteTarget)
|
||||
for (const f of mine) {
|
||||
if (f.status !== "succeeded" || !f.target_path) continue;
|
||||
const abs = isAbsolute(f.target_path) ? f.target_path : f.root_path ? join(f.root_path, f.target_path) : null;
|
||||
if (abs) want.push({ abs, side: "targets" });
|
||||
}
|
||||
for (const w of want) {
|
||||
// 已经不在的(移动过、之前删过)不算,也不拦
|
||||
if (plan[w.side].includes(w.abs) || !(await exists(w.abs))) continue;
|
||||
const ref = toRef(w.abs);
|
||||
if (!ref) {
|
||||
plan.blocked ??= "outside_roots";
|
||||
plan.message ??= `${basename(w.abs)}:${BLOCK_TEXT.outside_roots}`;
|
||||
continue;
|
||||
}
|
||||
refs.set(w.abs, ref);
|
||||
plan[w.side].push(w.abs);
|
||||
}
|
||||
}
|
||||
return { plans, refs };
|
||||
}
|
||||
|
||||
/** 文件管理删除的核对(dryRun),按绝对路径回填每条记录的阻断原因 */
|
||||
async function checkFiles(
|
||||
d: FmDeps,
|
||||
principal: Principal | undefined,
|
||||
plans: Plan[],
|
||||
refs: Map<string, Ref>,
|
||||
): Promise<Map<string, FilesDeleteResult["checks"][number]>> {
|
||||
const out = new Map<string, FilesDeleteResult["checks"][number]>();
|
||||
const paths = [...new Set(plans.filter((p) => !p.blocked).flatMap((p) => [...p.sources, ...p.targets]))];
|
||||
if (paths.length === 0) return out;
|
||||
const dry = await deleteFiles(d, principal, {
|
||||
idempotencyKey: randomUUID(),
|
||||
items: paths.map((abs) => ({ root: refs.get(abs)!.root, path: refs.get(abs)!.path })),
|
||||
mode: "trash",
|
||||
dryRun: true,
|
||||
retireAssets: true,
|
||||
unbind: false,
|
||||
});
|
||||
paths.forEach((abs, i) => {
|
||||
const c = dry.checks[i];
|
||||
if (c) out.set(abs, c);
|
||||
});
|
||||
for (const p of plans) {
|
||||
if (p.blocked) continue;
|
||||
for (const abs of [...p.sources, ...p.targets]) {
|
||||
const b = out.get(abs)?.blocked;
|
||||
if (!b || b === "not_found" || b === "needs_retire") continue;
|
||||
p.blocked = b === "escape" ? "escape" : b;
|
||||
p.message = `${basename(abs)}:${BLOCK_TEXT[p.blocked]}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function deleteRecords(
|
||||
d: FmDeps,
|
||||
principal: Principal | undefined,
|
||||
body: OrganizationDeleteRequest,
|
||||
): Promise<OrganizationDeleteResult> {
|
||||
const withFiles = body.deleteSource || body.deleteTarget;
|
||||
if (withFiles && !isAdmin(principal))
|
||||
throw new FilesError(
|
||||
"forbidden",
|
||||
"API Key 不能在这里删文件。先用 /files/delete 删文件(需要一次性审批),再删记录",
|
||||
);
|
||||
const db = d.ctx.db;
|
||||
const { plans, refs } = await planRecords(d, body);
|
||||
const checks = await checkFiles(d, principal, plans, refs);
|
||||
const sizeOf = (abs: string) => checks.get(abs)?.size ?? 0;
|
||||
|
||||
// 删记录后没有别的整理记录、下载已完成但没入库的任务,会被自动整理再处理一次
|
||||
const okIds = plans.filter((p) => !p.blocked).map((p) => p.id);
|
||||
const taskIds = [...new Set(plans.filter((p) => !p.blocked && p.downloadTaskId).map((p) => p.downloadTaskId!))];
|
||||
const retry = taskIds.length
|
||||
? await db
|
||||
.selectFrom("download_task as t")
|
||||
.select("t.id")
|
||||
.where("t.id", "in", taskIds)
|
||||
.where("t.status", "=", "completed")
|
||||
.where("t.managed", "=", true)
|
||||
.where("t.removed_at", "is", null)
|
||||
.where((eb) =>
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("organization_record as r")
|
||||
.select("r.id")
|
||||
.whereRef("r.download_task_id", "=", "t.id")
|
||||
.where("r.id", "not in", okIds),
|
||||
),
|
||||
),
|
||||
)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
const summary = (removed: string[], trashRecordId: string | null): OrganizationDeleteResult => {
|
||||
const live = plans.filter((p) => (body.dryRun ? !p.blocked : removed.includes(p.id)));
|
||||
const src = [...new Set(live.flatMap((p) => p.sources))];
|
||||
const dst = [...new Set(live.flatMap((p) => p.targets))];
|
||||
return {
|
||||
dryRun: body.dryRun,
|
||||
items: plans.map((p) => ({
|
||||
id: p.id,
|
||||
sourceFiles: p.sources.length,
|
||||
targetFiles: p.targets.length,
|
||||
bytes: [...p.sources, ...p.targets].reduce((a, x) => a + sizeOf(x), 0),
|
||||
blocked: p.blocked,
|
||||
message: p.message ?? (p.blocked ? BLOCK_TEXT[p.blocked] : null),
|
||||
})),
|
||||
removed,
|
||||
sourceFiles: src.length,
|
||||
targetFiles: dst.length,
|
||||
bytes: [...src, ...dst].reduce((a, x) => a + sizeOf(x), 0),
|
||||
assets: dst.reduce((a, x) => a + (checks.get(x)?.assets.length ?? 0), 0),
|
||||
autoRetry: retry.length,
|
||||
trashRecordId,
|
||||
};
|
||||
};
|
||||
if (body.dryRun) return summary([], null);
|
||||
|
||||
// 执行:先把文件移入回收站,失败的文件所属记录保留
|
||||
let trashRecordId: string | null = null;
|
||||
const failedPaths = new Set<string>();
|
||||
const paths = [...new Set(plans.filter((p) => !p.blocked).flatMap((p) => [...p.sources, ...p.targets]))].filter(
|
||||
(abs) => checks.get(abs)?.blocked !== "not_found",
|
||||
);
|
||||
if (paths.length > 0) {
|
||||
const items = paths.map((abs) => ({ root: refs.get(abs)!.root, path: refs.get(abs)!.path }));
|
||||
const base = { mode: "trash" as const, retireAssets: true, unbind: false };
|
||||
const dry = await deleteFiles(d, principal, { idempotencyKey: randomUUID(), items, dryRun: true, ...base });
|
||||
const res = await deleteFiles(d, principal, {
|
||||
idempotencyKey: randomUUID(),
|
||||
items,
|
||||
dryRun: false,
|
||||
planId: dry.planId ?? undefined,
|
||||
...base,
|
||||
});
|
||||
trashRecordId = res.recordId;
|
||||
for (const f of res.failed) {
|
||||
const hit = paths.find((abs, i) => items[i]!.path === f.path || abs.endsWith(`/${f.path}`));
|
||||
if (hit) failedPaths.add(hit);
|
||||
}
|
||||
}
|
||||
for (const p of plans) {
|
||||
if (p.blocked) continue;
|
||||
const bad = [...p.sources, ...p.targets].find((abs) => failedPaths.has(abs));
|
||||
if (bad) {
|
||||
p.blocked = "failed";
|
||||
p.message = `${basename(bad)}:${BLOCK_TEXT.failed}`;
|
||||
}
|
||||
}
|
||||
const removable = plans.filter((p) => !p.blocked).map((p) => p.id);
|
||||
const removed = removable.length
|
||||
? (await db.deleteFrom("organization_record").where("id", "in", removable).returning("id").execute()).map(
|
||||
(r) => r.id,
|
||||
)
|
||||
: [];
|
||||
await audit(d, principal, "organize_records_delete", {
|
||||
removed,
|
||||
deleteSource: body.deleteSource,
|
||||
deleteTarget: body.deleteTarget,
|
||||
trashRecordId,
|
||||
kept: plans.filter((p) => p.blocked).map((p) => ({ id: p.id, blocked: p.blocked })),
|
||||
});
|
||||
return summary(removed, trashRecordId);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { mkdirIn, renameEntry } from "./mutate.ts";
|
||||
import { organizeFiles } from "./organize.ts";
|
||||
import { previewFile } from "./preview.ts";
|
||||
import { probeFiles, scrape, scrapeBatch } from "./probe.ts";
|
||||
import { deleteRecords } from "./record-delete.ts";
|
||||
import { findRecords } from "./records.ts";
|
||||
import { suggestName } from "./suggest-name.ts";
|
||||
import { transfer } from "./transfer.ts";
|
||||
@@ -123,4 +124,6 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
|
||||
mount(app, F.tree, async (_c, { query }) => treeOf(d, query));
|
||||
mount(app, F.recordSources, async (_c, { query }) => recordSources(d, query.recordId));
|
||||
mount(app, F.suggestName, async (_c, { body }) => suggestName(d, body));
|
||||
// HIS-1:删除整理记录(可选源 / 媒体库文件进回收站);路径在 /organization-records 下,实现复用文件管理的删除
|
||||
mount(app, Api.organization.remove, async (c, { body }) => deleteRecords(d, c.get("principal"), body));
|
||||
}
|
||||
Reference in new issue
Block a user