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

+15 -8
View File
@@ -1,9 +1,9 @@
import { lstat, mkdir } from "node:fs/promises";
import { basename, dirname, extname, join } from "node:path";
import { moveSafe } from "@umt/adapters";
import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts";
import { sanitizeSegment } from "@umt/domain";
import type { Principal } from "../../http/auth.ts";
import { isRemotePath, vexists, vlstat, vmkdirp, vrenameNoClobber } from "../../storage/vfs.ts";
import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts";
import { audit, filesChanged } from "./ledger.ts";
import { downloadsFor, isWritableTarget, kindOfName, managedDownloads, sidecarsOf } from "./list.ts";
@@ -31,7 +31,10 @@ export async function mkdirIn(
await recheck(r.abs, r.rootReal);
const abs = join(r.abs, body.name);
try {
await mkdir(abs);
if (r.def.storageId) {
if (await vexists(abs)) throw Object.assign(new Error("exists"), { code: "EEXIST" });
await vmkdirp(abs);
} else await mkdir(abs);
} catch (e) {
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在:${body.name}`);
throw new FilesError("bad_request", `创建目录失败:${errMessage(e)}`);
@@ -88,7 +91,9 @@ export async function renameEntry(
// 不覆盖:任一目标已存在 → 409,原文件不动
const taken: string[] = [];
for (const p of pairs) {
const st = await lstat(p.toAbs).catch((e) => (errnoOf(e) === "ENOENT" ? null : e));
const st = await (isRemotePath(p.toAbs) ? vlstat(p.toAbs) : lstat(p.toAbs)).catch((e) =>
errnoOf(e) === "ENOENT" ? null : e,
);
if (st) taken.push(p.to);
}
if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖:${taken.join(", ")}`);
@@ -116,17 +121,19 @@ export async function renameEntry(
// 执行:逐个 no-clobber 改名;任一失败回滚已改的
const done: Pair[] = [];
const rollback = async () => {
for (const p of done.reverse()) await moveSafe(p.toAbs, p.fromAbs).catch(() => undefined);
for (const p of done.reverse()) await vrenameNoClobber(p.toAbs, p.fromAbs).catch(() => undefined);
};
for (const p of pairs) {
try {
await recheck(p.fromAbs, r.rootReal);
const before = await lstat(p.fromAbs);
const res = await moveSafe(p.fromAbs, p.toAbs);
const remote = isRemotePath(p.fromAbs);
const before = remote ? null : await lstat(p.fromAbs);
const res = await vrenameNoClobber(p.fromAbs, p.toAbs);
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖:${p.to}`);
if (res.status !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`);
const after = await lstat(p.toAbs);
if (after.ino !== before.ino || after.dev !== before.dev)
// 远端没有 inode:Alist 改名接口成功即算成功(同 MP)
const after = remote ? null : await lstat(p.toAbs);
if (before && after && (after.ino !== before.ino || after.dev !== before.dev))
throw new FilesError("bad_request", `重命名后 inode 不一致,已中止:${p.to}`);
done.push(p);
} catch (e) {