- 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
171 lines
6.2 KiB
TypeScript
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;
|
|
}
|