背景:生产 32 天内 >500ms 慢查询从 2.4k/天涨到 1.3 万/天(09-26 尖峰 14.3 万), pg_stat_statements 显示后台工单列表族占 57%、打手端 visible_orders 占 27%; 同时 backend 容器十天从 194MB 涨到 754MB(768MB 限额的 98%),贴顶后整体变慢。 1. 新增迁移 083:work_orders 增加派生列 source_order_amount_fen,并从 material_json.source.orderAmountFen 回填(只认 15 位以内整数,与 JS/SQL 口径一致)。 统计查询原来每行要解析 1.2KB JSON(全表约 132MB)且同一表达式求值两次, 生产实测 3.5~4.4 秒;改走派生列后 COALESCE 短路,未命中列的行才回退 JSON/orders.total_amount, 口径与改造前完全一致。写入点:createWorkOrder / updateWorkOrder 各一处,并加静态守卫。 2. worker_sessions.last_seen_at 从"每个请求都写"改为"5 分钟才写一次": 32 天被写了 3300 万次(占 DB 总时间 2.8%,并堆出 14% 死元组)。仍用一条语句返回鉴权有效性 (EXISTS target AS valid),不能因为不写就让上层 401。 3. 保留策略补上 work_order_events(71.7 万行 / 269MB,此前完全没纳入清理,只增不减), 新增配置 retention.workOrderEventDays(默认 180 天)与 WORK_ORDER_EVENT_RETENTION_DAYS 出口。 4. 内存治理:compose 后端限额 768m→1g 并显式设置 V8 老生代上限(--max-old-space-size=768), 避免贴 cgroup 上限后 GC 抖动;新增每 10 分钟的进程内存快照日志(rss/heapUsed/heapLimit), 用于区分"正常 GC 曲线"与"真泄漏"。 5. 限流桶加 5 万条硬上限:key 里含请求体字段(collect 的 orderNo)时可被构造成高基数, 只按过期清理挡不住内存增长。 验证:后端 530 项单测全绿(新增 11 项,含派生列/会话/保留的静态守卫与解析函数单测)。
274 lines
9.9 KiB
TypeScript
274 lines
9.9 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']),
|
|
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']),
|
|
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('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']),
|
|
]
|
|
|
|
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 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
|
|
}
|