Files
UHD_Media_Tool/apps/server/src/modules/file-manager/trash.ts
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:35:43 +08:00

616 lines
25 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { lstat, mkdir, readdir, realpath, rm } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { moveSafe } from "@umt/adapters";
import type {
FileDeleteCheck,
FilesDeleteRequest,
FilesDeleteResult,
FilesTrashList,
FilesTrashPurgeRequest,
FilesTrashPurgeResult,
FilesTrashQuery,
FilesTrashRestoreRequest,
FilesTrashRestoreResult,
TrashItem,
} from "@umt/contracts";
import { sql, type TrashItemRow } from "@umt/db";
import type { Principal } from "../../http/auth.ts";
import { upsertDecision } from "../acquisition-organize/services/common.ts";
import { fsRuntime } from "../acquisition-organize/services/organize.ts";
import {
actorOfP,
errMessage,
errnoOf,
FilesError,
type FmDeps,
hashJson,
isAdmin,
isAdminSession,
jsonb,
onlineRoot,
recheck,
resolveIn,
rootDef,
within,
} from "./common.ts";
import { audit, consumeApproval, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { bindingsAt, downloadsFor, isDownloading, isWritableTarget, managedDownloads } from "./list.ts";
import { type RecordFile, writeRecord } from "./records.ts";
// 删除(原则 5):默认进回收站(同卷 <根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>,O(1) rename;
// 不可写或跨卷时复制到 DATA_DIR/trash,超过上限拒绝)。引用检查:纳管做种 / 已登记资产 / 路径绑定 / 根本身。
// 删除永不连带下载器任务。
export const TRASH_DIR = ".umt-trash";
const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`;
const day = (t: Date) => t.toISOString().slice(0, 10).replace(/-/g, "");
async function sizeOf(abs: string, cap = 200_000): Promise<{ size: number; files: string[] }> {
const st = await lstat(abs);
if (!st.isDirectory()) return { size: Number(st.size), files: [""] };
let size = 0;
const files: string[] = [];
const walk = async (dir: string, prefix: string) => {
for (const n of await readdir(dir)) {
if (files.length >= cap) return;
const p = join(dir, n);
const s = await lstat(p);
const rel = prefix ? `${prefix}/${n}` : n;
if (s.isDirectory()) await walk(p, rel);
else {
size += Number(s.size);
files.push(rel);
}
}
};
await walk(abs, "");
return { size, files };
}
interface CheckCtx {
abs: string | null;
rootReal: string | null;
libraryRootId: string | null;
}
async function checkItem(
d: FmDeps,
body: FilesDeleteRequest,
it: { root: string; path: string },
downloads: Awaited<ReturnType<typeof managedDownloads>>,
): Promise<{ check: FileDeleteCheck; ctx: CheckCtx }> {
const empty = (blocked: FileDeleteCheck["blocked"]): { check: FileDeleteCheck; ctx: CheckCtx } => ({
check: {
root: it.root as FileDeleteCheck["root"],
path: it.path,
isDir: false,
size: 0,
blocked,
downloads: [],
assets: [],
bindings: [],
sharedInode: false,
},
ctx: { abs: null, rootReal: null, libraryRootId: null },
});
let r: Awaited<ReturnType<typeof resolveIn>>;
try {
r = await resolveIn(d, it.root, it.path);
} catch (e) {
if (e instanceof FilesError && e.reason === "root_unavailable") return empty("root_unavailable");
if (e instanceof FilesError && e.reason === "not_found") return empty("not_found");
if (e instanceof FilesError && e.reason === "path_escape") return empty("escape");
throw e;
}
if (r.rel === "") return empty("is_root");
const isDir = !!r.lst?.isDirectory() && !r.isSymlink;
const size = r.isSymlink ? 0 : (await sizeOf(r.abs)).size;
const dls = downloadsFor(downloads, r.abs);
const lib = r.def.libraryRootId;
const assets = lib
? (
await d.ctx.db
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select(["l.id", "l.relative_path", "a.id as asset_id", "a.media_entity_id"])
.where("l.library_root_id", "=", lib)
.where("l.status", "=", "available")
.where((eb) =>
isDir
? eb.or([eb("l.relative_path", "=", r.rel), eb("l.relative_path", "like", likePrefix(r.rel))])
: eb("l.relative_path", "=", r.rel),
)
.execute()
).filter((a) => a.media_entity_id)
: [];
const bindings = isDir ? await bindingsAt(d, r.def, r.rel, true) : [];
let blocked: FileDeleteCheck["blocked"] = null;
if (!r.def.modifiable) blocked = "is_root";
else if (dls.some((t) => isDownloading(t.status) || t.hnr !== "satisfied")) blocked = "download_active";
else if (assets.length > 0 && !body.retireAssets) blocked = "needs_retire";
else if (bindings.length > 0 && !body.unbind) blocked = "needs_unbind";
return {
check: {
root: it.root as FileDeleteCheck["root"],
path: r.rel,
isDir,
size,
blocked,
downloads: dls.map((t) => ({ taskId: t.taskId, status: t.status, hnr: t.hnr })),
assets: assets.map((a) => ({
assetId: a.asset_id,
locationId: a.id,
mediaEntityId: a.media_entity_id!,
relativePath: a.relative_path,
})),
bindings,
sharedInode: !isDir && Number(r.lst?.nlink ?? 1) > 1,
},
ctx: { abs: r.abs, rootReal: r.rootReal, libraryRootId: lib },
};
}
const BLOCK_ERR: Record<
NonNullable<FileDeleteCheck["blocked"]>,
[ConstructorParameters<typeof FilesError>[0], string]
> = {
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
is_root: ["bad_request", "不能删除根目录或只读根下的内容"],
needs_retire: ["needs_retire", "含已入库文件,需勾选「同时从媒体库移除」"],
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选「解除绑定」"],
root_unavailable: ["root_unavailable", "根不可用"],
not_found: ["not_found", "不存在"],
escape: ["path_escape", "路径指向根外"],
};
export async function deleteFiles(
d: FmDeps,
principal: Principal | undefined,
body: FilesDeleteRequest,
): Promise<FilesDeleteResult> {
if (body.mode === "permanent") {
if (!d.options.allowPermanentDelete)
throw new FilesError("permanent_disabled", "未开启永久删除(FILES_ALLOW_PERMANENT_DELETE)。改用回收站");
if (!isAdminSession(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话");
}
const downloads = await managedDownloads(d);
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({
items: body.items,
mode: body.mode,
retireAssets: body.retireAssets,
unbind: body.unbind,
});
if (body.dryRun) {
const plan = await savePlan(d, "delete", requestHash, principal, { checks });
return {
dryRun: true,
planId: plan.id,
planExpiresAt: plan.expiresAt.toISOString(),
mode: body.mode,
checks,
trashed: [],
deleted: [],
failed: [],
recordId: null,
};
}
const blocked = checks.find((c) => c.blocked);
if (blocked) {
const [reason, msg] = BLOCK_ERR[blocked.blocked!];
throw new FilesError(reason, `${blocked.path}: ${msg}`);
}
let planId: string | null = null;
if (body.planId) planId = (await loadPlan(d, body.planId, "delete", requestHash)).id;
if (!isAdmin(principal)) {
// API Key:每次删除都要一次性审批,审批绑定 dryRun 的计划 id + 内容哈希
if (!planId) throw new FilesError("approval_required", "API Key 删除需先 dryRun 取得 planId 并获得一次性审批");
const plan = await loadPlan(d, planId, "delete", requestHash);
const ok = await consumeApproval(d, plan.id, plan.contentHash);
if (!ok) throw new FilesError("approval_required", `删除需要一次性审批(planType=files_plan, planId=${plan.id})`);
}
if (body.mode === "permanent" && body.items.length === 1 && body.confirmName !== basename(checks[0]!.path))
throw new FilesError("bad_request", "永久删除需在 confirmName 中输入文件名确认");
if (planId) await retirePlan(d, planId);
const now = d.ao.ports.now();
const expiresAt = new Date(now.getTime() + d.options.trashRetentionDays * 86_400_000);
const trashed: TrashItem[] = [];
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) {
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}` });
}
}
// 资产退役 / 解除绑定(只对已实际删除/移入回收站的项)+ 整理记录
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();
const assetIds = [...new Set(done.flatMap((r) => r.check.assets.map((a) => a.assetId)))];
const retired = await sql<{ id: string }>`
update media_asset set status = 'retired'
where id = any(${assetIds}::uuid[])
and not exists (select 1 from asset_location l where l.media_asset_id = media_asset.id and l.status <> 'removed')
returning id`.execute(trx);
const retiredIds = retired.rows.map((x) => x.id);
if (retiredIds.length > 0) {
const slots = await trx
.updateTable("acquisition_slot")
.set({ status: "attention", status_reason: "asset_deleted" })
.where("current_asset_id", "in", retiredIds)
.returning(["id", "media_entity_id", "name"])
.execute();
for (const s of slots)
await upsertDecision(d.ao, trx, {
kind: "other",
title: `收录版本的文件已删除:${s.name}`,
summary: "满足该收录版本的文件已在文件管理中删除,收录版本改为需关注。确认是否重新获取",
subjectType: "acquisition_slot",
subjectId: s.id,
dedupeKey: `files_delete_slot:${s.id}`,
evidence: { mediaEntityId: s.media_entity_id, assets: retiredIds, by: actorOfP(principal) },
});
}
}
const bindIds = done.flatMap((r) => r.check.bindings.map((b) => b.bindingId));
if (bindIds.length > 0)
await trx
.updateTable("path_binding")
.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", {
planId,
items: checks.map((c) => ({ root: c.root, path: c.path, assets: c.assets.length, bindings: c.bindings.length })),
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 {
dryRun: false,
planId,
planExpiresAt: null,
mode: body.mode,
checks,
trashed,
deleted,
failed,
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 }));
}
/** 跨卷复制(进回收站 / 从回收站还原):逐文件经 fsproxy(挂死隔离:停滞超时 → SIGKILL → TIMEOUT),复制后核验 */
async function copyTree(d: FmDeps, src: string, dst: string, files: string[]) {
const proxy = fsRuntime(d.ao).proxy;
const hash = d.ao.ports.options.verifyHash;
const st = await lstat(src);
if (!st.isDirectory()) {
await proxy.copy(src, dst, { hash, createParents: true });
return;
}
await mkdir(dst, { recursive: true });
for (const f of files) await proxy.copy(join(src, f), join(dst, f), { hash, createParents: true });
}
async function moveToTrash(
d: FmDeps,
principal: Principal | undefined,
check: FileDeleteCheck,
ctx: CheckCtx,
expiresAt: Date,
): Promise<TrashItemRow> {
const abs = ctx.abs!;
const id = randomUUID();
const stamp = day(d.ao.ports.now());
let trashPath = join(ctx.rootReal!, TRASH_DIR, stamp, id, check.path);
let moved = false;
try {
await mkdir(dirname(trashPath), { recursive: true });
const r = await moveSafe(abs, trashPath);
if (r.status === "exists") throw new FilesError("exists", `回收站路径已存在:${trashPath}`);
moved = r.status === "moved";
} catch (e) {
if (e instanceof FilesError) throw e;
const code = errnoOf(e) ?? (e as { code?: string }).code;
if (!["EACCES", "EPERM", "EROFS", "EXDEV"].includes(code ?? "")) throw e;
}
if (!moved) {
// 根不可写或跨卷:复制到 DATA_DIR/trash(有上限),核验后删源
if (check.size > d.options.trashCrossMaxBytes)
throw new FilesError(
"bad_request",
`无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes}。改用永久删除`,
);
await rm(join(ctx.rootReal!, TRASH_DIR, stamp, id), { recursive: true, force: true }).catch(() => undefined);
trashPath = join(resolve(d.options.dataDir), "trash", stamp, id, check.path);
const { files } = await sizeOf(abs);
await copyTree(d, abs, trashPath, files);
await rm(abs, { recursive: true });
}
return d.ctx.db
.insertInto("trash_item")
.values({
id,
root_id: check.root,
library_root_id: ctx.libraryRootId,
root_host_path: ctx.rootReal!,
original_path: check.path,
trash_path: trashPath,
is_dir: check.isDir,
size_bytes: check.size,
actor: actorOfP(principal),
deleted_at: d.ao.ports.now(),
expires_at: expiresAt,
asset_snapshot:
check.assets.length || check.bindings.length ? jsonb({ assets: check.assets, bindings: check.bindings }) : null,
})
.returningAll()
.executeTakeFirstOrThrow();
}
export function toTrashItem(r: TrashItemRow): TrashItem {
return {
id: r.id,
rootId: r.root_id as TrashItem["rootId"],
originalPath: r.original_path,
trashPath: r.trash_path,
isDir: r.is_dir,
size: Number(r.size_bytes),
actor: r.actor,
deletedAt: r.deleted_at.toISOString(),
expiresAt: r.expires_at.toISOString(),
assetSnapshot: (r.asset_snapshot as Record<string, unknown> | null) ?? null,
};
}
export async function listTrash(d: FmDeps, q: FilesTrashQuery): Promise<FilesTrashList> {
let qb = d.ctx.db.selectFrom("trash_item").selectAll();
if (q.root) qb = qb.where("root_id", "=", q.root);
if (q.cursor) {
const [at, id] = Buffer.from(q.cursor, "base64url").toString("utf8").split("|");
if (!at || !id || Number.isNaN(Date.parse(at))) throw new FilesError("bad_request", "cursor 无效");
qb = qb.where(sql<boolean>`(deleted_at, id) < (${at}::timestamptz, ${id}::uuid)`);
}
const rows = await qb
.orderBy("deleted_at", "desc")
.orderBy("id", "desc")
.limit(q.limit + 1)
.execute();
const page = rows.slice(0, q.limit);
const last = page.at(-1);
return {
items: page.map(toTrashItem),
nextCursor:
rows.length > q.limit && last
? Buffer.from(`${last.deleted_at.toISOString()}|${last.id}`, "utf8").toString("base64url")
: null,
};
}
/** 回收站路径必须在某个根的 .umt-trash 或 DATA_DIR/trash 下(清除前的最后一道闸) */
function trashPathSafe(d: FmDeps, row: TrashItemRow): boolean {
const inRoot = within(row.trash_path, join(row.root_host_path, TRASH_DIR));
const inData = within(row.trash_path, join(resolve(d.options.dataDir), "trash"));
return (inRoot || inData) && !row.trash_path.split("/").includes("..");
}
/**
* 在根内逐级建目录:每一级先 lstat,已存在的必须是真目录(符号链接一律拒绝,哪怕指向根内),不存在才 mkdir(非递归)。
* 避免 mkdir -p 跟随中间的符号链接在根外建出目录(验收 07 S-5)。
*/
export async function mkdirWithin(rootReal: string, rel: string): Promise<void> {
if (rel === "" || rel === ".") return;
let cur = rootReal;
for (const seg of rel.split("/")) {
if (seg === "" || seg === ".") continue;
if (seg === "..") throw new FilesError("path_escape", `路径含 ..: ${rel}`);
cur = join(cur, seg);
const st = await lstat(cur).catch((e) => {
if (errnoOf(e) === "ENOENT") return null;
throw e;
});
if (st?.isSymbolicLink())
throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,不能还原`);
if (st && !st.isDirectory()) throw new FilesError("exists", `还原路径中的 ${relOfRoot(rootReal, cur)} 不是目录`);
if (!st) await mkdir(cur);
if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径指向根外:${rel}`);
}
}
const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1));
export async function restoreTrash(
d: FmDeps,
principal: Principal | undefined,
body: FilesTrashRestoreRequest,
): 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", "还原目标必须是已存在的目录");
// "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5)
if (target && !(await isWritableTarget(target.def, target.rootReal)))
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 可写的媒体库目录`);
for (const id of body.ids) {
try {
const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new FilesError("not_found", "回收站条目不存在");
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝操作");
let rootId = row.root_id;
let dst: string;
let rootReal: string;
if (target) {
rootId = body.target!.root;
rootReal = target.rootReal;
dst = join(target.abs, basename(row.original_path));
} else {
const def = await rootDef(d.ctx.db, row.root_id);
rootReal = await onlineRoot(d, def);
const parent = await resolveIn(d, row.root_id, row.original_path, { mustExist: false, def, rootReal }).catch(
async (e) => {
// 父目录已不存在:在根内逐级重建(每级先 lstat,已存在的必须是真目录,不跟随符号链接)后再解析
if (e instanceof FilesError && e.reason === "not_found") {
await mkdirWithin(rootReal, dirname(row.original_path));
return resolveIn(d, row.root_id, row.original_path, { mustExist: false, def, rootReal });
}
throw e;
},
);
dst = parent.abs;
}
if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择「还原到…」");
const r = await moveSafe(row.trash_path, dst);
if (r.status === "exists") throw new FilesError("exists", "原位置已被占用");
if (r.status === "exdev") {
// 跨卷还原:与跨卷进回收站同一上限(FILES_TRASH_CROSS_MAX_BYTES),复制经 fsproxy,核验后删回收站副本
const { size, files } = await sizeOf(row.trash_path);
const bytes = Math.max(size, Number(row.size_bytes));
if (bytes > d.options.trashCrossMaxBytes)
throw new FilesError(
"too_large",
`跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes}。在同卷还原,或手工处理 ${row.trash_path}`,
);
await copyTree(d, row.trash_path, dst, files);
await rm(row.trash_path, { recursive: true });
}
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) {
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}` });
}
}
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 缺省 = 只清过期项 */
export async function purgeTrash(
d: FmDeps,
principal: Principal | undefined,
body: Pick<FilesTrashPurgeRequest, "ids">,
): Promise<FilesTrashPurgeResult> {
let q = d.ctx.db.selectFrom("trash_item").selectAll();
q = body.ids ? q.where("id", "in", body.ids) : q.where("expires_at", "<=", d.ao.ports.now());
const rows = await q.limit(1000).execute();
const purged: string[] = [];
const failed: FilesTrashPurgeResult["failed"] = [];
for (const row of rows) {
try {
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst();
if (!gone) continue;
await rm(row.trash_path, { recursive: true, force: true });
// 顺带清掉空的 <uuid>/<yyyymmdd> 目录
const uuidDir = row.trash_path.slice(0, row.trash_path.length - row.original_path.length - 1);
await rm(uuidDir, { recursive: true, force: true }).catch(() => undefined);
purged.push(row.id);
} catch (e) {
failed.push({ id: row.id, code: e instanceof FilesError ? e.reason : "io_error", message: errMessage(e) });
}
}
if (purged.length || failed.length)
await audit(d, principal, "trash_purge", { purged, failed, expiredOnly: !body.ids });
return { purged, failed };
}