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:
rootandClaude Opus 5.5 committed 2026-09-30 23:57:19 +08:00
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));
}