import crypto from 'node:crypto'; import { decryptSecret, encryptSecret, maskApiKey } from './llm-providers.mjs'; const CONFIG_TABLE = 'h5_image_make_admin_config'; const CONFIG_SCOPE = 'global'; export const IMAGE_MAKE_PROVIDER_IDS = Object.freeze(['mock', 'aliyun_bailian', 'comfyui']); export const IMAGE_MAKE_ALIYUN_MODELS = Object.freeze([ 'qwen-image-plus', 'qwen-image', ]); export const IMAGE_MAKE_DEFAULT_CONFIG = Object.freeze({ defaultProvider: 'mock', jobDefaultTimeoutSeconds: 120, providers: { mock: { enabled: true }, aliyun_bailian: { enabled: false, model: 'qwen-image-plus', apiBase: 'https://dashscope.aliyuncs.com/api/v1', }, comfyui: { enabled: false, apiBase: 'http://127.0.0.1:8188', checkpoint: '', workflowPath: './config/comfyui/workflows/core_txt2img_v1.json', outputNodeId: '9', timeoutSeconds: 600, steps: 20, cfg: 7, sampler: 'euler', scheduler: 'normal', }, }, }); const clone = (value) => JSON.parse(JSON.stringify(value)); function bool(value, fallback = false) { if (typeof value === 'boolean') return value; if (value == null || value === '') return fallback; return /^(1|true|yes|on)$/i.test(String(value)); } function clampInt(value, fallback, min, max) { const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.max(min, Math.min(max, Math.floor(number))); } function clampFloat(value, fallback, min, max) { const number = Number(value); if (!Number.isFinite(number)) return fallback; return Math.max(min, Math.min(max, number)); } function trim(value) { return String(value ?? '').trim(); } function parseJsonLike(value, fallback = {}) { if (value == null) return fallback; if (typeof value === 'object' && !Array.isArray(value)) return value; try { const parsed = JSON.parse(String(value)); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback; } catch { return fallback; } } function defaultSecrets() { return { aliyun_bailian: { apiKey: '' } }; } export function normalizeImageMakeAdminConfig(input = {}) { const providers = clone(IMAGE_MAKE_DEFAULT_CONFIG.providers); const rawProviders = input.providers && typeof input.providers === 'object' ? input.providers : {}; providers.mock.enabled = bool(rawProviders.mock?.enabled, providers.mock.enabled); providers.aliyun_bailian.enabled = bool(rawProviders.aliyun_bailian?.enabled, providers.aliyun_bailian.enabled); providers.aliyun_bailian.model = IMAGE_MAKE_ALIYUN_MODELS.includes(rawProviders.aliyun_bailian?.model) ? rawProviders.aliyun_bailian.model : providers.aliyun_bailian.model; providers.aliyun_bailian.apiBase = trim(rawProviders.aliyun_bailian?.apiBase || providers.aliyun_bailian.apiBase); providers.comfyui.enabled = bool(rawProviders.comfyui?.enabled, providers.comfyui.enabled); providers.comfyui.apiBase = trim(rawProviders.comfyui?.apiBase || providers.comfyui.apiBase); providers.comfyui.checkpoint = trim(rawProviders.comfyui?.checkpoint); providers.comfyui.workflowPath = trim(rawProviders.comfyui?.workflowPath || providers.comfyui.workflowPath); providers.comfyui.outputNodeId = trim(rawProviders.comfyui?.outputNodeId || providers.comfyui.outputNodeId) || '9'; providers.comfyui.timeoutSeconds = clampInt(rawProviders.comfyui?.timeoutSeconds, providers.comfyui.timeoutSeconds, 2, 1800); providers.comfyui.steps = clampInt(rawProviders.comfyui?.steps, providers.comfyui.steps, 1, 100); providers.comfyui.cfg = clampFloat(rawProviders.comfyui?.cfg, providers.comfyui.cfg, 0, 30); providers.comfyui.sampler = trim(rawProviders.comfyui?.sampler || providers.comfyui.sampler) || 'euler'; providers.comfyui.scheduler = trim(rawProviders.comfyui?.scheduler || providers.comfyui.scheduler) || 'normal'; let defaultProvider = trim(input.defaultProvider || IMAGE_MAKE_DEFAULT_CONFIG.defaultProvider); if (!IMAGE_MAKE_PROVIDER_IDS.includes(defaultProvider)) defaultProvider = 'mock'; if (!providers[defaultProvider]?.enabled) { defaultProvider = IMAGE_MAKE_PROVIDER_IDS.find((id) => providers[id]?.enabled) ?? 'mock'; } return { defaultProvider, jobDefaultTimeoutSeconds: clampInt( input.jobDefaultTimeoutSeconds, IMAGE_MAKE_DEFAULT_CONFIG.jobDefaultTimeoutSeconds, 5, 1800, ), providers, }; } function buildMaskedView(config, dashScopeCredentials) { const view = clone(config); const apiKeyMasked = dashScopeCredentials?.apiKeyMasked ?? ''; view.providers.aliyun_bailian.apiKeyConfigured = Boolean(dashScopeCredentials?.apiKey); view.providers.aliyun_bailian.apiKeyMasked = apiKeyMasked; view.providers.aliyun_bailian.dashScopeSource = dashScopeCredentials?.source ?? null; view.providers.aliyun_bailian.dashScopeKeyName = dashScopeCredentials?.keyName ?? null; return view; } async function resolveDashScopeCredentials(llmProviderService, secrets) { if (llmProviderService?.resolveDashScopeCredentials) { const resolved = await llmProviderService.resolveDashScopeCredentials(); if (resolved?.apiKey) return resolved; } const legacyKey = trim(secrets?.aliyun_bailian?.apiKey); if (legacyKey) { return { apiKey: legacyKey, keyId: null, keyName: '历史 image_make 配置', providerLabel: 'DashScope', source: 'legacy', apiKeyMasked: maskApiKey(legacyKey), }; } return null; } function validateRuntimeConfig(config, dashScopeCredentials) { const defaultProvider = config.defaultProvider; if (!config.providers[defaultProvider]?.enabled) { return { ok: false, message: '默认 Provider 必须处于启用状态' }; } if (config.providers.aliyun_bailian.enabled) { if (!trim(config.providers.aliyun_bailian.apiBase)) { return { ok: false, message: '百炼 Qwen-Image 启用时必须填写 API Base' }; } if (!trim(dashScopeCredentials?.apiKey)) { return { ok: false, message: '百炼 Qwen-Image 启用时需在「统一模型中心」配置 DashScope Provider(图片任务模型或 Qwen VL Key)', }; } } if (config.providers.comfyui.enabled) { if (!trim(config.providers.comfyui.apiBase)) { return { ok: false, message: 'ComfyUI 启用时必须填写 API Base' }; } if (!trim(config.providers.comfyui.checkpoint)) { return { ok: false, message: 'ComfyUI 启用时必须填写 Checkpoint 文件名' }; } if (!trim(config.providers.comfyui.workflowPath)) { return { ok: false, message: 'ComfyUI 启用时必须填写 Workflow 路径' }; } } return { ok: true }; } function runtimeFingerprint(config, dashScopeCredentials) { return crypto .createHash('sha256') .update(JSON.stringify({ config, dashScopeKeyId: dashScopeCredentials?.keyId ?? null, dashScopeSource: dashScopeCredentials?.source ?? null, })) .digest('hex'); } async function ensureConfigTable(pool) { await pool.query(`CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} ( config_scope VARCHAR(32) PRIMARY KEY, config_json JSON NOT NULL, secrets_ciphertext MEDIUMTEXT NULL, secrets_iv VARCHAR(255) NULL, secrets_tag VARCHAR(255) NULL, updated_by CHAR(36) NULL, updated_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); } export async function ensureImageMakeAdminConfigSchema(pool) { if (!pool) return; await ensureConfigTable(pool); } export function createImageMakeAdminConfigService(pool, { env = process.env, llmProviderService = null } = {}) { const serviceToken = trim(env.IMAGE_MAKE_TOKEN); async function loadStoredState() { if (!pool) return null; await ensureConfigTable(pool); const [rows] = await pool.query( `SELECT config_json, secrets_ciphertext, secrets_iv, secrets_tag, updated_by, updated_at FROM ${CONFIG_TABLE} WHERE config_scope = ? LIMIT 1`, [CONFIG_SCOPE], ); const row = rows?.[0]; if (!row) return null; let secrets = defaultSecrets(); if (row.secrets_ciphertext && row.secrets_iv && row.secrets_tag) { secrets = { ...defaultSecrets(), ...parseJsonLike(decryptSecret({ ciphertext: row.secrets_ciphertext, iv: row.secrets_iv, tag: row.secrets_tag, }), {}), }; } return { config: normalizeImageMakeAdminConfig(parseJsonLike(row.config_json, {})), secrets, updatedAt: Number(row.updated_at ?? 0) || null, updatedBy: row.updated_by ?? null, }; } async function loadState() { const stored = await loadStoredState(); if (!stored) { return { config: normalizeImageMakeAdminConfig({}), secrets: defaultSecrets(), updatedAt: null, updatedBy: null, source: 'default', }; } return { ...stored, source: 'admin-db' }; } function mergePatch(currentConfig, currentSecrets, patch = {}) { const nextConfig = normalizeImageMakeAdminConfig({ ...currentConfig, ...patch, providers: { ...currentConfig.providers, ...(patch.providers ?? {}), mock: { ...currentConfig.providers.mock, ...(patch.providers?.mock ?? {}) }, aliyun_bailian: { ...currentConfig.providers.aliyun_bailian, ...(patch.providers?.aliyun_bailian ?? {}), }, comfyui: { ...currentConfig.providers.comfyui, ...(patch.providers?.comfyui ?? {}), }, }, }); const nextSecrets = clone(currentSecrets); if (patch?.providers?.aliyun_bailian && 'apiKey' in patch.providers.aliyun_bailian) { const apiKey = patch.providers.aliyun_bailian.apiKey; if (apiKey !== undefined) { nextSecrets.aliyun_bailian.apiKey = trim(apiKey); } } return { nextConfig, nextSecrets }; } return { ensureSchema: () => ensureImageMakeAdminConfigSchema(pool), async getAdminConfig() { const state = await loadState(); const dashScopeCredentials = await resolveDashScopeCredentials(llmProviderService, state.secrets); return { config: buildMaskedView(state.config, dashScopeCredentials), source: state.source, updatedAt: state.updatedAt, updatedBy: state.updatedBy, }; }, async updateAdminConfig(patch = {}, { updatedBy = null } = {}) { const state = await loadState(); const { nextConfig, nextSecrets } = mergePatch(state.config, state.secrets, patch); const dashScopeCredentials = await resolveDashScopeCredentials(llmProviderService, nextSecrets); const validation = validateRuntimeConfig(nextConfig, dashScopeCredentials); if (!validation.ok) return validation; if (!pool) { return { ok: true, config: buildMaskedView(nextConfig, dashScopeCredentials), source: 'default', updatedAt: null, updatedBy: null, }; } await ensureConfigTable(pool); const now = Date.now(); const encryptedSecrets = encryptSecret(JSON.stringify(nextSecrets)); await pool.query( `INSERT INTO ${CONFIG_TABLE} (config_scope, config_json, secrets_ciphertext, secrets_iv, secrets_tag, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE config_json = VALUES(config_json), secrets_ciphertext = VALUES(secrets_ciphertext), secrets_iv = VALUES(secrets_iv), secrets_tag = VALUES(secrets_tag), updated_by = VALUES(updated_by), updated_at = VALUES(updated_at)`, [ CONFIG_SCOPE, JSON.stringify(nextConfig), encryptedSecrets.ciphertext, encryptedSecrets.iv, encryptedSecrets.tag, updatedBy, now, ], ); return { ok: true, ...(await this.getAdminConfig()), updatedAt: now, updatedBy, }; }, async getRuntimeConfig() { const state = await loadState(); const dashScopeCredentials = await resolveDashScopeCredentials(llmProviderService, state.secrets); const validation = validateRuntimeConfig(state.config, dashScopeCredentials); return { ok: validation.ok, message: validation.message ?? null, fingerprint: runtimeFingerprint(state.config, dashScopeCredentials), source: state.source, updatedAt: state.updatedAt, defaultProvider: state.config.defaultProvider, jobDefaultTimeoutSeconds: state.config.jobDefaultTimeoutSeconds, providers: { mock: { enabled: state.config.providers.mock.enabled }, aliyun_bailian: state.config.providers.aliyun_bailian.enabled ? { enabled: true, model: state.config.providers.aliyun_bailian.model, apiBase: state.config.providers.aliyun_bailian.apiBase, apiKey: trim(dashScopeCredentials?.apiKey), } : { enabled: false }, comfyui: state.config.providers.comfyui.enabled ? { enabled: true, ...state.config.providers.comfyui, } : { enabled: false }, }, }; }, authorizeRuntimeRequest(token) { if (!serviceToken) return false; return trim(token) === serviceToken; }, }; }