feat(files): 文件管理接入 Alist 存储(浏览、增删改、移动复制、上传下载、回收站)

- 文件管理根多出 st:<存储 id>(对照 MP 存储切换),Alist 媒体库目录照常出现在媒体库组
- 列目录一次 list,refresh 只在用户刷新时带;连不上返回 503 不当成空目录
- 新建目录 / 改名(同名不覆盖)/ 删除进 Alist 上的 .umt-trash 并可还原
- 存储内移动复制走 Alist API,本地 ↔ Alist 走整理同一套执行与账本
- 新增 PUT /files/upload(同名不覆盖,Alist 经服务端转传);下载 / 预览流式转发直链

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-06 23:52:39 +08:00
1 parent c2bf8a071a
commit 9d95076a0e
24 files changed
+901 -72

No files matched your search

+90 -7
View File
@@ -16,6 +16,8 @@ import type {
} from "@umt/contracts";
import { sql, type TrashItemRow } from "@umt/db";
import type { Principal } from "../../http/auth.ts";
import { isRemotePath, vlstat, vmkdirp, vreaddirStats, vremove, vrenameNoClobber } from "../../storage/vfs.ts";
import { parseVPath, toVPath } from "../../storage/vpath.ts";
import { upsertDecision } from "../acquisition-organize/services/common.ts";
import { fsRuntime } from "../acquisition-organize/services/organize.ts";
import {
@@ -47,6 +49,7 @@ 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[] }> {
if (isRemotePath(abs)) return remoteSizeOf(abs, cap);
const st = await lstat(abs);
if (!st.isDirectory()) return { size: Number(st.size), files: [""] };
let size = 0;
@@ -68,6 +71,39 @@ async function sizeOf(abs: string, cap = 200_000): Promise<{ size: number; files
return { size, files };
}
/** 远端:按目录 list(每个子目录一次请求) */
async function remoteSizeOf(abs: string, cap: number): Promise<{ size: number; files: string[] }> {
const st = await vlstat(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 it of await vreaddirStats(dir)) {
if (files.length >= cap) return;
const rel = prefix ? `${prefix}/${it.name}` : it.name;
if (it.stats.isDirectory()) await walk(join(dir, it.name), rel);
else {
size += Number(it.stats.size);
files.push(rel);
}
}
};
await walk(abs, "");
return { size, files };
}
/**
* 回收站所在目录:本地 = <根>/.umt-trash;远端同布局,用 Alist 同驱动 move(立即完成)。
* 远端根是 Alist 的 "/"(各挂载的上一级,不能建目录)时放进该项所在挂载的 .umt-trash。
*/
export function trashBase(rootReal: string, abs: string): string {
const v = parseVPath(rootReal);
if (v?.remote !== "/") return rootReal;
const a = parseVPath(abs);
const mount = a?.remote.split("/").filter(Boolean)[0];
return mount ? toVPath(v.storageId, `/${mount}`) : rootReal;
}
interface CheckCtx {
abs: string | null;
rootReal: string | null;
@@ -234,7 +270,8 @@ export async function deleteFiles(
await recheck(abs, ctx.rootReal!);
let where: string | null = null;
if (body.mode === "permanent") {
await rm(abs, { recursive: true, force: false });
if (isRemotePath(abs)) await vremove(abs);
else await rm(abs, { recursive: true, force: false });
deleted.push(check.path);
} else {
const row = await moveToTrash(d, principal, check, ctx, expiresAt);
@@ -379,6 +416,16 @@ async function moveToTrash(
const abs = ctx.abs!;
const id = randomUUID();
const stamp = day(d.ao.ports.now());
if (isRemotePath(abs)) {
// Alist:同存储 move 进 <根>/.umt-trash(MP 无回收站;Alist 驱动自己的回收站不依赖)
const base = trashBase(ctx.rootReal!, abs);
const rel = base === ctx.rootReal ? check.path : relOfRoot(base, abs);
const trashPath = join(base, TRASH_DIR, stamp, id, rel);
await vmkdirp(dirname(trashPath));
const r = await vrenameNoClobber(abs, trashPath);
if (r.status !== "moved") throw new FilesError("exists", `移入回收站失败(${r.status}):${trashPath}`);
return insertTrashRow(d, principal, check, ctx, { id, rootHost: base, trashPath, expiresAt });
}
let trashPath = join(ctx.rootReal!, TRASH_DIR, stamp, id, check.path);
let moved = false;
try {
@@ -404,13 +451,24 @@ async function moveToTrash(
await copyTree(d, abs, trashPath, files);
await rm(abs, { recursive: true });
}
return insertTrashRow(d, principal, check, ctx, { id, rootHost: ctx.rootReal!, trashPath, expiresAt });
}
function insertTrashRow(
d: FmDeps,
principal: Principal | undefined,
check: FileDeleteCheck,
ctx: CheckCtx,
x: { id: string; rootHost: string; trashPath: string; expiresAt: Date },
): Promise<TrashItemRow> {
const { id, trashPath, expiresAt } = x;
return d.ctx.db
.insertInto("trash_item")
.values({
id,
root_id: check.root,
library_root_id: ctx.libraryRootId,
root_host_path: ctx.rootReal!,
root_host_path: x.rootHost,
original_path: check.path,
trash_path: trashPath,
is_dir: check.isDir,
@@ -477,6 +535,11 @@ function trashPathSafe(d: FmDeps, row: TrashItemRow): boolean {
*/
export async function mkdirWithin(rootReal: string, rel: string): Promise<void> {
if (rel === "" || rel === ".") return;
if (isRemotePath(rootReal)) {
if (rel.split("/").includes("..")) throw new FilesError("path_escape", `路径含 ..: ${rel}`);
await vmkdirp(join(rootReal, rel));
return;
}
let cur = rootReal;
for (const seg of rel.split("/")) {
if (seg === "" || seg === ".") continue;
@@ -494,6 +557,13 @@ export async function mkdirWithin(rootReal: string, rel: string): Promise<void>
}
}
const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1));
const sameStorageOf = (a: string, b: string) => parseVPath(a)?.storageId === parseVPath(b)?.storageId;
/** <…>/.umt-trash/<yyyymmdd>/<uuid>:回收站路径里 uuid 那一级 */
function trashUuidDir(row: TrashItemRow): string | null {
const segs = row.trash_path.split("/");
const i = segs.indexOf(row.id);
return i > 0 ? segs.slice(0, i + 1).join("/") : null;
}
export async function restoreTrash(
d: FmDeps,
@@ -537,8 +607,14 @@ export async function restoreTrash(
);
dst = parent.abs;
}
if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择「还原到…」");
const r = await moveSafe(row.trash_path, dst);
if (await (isRemotePath(dst) ? vlstat(dst) : lstat(dst)).catch(() => null))
throw new FilesError("exists", "原位置已被占用,可选择「还原到…」");
if (
isRemotePath(dst) !== isRemotePath(row.trash_path) ||
(isRemotePath(dst) && !sameStorageOf(dst, row.trash_path))
)
throw new FilesError("bad_request", "回收站里的项只能还原到它原来所在的存储");
const r = await vrenameNoClobber(row.trash_path, dst);
if (r.status === "exists") throw new FilesError("exists", "原位置已被占用");
if (r.status === "exdev") {
// 跨卷还原:与跨卷进回收站同一上限(FILES_TRASH_CROSS_MAX_BYTES),复制经 fsproxy,核验后删回收站副本
@@ -601,10 +677,17 @@ export async function purgeTrash(
for (const row of rows) {
try {
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
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);
const uuidDir = trashUuidDir(row);
if (isRemotePath(row.trash_path)) {
await vremove(row.trash_path).catch((e) => {
if (errnoOf(e) !== "ENOENT") throw e;
});
if (uuidDir) await vremove(uuidDir).catch(() => undefined);
} else {
await rm(row.trash_path, { recursive: true, force: true });
if (uuidDir) await rm(uuidDir, { recursive: true, force: true }).catch(() => undefined);
}
const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst();
if (gone) purged.push(row.id);
} catch (e) {