import path from 'node:path' import process from 'node:process' import type { AdminDefaultUser, KuaishouFeifeiProductRule, RuntimeConfig, } from '../types/runtime-config.js' import { parseBoolean, parseInteger, parseJsonArray } from './runtime-env.js' import { deepMerge } from './runtime-merge.js' type RuntimeConfigPath = readonly [string, ...string[]] type RuntimeConfigValue = | string | number | boolean | AdminDefaultUser[] | KuaishouFeifeiProductRule[] | string[] | Record type RuntimeEnv = Record type EnvOverride = { env: string path: RuntimeConfigPath read: (rawValue: unknown) => RuntimeConfigValue | null } export const ENV_OVERRIDES: readonly EnvOverride[] = [ integerEnv('PORT', ['server', 'port']), stringEnv('DATA_ROOT', ['data', 'root'], path.resolve), stringEnv('LOG_LEVEL', ['logging', 'level']), stringEnv('LOG_INTEGRATION_LEVEL', ['logging', 'integrationLevel']), integerEnv('LOG_RETENTION_DAYS', ['logging', 'retentionDays']), stringEnv('DATABASE_URL', ['database', 'url']), booleanEnv('DATABASE_SSL', ['database', 'ssl']), integerEnv('DATABASE_MAX_CONNECTIONS', ['database', 'maxConnections']), stringEnv('STORAGE_ENDPOINT', ['storage', 'endpoint']), stringEnv('STORAGE_BUCKET', ['storage', 'bucket']), stringEnv('STORAGE_ACCESS_KEY_ID', ['storage', 'accessKeyId']), stringEnv('STORAGE_SECRET_ACCESS_KEY', ['storage', 'secretAccessKey']), stringEnv('STORAGE_REGION', ['storage', 'region']), integerEnv('STORAGE_MAX_UPLOAD_SIZE_MB', ['storage', 'maxUploadSizeMb']), integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']), stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']), integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionTtlHours']), adminUsersJsonEnv('ADMIN_DEFAULT_USERS_JSON', ['admin', 'defaultUsers']), stringEnv('KAQUAN91_USER_ID', ['platforms', 'ninetyone', 'userId']), stringEnv('KAQUAN91_SECRET', ['platforms', 'ninetyone', 'secret']), stringEnv('KAQUAN91_VERSION', ['platforms', 'ninetyone', 'version']), stringEnv('KAQUAN91_SHOP_ID', ['platforms', 'ninetyone', 'shopId']), stringEnv('KAQUAN91_SHOP_NAME', ['platforms', 'ninetyone', 'shopName']), integerEnv('KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS', [ 'platforms', 'ninetyone', 'timestampToleranceSeconds', ]), stringEnv('KAQUAN91_CARDS_ENCODING', ['platforms', 'ninetyone', 'cardsEncoding']), stringEnv('CLOUDTENTACLES_BASE_URL', ['platforms', 'cloudtentacles', 'baseUrl']), integerEnv('CLOUDTENTACLES_TIMEOUT_MS', ['platforms', 'cloudtentacles', 'timeoutMs']), stringEnv('CLOUDTENTACLES_SEND_SMS_PATH', ['platforms', 'cloudtentacles', 'sendSmsPath']), stringEnv('CLOUDTENTACLES_LOGIN_PATH', ['platforms', 'cloudtentacles', 'loginPath']), stringEnv('CLOUDTENTACLES_USER_INFO_PATH', ['platforms', 'cloudtentacles', 'userInfoPath']), stringEnv('CLOUDTENTACLES_ASSET_PATH', ['platforms', 'cloudtentacles', 'assetPath']), stringEnv('CLOUDTENTACLES_PERMISSION_PATH', ['platforms', 'cloudtentacles', 'permissionPath']), stringEnv('CLOUDTENTACLES_CATEGORIES_PATH', ['platforms', 'cloudtentacles', 'categoriesPath']), stringEnv('CLOUDTENTACLES_SKU_LIST_PATH', ['platforms', 'cloudtentacles', 'skuListPath']), stringEnv('CLOUDTENTACLES_SKU_BUY_PATH', ['platforms', 'cloudtentacles', 'skuBuyPath']), stringEnv('CLOUDTENTACLES_KNAPSACK_PATH', ['platforms', 'cloudtentacles', 'knapsackPath']), stringEnv('CLOUDTENTACLES_VN_LIST_PATH', ['platforms', 'cloudtentacles', 'vnListPath']), stringEnv('CLOUDTENTACLES_VN_APPOINT_PATH', ['platforms', 'cloudtentacles', 'vnAppointPath']), stringEnv('CLOUDTENTACLES_VN_GENERATE_LOGIN_CODE_PATH', [ 'platforms', 'cloudtentacles', 'vnGenerateLoginCodePath', ]), stringEnv('CLOUDTENTACLES_VN_VERIF_CODE_PATH', [ 'platforms', 'cloudtentacles', 'vnVerifCodePath', ]), stringEnv('CLOUDTENTACLES_VN_VERIFY_LOGIN_CODE_PATH', [ 'platforms', 'cloudtentacles', 'vnVerifyLoginCodePath', ]), stringEnv('CLOUDTENTACLES_VN_BIND_URL_PATH', ['platforms', 'cloudtentacles', 'vnBindUrlPath']), stringEnv('CLOUDTENTACLES_VN_BIND_INFO_PATH', ['platforms', 'cloudtentacles', 'vnBindInfoPath']), stringEnv('CLOUDTENTACLES_VN_BACK_PATH', ['platforms', 'cloudtentacles', 'vnBackPath']), integerEnv('CLOUDTENTACLES_BIND_URL_TTL_SECONDS', [ 'platforms', 'cloudtentacles', 'bindUrlTtlSeconds', ]), integerEnv('CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS', [ 'platforms', 'cloudtentacles', 'bindUrlProbeIntervalSeconds', ]), integerEnv('CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS', [ 'platforms', 'cloudtentacles', 'bindUrlProbeTimeoutMs', ]), stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT', [ 'platforms', 'cloudtentacles', 'bindUrlProbeUserAgent', ]), stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT', [ 'platforms', 'cloudtentacles', 'bindUrlProbeEndpoint', ]), stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID', [ 'platforms', 'cloudtentacles', 'bindUrlProbeChartId', ]), stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID', [ 'platforms', 'cloudtentacles', 'bindUrlProbeSubChartId', ]), stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN', [ 'platforms', 'cloudtentacles', 'bindUrlProbeIdeToken', ]), stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL', [ 'platforms', 'cloudtentacles', 'bindUrlProbeActivityUrl', ]), stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_REFERER', [ 'platforms', 'cloudtentacles', 'bindUrlProbeReferer', ]), stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE', [ 'platforms', 'cloudtentacles', 'bindUrlProbeExtraCookie', ]), stringEnv('CLOUDTENTACLES_PUBLIC_KEY_PEM', ['platforms', 'cloudtentacles', 'publicKeyPem']), stringEnv('CLOUDTENTACLES_CLIENT_SOURCE', ['platforms', 'cloudtentacles', 'clientSource']), stringEnv('CLOUDTENTACLES_DEVICE_ID', ['platforms', 'cloudtentacles', 'deviceId']), integerEnv('CLOUDTENTACLES_DEVICE_TYPE', ['platforms', 'cloudtentacles', 'deviceType']), stringEnv('KUASHOU_INDUSTRY_APP_KEY', ['platforms', 'kuaishouIndustry', 'appKey']), stringEnv('KUASHOU_INDUSTRY_APP_SECRET', ['platforms', 'kuaishouIndustry', 'appSecret']), stringEnv('KUASHOU_INDUSTRY_SIGN_SECRET', ['platforms', 'kuaishouIndustry', 'signSecret']), stringEnv('KUASHOU_INDUSTRY_MESSAGE_SECRET', ['platforms', 'kuaishouIndustry', 'messageSecret']), stringEnv('KUASHOU_INDUSTRY_PROVIDER', ['platforms', 'kuaishouIndustry', 'provider']), stringEnv('KUASHOU_INDUSTRY_SHOP_ID', ['platforms', 'kuaishouIndustry', 'shopId']), stringEnv('KUASHOU_INDUSTRY_SHOP_NAME', ['platforms', 'kuaishouIndustry', 'shopName']), stringEnv('KUASHOU_INDUSTRY_VERSION', ['platforms', 'kuaishouIndustry', 'version']), stringEnv('KUAISHOU_FEIFEI_BASE_URL', ['platforms', 'kuaishouFeifei', 'baseUrl']), stringEnv('KUAISHOU_FEIFEI_APP_KEY', ['platforms', 'kuaishouFeifei', 'appKey']), stringEnv('KUAISHOU_FEIFEI_APP_SECRET', ['platforms', 'kuaishouFeifei', 'appSecret']), stringEnv('WORKER_SMS_PROVIDER', ['worker', 'sms', 'provider']), stringEnv('WORKER_SMS_ACCESS_KEY_ID', ['worker', 'sms', 'accessKeyId']), stringEnv('WORKER_SMS_ACCESS_KEY_SECRET', ['worker', 'sms', 'accessKeySecret']), stringEnv('WORKER_SMS_SIGN_NAME', ['worker', 'sms', 'signName']), stringEnv('WORKER_SMS_TEMPLATE_CODE', ['worker', 'sms', 'templateCode']), integerEnv('WORKER_SMS_CODE_TTL_SECONDS', ['worker', 'sms', 'codeTtlSeconds']), integerEnv('WORKER_SMS_COOLDOWN_SECONDS', ['worker', 'sms', 'cooldownSeconds']), integerEnv('WORKER_SMS_DAILY_LIMIT', ['worker', 'sms', 'dailyLimit']), integerEnv('KUAISHOU_FEIFEI_TIMEOUT_MS', ['platforms', 'kuaishouFeifei', 'timeoutMs']), stringEnv('KUAISHOU_FEIFEI_NOTIFY_URL', ['platforms', 'kuaishouFeifei', 'notifyUrl']), kuaishouFeifeiProductRulesEnv('KUAISHOU_FEIFEI_PRODUCT_RULES_JSON', [ 'platforms', 'kuaishouFeifei', 'productRules', ]), stringEnv('AFFILIATE_DASH_BASE_URL', ['platforms', 'affiliateDash', 'baseUrl']), stringEnv('AFFILIATE_DASH_APP_KEY', ['platforms', 'affiliateDash', 'appKey']), stringEnv('AFFILIATE_DASH_APP_SECRET', ['platforms', 'affiliateDash', 'appSecret']), stringEnv('AFFILIATE_DASH_CALLBACK_SECRET', ['platforms', 'affiliateDash', 'callbackSecret']), integerEnv('AFFILIATE_DASH_TIMEOUT_MS', ['platforms', 'affiliateDash', 'timeoutMs']), stringEnv('AFFILIATE_DASH_NOTIFY_URL', ['platforms', 'affiliateDash', 'notifyUrl']), integerEnv('AFFILIATE_DASH_TIMESTAMP_TOLERANCE_SECONDS', [ 'platforms', 'affiliateDash', 'timestampToleranceSeconds', ]), affiliateDashSkuMappingEnv('AFFILIATE_DASH_SKU_MAPPING_JSON', [ 'platforms', 'affiliateDash', 'skuMapping', ]), corsOriginsEnv('CORS_ALLOWED_ORIGINS', ['cors', 'allowedOrigins']), ] export function applyEnvOverrides( baseConfig: RuntimeConfig, env: RuntimeEnv = process.env, ): RuntimeConfig { const nextConfig = deepMerge(baseConfig, {}) applyClaimBaseUrlOverride(nextConfig, env) for (const override of ENV_OVERRIDES) { const value = override.read(env[override.env]) if (value !== null) { setConfigValue(nextConfig, override.path, value) } } return nextConfig } function applyClaimBaseUrlOverride(config: RuntimeConfig, env: RuntimeEnv): void { const claimBaseUrl = parseString(env.CLAIM_BASE_URL) if (claimBaseUrl !== null) { config.orders.claimBaseUrl = claimBaseUrl return } const appBaseUrl = parseString(env.APP_BASE_URL) if (appBaseUrl !== null) { config.orders.claimBaseUrl = `${appBaseUrl.replace(/\/+$/, '')}/#/claim` } } function stringEnv( env: string, configPath: RuntimeConfigPath, transform: (value: string) => string = (value) => value, ): EnvOverride { return { env, path: configPath, read(rawValue) { const value = parseString(rawValue) return value === null ? null : transform(value) }, } } function integerEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { return { env, path: configPath, read: parseInteger, } } function booleanEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { return { env, path: configPath, read: parseBoolean, } } function adminUsersJsonEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { return { env, path: configPath, read(rawValue) { return parseJsonArray(rawValue) }, } } function kuaishouFeifeiProductRulesEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { return { env, path: configPath, read(rawValue) { const parsed = parseJsonArray(rawValue) return parsed === null ? null : parsed }, } } function affiliateDashSkuMappingEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { return { env, path: configPath, read(rawValue) { const text = String(rawValue ?? '').trim() if (!text) { return null } try { const parsed = JSON.parse(text) if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { return parsed as Record } } catch { // 忽略非法 JSON } return null }, } } function corsOriginsEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { return { env, path: configPath, read(rawValue) { const value = parseString(rawValue) if (value === null) { return null } if (value.trim() === '*') { return ['*'] } return value .split(',') .map((s) => s.trim()) .filter(Boolean) }, } } function parseString(rawValue: unknown): string | null { const value = String(rawValue || '').trim() return value ? value : null } function setConfigValue( config: RuntimeConfig, configPath: RuntimeConfigPath, value: RuntimeConfigValue, ): void { let current: Record = config as unknown as Record for (const key of configPath.slice(0, -1)) { const nextValue = current[key] if (!nextValue || typeof nextValue !== 'object' || Array.isArray(nextValue)) { current[key] = {} } current = current[key] as Record } const lastKey = configPath[configPath.length - 1] if (!lastKey) { return } current[lastKey] = value }