import { loadBillingConfig } from './billing.mjs'; const CONFIG_TABLE = 'h5_billing_admin_config'; const CONFIG_SCOPE = 'global'; const SOURCE_ADMIN_DB = 'admin-db'; const SOURCE_ENV = 'env'; const SOURCE_ENV_OVERRIDE = 'env-override'; const SOURCE_DEFAULT = 'default'; const CACHE_TTL_MS = 5_000; const DEFAULT_ESTIMATE_INPUT_USD_PER_1M = 0.27; const DEFAULT_ESTIMATE_OUTPUT_USD_PER_1M = 1.1; function normalizeBoolean(value, fallback = false) { if (value == null || value === '') return fallback; if (typeof value === 'boolean') return value; const normalized = String(value).trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; if (['0', 'false', 'no', 'off'].includes(normalized)) return false; return fallback; } function normalizePositiveNumber(value, fallback) { const num = Number(value); if (!Number.isFinite(num) || num <= 0) return fallback; return num; } function normalizeNonNegativeNumber(value, fallback) { const num = Number(value); if (!Number.isFinite(num) || num < 0) return fallback; return num; } function defaultConfigShape(env = process.env) { const billing = loadBillingConfig(env); const costEstimateFromTokens = env.H5_COST_ESTIMATE_FROM_TOKENS !== '0'; const inputUsdPer1M = Number(env.H5_COST_ESTIMATE_INPUT_USD_PER_1M ?? DEFAULT_ESTIMATE_INPUT_USD_PER_1M); const outputUsdPer1M = Number(env.H5_COST_ESTIMATE_OUTPUT_USD_PER_1M ?? DEFAULT_ESTIMATE_OUTPUT_USD_PER_1M); return { useBackendCost: billing.useBackendCost, usdCnyRate: billing.usdCnyRate, marginMultiplier: billing.marginMultiplier, inputCentsPer1k: billing.inputCentsPer1k, outputCentsPer1k: billing.outputCentsPer1k, minBillCents: billing.minBillCents, costEstimateFromTokens, costEstimateInputUsdPer1M: Number.isFinite(inputUsdPer1M) && inputUsdPer1M >= 0 ? inputUsdPer1M : DEFAULT_ESTIMATE_INPUT_USD_PER_1M, costEstimateOutputUsdPer1M: Number.isFinite(outputUsdPer1M) && outputUsdPer1M >= 0 ? outputUsdPer1M : DEFAULT_ESTIMATE_OUTPUT_USD_PER_1M, }; } function cloneConfig(config = null, env = process.env) { return structuredClone?.(config ?? defaultConfigShape(env)) ?? JSON.parse(JSON.stringify(config ?? defaultConfigShape(env))); } function parseJsonLike(value, fallback) { if (value == null || value === '') return fallback; if (typeof value === 'string') { try { return JSON.parse(value); } catch { return fallback; } } if (typeof value === 'object') return value; return fallback; } function envLocked(env = process.env) { return String(env.H5_BILLING_CONFIG_SOURCE ?? '').trim().toLowerCase() === 'env'; } function mergePatch(currentConfig, patch = {}, env = process.env) { const base = defaultConfigShape(env); const next = cloneConfig({ ...base, ...currentConfig }, env); const raw = patch?.config && typeof patch.config === 'object' ? patch.config : patch; if ('useBackendCost' in raw) next.useBackendCost = normalizeBoolean(raw.useBackendCost, next.useBackendCost); if ('usdCnyRate' in raw) next.usdCnyRate = normalizePositiveNumber(raw.usdCnyRate, next.usdCnyRate); if ('marginMultiplier' in raw) { next.marginMultiplier = normalizePositiveNumber(raw.marginMultiplier, next.marginMultiplier); } if ('inputCentsPer1k' in raw) { next.inputCentsPer1k = normalizeNonNegativeNumber(raw.inputCentsPer1k, next.inputCentsPer1k); } if ('outputCentsPer1k' in raw) { next.outputCentsPer1k = normalizeNonNegativeNumber(raw.outputCentsPer1k, next.outputCentsPer1k); } if ('minBillCents' in raw) { next.minBillCents = normalizePositiveNumber(raw.minBillCents, next.minBillCents); } if ('costEstimateFromTokens' in raw) { next.costEstimateFromTokens = normalizeBoolean(raw.costEstimateFromTokens, next.costEstimateFromTokens); } if ('costEstimateInputUsdPer1M' in raw) { next.costEstimateInputUsdPer1M = normalizeNonNegativeNumber( raw.costEstimateInputUsdPer1M, next.costEstimateInputUsdPer1M, ); } if ('costEstimateOutputUsdPer1M' in raw) { next.costEstimateOutputUsdPer1M = normalizeNonNegativeNumber( raw.costEstimateOutputUsdPer1M, next.costEstimateOutputUsdPer1M, ); } return next; } export function toComputeBillingConfig(config) { return { useBackendCost: Boolean(config?.useBackendCost), usdCnyRate: Number(config?.usdCnyRate ?? 7.2), marginMultiplier: Number(config?.marginMultiplier ?? 1), inputCentsPer1k: Number(config?.inputCentsPer1k ?? 2), outputCentsPer1k: Number(config?.outputCentsPer1k ?? 6), minBillCents: Number(config?.minBillCents ?? 1), }; } export function toCostEstimateConfig(config) { const useBackendCost = Boolean(config?.useBackendCost); const enabled = useBackendCost && config?.costEstimateFromTokens !== false; return { enabled, inputUsdPer1M: Number(config?.costEstimateInputUsdPer1M ?? 0.27), outputUsdPer1M: Number(config?.costEstimateOutputUsdPer1M ?? 1.1), }; } function envLooksCustomized(env = process.env) { return [ 'H5_USE_BACKEND_COST', 'H5_USD_CNY_RATE', 'H5_MARGIN_MULTIPLIER', 'H5_BILL_INPUT_CENTS_PER_1K', 'H5_BILL_OUTPUT_CENTS_PER_1K', 'H5_MIN_BILL_CENTS', 'H5_COST_ESTIMATE_FROM_TOKENS', 'H5_COST_ESTIMATE_INPUT_USD_PER_1M', 'H5_COST_ESTIMATE_OUTPUT_USD_PER_1M', ].some((key) => String(env[key] ?? '').trim() !== ''); } 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, updated_by CHAR(36) NULL, updated_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } async function loadStoredState(pool) { await ensureConfigTable(pool); const [rows] = await pool.query( `SELECT config_json, updated_by, updated_at FROM ${CONFIG_TABLE} WHERE config_scope = ? LIMIT 1`, [CONFIG_SCOPE], ); const row = rows[0]; if (!row) return null; return { config: mergePatch(defaultConfigShape(), parseJsonLike(row.config_json, {})), updatedAt: Number(row.updated_at ?? 0) || null, updatedBy: row.updated_by ?? null, }; } export function createBillingAdminConfigService(pool, { env = process.env, cacheTtlMs = CACHE_TTL_MS } = {}) { let cache = null; function clearCache() { cache = null; } async function loadEffectiveConfig({ bypassCache = false } = {}) { const now = Date.now(); if (!bypassCache && cache && now - cache.loadedAt < cacheTtlMs) { return cache.state; } if (envLocked(env)) { const state = { config: defaultConfigShape(env), updatedAt: null, updatedBy: null, source: SOURCE_ENV_OVERRIDE, }; cache = { loadedAt: now, state }; return state; } const stored = await loadStoredState(pool); let state; if (stored) { state = { config: cloneConfig(stored.config, env), updatedAt: stored.updatedAt, updatedBy: stored.updatedBy, source: SOURCE_ADMIN_DB, }; } else if (envLooksCustomized(env)) { state = { config: defaultConfigShape(env), updatedAt: null, updatedBy: null, source: SOURCE_ENV, }; } else { state = { config: defaultConfigShape(env), updatedAt: null, updatedBy: null, source: SOURCE_DEFAULT, }; } cache = { loadedAt: now, state }; return state; } return { async ensureSchema() { await ensureConfigTable(pool); }, clearCache, async getAdminConfig() { const state = await loadEffectiveConfig({ bypassCache: true }); return { config: state.config, updatedAt: state.updatedAt, updatedBy: state.updatedBy, source: state.source, envOverrideActive: state.source === SOURCE_ENV_OVERRIDE, formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数(成本模式);无上游成本时回退 Token 单价', }; }, async updateAdminConfig(patch = {}, { updatedBy = null } = {}) { if (envLocked(env)) { throw Object.assign(new Error('H5_BILLING_CONFIG_SOURCE=env 时不允许通过后台修改'), { code: 'BILLING_CONFIG_ENV_LOCKED', }); } const stored = await loadStoredState(pool); const base = stored?.config ?? defaultConfigShape(env); const nextConfig = mergePatch(base, patch, env); await ensureConfigTable(pool); const now = Date.now(); await pool.query( `INSERT INTO ${CONFIG_TABLE} (config_scope, config_json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE config_json = VALUES(config_json), updated_by = VALUES(updated_by), updated_at = VALUES(updated_at)`, [CONFIG_SCOPE, JSON.stringify(nextConfig), updatedBy, now], ); clearCache(); return this.getAdminConfig(); }, async getRuntimeState() { const state = await loadEffectiveConfig(); return { source: state.source, updatedAt: state.updatedAt, updatedBy: state.updatedBy, config: state.config, compute: toComputeBillingConfig(state.config), estimate: toCostEstimateConfig(state.config), envOverrideActive: state.source === SOURCE_ENV_OVERRIDE, }; }, async getEffectiveBillingConfig() { const state = await loadEffectiveConfig(); return toComputeBillingConfig(state.config); }, async getEffectiveCostEstimateConfig() { const state = await loadEffectiveConfig(); return toCostEstimateConfig(state.config); }, }; } export const billingAdminConfigInternals = { CONFIG_SCOPE, CONFIG_TABLE, defaultConfigShape, mergePatch, toComputeBillingConfig, toCostEstimateConfig, envLocked, };