380 lines
13 KiB
JavaScript
380 lines
13 KiB
JavaScript
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;
|
||
},
|
||
};
|
||
}
|