Files
UHD_Media_Tool/apps/server/src/storage/transfer.ts
T
rootandClaude Opus 5.5 521c86d730 fix(storage): 本地写入口拒绝 Alist 虚拟路径,补字幕在 Alist 库根上明确失败
虚拟前缀 /@alist/… 读到本地是 ENOENT,但 mkdir -p、复制 / 移动目标、写文件、回收站落点会在本机真建出
/@alist 并写入文件。统一守卫:任何本地写类操作参数是虚拟路径即抛 REMOTE_PATH_ON_LOCAL_FS
(remote_path_on_local_fs),不落盘。

- adapters:fs/remote-guard.ts,接在 runFsOp、FsProxy.run(父进程拒绝不起子进程)、copyWithVerify、
  crossDeviceMove、moveSafe、commitNoClobber、sweepStalePartials、writeRootSentinel
- server:storage/local-fs.ts 带守卫的写函数,整理 / 替换 / 刮削 / 回收站 / 上传 / 字幕 / vfs 本地分支改用它
- 补字幕目标在 Alist 媒体库上返回失败原因,撤销同样拒绝

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 00:35:41 +08:00

279 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomUUID } from "node:crypto";
import { basename, dirname, join, posix } from "node:path";
import { AlistError, type ProgressCallback } from "@umt/adapters";
import { mkdir, rename, rm, stat, unlink } from "./local-fs.ts";
import { invalidateRemote, relayDir, remote, remoteError } from "./vfs.ts";
import { parseVPath, sameStorage } from "./vpath.ts";
// 整理矩阵中涉及 Alist 的方向(docs/design/storage-alist.md §5):
// 本地→Alist 上传、Alist→本地 下载、同实例 Alist→Alist API copy / move、跨实例 下载→上传。
// 成功判定与 MP 一致(用户决定):接口成功 + 目标存在,不比对大小 / 哈希;移动在判定通过后删源,删源失败 = attention。
// 本地 ↔ 本地不经这里(仍走 fsproxy 与现有核验)。
export interface RemoteTransferInput {
kind: "copy" | "move";
src: string;
dst: string;
/** 目标已存在:conflict 失败;replace 先把旧文件移到 replaceTrashDir(同存储),成功后旧文件留在那里,失败移回 */
onExisting: "conflict" | "replace";
replaceTrashDir?: string;
/** 本次是重试(上次可能已完成外部动作) */
resumed?: boolean;
signal?: AbortSignal;
onProgress?: ProgressCallback;
}
export interface RemoteTransferResult {
status: "succeeded" | "failed" | "attention";
bytes?: number;
/** 顺带得到的本地文件 sha256(上传的源 / 下载的结果),只是事实记录 */
checksum?: string;
resumed?: boolean;
error?: { code: string; message: string };
replaced?: { disposition: "trash"; path: string; size: number; mtimeMs: number };
steps?: { step: string; status: string; at: string; detail?: string; error?: { code: string; message: string } }[];
via: "upload" | "download" | "alist_copy" | "alist_move" | "relay";
}
/** Alist / 本地错误 → 整理账本错误码(可重试的 IO/TIMEOUT 走任务重试) */
export function transferErrorCode(e: unknown, role: "源" | "目标" | null = null): { code: string; message: string } {
if (e instanceof AlistError) {
const code =
e.kind === "not_found"
? role === "源"
? "SOURCE_MISSING"
: "IO"
: e.kind === "conflict"
? "EEXIST"
: e.kind === "auth"
? "EACCES"
: e.kind === "timeout" || e.kind === "rate_limited"
? "TIMEOUT"
: e.kind === "aborted"
? "ABORTED"
: "IO";
return { code, message: `Alist:${e.message}` };
}
const c = (e as { code?: string } | null)?.code;
const message = (e as Error)?.message ?? String(e);
if (c === "ENOENT") return { code: role === "源" ? "SOURCE_MISSING" : "IO", message };
if (c === "EEXIST") return { code: "EEXIST", message };
if (c === "EACCES" || c === "EPERM") return { code: "EACCES", message };
if (c === "ENOSPC") return { code: "ENOSPC", message };
if (c === "ETIMEDOUT") return { code: "TIMEOUT", message };
if (c === "ABORT_ERR") return { code: "ABORTED", message };
return { code: "IO", message };
}
const now = () => new Date().toISOString();
async function localExists(p: string): Promise<{ size: number; mtimeMs: number } | null> {
try {
const s = await stat(p);
return { size: s.size, mtimeMs: s.mtimeMs };
} catch (e) {
if ((e as { code?: string }).code === "ENOENT") return null;
throw e;
}
}
/** 任一端是远端路径? */
export const involvesRemote = (src: string, dst: string) => !!parseVPath(src) || !!parseVPath(dst);
/** 执行一次涉及远端的复制 / 移动 */
export async function remoteTransfer(x: RemoteTransferInput): Promise<RemoteTransferResult> {
const rs = parseVPath(x.src);
const rd = parseVPath(x.dst);
if (rs) invalidateRemote(x.src);
if (rd) invalidateRemote(x.dst);
const via: RemoteTransferResult["via"] =
rs && rd
? sameStorage(x.src, x.dst)
? x.kind === "copy"
? "alist_copy"
: "alist_move"
: "relay"
: rd
? "upload"
: "download";
const fail = (e: unknown, role: "源" | "目标" | null = null): RemoteTransferResult => ({
status: "failed",
error: transferErrorCode(e, role),
via,
});
// ── 源 / 目标状态 ──
let srcSize = 0;
let srcExists = true;
try {
if (rs) {
const { client } = await remote(x.src);
const o = await client.stat(rs.remote);
srcExists = !!o;
srcSize = o?.size ?? 0;
if (o?.isDir) return fail(new AlistError("error", `源是目录:${x.src}`));
} else {
const s = await localExists(x.src);
srcExists = !!s;
srcSize = s?.size ?? 0;
}
} catch (e) {
return fail(e, "源");
}
let dstFacts: { size: number; mtimeMs: number } | null;
try {
if (rd) {
const { client } = await remote(x.dst);
const o = await client.stat(rd.remote);
dstFacts = o ? { size: o.size, mtimeMs: o.modified } : null;
} else dstFacts = await localExists(x.dst);
} catch (e) {
return fail(e, "目标");
}
// 对账:上次尝试已完成外部动作但没留下结果(目标都是「临时名 → 改名」落地的,目标在 = 上次已写完)
if (dstFacts && x.resumed && x.onExisting !== "replace") {
if (x.kind === "move" && srcExists) {
const del = await deleteSource(x.src).then(
() => null,
(e) => transferErrorCode(e),
);
if (del)
return {
status: "attention",
bytes: dstFacts.size,
resumed: true,
via,
steps: [{ step: "delete_source", status: "failed", at: now(), error: del }],
};
}
return { status: "succeeded", bytes: dstFacts.size, resumed: true, via };
}
if (!srcExists) {
if (x.kind === "move" && dstFacts) return { status: "succeeded", bytes: dstFacts.size, resumed: true, via };
return { status: "failed", error: { code: "SOURCE_MISSING", message: `源文件不存在:${x.src}` }, via };
}
if (dstFacts && x.onExisting !== "replace")
return { status: "failed", error: { code: "EEXIST", message: `目标已存在:${x.dst}` }, via };
// ── 替换:旧目标先移走 ──
let parked: { path: string; size: number; mtimeMs: number } | null = null;
if (dstFacts && x.onExisting === "replace") {
const trashDir = x.replaceTrashDir ?? join(dirname(x.dst), ".umt-trash");
const to = join(trashDir, `replaced-${Date.now()}-${randomUUID().slice(0, 6)}`, basename(x.dst));
try {
await parkOld(x.dst, to);
parked = { path: to, size: dstFacts.size, mtimeMs: dstFacts.mtimeMs };
} catch (e) {
return fail(e, "目标");
}
}
const restoreParked = async () => {
if (parked) await parkOld(parked.path, x.dst).catch(() => undefined);
};
// ── 传输 ──
let bytes = srcSize;
let checksum: string | undefined;
try {
if (rd && !rs) {
const { client } = await remote(x.dst);
const r = await client.upload(x.src, rd.remote, {
...(x.signal ? { signal: x.signal } : {}),
...(x.onProgress ? { onProgress: x.onProgress } : {}),
});
bytes = r.bytes;
checksum = `sha256:${r.hashes.sha256}`;
} else if (rs && !rd) {
await mkdir(dirname(x.dst), { recursive: true });
const { client } = await remote(x.src);
const r = await client.download(rs.remote, x.dst, {
...(x.signal ? { signal: x.signal } : {}),
...(x.onProgress ? { onProgress: x.onProgress } : {}),
});
bytes = r.bytes;
checksum = `sha256:${r.sha256}`;
} else if (rs && rd && sameStorage(x.src, x.dst)) {
const { client } = await remote(x.src);
const opts = x.signal ? { signal: x.signal } : {};
const o =
x.kind === "copy"
? await client.copyFile(rs.remote, rd.remote, opts)
: await client.moveFile(rs.remote, rd.remote, opts);
bytes = o.size || srcSize;
} else if (rs && rd) {
// 跨实例:下载到本机中转 → 上传(MP 不支持,我们补上)
const tmpDir = join(relayDir(), randomUUID());
await mkdir(tmpDir, { recursive: true });
const tmp = join(tmpDir, posix.basename(rs.remote));
try {
const a = await remote(x.src);
await a.client.download(rs.remote, tmp, {
...(x.signal ? { signal: x.signal } : {}),
...(x.onProgress ? { onProgress: (d, t) => x.onProgress?.(d / 2, t) } : {}),
});
const b = await remote(x.dst);
const r = await b.client.upload(tmp, rd.remote, {
...(x.signal ? { signal: x.signal } : {}),
...(x.onProgress ? { onProgress: (d, t) => x.onProgress?.(t / 2 + d / 2, t) } : {}),
});
bytes = r.bytes;
checksum = `sha256:${r.hashes.sha256}`;
} finally {
await rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
}
}
} catch (e) {
await restoreParked();
return fail(e);
}
const out: RemoteTransferResult = {
status: "succeeded",
bytes,
...(checksum ? { checksum } : {}),
via,
...(parked ? { replaced: { disposition: "trash" as const, ...parked } } : {}),
};
// 同实例 API move 自己删了源;其它移动在成功后删源
if (x.kind === "move" && via !== "alist_move") {
const del = await deleteSource(x.src).then(
() => null,
(e) => transferErrorCode(e),
);
if (del)
return {
...out,
status: "attention",
steps: [{ step: "delete_source", status: "failed", at: now(), error: del }],
};
}
return out;
}
async function deleteSource(src: string): Promise<void> {
const v = parseVPath(src);
if (!v) {
await unlink(src);
return;
}
const { client } = await remote(src);
try {
await client.removePath(v.remote);
} catch (e) {
throw remoteError(e, src);
}
}
/** 同存储内把 from 移到 to(替换时暂放旧文件 / 失败移回) */
async function parkOld(from: string, to: string): Promise<void> {
const a = parseVPath(from);
const b = parseVPath(to);
if (!a && !b) {
await mkdir(dirname(to), { recursive: true });
await rename(from, to);
return;
}
if (!a || !b || a.storageId !== b.storageId) throw new AlistError("error", "替换暂存必须与目标在同一存储");
const { client } = await remote(from);
await client.moveFile(a.remote, b.remote);
}