diff --git a/apps/server/src/modules/file-manager/trash.ts b/apps/server/src/modules/file-manager/trash.ts index 2806eb17..e330e0fb 100644 --- a/apps/server/src/modules/file-manager/trash.ts +++ b/apps/server/src/modules/file-manager/trash.ts @@ -584,7 +584,10 @@ export async function restoreTrash( return { restored, failed, recordId }; } -/** 清除:先删行(复核仍存在)再删文件;ids 缺省 = 只清过期项 */ +/** + * 清除:先删文件再删行(删文件失败时行留着,下次再清,不留没有登记的孤儿文件);ids 缺省 = 只清过期项, + * 并顺带清掉各库根 `.umt-trash/replaced-*` 里过期、没有回收站登记的旧文件(替换旧文件登记回收站失败时留下的)。 + */ export async function purgeTrash( d: FmDeps, principal: Principal | undefined, @@ -598,18 +601,51 @@ export async function purgeTrash( 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 }); // 顺带清掉空的 / 目录 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); + const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst(); + if (gone) 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 }); + const orphans = body.ids ? [] : await purgeReplacedOrphans(d); + if (purged.length || failed.length || orphans.length) + await audit(d, principal, "trash_purge", { purged, failed, orphans, expiredOnly: !body.ids }); return { purged, failed }; } + +/** 各启用库根 `.umt-trash/replaced-*`:修改时间早于保留期、里面没有任何回收站登记的,整个删掉;返回删掉的目录 */ +async function purgeReplacedOrphans(d: FmDeps): Promise { + const out: string[] = []; + const cutoff = d.ao.ports.now().getTime() - d.options.trashRetentionDays * 86_400_000; + const roots = await d.ctx.db + .selectFrom("library_root") + .select(["local_path"]) + .where("enabled", "=", true) + .where("management_mode", "=", "managed") + .execute(); + for (const r of roots) { + const rootReal = await realpath(r.local_path).catch(() => null); + if (!rootReal) continue; + const bin = join(rootReal, TRASH_DIR); + for (const name of await readdir(bin).catch(() => [] as string[])) { + if (!name.startsWith("replaced-")) continue; + const dir = join(bin, name); + const st = await lstat(dir).catch(() => null); + if (!st?.isDirectory() || st.mtimeMs > cutoff) continue; + const held = await d.ctx.db + .selectFrom("trash_item") + .select("id") + .where("trash_path", "like", likePrefix(dir)) + .executeTakeFirst(); + if (held) continue; + await rm(dir, { recursive: true, force: true }) + .then(() => out.push(dir)) + .catch(() => undefined); + } + } + return out; +} diff --git a/apps/server/test/file-manager/trash.test.ts b/apps/server/test/file-manager/trash.test.ts index 8711c8cd..86595d7f 100644 --- a/apps/server/test/file-manager/trash.test.ts +++ b/apps/server/test/file-manager/trash.test.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { findCrossDeviceBase } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -171,6 +171,21 @@ describe.skipIf(skip)("files 删除与回收站", () => { expect(left.map((x) => x.id)).not.toContain(oldId); }); + it("purge 过期项时,过期且没有登记的 .umt-trash/replaced-* 一并清掉", async () => { + const oldBin = join(h.lib, ".umt-trash", "replaced-2020-01-01T00-00-00-000Z-aaaaaa"); + const newBin = join(h.lib, ".umt-trash", "replaced-2099-01-01T00-00-00-000Z-bbbbbb"); + await mkdir(oldBin, { recursive: true }); + await mkdir(newBin, { recursive: true }); + await writeFile(join(oldBin, "old.mkv"), "o"); + await writeFile(join(newBin, "new.mkv"), "n"); + const past = new Date(Date.now() - 30 * 86_400_000); + await utimes(oldBin, past, past); + const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, SESSION); + expect(r.status).toBe(200); + expect(await exists(oldBin)).toBe(false); + expect(await exists(join(newBin, "new.mkv"))).toBe(true); + }); + it("永久删除默认关闭;API Key 删除需一次性审批", async () => { await writeFile(join(h.lib, "perm.txt"), "p"); const p = await del({ items: [{ root: h.libRoot, path: "perm.txt" }], mode: "permanent", dryRun: false });