From f4d98970729418ce7f32b310b103d399e73b387a Mon Sep 17 00:00:00 2001 From: john Date: Sun, 12 Jul 2026 09:51:13 +0800 Subject: [PATCH] feat: add optional asset gateway control plane --- admin-bootstrap.mjs | 8 +- admin-routes.mjs | 28 ++++++ admin-routes.test.mjs | 42 ++++++++ admin-server.mjs | 1 + asset-gateway.mjs | 213 +++++++++++++++++++++++++++++++++++++++++ asset-gateway.test.mjs | 102 ++++++++++++++++++++ db.mjs | 33 +++++++ schema.sql | 23 +++++ 8 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 asset-gateway.mjs create mode 100644 asset-gateway.test.mjs diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index a7175d1..002cf92 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -13,9 +13,10 @@ // domain logic has a single source of truth; only the wiring differs. import path from 'node:path'; import { createSubscriptionService } from './billing-subscription.mjs'; -import { createDbPool, isDatabaseConfigured } from './db.mjs'; +import { createDbPool, ensureAssetGatewaySchema, isDatabaseConfigured } from './db.mjs'; import { createUserAuth } from './user-auth.mjs'; import { createLlmProviderService } from './llm-providers.mjs'; +import { createAssetGatewayConfigService } from './asset-gateway.mjs'; import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs'; import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs'; @@ -55,6 +56,9 @@ export async function createAdminServices(env = {}) { ); const pool = createDbPool(); + // The back-office process can boot before the public Portal. Create only the + // optional control-plane tables here instead of requiring the public boot path. + await ensureAssetGatewaySchema(pool); // --- plaza graph (review queue, reports, featured, analytics, creators) --- const plazaRedis = createNoopPlazaRedis(); @@ -94,6 +98,7 @@ export async function createAdminServices(env = {}) { } const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret }); + const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService }); const memoryV2ConfigService = createMemoryV2AdminConfigService(pool); const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); const adminSystemTestService = createAdminSystemTestService({ @@ -131,6 +136,7 @@ export async function createAdminServices(env = {}) { pool, userAuth, llmProviderService, + assetGatewayConfigService, memoryV2ConfigService, wechatScheduleLlmConfigService, adminSystemTestService, diff --git a/admin-routes.mjs b/admin-routes.mjs index e55da58..ae245a8 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -42,6 +42,7 @@ export function createAdminApi({ ready, userAuth, llmProviderService, + assetGatewayConfigService, memoryV2ConfigService, adminSystemTestService, plazaPosts, @@ -91,6 +92,33 @@ export function createAdminApi({ res.json({ summary: { ...summary, llm } }); }); + adminApi.get('/asset-gateway/config', requireAdmin, async (_req, res) => { + if (!assetGatewayConfigService?.getConfig) { + return res.status(503).json({ message: '资产能力配置服务未启用' }); + } + return res.json(await assetGatewayConfigService.getConfig()); + }); + + adminApi.put('/asset-gateway/config', requireAdmin, async (req, res) => { + if (!assetGatewayConfigService?.updateGlobalConfig) { + return res.status(503).json({ message: '资产能力配置服务未启用' }); + } + return res.json(await assetGatewayConfigService.updateGlobalConfig(req.body ?? {}, { + updatedBy: req.currentUser.id, + })); + }); + + adminApi.put('/asset-gateway/plugins/:pluginId', requireAdmin, async (req, res) => { + if (!assetGatewayConfigService?.updatePluginConfig) { + return res.status(503).json({ message: '资产能力配置服务未启用' }); + } + const result = await assetGatewayConfigService.updatePluginConfig(req.params.pluginId, req.body ?? {}, { + updatedBy: req.currentUser.id, + }); + if (!result.ok) return res.status(400).json({ message: result.message }); + return res.json(result); + }); + adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => { if (!memoryV2ConfigService?.getAdminConfig) { return res.status(503).json({ message: 'Memory V2 配置服务未启用' }); diff --git a/admin-routes.test.mjs b/admin-routes.test.mjs index 04301f2..d742f43 100644 --- a/admin-routes.test.mjs +++ b/admin-routes.test.mjs @@ -90,6 +90,48 @@ test('admin memory-v2 config routes expose config and runtime state', async () = } }); +test('admin asset gateway routes preserve an explicit, admin-only control plane', async () => { + const calls = []; + const router = createAdminApi({ + jsonBody: express.json(), + getToken() { return 'token-admin'; }, + userAuth: { async getMe() { return { id: 'admin-1', role: 'admin' }; } }, + llmProviderService: null, + memoryV2ConfigService: null, + assetGatewayConfigService: { + async getConfig() { return { enabled: false, plugins: [] }; }, + async updateGlobalConfig(payload, context) { calls.push({ type: 'global', payload, context }); return { enabled: true }; }, + async updatePluginConfig(pluginId, payload, context) { + calls.push({ type: 'plugin', pluginId, payload, context }); + return { ok: true, config: { enabled: true, plugins: [] } }; + }, + }, + plazaPosts: null, + plazaOps: null, + wechatAdmin: null, + subscriptionService: null, + }); + const server = await startTestServer(router); + try { + const read = await fetch(`${server.baseUrl}/admin-api/asset-gateway/config`, { + headers: { cookie: 'h5_user_session=token-admin' }, + }); + assert.equal(read.status, 200); + assert.deepEqual(await read.json(), { enabled: false, plugins: [] }); + + const update = await fetch(`${server.baseUrl}/admin-api/asset-gateway/plugins/asset-generate`, { + method: 'PUT', + headers: { 'content-type': 'application/json', cookie: 'h5_user_session=token-admin' }, + body: JSON.stringify({ enabled: true, provider: 'flux-schnell' }), + }); + assert.equal(update.status, 200); + assert.equal(calls[0].pluginId, 'asset-generate'); + assert.equal(calls[0].context.updatedBy, 'admin-1'); + } finally { + await server.close(); + } +}); + test('admin system test route executes shared validation service', async () => { const calls = []; const router = createAdminApi({ diff --git a/admin-server.mjs b/admin-server.mjs index ca66418..cf5aacf 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -76,6 +76,7 @@ const CONSOLES = { getToken, userAuth: services.userAuth, llmProviderService: services.llmProviderService, + assetGatewayConfigService: services.assetGatewayConfigService, memoryV2ConfigService: services.memoryV2ConfigService, adminSystemTestService: services.adminSystemTestService, plazaPosts: services.plazaPosts, diff --git a/asset-gateway.mjs b/asset-gateway.mjs new file mode 100644 index 0000000..6f9be71 --- /dev/null +++ b/asset-gateway.mjs @@ -0,0 +1,213 @@ +import crypto from 'node:crypto'; + +export const ASSET_PLUGIN_CATALOG = [ + { + id: 'asset-search', + label: '素材检索', + description: '从已授权图库检索素材;不改变现有页面默认生成流程。', + providers: ['pexels', 'unsplash'], + supportsLlm: true, + }, + { + id: 'asset-generate', + label: '图片生成', + description: '异步生成页面所需图片;关闭或失败时必须降级为无图页面。', + providers: ['flux-schnell', 'comfyui'], + supportsLlm: true, + }, + { + id: 'asset-transform', + label: '图片处理', + description: '压缩、裁剪和格式转换等确定性处理。', + providers: ['sharp'], + supportsLlm: false, + }, + { + id: 'asset-analyze', + label: '图片理解', + description: '对图片进行描述、结构分析或辅助可访问性标注。', + providers: ['qwen25-vl'], + supportsLlm: true, + }, +]; + +const pluginById = new Map(ASSET_PLUGIN_CATALOG.map((plugin) => [plugin.id, plugin])); + +function normalizePluginId(value) { + const id = String(value ?? '').trim().toLowerCase(); + return pluginById.has(id) ? id : null; +} + +function normalizeProvider(plugin, value) { + const provider = String(value ?? '').trim().toLowerCase(); + return plugin?.providers.includes(provider) ? provider : null; +} + +function toBoolean(value, defaultValue = false) { + if (value === undefined || value === null) return defaultValue; + return Boolean(value); +} + +function rowToPluginConfig(row, providerKey = null) { + const plugin = pluginById.get(row.plugin_id); + return { + pluginId: row.plugin_id, + label: plugin?.label ?? row.plugin_id, + description: plugin?.description ?? '', + providers: plugin?.providers ?? [], + supportsLlm: Boolean(plugin?.supportsLlm), + enabled: Boolean(row.enabled), + provider: row.provider, + llmProviderKeyId: row.llm_provider_key_id ?? null, + llmProviderName: providerKey?.name ?? null, + llmProviderId: providerKey?.providerId ?? null, + llmModel: row.llm_model ?? null, + updatedAt: Number(row.updated_at ?? 0) || null, + }; +} + +/** + * Configuration-only control plane for optional asset plugins. + * + * This service deliberately does not invoke a model, external API, queue, or + * storage. Callers must opt in to it, which keeps the current chat and page + * generation path unchanged until a later adapter explicitly uses a plugin. + */ +export function createAssetGatewayConfigService(pool, { llmProviderService = null } = {}) { + async function getGlobalRow() { + const [rows] = await pool.query( + 'SELECT * FROM h5_asset_gateway_config WHERE config_key = ? LIMIT 1', + ['default'], + ); + return rows[0] ?? null; + } + + async function listRows() { + const [rows] = await pool.query('SELECT * FROM h5_asset_plugin_configs ORDER BY plugin_id'); + return rows; + } + + async function getRow(pluginId) { + const [rows] = await pool.query( + 'SELECT * FROM h5_asset_plugin_configs WHERE plugin_id = ? LIMIT 1', + [pluginId], + ); + return rows[0] ?? null; + } + + async function listKeyMap() { + if (!llmProviderService?.listKeys) return new Map(); + const keys = await llmProviderService.listKeys(); + return new Map(keys.map((key) => [key.id, key])); + } + + async function ensureLlmBinding({ plugin, keyId, model, enabled }) { + if (!plugin.supportsLlm && (keyId || model)) { + return { ok: false, message: `${plugin.label}不支持 LLM 绑定` }; + } + if (!enabled || (!keyId && !model)) return { ok: true }; + if (!keyId || !model) return { ok: false, message: 'LLM Provider 与模型必须同时选择' }; + if (!llmProviderService?.listKeys) return { ok: false, message: 'LLM Provider 服务未启用' }; + const key = (await llmProviderService.listKeys()).find((item) => item.id === keyId); + if (!key || key.status !== 'active') return { ok: false, message: 'LLM Provider 不存在或已禁用' }; + if (!key.models.includes(model)) return { ok: false, message: '模型不在该 Provider 支持列表中' }; + return { ok: true }; + } + + return { + catalog: ASSET_PLUGIN_CATALOG, + + async getConfig() { + const [globalRow, rows, keyMap] = await Promise.all([getGlobalRow(), listRows(), listKeyMap()]); + const byPlugin = new Map(rows.map((row) => [row.plugin_id, row])); + return { + enabled: Boolean(globalRow?.enabled), + updatedAt: Number(globalRow?.updated_at ?? 0) || null, + plugins: ASSET_PLUGIN_CATALOG.map((plugin) => { + const row = byPlugin.get(plugin.id); + return row + ? rowToPluginConfig(row, keyMap.get(row.llm_provider_key_id) ?? null) + : { + pluginId: plugin.id, + label: plugin.label, + description: plugin.description, + providers: plugin.providers, + supportsLlm: plugin.supportsLlm, + enabled: false, + provider: null, + llmProviderKeyId: null, + llmProviderName: null, + llmProviderId: null, + llmModel: null, + updatedAt: null, + }; + }), + }; + }, + + async updateGlobalConfig(payload = {}, { updatedBy = null } = {}) { + const enabled = toBoolean(payload.enabled); + const now = Date.now(); + const existing = await getGlobalRow(); + if (existing) { + await pool.query( + 'UPDATE h5_asset_gateway_config SET enabled = ?, updated_by = ?, updated_at = ? WHERE config_key = ?', + [enabled ? 1 : 0, updatedBy, now, 'default'], + ); + } else { + await pool.query( + 'INSERT INTO h5_asset_gateway_config (config_key, enabled, updated_by, updated_at) VALUES (?, ?, ?, ?)', + ['default', enabled ? 1 : 0, updatedBy, now], + ); + } + return this.getConfig(); + }, + + async updatePluginConfig(rawPluginId, payload = {}, { updatedBy = null } = {}) { + const pluginId = normalizePluginId(rawPluginId); + if (!pluginId) return { ok: false, message: '不支持的资产插件' }; + const plugin = pluginById.get(pluginId); + const enabled = toBoolean(payload.enabled); + const provider = normalizeProvider(plugin, payload.provider); + if (enabled && !provider) return { ok: false, message: '请选择该插件支持的 Provider' }; + + const keyId = String(payload.llmProviderKeyId ?? payload.keyId ?? '').trim() || null; + const model = String(payload.llmModel ?? payload.model ?? '').trim() || null; + const binding = await ensureLlmBinding({ plugin, keyId, model, enabled }); + if (!binding.ok) return binding; + + const existing = await getRow(pluginId); + const now = Date.now(); + if (existing) { + await pool.query( + `UPDATE h5_asset_plugin_configs + SET enabled = ?, provider = ?, llm_provider_key_id = ?, llm_model = ?, updated_by = ?, updated_at = ? + WHERE plugin_id = ?`, + [enabled ? 1 : 0, provider, keyId, model, updatedBy, now, pluginId], + ); + } else { + await pool.query( + `INSERT INTO h5_asset_plugin_configs + (id, plugin_id, enabled, provider, llm_provider_key_id, llm_model, updated_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [crypto.randomUUID(), pluginId, enabled ? 1 : 0, provider, keyId, model, updatedBy, now, now], + ); + } + return { ok: true, config: await this.getConfig() }; + }, + + async resolvePlugin(rawPluginId) { + const pluginId = normalizePluginId(rawPluginId); + if (!pluginId) return { ok: false, code: 'unsupported_plugin', message: '不支持的资产插件' }; + const global = await getGlobalRow(); + if (!global?.enabled) { + return { ok: false, code: 'gateway_disabled', message: '资产能力未启用,可安全降级到原有流程' }; + } + const row = await getRow(pluginId); + if (!row?.enabled) { + return { ok: false, code: 'plugin_disabled', message: '资产插件未启用,可安全降级到原有流程' }; + } + return { ok: true, plugin: rowToPluginConfig(row) }; + }, + }; +} diff --git a/asset-gateway.test.mjs b/asset-gateway.test.mjs new file mode 100644 index 0000000..ae372e7 --- /dev/null +++ b/asset-gateway.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createAssetGatewayConfigService } from './asset-gateway.mjs'; + +function createPool() { + let global = null; + const plugins = new Map(); + return { + async query(sql, params = []) { + const compact = sql.replace(/\s+/g, ' ').trim(); + if (compact.startsWith('SELECT * FROM h5_asset_gateway_config')) return [[global].filter(Boolean)]; + if (compact === 'SELECT * FROM h5_asset_plugin_configs ORDER BY plugin_id') { + return [[...plugins.values()].sort((a, b) => a.plugin_id.localeCompare(b.plugin_id))]; + } + if (compact.startsWith('SELECT * FROM h5_asset_plugin_configs WHERE plugin_id')) { + return [[plugins.get(params[0])].filter(Boolean)]; + } + if (compact.startsWith('INSERT INTO h5_asset_gateway_config')) { + global = { config_key: params[0], enabled: params[1], updated_by: params[2], updated_at: params[3] }; + return [{}]; + } + if (compact.startsWith('UPDATE h5_asset_gateway_config')) { + global = { ...global, enabled: params[0], updated_by: params[1], updated_at: params[2] }; + return [{}]; + } + if (compact.startsWith('INSERT INTO h5_asset_plugin_configs')) { + plugins.set(params[1], { + id: params[0], plugin_id: params[1], enabled: params[2], provider: params[3], + llm_provider_key_id: params[4], llm_model: params[5], updated_by: params[6], + created_at: params[7], updated_at: params[8], + }); + return [{}]; + } + if (compact.startsWith('UPDATE h5_asset_plugin_configs')) { + const pluginId = params[6]; + plugins.set(pluginId, { + ...plugins.get(pluginId), enabled: params[0], provider: params[1], + llm_provider_key_id: params[2], llm_model: params[3], updated_by: params[4], updated_at: params[5], + }); + return [{}]; + } + throw new Error(`Unexpected SQL: ${compact}`); + }, + }; +} + +const llmProviderService = { + async listKeys() { + return [{ + id: 'provider-key-1', name: 'Primary', providerId: 'custom', status: 'active', models: ['fast-model'], + }]; + }, +}; + +test('asset gateway stays disabled by default and never blocks callers that choose to fall back', async () => { + const service = createAssetGatewayConfigService(createPool(), { llmProviderService }); + const config = await service.getConfig(); + assert.equal(config.enabled, false); + assert.equal(config.plugins.length, 4); + assert.deepEqual(await service.resolvePlugin('asset-generate'), { + ok: false, + code: 'gateway_disabled', + message: '资产能力未启用,可安全降级到原有流程', + }); +}); + +test('each optional asset plugin can bind its own approved LLM model', async () => { + const service = createAssetGatewayConfigService(createPool(), { llmProviderService }); + await service.updateGlobalConfig({ enabled: true }, { updatedBy: 'admin-1' }); + const result = await service.updatePluginConfig('asset-generate', { + enabled: true, + provider: 'flux-schnell', + llmProviderKeyId: 'provider-key-1', + llmModel: 'fast-model', + }, { updatedBy: 'admin-1' }); + + assert.equal(result.ok, true); + const plugin = result.config.plugins.find((item) => item.pluginId === 'asset-generate'); + assert.equal(plugin.enabled, true); + assert.equal(plugin.provider, 'flux-schnell'); + assert.equal(plugin.llmModel, 'fast-model'); + assert.equal((await service.resolvePlugin('asset-generate')).ok, true); +}); + +test('asset plugins reject unknown providers and unsupported LLM bindings', async () => { + const service = createAssetGatewayConfigService(createPool(), { llmProviderService }); + const invalidProvider = await service.updatePluginConfig('asset-generate', { + enabled: true, + provider: 'unknown-provider', + }); + assert.equal(invalidProvider.ok, false); + assert.match(invalidProvider.message, /Provider/); + + const unsupportedLlm = await service.updatePluginConfig('asset-transform', { + enabled: true, + provider: 'sharp', + llmProviderKeyId: 'provider-key-1', + llmModel: 'fast-model', + }); + assert.equal(unsupportedLlm.ok, false); + assert.match(unsupportedLlm.message, /不支持 LLM/); +}); diff --git a/db.mjs b/db.mjs index 9ff9aef..7a72329 100644 --- a/db.mjs +++ b/db.mjs @@ -96,6 +96,35 @@ async function ensureForeignKeyDeleteRule( ); } +/** Creates only the optional asset control-plane tables needed by memind_adm. */ +export async function ensureAssetGatewaySchema(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_asset_gateway_config ( + config_key VARCHAR(32) PRIMARY KEY, + enabled TINYINT(1) NOT NULL DEFAULT 0, + updated_by CHAR(36) NULL, + updated_at BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_asset_plugin_configs ( + id CHAR(36) PRIMARY KEY, + plugin_id VARCHAR(64) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 0, + provider VARCHAR(64) NULL, + llm_provider_key_id CHAR(36) NULL, + llm_model VARCHAR(128) NULL, + updated_by CHAR(36) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_asset_plugin (plugin_id), + KEY idx_h5_asset_plugin_llm_provider (llm_provider_key_id), + CONSTRAINT fk_h5_asset_plugin_llm_provider FOREIGN KEY (llm_provider_key_id) + REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + export async function migrateSchema(pool) { const renames = [ ['h5_user_sessions', 'goose_session_id', 'agent_session_id'], @@ -217,6 +246,10 @@ export async function migrateSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + // Optional asset capability control plane. These rows configure no runtime + // worker by themselves; the existing chat/page path remains independent. + await ensureAssetGatewaySchema(pool); + // Shared experience store (etat C). Keep in sync with schema.sql. await pool.query(` CREATE TABLE IF NOT EXISTS h5_experience ( diff --git a/schema.sql b/schema.sql index 8972707..3f7a20a 100644 --- a/schema.sql +++ b/schema.sql @@ -605,6 +605,29 @@ CREATE TABLE IF NOT EXISTS h5_llm_executor_bindings ( CONSTRAINT fk_h5_llm_executor_provider FOREIGN KEY (provider_key_id) REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS h5_asset_gateway_config ( + config_key VARCHAR(32) PRIMARY KEY, + enabled TINYINT(1) NOT NULL DEFAULT 0, + updated_by CHAR(36) NULL, + updated_at BIGINT NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_asset_plugin_configs ( + id CHAR(36) PRIMARY KEY, + plugin_id VARCHAR(64) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 0, + provider VARCHAR(64) NULL, + llm_provider_key_id CHAR(36) NULL, + llm_model VARCHAR(128) NULL, + updated_by CHAR(36) NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_asset_plugin (plugin_id), + KEY idx_h5_asset_plugin_llm_provider (llm_provider_key_id), + CONSTRAINT fk_h5_asset_plugin_llm_provider FOREIGN KEY (llm_provider_key_id) + REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS h5_payment_orders ( id CHAR(36) PRIMARY KEY, user_id CHAR(36) NOT NULL,