feat: add optional asset gateway control plane
This commit is contained in:
+7
-1
@@ -13,9 +13,10 @@
|
|||||||
// domain logic has a single source of truth; only the wiring differs.
|
// domain logic has a single source of truth; only the wiring differs.
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { createSubscriptionService } from './billing-subscription.mjs';
|
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 { createUserAuth } from './user-auth.mjs';
|
||||||
import { createLlmProviderService } from './llm-providers.mjs';
|
import { createLlmProviderService } from './llm-providers.mjs';
|
||||||
|
import { createAssetGatewayConfigService } from './asset-gateway.mjs';
|
||||||
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
||||||
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
|
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
|
||||||
import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
|
import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
|
||||||
@@ -55,6 +56,9 @@ export async function createAdminServices(env = {}) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const pool = createDbPool();
|
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) ---
|
// --- plaza graph (review queue, reports, featured, analytics, creators) ---
|
||||||
const plazaRedis = createNoopPlazaRedis();
|
const plazaRedis = createNoopPlazaRedis();
|
||||||
@@ -94,6 +98,7 @@ export async function createAdminServices(env = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret });
|
const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret });
|
||||||
|
const assetGatewayConfigService = createAssetGatewayConfigService(pool, { llmProviderService });
|
||||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
||||||
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
||||||
const adminSystemTestService = createAdminSystemTestService({
|
const adminSystemTestService = createAdminSystemTestService({
|
||||||
@@ -131,6 +136,7 @@ export async function createAdminServices(env = {}) {
|
|||||||
pool,
|
pool,
|
||||||
userAuth,
|
userAuth,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
|
assetGatewayConfigService,
|
||||||
memoryV2ConfigService,
|
memoryV2ConfigService,
|
||||||
wechatScheduleLlmConfigService,
|
wechatScheduleLlmConfigService,
|
||||||
adminSystemTestService,
|
adminSystemTestService,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function createAdminApi({
|
|||||||
ready,
|
ready,
|
||||||
userAuth,
|
userAuth,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
|
assetGatewayConfigService,
|
||||||
memoryV2ConfigService,
|
memoryV2ConfigService,
|
||||||
adminSystemTestService,
|
adminSystemTestService,
|
||||||
plazaPosts,
|
plazaPosts,
|
||||||
@@ -91,6 +92,33 @@ export function createAdminApi({
|
|||||||
res.json({ summary: { ...summary, llm } });
|
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) => {
|
adminApi.get('/memory-v2/config', requireAdmin, async (_req, res) => {
|
||||||
if (!memoryV2ConfigService?.getAdminConfig) {
|
if (!memoryV2ConfigService?.getAdminConfig) {
|
||||||
return res.status(503).json({ message: 'Memory V2 配置服务未启用' });
|
return res.status(503).json({ message: 'Memory V2 配置服务未启用' });
|
||||||
|
|||||||
@@ -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 () => {
|
test('admin system test route executes shared validation service', async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const router = createAdminApi({
|
const router = createAdminApi({
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ const CONSOLES = {
|
|||||||
getToken,
|
getToken,
|
||||||
userAuth: services.userAuth,
|
userAuth: services.userAuth,
|
||||||
llmProviderService: services.llmProviderService,
|
llmProviderService: services.llmProviderService,
|
||||||
|
assetGatewayConfigService: services.assetGatewayConfigService,
|
||||||
memoryV2ConfigService: services.memoryV2ConfigService,
|
memoryV2ConfigService: services.memoryV2ConfigService,
|
||||||
adminSystemTestService: services.adminSystemTestService,
|
adminSystemTestService: services.adminSystemTestService,
|
||||||
plazaPosts: services.plazaPosts,
|
plazaPosts: services.plazaPosts,
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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) {
|
export async function migrateSchema(pool) {
|
||||||
const renames = [
|
const renames = [
|
||||||
['h5_user_sessions', 'goose_session_id', 'agent_session_id'],
|
['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
|
) 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.
|
// Shared experience store (etat C). Keep in sync with schema.sql.
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS h5_experience (
|
CREATE TABLE IF NOT EXISTS h5_experience (
|
||||||
|
|||||||
+23
@@ -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
|
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;
|
) 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 (
|
CREATE TABLE IF NOT EXISTS h5_payment_orders (
|
||||||
id CHAR(36) PRIMARY KEY,
|
id CHAR(36) PRIMARY KEY,
|
||||||
user_id CHAR(36) NOT NULL,
|
user_id CHAR(36) NOT NULL,
|
||||||
|
|||||||
Reference in New Issue
Block a user