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

@@ -0,0 +1,418 @@
import { randomUUID } from "node:crypto";
import { readdir, rmdir } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { checkRoot, DEFAULT_ROOT_SENTINEL, statSafe } from "@umt/adapters";
import { fileKindOf, type OrganizeFileKind, type OrganizePlan, type PlannedFile } from "@umt/domain";
import { organizationStatusFromFiles, organizePlanToFileResults } from "@umt/mapping";
import { hashJson, jsonb } from "../util.ts";
import type { AoDeps, Exec } from "./common.ts";
import { type ExecuteResult, emitRecord, type FrozenPlan, type ImportRequest, type PlanResult } from "./organize.ts";
// W5-A2 原样搬运(文件管理 移动/复制到目录):与整理共用 planImport 入口、冻结计划、organization_record、
// verifyAndImport 任务与 executeRecord(逐文件账本可恢复、fsproxy 挂死隔离、跨卷 Copy→核验→删源、EXDEV 不降级)。
// 与作品整理的差别:保持文件名与目录结构;不识别作品、不登记新资产;move 成功后已登记的 asset_location 跟随、
// 整个目录搬完时命中的 path_binding 写修订,并删掉源侧留下的空目录。
export interface TransferItem {
/** 源(绝对路径,文件或目录) */
src: string;
/** 目标(绝对路径,不存在) */
dst: string;
/** 源所在库根与根内相对路径(源为下载根时 null,不做位置跟随) */
srcLibraryRootId: string | null;
srcRel: string | null;
/** 目标在目标库根内的相对路径 */
dstRel: string;
}
export interface TransferSpec {
items: TransferItem[];
targetLibraryRootId: string;
/** 目标库根 realpath(相对路径以它为基准) */
targetRootPath: string;
/** 遍历到的每个文件都经它复核(realpath 在允许的根下) */
guard?: ((p: string) => Promise<string>) | undefined;
}
export interface FrozenTransfer {
items: (TransferItem & { isDir: boolean; files: number })[];
targetLibraryRootId: string;
}
const KIND_OF = (p: string): OrganizeFileKind => {
const k = fileKindOf(p);
return k === "video" || k === "subtitle" || k === "audio" || k === "nfo" ? k : "other";
};
/** 递归列出目录下的普通文件与子目录(不跟随符号链接;符号链接列为 skipped) */
async function walkTree(
dir: string,
out: { files: string[]; dirs: string[]; links: string[] },
limit: number,
): Promise<boolean> {
const names = (await readdir(dir)).sort();
for (const n of names) {
if (out.files.length >= limit) return true;
const p = join(dir, n);
const st = await statSafe(p);
if (st.kind !== "ok") throw new Error(`源不可访问: ${p} (${st.kind})`);
if (st.facts.isSymbolicLink) out.links.push(p);
else if (st.facts.isDirectory) {
out.dirs.push(p);
if (await walkTree(p, out, limit)) return true;
} else if (st.facts.isFile) out.files.push(p);
}
return false;
}
/** 原样搬运计划;dryRun 只返回冻结计划,否则写 organization_record(status=running)等 verifyAndImport 执行 */
export async function planTransfer(d: AoDeps, req: ImportRequest, t: TransferSpec): Promise<PlanResult> {
const db = d.ctx.db;
const dry = req.dryRun === true;
const action = req.action ?? "copy";
const failed = (reason: string): PlanResult => ({ recordId: null, status: "failed", reason });
const root = await db
.selectFrom("library_root")
.selectAll()
.where("id", "=", t.targetLibraryRootId)
.executeTakeFirst();
if (root?.management_mode !== "managed") return failed("target_root_not_managed");
const sentinel = root.sentinel_file ?? DEFAULT_ROOT_SENTINEL;
const health = await checkRoot(t.targetRootPath, sentinel);
if (health.kind === "offline") return failed(`library_root_offline:${health.reason}`);
const max = d.ports.options.walkMaxFiles;
const files: PlannedFile[] = [];
const excluded: OrganizePlan["excluded"] = [];
const dirs = new Set<string>();
const items: FrozenTransfer["items"] = [];
let truncated = false;
for (const it of t.items) {
const st = await statSafe(it.src);
if (st.kind !== "ok") return failed(`source_missing:${it.src}`);
const add = async (src: string, dst: string) => {
if (t.guard) await t.guard(src);
const s = await statSafe(src);
if (s.kind !== "ok") throw new Error(`源不可访问: ${src} (${s.kind})`);
files.push({
src,
dst,
size: s.facts.size,
kind: KIND_OF(src),
role: "main",
action: "transfer",
crossDevice: s.facts.dev !== health.dev,
});
};
if (!st.facts.isDirectory) {
await add(it.src, it.dst);
items.push({ ...it, isDir: false, files: 1 });
continue;
}
const tree = { files: [] as string[], dirs: [] as string[], links: [] as string[] };
if (await walkTree(it.src, tree, max - files.length + 1)) truncated = true;
const map = (p: string) => join(it.dst, relative(it.src, p));
dirs.add(it.dst);
for (const sub of tree.dirs) dirs.add(map(sub));
for (const f of tree.files) await add(f, map(f));
for (const l of tree.links)
excluded.push({ src: l, kind: KIND_OF(l), reason: "not_imported_kind", detail: "symlink" });
items.push({ ...it, isDir: true, files: tree.files.length });
}
if (files.length > max) {
files.length = max;
truncated = true;
}
if (truncated && !dry) return failed("too_many_files");
// 目标已存在(或状态读不出):不覆盖,该文件执行前即记失败
const conflicts: OrganizePlan["conflicts"] = [];
for (const f of files) {
const st = await statSafe(f.dst);
if (st.kind === "missing") continue;
f.action = "hold";
conflicts.push({ kind: "target_exists", blocking: false, src: f.src, dst: f.dst });
}
for (const f of files) dirs.add(dirname(f.dst));
const plan: OrganizePlan = {
operation: action,
crossDevice: files.some((f) => f.crossDevice === true),
files,
excluded,
preconditions: [],
conflicts,
requiresApproval: false,
};
const frozen: FrozenPlan = {
version: 1,
organize: plan,
entityId: req.mediaEntityId ?? "",
mediaType: "movie",
libraryRootId: root.id,
rootPath: t.targetRootPath,
sentinel,
targetDirs: [...dirs].sort(),
newBindings: [],
sourceRoot: null,
facts: {},
transfer: {
items,
targetLibraryRootId: t.targetLibraryRootId,
},
};
if (dry) return { recordId: null, status: "queued", reason: null, plan: frozen, ...(truncated ? { truncated } : {}) };
const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id });
const now = d.ports.now();
const rel = (abs: string | null | undefined) => (abs ? relative(t.targetRootPath, abs) : null);
const heldSrc = new Set(files.filter((f) => f.action === "hold").map((f) => f.src));
return db.transaction().execute(async (trx) => {
const rec = await trx
.insertInto("organization_record")
.values({
task_id: req.taskId ?? null,
trigger: req.trigger,
actor_type: req.actorType,
actor_id: req.actorId ?? null,
media_entity_id: req.mediaEntityId ?? null,
source_path_view: "local",
source_path: items.length === 1 ? items[0]!.src : dirname(items[0]!.src),
target_library_root_id: root.id,
target_path: items.length === 1 ? items[0]!.dstRel : rel(dirname(items[0]!.dst)),
action,
status: "running",
plan: jsonb(frozen),
plan_fingerprint: hashJson(frozen),
file_total: drafts.length,
file_skipped: drafts.filter((f) => f.status === "skipped" && !heldSrc.has(f.sourcePath)).length,
file_failed: heldSrc.size,
started_at: now,
})
.returning(["id"])
.executeTakeFirstOrThrow();
for (const f of drafts) {
const held = heldSrc.has(f.sourcePath);
await trx
.insertInto("organization_file_result")
.values({
organization_record_id: rec.id,
ordinal: f.ordinal ?? 0,
role: f.role ?? "main",
mode: f.mode,
source_path: f.sourcePath,
source_size_bytes: f.sourceSizeBytes ?? null,
target_library_root_id: f.targetPath ? root.id : null,
target_path: rel(f.targetPath),
status: held ? "failed" : f.status,
skip_reason: held ? null : f.skipReason,
...(held ? { error: jsonb({ code: "EEXIST", message: "目标已存在,不覆盖" }), finished_at: now } : {}),
})
.execute();
}
await emitRecord(d, trx, rec.id);
return { recordId: rec.id, status: "running" as const, reason: null };
});
}
// ── 位置跟随 ──
const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`;
export interface RelocateInput {
fromLib: string;
fromRel: string;
toLib: string;
toRel: string;
isDir: boolean;
actor: string;
opKind: "rename" | "move";
}
export interface RelocateResult {
locations: { id: string; assetId: string; from: string; to: string }[];
bindings: { oldBindingId: string; newBindingId: string }[];
mediaEntityIds: string[];
}
/** 同事务:把 fromLib/fromRel(目录含其下全部)的已登记位置改到 toLib/toRel;目录命中的路径绑定写修订 */
export async function relocate(trx: Exec, input: RelocateInput): Promise<RelocateResult> {
const { fromLib, fromRel, toLib, toRel, isDir } = input;
const mapRel = (p: string) => (p === fromRel ? toRel : `${toRel}${p.slice(fromRel.length)}`);
const locs = await trx
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select(["l.id", "l.relative_path", "l.media_asset_id", "a.media_entity_id"])
.where("l.library_root_id", "=", fromLib)
.where("l.status", "<>", "removed")
.where((eb) =>
isDir
? eb.or([eb("l.relative_path", "=", fromRel), eb("l.relative_path", "like", likePrefix(fromRel))])
: eb("l.relative_path", "=", fromRel),
)
.execute();
const out: RelocateResult = { locations: [], bindings: [], mediaEntityIds: [] };
const entities = new Set<string>();
for (const l of locs) {
const to = mapRel(l.relative_path);
await trx
.updateTable("asset_location")
.set({ library_root_id: toLib, relative_path: to, last_seen_at: new Date() })
.where("id", "=", l.id)
.execute();
await trx
.insertInto("asset_operation")
.values({
operation_id: `files:${randomUUID()}`,
phase: "commit",
kind: input.opKind,
status: "succeeded",
intent: jsonb({
fromLibraryRootId: fromLib,
from: l.relative_path,
toLibraryRootId: toLib,
to,
by: input.actor,
}),
result: jsonb({ locationId: l.id }),
media_asset_id: l.media_asset_id,
started_at: new Date(),
finished_at: new Date(),
})
.execute();
out.locations.push({ id: l.id, assetId: l.media_asset_id, from: l.relative_path, to });
if (l.media_entity_id) entities.add(l.media_entity_id);
}
if (isDir) {
const binds = await trx
.selectFrom("path_binding")
.selectAll()
.where("library_root_id", "=", fromLib)
.where("status", "=", "active")
.where((eb) => eb.or([eb("relative_path", "=", fromRel), eb("relative_path", "like", likePrefix(fromRel))]))
.execute();
for (const b of binds) {
// 显式路径变更(REQ §7.1.1):旧绑定 superseded,新绑定 active 并互相链接;仅译名刷新永不走到这里
await trx.updateTable("path_binding").set({ status: "superseded" }).where("id", "=", b.id).execute();
const nb = await trx
.insertInto("path_binding")
.values({
media_entity_id: b.media_entity_id,
content_unit_id: b.content_unit_id,
season_number: b.season_number,
binding_kind: b.binding_kind,
library_root_id: toLib,
relative_path: mapRel(b.relative_path),
template_version: b.template_version,
status: "active",
bound_by: input.actor,
note: `files.${input.opKind}: ${b.relative_path} → ${mapRel(b.relative_path)}`,
})
.returning("id")
.executeTakeFirstOrThrow();
await trx.updateTable("path_binding").set({ superseded_by_id: nb.id }).where("id", "=", b.id).execute();
out.bindings.push({ oldBindingId: b.id, newBindingId: nb.id });
entities.add(b.media_entity_id);
}
}
out.mediaEntityIds = [...entities];
return out;
}
/** 目录下的子目录,深的在前(删空目录用) */
async function dirsDeepFirst(dir: string): Promise<string[]> {
const out: string[] = [];
const walk = async (p: string) => {
const ents = await readdir(p, { withFileTypes: true }).catch(() => []);
for (const e of ents) if (e.isDirectory()) await walk(join(p, e.name));
out.push(p);
};
await walk(dir);
return out;
}
/** 原样搬运的终态:记录状态 + move 的位置跟随/绑定修订 + 删源侧空目录;行锁 fencing,重复调用幂等 */
export async function finalizeTransfer(d: AoDeps, recordId: string): Promise<ExecuteResult> {
const db = d.ctx.db;
const pre = await db
.selectFrom("organization_record")
.select(["action", "plan", "status"])
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
const frozenPre = pre.plan as unknown as FrozenPlan;
const tr = frozenPre.transfer!;
const filesPre = await db
.selectFrom("organization_file_result")
.select(["source_path", "status"])
.where("organization_record_id", "=", recordId)
.execute();
const under = (item: { src: string }, p: string) => p === item.src || p.startsWith(`${item.src}/`);
const allOk = (item: { src: string }) =>
filesPre.filter((f) => under(item, f.source_path)).every((f) => f.status === "succeeded");
// move:整个目录都搬完才删源侧空目录(先于终态提交;失败/中断重跑幂等)
if (pre.action === "move" && (pre.status === "running" || pre.status === "queued"))
for (const it of tr.items.filter((x) => x.isDir && allOk(x)))
for (const dir of await dirsDeepFirst(it.src)) await rmdir(dir).catch(() => undefined);
return db.transaction().execute(async (trx) => {
const rec = await trx
.selectFrom("organization_record")
.selectAll()
.where("id", "=", recordId)
.forUpdate()
.executeTakeFirstOrThrow();
if (rec.status !== "running" && rec.status !== "queued")
return { recordId, status: rec.status, succeeded: rec.file_succeeded, failed: rec.file_failed };
const frozen = rec.plan as unknown as FrozenPlan;
const t = frozen.transfer!;
const files = await trx
.selectFrom("organization_file_result")
.selectAll()
.where("organization_record_id", "=", recordId)
.execute();
const entities = new Set<string>();
if (rec.action === "move") {
const actor = `organize:${recordId}`;
for (const it of t.items) {
if (it.srcRel === null || it.srcLibraryRootId === null) continue;
const mine = files.filter((f) => under(it, f.source_path));
const base = { fromLib: it.srcLibraryRootId, toLib: t.targetLibraryRootId, actor, opKind: "move" as const };
if (mine.length > 0 && mine.every((f) => f.status === "succeeded")) {
// 整项搬完:按前缀跟随(目录含路径绑定修订)
const r = await relocate(trx, { ...base, fromRel: it.srcRel, toRel: it.dstRel, isDir: it.isDir });
for (const e of r.mediaEntityIds) entities.add(e);
continue;
}
for (const f of mine.filter((x) => x.status === "succeeded")) {
const sub = f.source_path.slice(it.src.length);
const r = await relocate(trx, {
...base,
fromRel: `${it.srcRel}${sub}`,
toRel: `${it.dstRel}${sub}`,
isDir: false,
});
for (const e of r.mediaEntityIds) entities.add(e);
}
}
}
const statuses = files.map((f) => f.status);
const status = organizationStatusFromFiles(statuses);
const counts = {
file_succeeded: statuses.filter((s) => s === "succeeded").length,
file_failed: statuses.filter((s) => s === "failed").length,
file_skipped: statuses.filter((s) => s === "skipped").length,
};
await trx
.updateTable("organization_record")
.set({
status,
...counts,
finished_at: d.ports.now(),
...(status === "failed" && !rec.reason ? { reason: "transfer_failed" } : {}),
...(!rec.media_entity_id && entities.size === 1 ? { media_entity_id: [...entities][0]! } : {}),
})
.where("id", "=", recordId)
.execute();
await emitRecord(d, trx, recordId);
return { recordId, status, succeeded: counts.file_succeeded, failed: counts.file_failed };
});
}
@@ -27,6 +27,7 @@ import {
movieCompletion,
type NewBinding,
type OrganizePlan,
overrideFor,
type ParsedRelease,
parseReleasePath,
planOrganize,
@@ -42,6 +43,7 @@ import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
import { applySlotEvent } from "./downloads.ts";
import { PathGuardError } from "./organize-guard.ts";
import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts";
import { hnrSatisfied } from "./retention.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
@@ -67,6 +69,24 @@ export interface ImportRequest {
onExisting?: ExistingPolicy | undefined;
/** 已获替换授权(审批/手动确认) */
grantReplace?: boolean | undefined;
/**
* W5-A2 逐文件覆写(文件管理整理向导的人工确认,优先于文件名解析):键为源文件或目录的绝对路径
* (目录对其下文件生效,最长前缀优先)。season/episodes 决定剧集季目录与命名以及集单元覆盖;
* contentUnitIds 为核验后额外登记的内容单元覆盖。
*/
fileOverrides?: Record<string, FileOverrideInput> | undefined;
/** W5-A2 目标目录覆写(绝对路径,须在目标库根下):按模板命名放入该目录,不使用也不新建路径绑定 */
targetDir?: string | undefined;
/** W5-A2 手动整理核验通过后推进的 Slot(须属于该作品) */
slotId?: string | undefined;
/** W5-A2 原样搬运(文件管理 移动/复制):保持文件名与目录结构,不识别作品、不登记新资产,已登记位置跟随 */
transfer?: TransferSpec | undefined;
}
export interface FileOverrideInput {
season?: number;
episodes?: { start: number; end: number };
contentUnitIds?: string[];
}
/** organization_record.plan 的冻结内容 */
@@ -84,9 +104,20 @@ export interface FrozenPlan {
/** 主视频 src → 解析出的质量特征与单元键 */
facts: Record<
string,
{ quality: QualityFacts; unitKeys: string[]; season?: number; episodes?: { start: number; end: number } }
{
quality: QualityFacts;
unitKeys: string[];
season?: number;
episodes?: { start: number; end: number };
/** 人工指定的内容单元(W5-A2 覆写) */
unitIds?: string[];
}
>;
downgradedFromMove?: string;
/** W5-A2:手动整理指定的 Slot */
slotId?: string;
/** W5-A2:原样搬运计划(finalize 走 finalizeTransfer) */
transfer?: FrozenTransfer;
}
export interface PlanResult {
@@ -171,6 +202,7 @@ async function chooseRoot(
/** 生成并冻结整理计划;阻塞冲突 → 记录失败 + 待处理,不执行 */
export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanResult> {
if (req.transfer) return planTransfer(d, req, req.transfer);
const db = d.ctx.db;
let dt: DownloadTaskRow | undefined;
if (req.downloadTaskId) {
@@ -334,6 +366,21 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const bindings = bindingRows.map((r) => toDomainPathBinding(toApi<PathBinding>(r)));
const root = await chooseRoot(db, entity, bindings, req.targetLibraryRootId);
if (!root) return fail("no_library_root", {}, entity.id);
const targetDir = req.targetDir?.replace(/\/+$/, "");
if (targetDir !== undefined) {
const rp = root.local_path.replace(/\/+$/, "");
if (!(targetDir === rp || targetDir.startsWith(`${rp}/`)) || targetDir.split("/").includes(".."))
return fail("target_dir_outside_root", { targetDir, libraryRootId: root.id }, entity.id);
}
if (req.slotId) {
const slot = await db
.selectFrom("acquisition_slot")
.select(["id", "media_entity_id"])
.where("id", "=", req.slotId)
.executeTakeFirst();
if (!slot || slot.media_entity_id !== entity.id)
return fail("slot_entity_mismatch", { slotId: req.slotId }, entity.id);
}
const sentinel = root.sentinel_file ?? DEFAULT_ROOT_SENTINEL;
const health = await checkRoot(root.local_path, sentinel);
if (health.kind === "offline") {
@@ -361,6 +408,9 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
for (const f of files) {
const parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot));
if (parsed.fileKind !== "video") continue;
const ov = overrideFor(req.fileOverrides, f.path);
if (mediaType === "tv" && ov?.season !== undefined) parsed.season = ov.season;
if (mediaType === "tv" && ov?.episodes) parsed.episodes = ov.episodes;
const season = parsed.season ?? (parsed.episodes || parsed.airDate ? 1 : undefined);
if (mediaType === "tv" && season !== undefined) seasons.add(season);
facts[f.path] = {
@@ -368,6 +418,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
unitKeys: unitKeysOf({ ...parsed, ...(season !== undefined ? { season } : {}) }),
...(season !== undefined ? { season } : {}),
...(parsed.episodes ? { episodes: parsed.episodes } : {}),
...(ov?.contentUnitIds?.length ? { unitIds: ov.contentUnitIds } : {}),
};
}
const libRoot = { id: root.id, hostPath: root.local_path };
@@ -424,7 +475,11 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
return fail("binding_on_other_root", { libraryRootId: dec.libraryRootId }, entity.id);
return join(root.local_path, dec.relativePath);
};
if (mediaType === "movie") {
if (targetDir !== undefined) {
// 目标目录覆写:显式的一次性位置,不改路径绑定
if (mediaType === "movie") target.movieDir = targetDir;
else target.seasonDirs = Object.fromEntries([...seasons].map((s) => [s, targetDir]));
} else if (mediaType === "movie") {
const r = await handle(decide());
if (typeof r !== "string") return r;
target.movieDir = r;
@@ -451,6 +506,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
target: { ...target, ...(health.kind === "online" ? { device: health.dev } : {}) },
retention: { hnrSatisfied: hnr },
minVideoBytes: d.ports.options.minVideoBytes,
...(req.fileOverrides ? { overrides: req.fileOverrides } : {}),
...(d.ctx.config?.ORGANIZE_DEFAULT_SUBTITLE_LANG
? { defaultSubtitleLang: d.ctx.config.ORGANIZE_DEFAULT_SUBTITLE_LANG }
: {}),
@@ -490,6 +546,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
sourceRoot,
facts,
...(downgradedFromMove ? { downgradedFromMove } : {}),
...(req.slotId ? { slotId: req.slotId } : {}),
};
const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id });
const blocking = plan.conflicts.filter((c) => c.blocking);
@@ -1137,6 +1194,8 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
const frozenPre = pre.plan as unknown as FrozenPlan;
// 原样搬运(文件管理 移动/复制):不登记新资产,已登记位置/绑定跟随
if (frozenPre.transfer) return finalizeTransfer(d, recordId);
// ffprobe 在事务外做(可能较慢);未知不阻塞
const probes = new Map<string, unknown>();
const filesPre = await db
@@ -1327,6 +1386,19 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
.execute();
}
}
// 人工指定的内容单元(文件管理整理向导覆写)
for (const unitId of isMain ? (fx?.unitIds ?? []) : []) {
await trx
.insertInto("asset_unit_coverage")
.values({
media_asset_id: assetId,
content_unit_id: unitId,
coverage: "full",
evidence: jsonb({ from: "manual_override", recordId }),
})
.onConflict((oc) => oc.doNothing())
.execute();
}
}
assetBySrc.set(f.source_path, { assetId, locationId });
if (isMain) mainAssets.push(assetId);
@@ -1424,6 +1496,28 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
}
}
}
// 手动整理指定的 Slot(文件管理整理向导):核验通过后以新资产满足该 Slot(获取中的 Slot 不动,由其计划推进)
if (!dt && frozen.slotId && okAny) {
const slot = await trx
.selectFrom("acquisition_slot")
.selectAll()
.where("id", "=", frozen.slotId)
.forUpdate()
.executeTakeFirst();
if (slot && !["reserved", "downloading", "verifying"].includes(slot.status)) {
const complete = await slotComplete(trx, slot, frozen, verifiedAssets);
await trx
.updateTable("acquisition_slot")
.set({
status: complete ? "satisfied" : "empty",
current_asset_id: verifiedAssets[0]!,
revision: slot.revision + 1,
status_reason: complete ? "manual_organize" : "manual_organize_partial",
})
.where("id", "=", slot.id)
.execute();
}
}
await emitRecord(d, trx, recordId);
return { recordId, status, succeeded: counts.file_succeeded, failed: counts.file_failed, move, dt: dt ?? null };
});
+26 -1
View File
@@ -32,6 +32,10 @@ export interface FmOptions {
backgroundLoops: boolean;
/** 数据目录(跨卷回收站 DATA_DIR/trash、probe 缓存 DATA_DIR/cache/probe) */
dataDir: string;
/** 移动/复制执行后在请求内等待 verifyAndImport 完成的时长(超时返回 pending,后台继续) */
transferWaitMs: number;
/** GET /files/download 单文件上限 */
downloadMaxBytes: number;
}
export interface FmDeps {
@@ -59,12 +63,17 @@ export function defaultOptions(ctx: ServerContext): FmOptions {
purgeIntervalMs: 24 * 3600_000,
backgroundLoops: true,
dataDir: ctx.config?.DATA_DIR ?? "./data",
transferWaitMs: envNum("FILES_TRANSFER_WAIT_SEC", 20) * 1000,
downloadMaxBytes: envNum("FILES_DOWNLOAD_MAX_BYTES", 20 * 1024 ** 2),
};
}
// ── 错误 ──
const REASON_STATUS: Record<FilesErrorReason | "bad_request" | "forbidden", { status: number; code: ApiErrorCode }> = {
const REASON_STATUS: Record<
FilesErrorReason | "bad_request" | "forbidden" | "too_large",
{ status: number; code: ApiErrorCode }
> = {
root_unavailable: { status: 503, code: "upstream_unavailable" },
path_escape: { status: 400, code: "bad_request" },
bad_request: { status: 400, code: "bad_request" },
@@ -78,6 +87,7 @@ const REASON_STATUS: Record<FilesErrorReason | "bad_request" | "forbidden", { st
needs_unbind: { status: 409, code: "conflict" },
permanent_disabled: { status: 403, code: "forbidden" },
approval_required: { status: 403, code: "forbidden" },
too_large: { status: 413, code: "bad_request" },
};
/** 业务错误:路由统一转成信封,message 以 `<reason>: ` 开头便于前端分支 */
@@ -101,6 +111,21 @@ const OFFLINE_CODES = new Set(["EIO", "ENOTCONN", "ESTALE", "ETIMEDOUT", "EHOSTD
export const errnoOf = (e: unknown): string | undefined => (e as { code?: string } | null)?.code;
export const isOfflineErr = (e: unknown) => OFFLINE_CODES.has(errnoOf(e) ?? "");
/**
* 处理器里漏出的文件系统错误(检查与使用之间目录被删、挂载掉线等竞态)→ 业务错误,而不是 500:
* 离线类 → 503 root_unavailable(失败 ≠ 空目录),ENOENT/ENOTDIR → 404,EACCES/EPERM → 403,其余 errno → 400。
*/
export function fsErrorToFiles(e: unknown): FilesError | null {
if (e instanceof FilesError) return e;
const code = errnoOf(e);
if (typeof code !== "string" || !/^E[A-Z]+$/.test(code)) return null;
const msg = e instanceof Error ? e.message : String(e);
if (OFFLINE_CODES.has(code)) return new FilesError("root_unavailable", `读取失败(${code}),根可能已离线: ${msg}`);
if (code === "ENOENT" || code === "ENOTDIR") return new FilesError("not_found", `路径已不存在(${code})`);
if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `无权限访问(${code})`);
return new FilesError("bad_request", `文件系统错误(${code}): ${msg}`);
}
// ── 根 ──
export interface RootDef {
@@ -0,0 +1,82 @@
import { basename, extname } from "node:path";
import type { FilesDownloadQuery } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { FilesError, type FmDeps, isAdmin, recheck, resolveIn } from "./common.ts";
import { audit } from "./ledger.ts";
import { kindOfName } from "./list.ts";
// GET /files/download(W5-A2):仅管理员会话;只下载 NFO/字幕/图片小文件(视频不经浏览器,规格 §1 边界);
// 路径守卫同其它接口(realpath 在根内、符号链接目标也须在根内,打开前再复核一次);
// 打开后按文件句柄再 stat,校验的就是要流出去的那个文件;流式返回,写审计。
const ALLOWED = new Set(["nfo", "subtitle", "image"]);
const MIME: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
avif: "image/avif",
nfo: "text/plain; charset=utf-8",
xml: "text/plain; charset=utf-8",
srt: "text/plain; charset=utf-8",
ass: "text/plain; charset=utf-8",
ssa: "text/plain; charset=utf-8",
vtt: "text/plain; charset=utf-8",
sub: "text/plain; charset=utf-8",
sup: "application/octet-stream",
idx: "text/plain; charset=utf-8",
};
/** RFC 6266 / 5987:ASCII 兜底名 + UTF-8 原名 */
function disposition(kind: "inline" | "attachment", name: string): string {
const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
return `${kind}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
}
export async function downloadFile(
d: FmDeps,
principal: Principal | undefined,
q: FilesDownloadQuery,
): Promise<Response> {
if (!isAdmin(principal)) throw new FilesError("forbidden", "下载只允许控制台管理员会话(API Key 不可用)");
const r = await resolveIn(d, q.root, q.path);
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
const name = basename(r.abs);
const kind = kindOfName(name);
if (!ALLOWED.has(kind))
throw new FilesError("forbidden", `只允许下载 NFO/字幕/图片(${kind}),视频等大文件不经浏览器下载`);
const real = await recheck(r.abs, r.rootReal);
const file = await Deno.open(real, { read: true });
let size: number;
try {
const st = await file.stat();
if (!st.isFile) throw new FilesError("bad_request", "不是普通文件");
size = st.size;
if (size > d.options.downloadMaxBytes)
throw new FilesError(
"too_large",
`文件 ${size} 字节,超过下载上限 ${d.options.downloadMaxBytes}(FILES_DOWNLOAD_MAX_BYTES)`,
);
} catch (e) {
file.close();
throw e;
}
const ext = extname(name).replace(/^\./, "").toLowerCase();
// SVG 可带脚本:不内联、不给 image/svg+xml
const inline = q.inline && kind === "image" && ext !== "svg";
await audit(d, principal, "download", { root: q.root, path: r.rel, size, inline });
return new Response(file.readable, {
status: 200,
headers: {
"content-type": (ext !== "svg" && MIME[ext]) || "application/octet-stream",
"content-length": String(size),
"content-disposition": disposition(inline ? "inline" : "attachment", name),
"x-content-type-options": "nosniff",
"content-security-policy": "default-src 'none'; sandbox",
"cache-control": "private, no-store",
},
});
}
+154 -14
View File
@@ -1,8 +1,9 @@
import { stat } from "node:fs/promises";
import { join } from "node:path";
import type { FilesOrganizeGroup, FilesOrganizeRequest, FilesOrganizeResult } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts";
import { planImport } from "../acquisition-organize/services/organize.ts";
import { type FileOverrideInput, planImport } from "../acquisition-organize/services/organize.ts";
import {
authorizeMove,
hnrBlockForMove,
@@ -13,9 +14,12 @@ import {
} from "../acquisition-organize/services/organize-guard.ts";
import { actorOfP, errMessage, FilesError, type FmDeps, hashJson, isAdmin, resolveIn } from "./common.ts";
import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { isWritableTarget } from "./list.ts";
// 整理向导落地:按 (mediaEntityId, contentUnitId) 分组,每组走 planImport(与 POST /organization-records 同一实现,
// 同格式整理记录)。dryRun 冻结计划 + 源文件 size/mtime;执行校验 planId、源事实与重算的前后路径一致,
// 整理向导落地:按 (mediaEntityId, contentUnitId, 操作, 目标目录, Slot) 分组,每组走 planImport(与 POST /organization-records
// 同一实现,同格式整理记录)。逐项季/集/单元覆写以 fileOverrides 传入 planImport(计划前后路径即反映覆写),
// 目标目录覆写 = planImport.targetDir(不改路径绑定),Slot 覆写在核验通过后推进该 Slot;无法应用的覆写才进 warnings。
// dryRun 冻结计划 + 源文件 size/mtime;执行校验 planId、源事实与重算的前后路径一致,
// 任一组 plan_stale / 授权 / H&R 失败只影响该组。
type Req = {
@@ -35,6 +39,18 @@ interface Frozen {
reason: string | null;
}
interface Group {
entity: string;
unit: string | null;
action: "copy" | "move";
targetDir: string | null;
targetLibraryRootId: string | null;
slotId: string | null;
paths: string[];
overrides: Record<string, FileOverrideInput>;
warnings: string[];
}
const pairsOf = (plan: Awaited<ReturnType<typeof planImport>>) =>
(plan.plan?.organize.files ?? []).map((f) => ({ src: f.src, dst: f.dst ?? null, kind: f.kind, size: f.size }));
@@ -47,6 +63,16 @@ async function factsOf(paths: string[]) {
);
}
/** 集号数组 → 连续区间;不连续返回 null */
function episodeRange(eps: number[]): { start: number; end: number } | null {
const u = [...new Set(eps)].sort((a, b) => a - b);
if (u.length === 0) return null;
for (let i = 1; i < u.length; i++) if (u[i] !== u[i - 1]! + 1) return null;
return { start: u[0]!, end: u[u.length - 1]! };
}
const BUSY_SLOT = new Set(["reserved", "downloading", "verifying"]);
export async function organizeFiles(
d: FmDeps,
principal: Principal | undefined,
@@ -56,21 +82,119 @@ export async function organizeFiles(
const actorType = principal?.kind === "api_key" ? "api_key" : "user";
const actor = actorOfP(principal);
const entityIds = [...new Set(body.items.map((i) => i.mediaEntityId))];
const entities = new Map(
(await db.selectFrom("media_entity").select(["id", "kind"]).where("id", "in", entityIds).execute()).map((e) => [
e.id,
e.kind,
]),
);
const unitIds = [...new Set(body.items.map((i) => i.contentUnitId).filter((x): x is string => !!x))];
const units = new Map(
unitIds.length
? (
await db
.selectFrom("content_unit")
.select(["id", "media_entity_id", "kind"])
.where("id", "in", unitIds)
.execute()
).map((u) => [u.id, u])
: [],
);
const slotIds = [...new Set(body.items.map((i) => i.slotId).filter((x): x is string => !!x))];
const slots = new Map(
slotIds.length
? (
await db
.selectFrom("acquisition_slot")
.select(["id", "media_entity_id", "status"])
.where("id", "in", slotIds)
.execute()
).map((s) => [s.id, s])
: [],
);
// 分组(保持首次出现顺序)
const groups = new Map<string, { entity: string; unit: string | null; paths: string[]; warnings: string[] }>();
const groups = new Map<string, Group>();
const roots = new Set<string>();
for (const it of body.items) {
const r = await resolveIn(d, it.root, it.path);
if (r.rel === "") throw new FilesError("bad_request", "不能整理整个根");
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接: ${r.rel}`);
if (body.action === "move" && !r.def.modifiable)
const action = it.operation ?? body.action;
if (action === "move" && !r.def.modifiable)
throw new FilesError("not_writable", `源根 ${r.def.name} 为只读登记,不能 Move`);
roots.add(it.root);
const key = `${it.mediaEntityId}|${it.contentUnitId ?? ""}`;
const g = groups.get(key) ?? { entity: it.mediaEntityId, unit: it.contentUnitId ?? null, paths: [], warnings: [] };
const isDir = !!r.lst?.isDirectory();
const kind = entities.get(it.mediaEntityId);
const isTv = kind !== undefined && kind !== "movie";
const warnings: string[] = [];
const ov: FileOverrideInput = {};
// 目标目录覆写:writable 库根下已存在的目录
let targetDir: string | null = null;
let targetLib: string | null = body.targetLibraryRootId ?? null;
if (it.targetDir) {
const t = await resolveIn(d, it.targetDir.root, it.targetDir.path);
if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId)
throw new FilesError("bad_request", `目标目录必须是库根下已存在的目录: ${it.targetDir.path || "/"}`);
if (!(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写`);
targetDir = join(t.def.hostPath, t.rel);
targetLib = t.def.libraryRootId;
}
// 季/集
if (it.season !== undefined || it.episodes !== undefined) {
if (!isTv) warnings.push(`${r.rel}: 电影不适用季/集覆写,已忽略`);
else {
if (it.season !== undefined) ov.season = it.season;
if (it.episodes?.length) {
const range = episodeRange(it.episodes);
if (!range) warnings.push(`${r.rel}: 集号 ${it.episodes.join(",")} 不连续,未应用(只支持连续区间)`);
else if (isDir) warnings.push(`${r.rel}: 目录项不能指定集号(会套到其下所有视频),未应用`);
else ov.episodes = range;
}
}
}
// 内容单元
let unit = it.contentUnitId ?? null;
if (unit) {
const u = units.get(unit);
if (!u || u.media_entity_id !== it.mediaEntityId) {
warnings.push(`${r.rel}: 内容单元 ${unit} 不属于该作品,未应用`);
unit = null;
} else if (u.kind === "episode" || u.kind === "part") ov.contentUnitIds = [u.id];
}
// Slot
let slotId = it.slotId ?? null;
if (slotId) {
const s = slots.get(slotId);
if (!s || s.media_entity_id !== it.mediaEntityId) {
warnings.push(`${r.rel}: Slot ${slotId} 不属于该作品,未应用`);
slotId = null;
} else if (BUSY_SLOT.has(s.status)) {
warnings.push(`${r.rel}: Slot 正在获取中(${s.status}),不改其状态`);
slotId = null;
}
}
const key = [it.mediaEntityId, unit ?? "", action, targetDir ?? "", targetLib ?? "", slotId ?? ""].join("|");
const g =
groups.get(key) ??
({
entity: it.mediaEntityId,
unit,
action,
targetDir,
targetLibraryRootId: targetLib,
slotId,
paths: [],
overrides: {},
warnings: [],
} satisfies Group);
g.paths.push(r.abs);
if (it.season !== undefined || it.episodes !== undefined || it.slotId !== undefined)
g.warnings.push(`${r.rel}: 逐项季/集/Slot 覆写暂未生效(按文件名解析),待 planImport 支持 fileOverrides`);
if (Object.keys(ov).length > 0) g.overrides[r.abs] = ov;
g.warnings.push(...warnings);
groups.set(key, g);
}
const guard = await sourceGuard(db);
@@ -82,13 +206,29 @@ export async function organizeFiles(
sourcePaths: g.paths,
mediaEntityId: g.entity,
...(g.unit ? { contentUnitId: g.unit } : {}),
action: body.action,
...(body.targetLibraryRootId ? { targetLibraryRootId: body.targetLibraryRootId } : {}),
action: g.action,
...(g.targetLibraryRootId ? { targetLibraryRootId: g.targetLibraryRootId } : {}),
};
const hasOverrides = Object.keys(g.overrides).length > 0;
const reqHash = hashJson({
key: requestKey(req),
...(hasOverrides ? { overrides: g.overrides } : {}),
...(g.targetDir ? { targetDir: g.targetDir } : {}),
...(g.slotId ? { slotId: g.slotId } : {}),
});
const base = {
mediaEntityId: g.entity,
contentUnitId: g.unit,
warnings: g.warnings,
action: g.action,
targetDir: g.targetDir,
slotId: g.slotId,
};
const reqHash = hashJson(requestKey(req));
const base = { mediaEntityId: g.entity, contentUnitId: g.unit, warnings: g.warnings };
const importReq = (dryRun: boolean, taskId?: string) => ({
...req,
...(hasOverrides ? { fileOverrides: g.overrides } : {}),
...(g.targetDir ? { targetDir: g.targetDir } : {}),
...(g.slotId ? { slotId: g.slotId } : {}),
trigger: "manual" as const,
actorType: actorType as "api_key" | "user",
actorId: actor,
@@ -140,7 +280,7 @@ export async function organizeFiles(
throw new FilesError("plan_stale", "重新计算的前后路径与预览不一致(目标目录或绑定已变化),请重新预览");
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
if (body.action === "move") {
if (g.action === "move") {
const managed = await managedTasksUnder(db, req.sourcePaths);
if (!isAdmin(principal)) {
const auth = await authorizeMove(d.ao, principal, req, g.entity);
+48 -102
View File
@@ -1,8 +1,8 @@
import { randomUUID } from "node:crypto";
import type { OrganizationAction } from "@umt/contracts";
import type { FilesRecordsQuery, FilesRecordsResult, OrganizationAction } from "@umt/contracts";
import { sql } from "@umt/db";
import type { Principal } from "../../http/auth.ts";
import type { Exec } from "../acquisition-organize/services/common.ts";
import { emitRecord } from "../acquisition-organize/services/organize.ts";
import { emitRecord, loadRecordsPublic } from "../acquisition-organize/services/organize.ts";
import { actorOfP, type FmDeps, hashJson, jsonb } from "./common.ts";
// 显式路径变更的数据库同步:asset_location 前缀替换、path_binding 修订(旧 superseded → 新 active)、
@@ -10,105 +10,12 @@ import { actorOfP, type FmDeps, hashJson, jsonb } from "./common.ts";
const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`;
export interface RelocateInput {
fromLib: string;
fromRel: string;
toLib: string;
toRel: string;
isDir: boolean;
actor: string;
opKind: "rename" | "move";
}
export interface RelocateResult {
locations: { id: string; assetId: string; from: string; to: string }[];
bindings: { oldBindingId: string; newBindingId: string }[];
mediaEntityIds: string[];
}
/** 同事务:把 fromLib/fromRel(目录含其下全部)改到 toLib/toRel */
export async function relocate(trx: Exec, input: RelocateInput): Promise<RelocateResult> {
const { fromLib, fromRel, toLib, toRel, isDir } = input;
const mapRel = (p: string) => (p === fromRel ? toRel : `${toRel}${p.slice(fromRel.length)}`);
const locs = await trx
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select(["l.id", "l.relative_path", "l.media_asset_id", "a.media_entity_id"])
.where("l.library_root_id", "=", fromLib)
.where("l.status", "<>", "removed")
.where((eb) =>
isDir
? eb.or([eb("l.relative_path", "=", fromRel), eb("l.relative_path", "like", likePrefix(fromRel))])
: eb("l.relative_path", "=", fromRel),
)
.execute();
const out: RelocateResult = { locations: [], bindings: [], mediaEntityIds: [] };
const entities = new Set<string>();
for (const l of locs) {
const to = mapRel(l.relative_path);
await trx
.updateTable("asset_location")
.set({ library_root_id: toLib, relative_path: to, last_seen_at: new Date() })
.where("id", "=", l.id)
.execute();
await trx
.insertInto("asset_operation")
.values({
operation_id: `files:${randomUUID()}`,
phase: "commit",
kind: input.opKind,
status: "succeeded",
intent: jsonb({
fromLibraryRootId: fromLib,
from: l.relative_path,
toLibraryRootId: toLib,
to,
by: input.actor,
}),
result: jsonb({ locationId: l.id }),
media_asset_id: l.media_asset_id,
started_at: new Date(),
finished_at: new Date(),
})
.execute();
out.locations.push({ id: l.id, assetId: l.media_asset_id, from: l.relative_path, to });
if (l.media_entity_id) entities.add(l.media_entity_id);
}
if (isDir) {
const binds = await trx
.selectFrom("path_binding")
.selectAll()
.where("library_root_id", "=", fromLib)
.where("status", "=", "active")
.where((eb) => eb.or([eb("relative_path", "=", fromRel), eb("relative_path", "like", likePrefix(fromRel))]))
.execute();
for (const b of binds) {
// 显式路径变更(REQ §7.1.1):旧绑定 superseded,新绑定 active 并互相链接;仅译名刷新永不走到这里
await trx.updateTable("path_binding").set({ status: "superseded" }).where("id", "=", b.id).execute();
const nb = await trx
.insertInto("path_binding")
.values({
media_entity_id: b.media_entity_id,
content_unit_id: b.content_unit_id,
season_number: b.season_number,
binding_kind: b.binding_kind,
library_root_id: toLib,
relative_path: mapRel(b.relative_path),
template_version: b.template_version,
status: "active",
bound_by: input.actor,
note: `files.${input.opKind}: ${b.relative_path} → ${mapRel(b.relative_path)}`,
})
.returning("id")
.executeTakeFirstOrThrow();
await trx.updateTable("path_binding").set({ superseded_by_id: nb.id }).where("id", "=", b.id).execute();
out.bindings.push({ oldBindingId: b.id, newBindingId: nb.id });
entities.add(b.media_entity_id);
}
}
out.mediaEntityIds = [...entities];
return out;
}
// 位置跟随/绑定修订与原样搬运(organize-transfer)共用同一实现
export {
type RelocateInput,
type RelocateResult,
relocate,
} from "../acquisition-organize/services/organize-transfer.ts";
/** 预览:受影响的位置数与绑定(不写库) */
export async function relocatePreview(exec: Exec, lib: string, rel: string, isDir: boolean) {
@@ -213,3 +120,42 @@ export async function writeRecord(
await emitRecord(d.ao, trx, rec.id);
return rec.id;
}
/**
* 整理记录回查(整理页 `/console/organize?ids=` 预填):按记录 id,或按 /files 写操作的 planId
* (move/copy/organize 经 task.idempotency_key = files:<planId>;删除/改名等直接写的记录看 plan.planId)。
*/
export async function findRecords(d: FmDeps, q: FilesRecordsQuery): Promise<FilesRecordsResult> {
const db = d.ctx.db;
const planRecords: Record<string, string> = {};
const planIds = q.planIds ?? [];
if (planIds.length > 0) {
const viaTask = await db
.selectFrom("organization_record as r")
.innerJoin("task as t", "t.id", "r.task_id")
.select(["r.id", "t.idempotency_key"])
.where(
"t.idempotency_key",
"in",
planIds.map((p) => `files:${p}`),
)
.orderBy("r.created_at", "desc")
.execute();
for (const r of viaTask) planRecords[r.idempotency_key.slice("files:".length)] ??= r.id;
const direct = await sql<{ id: string; plan_id: string }>`
select id, plan->>'planId' as plan_id from organization_record
where plan->>'planId' = any(${planIds}::text[]) order by created_at desc`.execute(db);
for (const r of direct.rows) planRecords[r.plan_id] ??= r.id;
}
const ids = [...new Set([...(q.ids ?? []), ...Object.values(planRecords)])];
const rows = ids.length
? await db
.selectFrom("organization_record")
.selectAll()
.where("id", "in", ids)
.orderBy("created_at", "desc")
.execute()
: [];
const items = (await loadRecordsPublic(db, rows, q.includeFiles)) as FilesRecordsResult["items"];
return { items, planRecords };
}
+14 -5
View File
@@ -4,14 +4,17 @@ import type { Context } from "hono";
import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../http/auth.ts";
import { errorBody } from "../../http/errors.ts";
import { PathGuardError } from "../acquisition-organize/services/organize-guard.ts";
import type { ModuleApp } from "../types.ts";
import { FilesError, type FmDeps, isAdmin } from "./common.ts";
import { FilesError, type FmDeps, fsErrorToFiles, isAdmin } from "./common.ts";
import { downloadFile } from "./download.ts";
import { identify } from "./identify.ts";
import { idempotent } from "./ledger.ts";
import { listDir, listRoots, statEntry } from "./list.ts";
import { mkdirIn, renameEntry } from "./mutate.ts";
import { organizeFiles } from "./organize.ts";
import { probeFiles, scrape } from "./probe.ts";
import { findRecords } from "./records.ts";
import { transfer } from "./transfer.ts";
import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts";
@@ -22,7 +25,7 @@ import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts";
type Input = { query: any; body: any };
type Handler = (c: Context<AuthEnv>, input: Input) => Promise<unknown>;
function mount(app: ModuleApp, def: EndpointDef, handler: Handler) {
function mount(app: ModuleApp, def: EndpointDef, handler: Handler, raw = false) {
const route = createRoute({
method: def.method,
path: def.path,
@@ -34,7 +37,9 @@ function mount(app: ModuleApp, def: EndpointDef, handler: Handler) {
...(def.body ? { body: { content: { "application/json": { schema: def.body } }, required: true } } : {}),
},
responses: {
200: { description: "成功", content: { "application/json": { schema: apiResponse(def.response) } } },
200: raw
? { description: "文件流", content: { "application/octet-stream": { schema: def.response } } }
: { description: "成功", content: { "application/json": { schema: apiResponse(def.response) } } },
},
});
app.openapi(route, (async (c: Context<AuthEnv>) => {
@@ -44,10 +49,12 @@ function mount(app: ModuleApp, def: EndpointDef, handler: Handler) {
query: def.query ? v.valid("query") : {},
body: def.body ? v.valid("json") : undefined,
});
if (raw) return data as Response;
return c.json({ ok: true as const, data }, 200);
} catch (err) {
if (err instanceof FilesError)
return c.json(errorBody(err.code, `${err.reason}: ${err.message}`), err.status as 400);
// 检查与使用之间的文件系统竞态(目录被删、挂载掉线)也按业务错误回,不给 500
const fe = err instanceof PathGuardError ? new FilesError("path_escape", err.message) : fsErrorToFiles(err);
if (fe) return c.json(errorBody(fe.code, `${fe.reason}: ${fe.message}`), fe.status as 400);
throw err;
}
}) as never);
@@ -98,4 +105,6 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
mount(app, F.identify, async (c, { body }) => identify(d, c.get("principal"), body));
mount(app, F.scrape, async (c, { body }) => idem(d, c, "scrape", body, () => scrape(d, c.get("principal"), body)));
mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));
mount(app, F.records, async (_c, { query }) => findRecords(d, query));
mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true);
}
+149 -114
View File
@@ -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 },
};
}
+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 缺省 = 只清过期项 */