Files
memind/image-make-admin-config.mjs
T
john 830f8a4011
Memind CI / Test, build, and release guards (push) Successful in 5m36s
feat(wechat): add service-account push subscriptions and news morning cover delivery
Ship the 10-item numeric subscription loop on WeChat MP, wire schedule delivery
for news/weather/quote/health/finance/tech/knowledge/night/surprise pushes, and
extend news morning draft cover generation plus a one-shot draft push script.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 16:15:15 +08:00

455 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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', 'fal_ai']);
export const IMAGE_MAKE_FAL_MODELS = Object.freeze([
'fal-ai/flux/schnell',
'fal-ai/flux/dev',
]);
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',
},
fal_ai: {
enabled: false,
model: 'fal-ai/flux/dev',
apiBase: 'https://queue.fal.run',
numInferenceSteps: 28,
},
},
});
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 isFalDevModel(model) {
return String(model ?? '').includes('/dev');
}
function resolveFalNumInferenceSteps(model, configured) {
const dev = isFalDevModel(model);
const fallback = dev ? 28 : 4;
const min = dev ? 10 : 1;
const max = dev ? 50 : 12;
const number = Number(configured);
if (!Number.isFinite(number)) return fallback;
if (dev && number <= 12) return fallback;
return clampInt(number, fallback, min, max);
}
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: '' }, fal_ai: { 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';
providers.fal_ai.enabled = bool(rawProviders.fal_ai?.enabled, providers.fal_ai.enabled);
providers.fal_ai.model = IMAGE_MAKE_FAL_MODELS.includes(rawProviders.fal_ai?.model)
? rawProviders.fal_ai.model
: providers.fal_ai.model;
providers.fal_ai.apiBase = trim(rawProviders.fal_ai?.apiBase || providers.fal_ai.apiBase);
providers.fal_ai.numInferenceSteps = resolveFalNumInferenceSteps(
providers.fal_ai.model,
rawProviders.fal_ai?.numInferenceSteps ?? providers.fal_ai.numInferenceSteps,
);
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, secrets = defaultSecrets()) {
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;
const falKey = trim(secrets?.fal_ai?.apiKey);
view.providers.fal_ai.apiKeyConfigured = Boolean(falKey);
view.providers.fal_ai.apiKeyMasked = falKey ? maskApiKey(falKey) : '';
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 路径' };
}
}
if (config.providers.fal_ai.enabled) {
if (!trim(config.providers.fal_ai.apiBase)) {
return { ok: false, message: 'fal.ai 启用时必须填写 API Base' };
}
if (!IMAGE_MAKE_FAL_MODELS.includes(config.providers.fal_ai.model)) {
return { ok: false, message: 'fal.ai 模型无效' };
}
}
return { ok: true };
}
function validateRuntimeSecrets(config, dashScopeCredentials, secrets) {
const base = validateRuntimeConfig(config, dashScopeCredentials);
if (!base.ok) return base;
if (config.providers.fal_ai.enabled && !trim(secrets?.fal_ai?.apiKey)) {
return { ok: false, message: 'fal.ai 启用时必须配置 FAL API Key' };
}
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 ?? {}),
},
fal_ai: {
...currentConfig.providers.fal_ai,
...(patch.providers?.fal_ai ?? {}),
},
},
});
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);
}
}
if (patch?.providers?.fal_ai && 'apiKey' in patch.providers.fal_ai) {
const apiKey = patch.providers.fal_ai.apiKey;
if (apiKey !== undefined) {
nextSecrets.fal_ai.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, state.secrets),
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 = validateRuntimeSecrets(nextConfig, dashScopeCredentials, nextSecrets);
if (!validation.ok) return validation;
if (!pool) {
return {
ok: true,
config: buildMaskedView(nextConfig, dashScopeCredentials, nextSecrets),
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 = validateRuntimeSecrets(state.config, dashScopeCredentials, state.secrets);
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 },
fal_ai: state.config.providers.fal_ai.enabled
? {
enabled: true,
model: state.config.providers.fal_ai.model,
apiBase: state.config.providers.fal_ai.apiBase,
apiKey: trim(state.secrets?.fal_ai?.apiKey),
numInferenceSteps: state.config.providers.fal_ai.numInferenceSteps,
}
: { enabled: false },
},
};
},
authorizeRuntimeRequest(token) {
if (!serviceToken) return false;
return trim(token) === serviceToken;
},
};
}