Files
UHD_Media_Tool/apps/server/src/storage/registry.ts
T
rootandClaude Opus 5.5 c2bf8a071a feat(storage): 存储实例与 Alist 虚拟路径(迁移 0090、设置接口、注册表、vfs、健康检查)
- storage_instance 表 + secret 用途 storage_credential;令牌 / 密码 / 路径密码只写不读
- /storage-instances CRUD 与测试连接(版本、用户、根路径、容量);被库根引用不能删
- 远端路径 /@alist/<id>/<路径>,vfs 统一本地 / Alist 的 stat、列目录、建目录、读写、移动、删除
- 整理矩阵中涉及 Alist 的传输(storage/transfer.ts)与远端 ffprobe(直链只读头)
- 媒体库目录可登记在 Alist 上(不写哨兵,目录可访问即在线);健康检查列出存储实例

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 23:52:20 +08:00

171 lines
6.2 KiB
TypeScript

import { type AlistAuth, AlistClient, AlistError } from "@umt/adapters";
import { STORAGE_OPTION_DEFAULTS, type StorageOptions } from "@umt/contracts";
import type { DB, Kysely, StorageInstanceRow } from "@umt/db";
import type { ServerContext } from "../modules/types.ts";
// 存储实例注册表(docs/design/storage-alist.md §3):按 id 取实例 + 解密凭据 → AlistClient。
// 进程级:模块装配时 configureStorage(ctx);实例行按 updated_at 缓存,配置一改客户端重建(令牌缓存随之作废,同 MP init_storage)。
export interface StorageEntry {
id: string;
name: string;
enabled: boolean;
/** 本系统可用的 Alist 根路径 */
rootPath: string;
options: Required<StorageOptions>;
client: AlistClient;
/** 日志脱敏要遮的秘密值 */
secrets: string[];
}
interface Source {
dataDir?: string | undefined;
db: Kysely<DB>;
secrets: ServerContext["secrets"];
logger?: ServerContext["logger"] | undefined;
}
let source: Source | null = null;
const cache = new Map<string, { stamp: string; entry: StorageEntry; checkedAt: number }>();
const overrides = new Map<string, StorageEntry>();
const RECHECK_MS = 5_000;
/** 模块装配时调用(幂等;最后一次为准) */
export function configureStorage(
ctx: Pick<ServerContext, "db" | "secrets"> & {
logger?: ServerContext["logger"];
config?: Partial<Pick<ServerContext["config"], "DATA_DIR">>;
},
) {
if (source?.db !== ctx.db) cache.clear();
source = { db: ctx.db, secrets: ctx.secrets, logger: ctx.logger, dataDir: ctx.config?.DATA_DIR };
}
/** 数据目录(跨存储中转、上传暂存放在 <DATA_DIR>/cache/storage-relay) */
export const storageDataDir = (): string | undefined => source?.dataDir;
/** 测试用:直接给某个存储 id 指定客户端(mock Alist) */
export function overrideStorage(
id: string,
e: { client: AlistClient; name?: string; rootPath?: string; options?: StorageOptions } | null,
): void {
if (!e) {
overrides.delete(id);
return;
}
overrides.set(id, {
id,
name: e.name ?? "Alist",
enabled: true,
rootPath: e.rootPath ?? "/",
options: { ...STORAGE_OPTION_DEFAULTS, ...(e.options ?? {}) },
client: e.client,
secrets: [],
});
}
export function clearStorageCache(id?: string): void {
if (id) cache.delete(id);
else cache.clear();
}
export const storageOptions = (o: unknown): Required<StorageOptions> => ({
...STORAGE_OPTION_DEFAULTS,
...((o && typeof o === "object" ? o : {}) as StorageOptions),
});
/** 由实例行 + 明文凭据建客户端(测试连接、注册表共用) */
export function buildClient(
row: Pick<StorageInstanceRow, "base_url" | "auth_kind" | "username" | "options">,
plain: { credential: string | null; pathPassword: string | null },
logger?: ServerContext["logger"],
): AlistClient {
const o = storageOptions(row.options);
let auth: AlistAuth;
if (row.auth_kind === "token") {
if (!plain.credential) throw new AlistError("auth", "没有配置令牌");
auth = { kind: "token", token: plain.credential };
} else if (row.auth_kind === "password") {
if (!row.username || !plain.credential) throw new AlistError("auth", "账号密码认证需要用户名和密码");
auth = { kind: "password", username: row.username, password: plain.credential };
} else auth = { kind: "guest" };
return new AlistClient({
baseUrl: row.base_url,
auth,
pathPassword: plain.pathPassword,
qps: o.qps,
concurrency: o.concurrency,
timeoutMs: o.timeoutSec * 1000,
idleTimeoutMs: o.idleTimeoutSec * 1000,
taskTimeoutMs: o.taskTimeoutMin * 60_000,
retries: o.retries,
uploadAsTask: o.uploadAsTask,
...(logger
? {
logger: {
debug: (obj, msg) => logger.debug(obj, msg),
warn: (obj, msg) => logger.warn(obj, msg),
},
}
: {}),
});
}
export async function resolvePlain(
secrets: Source["secrets"],
row: Pick<StorageInstanceRow, "secret_ref" | "path_password_ref">,
) {
return {
credential: row.secret_ref ? await secrets.resolve(row.secret_ref) : null,
pathPassword: row.path_password_ref ? await secrets.resolve(row.path_password_ref) : null,
};
}
/** 取存储实例;不存在 / 停用 / 未装配抛 AlistError(unavailable,按「存储离线」处理,不当成文件不存在) */
export async function storageEntry(id: string, opts: { allowDisabled?: boolean } = {}): Promise<StorageEntry> {
const o = overrides.get(id);
if (o) return o;
if (!source) throw new AlistError("unavailable", "存储注册表未初始化");
const hit = cache.get(id);
if (hit && Date.now() - hit.checkedAt < RECHECK_MS) return check(hit.entry, opts);
const row = await source.db.selectFrom("storage_instance").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new AlistError("unavailable", `存储实例不存在:${id}`);
const stamp = new Date(row.updated_at).toISOString();
if (hit && hit.stamp === stamp) {
hit.checkedAt = Date.now();
return check(hit.entry, opts);
}
const plain = await resolvePlain(source.secrets, row);
const entry: StorageEntry = {
id: row.id,
name: row.name,
enabled: row.enabled,
rootPath: row.root_path,
options: storageOptions(row.options),
client: buildClient(row, plain, source.logger),
secrets: [plain.credential, plain.pathPassword].filter((s): s is string => !!s),
};
cache.set(id, { stamp, entry, checkedAt: Date.now() });
return check(entry, opts);
}
function check(e: StorageEntry, opts: { allowDisabled?: boolean }): StorageEntry {
if (!e.enabled && !opts.allowDisabled) throw new AlistError("unavailable", `存储实例已停用:${e.name}`);
return e;
}
/** 启用的实例(文件管理根、健康检查) */
export async function listStorages(db?: Kysely<DB>): Promise<StorageInstanceRow[]> {
const d = db ?? source?.db;
if (!d) return [];
return d.selectFrom("storage_instance").selectAll().orderBy("name").execute();
}
/** 存储名表(展示「存储名:/路径」) */
export async function storageNames(db?: Kysely<DB>): Promise<Map<string, string>> {
const m = new Map<string, string>();
for (const [id, e] of overrides) m.set(id, e.name);
for (const r of await listStorages(db)) m.set(r.id, r.name);
return m;
}