import { Api, GlobalSettings, type GlobalSettingsUpdate, mergeSettings, type SettingsKey } from "@umt/contracts"; import { ApiException } from "../../http/errors.ts"; import type { ModuleApp, ServerContext } from "../types.ts"; import { audit, endpoint } from "./endpoint.ts"; import { type CachedSettingsReader, SETTINGS_CHANGED_EVENT } from "./provided.ts"; // GET/PATCH /settings:按分组部分更新 → mergeSettings → GlobalSettings 整体校验 → 逐组 upsert; // Pi 上游密钥(pi.apiKey 只写)存为 secret,设置里只留 apiKeySecretRef;变更经 SSE 总线广播 settings.changed, // 各进程的 ctx.settings 缓存随之失效。自动化总开关 automation.enabled / 只读对账 automation.readOnlyReconcile 也走这里。 const isCached = (s: unknown): s is CachedSettingsReader => typeof (s as CachedSettingsReader)?.invalidate === "function"; export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) { endpoint(app, { ...Api.settings.get, tags: ["settings"] }, async () => { if (isCached(ctx.settings)) return await ctx.settings.fresh(); return await ctx.settings.all(); }); endpoint(app, { ...Api.settings.update, tags: ["settings"] }, async ({ body, actor }) => { const patch = body as GlobalSettingsUpdate; const current = isCached(ctx.settings) ? await ctx.settings.fresh() : await ctx.settings.all(); const merged = mergeSettings(current, patch); const oldRef = current.pi.apiKeySecretRef; let dropRef: string | null = null; let newRef: string | null = null; const apiKey = patch.pi?.apiKey; if (apiKey === null) { merged.pi.apiKeySecretRef = null; dropRef = oldRef; } // TMDB 密钥同样只写:存 secret(metadata_api_key),设置里只留 tmdbApiKeySecretRef const oldTmdbRef = current.metadata.tmdbApiKeySecretRef; const tmdbKey = patch.metadata?.tmdbApiKey; let dropTmdbRef: string | null = null; let newTmdbRef: string | null = null; if (tmdbKey === null) { merged.metadata.tmdbApiKeySecretRef = null; dropTmdbRef = oldTmdbRef; } const parsed = GlobalSettings.safeParse(merged); if (!parsed.success) { const msg = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "); throw new ApiException("validation_failed", msg); } const next = parsed.data; if (typeof apiKey === "string") { if (oldRef) { await ctx.secrets.replace(oldRef, apiKey); next.pi.apiKeySecretRef = oldRef; } else { newRef = await ctx.secrets.create({ purpose: "llm_api_key", plaintext: apiKey }); next.pi.apiKeySecretRef = newRef; } } if (typeof tmdbKey === "string") { if (oldTmdbRef) { await ctx.secrets.replace(oldTmdbRef, tmdbKey); next.metadata.tmdbApiKeySecretRef = oldTmdbRef; } else { newTmdbRef = await ctx.secrets.create({ purpose: "metadata_api_key", plaintext: tmdbKey, label: "tmdb" }); next.metadata.tmdbApiKeySecretRef = newTmdbRef; } } const keys = (Object.keys(patch) as SettingsKey[]).filter((k) => patch[k] !== undefined); try { await ctx.db.transaction().execute(async (trx) => { for (const key of keys) { await trx .insertInto("settings") .values({ key, value: JSON.stringify(next[key]), updated_at: new Date() }) .onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify(next[key]), updated_at: new Date() }), ) .execute(); } }); } catch (err) { if (newRef) await ctx.secrets.remove(newRef).catch(() => {}); if (newTmdbRef) await ctx.secrets.remove(newTmdbRef).catch(() => {}); throw err; } if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {}); if (dropTmdbRef) await ctx.secrets.remove(dropTmdbRef).catch(() => {}); if (isCached(ctx.settings)) ctx.settings.invalidate(); void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys }); // 审计里不留明文密钥 const logged: Record = { ...patch }; if (patch.pi && "apiKey" in patch.pi) logged.pi = { ...patch.pi, apiKey: apiKey === null ? "(cleared)" : "(set)" }; if (patch.metadata && "tmdbApiKey" in patch.metadata) logged.metadata = { ...patch.metadata, tmdbApiKey: tmdbKey === null ? "(cleared)" : "(set)" }; await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged }); return next; }); }