Files
order_site/apps/backend/src/config/env-overrides.ts
T

300 lines
11 KiB
TypeScript

import path from 'node:path'
import process from 'node:process'
import type { AdminDefaultUser, 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[]
| string[]
| Record<string, string>
type RuntimeEnv = Record<string, string | undefined>
type EnvOverride = {
env: string
path: RuntimeConfigPath
read: (rawValue: unknown) => RuntimeConfigValue | null
}
export const ENV_OVERRIDES: readonly EnvOverride[] = [
integerEnv('PORT', ['server', 'port']),
trustProxyEnv('HTTP_TRUST_PROXY', ['server', 'trustProxy']),
stringEnv('DATA_ROOT', ['data', 'root'], path.resolve),
stringEnv('LOG_LEVEL', ['logging', 'level']),
stringEnv('LOG_INTEGRATION_LEVEL', ['logging', 'integrationLevel']),
integerEnv('LOG_RETENTION_DAYS', ['logging', 'retentionDays']),
integerEnv('LOG_MAX_FILE_SIZE_MB', ['logging', 'maxFileSizeMb']),
integerEnv('LOG_MAX_FILES', ['logging', 'maxFiles']),
integerEnv('RAW_PAYLOAD_RETENTION_DAYS', ['retention', 'rawPayloadDays']),
integerEnv('TASK_EVENT_RETENTION_DAYS', ['retention', 'taskEventDays']),
integerEnv('WEBHOOK_EVENT_RETENTION_DAYS', ['retention', 'webhookEventDays']),
integerEnv('AUDIT_LOG_RETENTION_DAYS', ['retention', 'auditLogDays']),
integerEnv('WORK_ORDER_EVENT_RETENTION_DAYS', ['retention', 'workOrderEventDays']),
integerEnv('COST_EXCEPTION_RETENTION_DAYS', ['retention', 'costExceptionDays']),
stringEnv('DATABASE_URL', ['database', 'url']),
booleanEnv('DATABASE_SSL', ['database', 'ssl']),
integerEnv('DATABASE_MAX_CONNECTIONS', ['database', 'maxConnections']),
integerEnv('DATABASE_IDLE_TIMEOUT_MS', ['database', 'idleTimeoutMs']),
integerEnv('DATABASE_CONNECTION_TIMEOUT_MS', ['database', 'connectionTimeoutMs']),
integerEnv('DATABASE_STATEMENT_TIMEOUT_MS', ['database', 'statementTimeoutMs']),
integerEnv('DATABASE_SLOW_QUERY_THRESHOLD_MS', ['database', 'slowQueryThresholdMs']),
stringEnv('STORAGE_MODE', ['storage', 'mode']),
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']),
stringEnv('STORAGE_OSS_ENDPOINT', ['storage', 'oss', 'endpoint']),
stringEnv('STORAGE_OSS_BUCKET', ['storage', 'oss', 'bucket']),
stringEnv('STORAGE_OSS_ACCESS_KEY_ID', ['storage', 'oss', 'accessKeyId']),
stringEnv('STORAGE_OSS_SECRET_ACCESS_KEY', ['storage', 'oss', 'secretAccessKey']),
stringEnv('STORAGE_OSS_REGION', ['storage', 'oss', 'region']),
integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']),
stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']),
// 兼容旧变量:固定 TTL 时代按“绝对上限”理解,空闲窗口仍用新默认值并被绝对上限钳制。
integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionAbsoluteTtlHours']),
integerEnv('ADMIN_SESSION_IDLE_TTL_HOURS', ['admin', 'sessionIdleTtlHours']),
integerEnv('ADMIN_SESSION_ABSOLUTE_TTL_HOURS', ['admin', 'sessionAbsoluteTtlHours']),
integerEnv('WORKER_SESSION_IDLE_TTL_HOURS', ['worker', 'sessionIdleTtlHours']),
integerEnv('WORKER_SESSION_ABSOLUTE_TTL_HOURS', ['worker', 'sessionAbsoluteTtlHours']),
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('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('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']),
booleanEnv('WORKER_ORDER_TIMELINE_VISIBLE', ['worker', 'orderTimelineVisible']),
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']),
// 验收视频(选填):只走 .env,后台没有配置界面。
booleanEnv('WORKER_ACCEPTANCE_VIDEO_ENABLED', ['worker', 'acceptanceVideo', 'enabled']),
integerEnv('WORKER_ACCEPTANCE_VIDEO_MAX_DURATION_SECONDS', [
'worker',
'acceptanceVideo',
'maxDurationSeconds',
]),
integerEnv('WORKER_ACCEPTANCE_VIDEO_MAX_SIZE_MB', ['worker', 'acceptanceVideo', 'maxSizeMb']),
integerEnv('WORKER_ACCEPTANCE_VIDEO_TARGET_HEIGHT', [
'worker',
'acceptanceVideo',
'targetHeight',
]),
integerEnv('WORKER_ACCEPTANCE_VIDEO_BITRATE_KBPS', ['worker', 'acceptanceVideo', 'bitrateKbps']),
integerEnv('WORKER_ACCEPTANCE_VIDEO_AUDIO_BITRATE_KBPS', [
'worker',
'acceptanceVideo',
'audioBitrateKbps',
]),
integerEnv('WORKER_ACCEPTANCE_VIDEO_FPS', ['worker', 'acceptanceVideo', 'fps']),
]
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(/\/+$/, '')}/h5`
}
}
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 trustProxyEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
return {
env,
path: configPath,
read: parseTrustProxy,
}
}
/** HTTP_TRUST_PROXY 支持 true/false、反代层数(数字)与 loopback 等预定义子网,其余值回落到默认。 */
function parseTrustProxy(rawValue: unknown): RuntimeConfigValue | null {
const value = parseString(rawValue)
if (value === null) return null
const normalized = value.trim().toLowerCase()
if (normalized === 'true' || normalized === 'yes') return true
if (normalized === 'false' || normalized === 'no') return false
if (normalized === 'loopback' || normalized === 'uniquelocal' || normalized === 'linklocal') {
return normalized
}
if (/^\d+$/.test(normalized)) {
const hops = Number(normalized)
return hops >= 0 ? hops : null
}
return null
}
function adminUsersJsonEnv(env: string, configPath: RuntimeConfigPath): EnvOverride {
return {
env,
path: configPath,
read(rawValue) {
return parseJsonArray<AdminDefaultUser>(rawValue)
},
}
}
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<string, string>
}
} 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<string, unknown> = config as unknown as Record<string, unknown>
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<string, unknown>
}
const lastKey = configPath[configPath.length - 1]
if (!lastKey) {
return
}
current[lastKey] = value
}