import { loadBillingConfig } from './billing.mjs'; const CONFIG_TABLE = 'h5_billing_admin_config'; const CONFIG_SCOPE_LEGACY = 'global'; const CONFIG_SCOPE_A = 'formula-a'; const CONFIG_SCOPE_B = 'formula-b'; 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; const DEFAULT_FORMULA = 'A'; const FORMULA_KEYS = ['A', 'B']; const SCOPE_BY_FORMULA = { A: CONFIG_SCOPE_A, B: CONFIG_SCOPE_B, }; 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 normalizeFormulaKey(value) { const key = String(value ?? DEFAULT_FORMULA).trim().toUpperCase(); return FORMULA_KEYS.includes(key) ? key : DEFAULT_FORMULA; } 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 loadStoredStateByScope(pool, scope, env = process.env) { await ensureConfigTable(pool); const [rows] = await pool.query( `SELECT config_json, updated_by, updated_at FROM ${CONFIG_TABLE} WHERE config_scope = ? LIMIT 1`, [scope], ); const row = rows[0]; if (!row) return null; return { config: mergePatch(defaultConfigShape(env), parseJsonLike(row.config_json, {}), env), updatedAt: Number(row.updated_at ?? 0) || null, updatedBy: row.updated_by ?? null, }; } async function upsertStoredConfig(pool, scope, config, { updatedBy = null } = {}, env = process.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)`, [scope, JSON.stringify(cloneConfig(config, env)), updatedBy, now], ); return { config: cloneConfig(config, env), updatedAt: now, updatedBy, }; } function resolveDefaultSource(env = process.env) { if (envLocked(env)) return SOURCE_ENV_OVERRIDE; if (envLooksCustomized(env)) return SOURCE_ENV; return SOURCE_DEFAULT; } function buildFormulaState(stored, env = process.env) { if (stored) { return { config: cloneConfig(stored.config, env), updatedAt: stored.updatedAt, updatedBy: stored.updatedBy, source: SOURCE_ADMIN_DB, }; } return { config: defaultConfigShape(env), updatedAt: null, updatedBy: null, source: resolveDefaultSource(env), }; } async function migrateLegacyConfig(pool, env = process.env) { await ensureConfigTable(pool); const legacy = await loadStoredStateByScope(pool, CONFIG_SCOPE_LEGACY, env); const formulaA = await loadStoredStateByScope(pool, CONFIG_SCOPE_A, env); const formulaB = await loadStoredStateByScope(pool, CONFIG_SCOPE_B, env); const seedConfig = legacy?.config ?? formulaA?.config ?? formulaB?.config ?? defaultConfigShape(env); if (!formulaA) { await upsertStoredConfig( pool, CONFIG_SCOPE_A, seedConfig, { updatedBy: legacy?.updatedBy ?? formulaB?.updatedBy ?? null }, env, ); } if (!formulaB) { await upsertStoredConfig( pool, CONFIG_SCOPE_B, cloneConfig(formulaA?.config ?? seedConfig, env), { updatedBy: legacy?.updatedBy ?? formulaA?.updatedBy ?? null }, env, ); } } export function createBillingAdminConfigService(pool, { env = process.env, cacheTtlMs = CACHE_TTL_MS } = {}) { let cache = null; function clearCache() { cache = null; } async function loadAllFormulaStates({ bypassCache = false } = {}) { const now = Date.now(); if (!bypassCache && cache && now - cache.loadedAt < cacheTtlMs) { return cache.state; } if (envLocked(env)) { const shared = defaultConfigShape(env); const state = { formulas: { A: { config: cloneConfig(shared, env), updatedAt: null, updatedBy: null, source: SOURCE_ENV_OVERRIDE, }, B: { config: cloneConfig(shared, env), updatedAt: null, updatedBy: null, source: SOURCE_ENV_OVERRIDE, }, }, defaultFormula: DEFAULT_FORMULA, }; cache = { loadedAt: now, state }; return state; } await migrateLegacyConfig(pool, env); const storedA = await loadStoredStateByScope(pool, CONFIG_SCOPE_A, env); const storedB = await loadStoredStateByScope(pool, CONFIG_SCOPE_B, env); const state = { formulas: { A: buildFormulaState(storedA, env), B: buildFormulaState(storedB, env), }, defaultFormula: DEFAULT_FORMULA, }; cache = { loadedAt: now, state }; return state; } async function getUserFormulaKey(userId) { if (!userId) return DEFAULT_FORMULA; const [rows] = await pool.query( `SELECT billing_formula FROM h5_users WHERE id = ? LIMIT 1`, [userId], ); return normalizeFormulaKey(rows[0]?.billing_formula); } async function loadFormulaState(formulaKey, options = {}) { const all = await loadAllFormulaStates(options); return all.formulas[normalizeFormulaKey(formulaKey)]; } return { async ensureSchema() { await ensureConfigTable(pool); await migrateLegacyConfig(pool, env); }, clearCache, async getAdminConfig({ formula = DEFAULT_FORMULA } = {}) { const all = await loadAllFormulaStates({ bypassCache: true }); const activeFormula = normalizeFormulaKey(formula); const active = all.formulas[activeFormula]; return { config: active.config, formulas: { A: all.formulas.A.config, B: all.formulas.B.config, }, formulaMeta: { A: { updatedAt: all.formulas.A.updatedAt, updatedBy: all.formulas.A.updatedBy, source: all.formulas.A.source, }, B: { updatedAt: all.formulas.B.updatedAt, updatedBy: all.formulas.B.updatedBy, source: all.formulas.B.source, }, }, activeFormula, defaultFormula: DEFAULT_FORMULA, updatedAt: active.updatedAt, updatedBy: active.updatedBy, source: active.source, envOverrideActive: active.source === SOURCE_ENV_OVERRIDE, formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数(成本模式);无上游成本时回退 Token 单价', }; }, async updateAdminConfig(patch = {}, { updatedBy = null, formula = DEFAULT_FORMULA } = {}) { if (envLocked(env)) { throw Object.assign(new Error('H5_BILLING_CONFIG_SOURCE=env 时不允许通过后台修改'), { code: 'BILLING_CONFIG_ENV_LOCKED', }); } const formulaKey = normalizeFormulaKey(formula); const scope = SCOPE_BY_FORMULA[formulaKey]; const stored = await loadStoredStateByScope(pool, scope, env); const base = stored?.config ?? defaultConfigShape(env); const nextConfig = mergePatch(base, patch, env); await upsertStoredConfig(pool, scope, nextConfig, { updatedBy }, env); clearCache(); return this.getAdminConfig({ formula: formulaKey }); }, async assignBillingFormula(userIds = [], formula = DEFAULT_FORMULA) { const formulaKey = normalizeFormulaKey(formula); const ids = [...new Set((Array.isArray(userIds) ? userIds : []).map((id) => String(id).trim()).filter(Boolean))]; if (!ids.length) { return { ok: false, message: '请选择至少一个用户', updated: 0 }; } const now = Date.now(); const placeholders = ids.map(() => '?').join(', '); const [result] = await pool.query( `UPDATE h5_users SET billing_formula = ?, updated_at = ? WHERE id IN (${placeholders})`, [formulaKey, now, ...ids], ); return { ok: true, updated: Number(result.affectedRows ?? 0), formula: formulaKey }; }, async getRuntimeState({ formula = DEFAULT_FORMULA, userId = null } = {}) { const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula); const state = await loadFormulaState(formulaKey); return { source: state.source, updatedAt: state.updatedAt, updatedBy: state.updatedBy, formula: formulaKey, config: state.config, compute: toComputeBillingConfig(state.config), estimate: toCostEstimateConfig(state.config), envOverrideActive: state.source === SOURCE_ENV_OVERRIDE, }; }, async getEffectiveBillingConfig({ userId = null, formula = DEFAULT_FORMULA } = {}) { const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula); const state = await loadFormulaState(formulaKey); return toComputeBillingConfig(state.config); }, async getEffectiveCostEstimateConfig({ userId = null, formula = DEFAULT_FORMULA } = {}) { const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula); const state = await loadFormulaState(formulaKey); return toCostEstimateConfig(state.config); }, }; } export const billingAdminConfigInternals = { CONFIG_SCOPE_LEGACY, CONFIG_SCOPE_A, CONFIG_SCOPE_B, DEFAULT_FORMULA, defaultConfigShape, mergePatch, normalizeFormulaKey, toComputeBillingConfig, toCostEstimateConfig, envLocked, };