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
This commit is contained in:
1 parent
5ff24110f8
commit
c2bf8a071a
20 files changed
+1625
-8
No files matched your search
@@ -0,0 +1,170 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user