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:
1 parent
c532060c83
commit
4b49be7228
2 files changed
+58
-7
No files matched your search
@@ -584,7 +584,10 @@ export async function restoreTrash(
|
|||||||
return { restored, failed, recordId };
|
return { restored, failed, recordId };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清除:先删行(复核仍存在)再删文件;ids 缺省 = 只清过期项 */
|
/**
|
||||||
|
* 清除:先删文件再删行(删文件失败时行留着,下次再清,不留没有登记的孤儿文件);ids 缺省 = 只清过期项,
|
||||||
|
* 并顺带清掉各库根 `.umt-trash/replaced-*` 里过期、没有回收站登记的旧文件(替换旧文件登记回收站失败时留下的)。
|
||||||
|
*/
|
||||||
export async function purgeTrash(
|
export async function purgeTrash(
|
||||||
d: FmDeps,
|
d: FmDeps,
|
||||||
principal: Principal | undefined,
|
principal: Principal | undefined,
|
||||||
@@ -598,18 +601,51 @@ export async function purgeTrash(
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
try {
|
try {
|
||||||
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
|
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 });
|
await rm(row.trash_path, { recursive: true, force: true });
|
||||||
// 顺带清掉空的 <uuid>/<yyyymmdd> 目录
|
// 顺带清掉空的 <uuid>/<yyyymmdd> 目录
|
||||||
const uuidDir = row.trash_path.slice(0, row.trash_path.length - row.original_path.length - 1);
|
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);
|
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) {
|
} catch (e) {
|
||||||
failed.push({ id: row.id, code: e instanceof FilesError ? e.reason : "io_error", message: errMessage(e) });
|
failed.push({ id: row.id, code: e instanceof FilesError ? e.reason : "io_error", message: errMessage(e) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (purged.length || failed.length)
|
const orphans = body.ids ? [] : await purgeReplacedOrphans(d);
|
||||||
await audit(d, principal, "trash_purge", { purged, failed, expiredOnly: !body.ids });
|
if (purged.length || failed.length || orphans.length)
|
||||||
|
await audit(d, principal, "trash_purge", { purged, failed, orphans, expiredOnly: !body.ids });
|
||||||
return { purged, failed };
|
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;
|
||||||
|
}
|
||||||
@@ -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 { dirname, join } from "node:path";
|
||||||
import { findCrossDeviceBase } from "@umt/testkit";
|
import { findCrossDeviceBase } from "@umt/testkit";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
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);
|
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 () => {
|
it("永久删除默认关闭;API Key 删除需一次性审批", async () => {
|
||||||
await writeFile(join(h.lib, "perm.txt"), "p");
|
await writeFile(join(h.lib, "perm.txt"), "p");
|
||||||
const p = await del({ items: [{ root: h.libRoot, path: "perm.txt" }], mode: "permanent", dryRun: false });
|
const p = await del({ items: [{ root: h.libRoot, path: "perm.txt" }], mode: "permanent", dryRun: false });
|
||||||
|
|||||||
Reference in new issue
Block a user