fix(files): 回收站清除先删文件再删行,顺带清过期的无登记 replaced-* 目录

- purge 先删文件、成功后再删行,删文件失败时行留着下次再清,不再留下没有登记的孤儿文件;
- 清过期项时扫各可写库根 .umt-trash/replaced-*,过期且没有任何回收站登记的整个删掉(替换旧文件登记回收站失败时留下的)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 23:37:18 +08:00
1 parent c532060c83
commit 4b49be7228
2 files changed
+58 -7

No files matched your search

+42 -6
View File
@@ -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 });
// 顺带清掉空的 <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);
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<string[]> {
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;
}
+16 -1
View File
@@ -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 });