/** 应用环境仅允许显式的开发或生产态,禁止隐式 test/staging 绕过生产约束。 */ export type AppEnvironment = 'development' | 'production'; /** 四级日志口径由进程入口统一校验,避免基础设施各自解释字符串。 */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; /** 每个进程只接收自己的 Redis DB 与连接参数,凭据不得进入安全摘要。 */ export interface RedisRuntimeConfig { host: string; port: number; username: string; password: string; database: number; tls: boolean; } /** MySQL 配置固定 5.7.x 兼容口径,启用状态决定凭据是否必填。 */ export interface MysqlRuntimeConfig { enabled: boolean; host: string; port: number; database: string; username: string; password: string; charset: 'utf8mb4'; collation: 'utf8mb4_general_ci'; timezone: '+08:00'; } /** OpenAI 兼容接口配置;关闭 AI 时允许凭据为空,且不影响规则分析。 */ export interface AiRuntimeConfig { enabled: boolean; baseUrl: string; apiKey: string; model: string; timeoutMs: number; } /** RocketMQ 公共寻址配置;实际生产/消费参数由相应进程扩展。 */ export interface RocketMqRuntimeConfig { enabled: boolean; nameServerAddress: string; endpoint: string; topicPrefix: string; } interface CommonRuntimeConfig { processName: 'api' | 'scheduler' | 'worker'; appEnv: AppEnvironment; logLevel: LogLevel; redis: RedisRuntimeConfig; } /** API 仅获得 HTTP、Redis、MySQL 与可选 AI 配置,不读取调度和消费配置。 */ export interface ApiRuntimeConfig extends CommonRuntimeConfig { processName: 'api'; api: Readonly<{ host: string; port: number }>; mysql: MysqlRuntimeConfig; ai: AiRuntimeConfig; } /** Scheduler 只决定何时投递,因此不获得 MySQL、行情源或 AI 凭据。 */ export interface SchedulerRuntimeConfig extends CommonRuntimeConfig { processName: 'scheduler'; scheduler: Readonly<{ enabled: boolean; timezone: 'Asia/Shanghai' }>; rocketMq: RocketMqRuntimeConfig & Readonly<{ sendTimeoutMs: number }>; } /** Worker 获得执行任务所需配置,但不读取 API 监听或 Scheduler 开关。 */ export interface WorkerRuntimeConfig extends CommonRuntimeConfig { processName: 'worker'; marketData: Readonly<{ mode: 'mock' | 'live'; provider: string; baseUrl: string; apiKey: string; }>; mysql: MysqlRuntimeConfig; rocketMq: RocketMqRuntimeConfig & Readonly<{ consumerGroup: string; consumerConcurrency: number }>; ai: AiRuntimeConfig; } /** 三类配置的联合类型仅用于安全摘要等跨进程基础能力。 */ export type RuntimeConfig = ApiRuntimeConfig | SchedulerRuntimeConfig | WorkerRuntimeConfig; type EnvironmentSource = Readonly>; function requireText(env: EnvironmentSource, key: string): string { const value = env[key]?.trim(); if (!value) throw new Error(`${key} 不能为空`); return value; } function optionalText(env: EnvironmentSource, key: string): string { return env[key]?.trim() ?? ''; } function parseInteger( env: EnvironmentSource, key: string, constraints: Readonly<{ min: number; max: number }>, ): number { const raw = requireText(env, key); if (!/^\d+$/.test(raw)) throw new Error(`${key} 必须为整数`); const value = Number(raw); if (!Number.isSafeInteger(value) || value < constraints.min || value > constraints.max) { throw new Error(`${key} 必须位于 ${constraints.min}-${constraints.max}`); } return value; } function parseBoolean(env: EnvironmentSource, key: string): boolean { const raw = requireText(env, key); if (raw !== 'true' && raw !== 'false') throw new Error(`${key} 必须为 true 或 false`); return raw === 'true'; } function parseEnum( env: EnvironmentSource, key: string, allowed: readonly TValue[], ): TValue { const value = requireText(env, key); if (!allowed.includes(value as TValue)) { throw new Error(`${key} 必须为 ${allowed.join('、')}`); } return value as TValue; } function deepFreeze(value: TValue): Readonly { if (value && typeof value === 'object' && !Object.isFrozen(value)) { for (const child of Object.values(value as Record)) deepFreeze(child); Object.freeze(value); } return value; } function loadAppEnv(env: EnvironmentSource): AppEnvironment { return parseEnum(env, 'APP_ENV', ['development', 'production']); } function loadLogLevel(env: EnvironmentSource): LogLevel { return parseEnum(env, 'LOG_LEVEL', ['debug', 'info', 'warn', 'error']); } function loadRedisConfig(env: EnvironmentSource): RedisRuntimeConfig { return { host: requireText(env, 'REDIS_HOST'), port: parseInteger(env, 'REDIS_PORT', { min: 1, max: 65_535 }), username: optionalText(env, 'REDIS_USERNAME'), password: optionalText(env, 'REDIS_PASSWORD'), database: parseInteger(env, 'REDIS_DATABASE', { min: 0, max: 15 }), tls: parseBoolean(env, 'REDIS_TLS'), }; } function loadMysqlConfig(env: EnvironmentSource): MysqlRuntimeConfig { const enabled = parseBoolean(env, 'MYSQL_ENABLED'); const username = optionalText(env, 'MYSQL_USERNAME'); const password = optionalText(env, 'MYSQL_PASSWORD'); const charset = parseEnum(env, 'MYSQL_CHARSET', ['utf8mb4']); const collation = parseEnum(env, 'MYSQL_COLLATION', ['utf8mb4_general_ci']); const timezone = parseEnum(env, 'MYSQL_TIMEZONE', ['+08:00']); if (enabled && !username) throw new Error('MYSQL_USERNAME 在 MySQL 启用时不能为空'); if (enabled && !password) throw new Error('MYSQL_PASSWORD 在 MySQL 启用时不能为空'); return { enabled, host: requireText(env, 'MYSQL_HOST'), port: parseInteger(env, 'MYSQL_PORT', { min: 1, max: 65_535 }), database: requireText(env, 'MYSQL_DATABASE'), username, password, charset, collation, timezone, }; } function loadAiConfig(env: EnvironmentSource): AiRuntimeConfig { const enabled = parseBoolean(env, 'AI_ENABLED'); const baseUrl = optionalText(env, 'AI_BASE_URL'); const apiKey = optionalText(env, 'AI_API_KEY'); const model = optionalText(env, 'AI_MODEL'); if (enabled && !baseUrl) throw new Error('AI_BASE_URL 在 AI 启用时不能为空'); if (enabled && !apiKey) throw new Error('AI_API_KEY 在 AI 启用时不能为空'); if (enabled && !model) throw new Error('AI_MODEL 在 AI 启用时不能为空'); return { enabled, baseUrl, apiKey, model, timeoutMs: parseInteger(env, 'AI_TIMEOUT_MS', { min: 1, max: 300_000 }), }; } function loadRocketMqConfig(env: EnvironmentSource): RocketMqRuntimeConfig { const enabled = parseBoolean(env, 'ROCKETMQ_ENABLED'); const nameServerAddress = optionalText(env, 'ROCKETMQ_NAMESRV_ADDR'); const endpoint = optionalText(env, 'ROCKETMQ_ENDPOINT'); // GOV-009 尚未选定通过真实链路验证的 Node 客户端,禁止以内存队列或空实现伪装启用。 if (enabled) throw new Error('RocketMQ Node 客户端尚未落地验证,当前禁止启用'); if (enabled && !nameServerAddress && !endpoint) { throw new Error('ROCKETMQ_NAMESRV_ADDR 或 ROCKETMQ_ENDPOINT 至少填写一个'); } return { enabled, nameServerAddress, endpoint, topicPrefix: requireText(env, 'ROCKETMQ_TOPIC_PREFIX'), }; } /** 从 API 专属环境变量生成深冻结配置,启动后禁止被运行时代码改写。 */ export function loadApiConfig(env: EnvironmentSource): ApiRuntimeConfig { return deepFreeze({ processName: 'api', appEnv: loadAppEnv(env), logLevel: loadLogLevel(env), api: { host: requireText(env, 'API_HOST'), port: parseInteger(env, 'API_PORT', { min: 1, max: 65_535 }), }, redis: loadRedisConfig(env), mysql: loadMysqlConfig(env), ai: loadAiConfig(env), }) as ApiRuntimeConfig; } /** 从 Scheduler 专属变量生成深冻结配置,并保持 RocketMQ 默认禁用边界。 */ export function loadSchedulerConfig(env: EnvironmentSource): SchedulerRuntimeConfig { const rocketMq = loadRocketMqConfig(env); return deepFreeze({ processName: 'scheduler', appEnv: loadAppEnv(env), logLevel: loadLogLevel(env), scheduler: { enabled: parseBoolean(env, 'SCHEDULER_ENABLED'), timezone: parseEnum(env, 'SCHEDULER_TIMEZONE', ['Asia/Shanghai']), }, redis: loadRedisConfig(env), rocketMq: { ...rocketMq, sendTimeoutMs: parseInteger(env, 'ROCKETMQ_SEND_TIMEOUT_MS', { min: 1, max: 60_000, }), }, }) as SchedulerRuntimeConfig; } /** 从 Worker 专属变量生成深冻结配置,并阻止生产环境使用模拟行情。 */ export function loadWorkerConfig(env: EnvironmentSource): WorkerRuntimeConfig { const appEnv = loadAppEnv(env); const mode = parseEnum(env, 'MARKET_DATA_MODE', ['mock', 'live']); const provider = requireText(env, 'MARKET_DATA_PROVIDER'); const rocketMq = loadRocketMqConfig(env); if (appEnv === 'production' && mode !== 'live') { throw new Error('生产环境 MARKET_DATA_MODE 必须为 live'); } return deepFreeze({ processName: 'worker', appEnv, logLevel: loadLogLevel(env), marketData: { mode, provider, baseUrl: optionalText(env, 'MARKET_DATA_BASE_URL'), apiKey: optionalText(env, 'MARKET_DATA_API_KEY'), }, redis: loadRedisConfig(env), mysql: loadMysqlConfig(env), rocketMq: { ...rocketMq, consumerGroup: requireText(env, 'ROCKETMQ_CONSUMER_GROUP'), consumerConcurrency: parseInteger(env, 'ROCKETMQ_CONSUMER_CONCURRENCY', { min: 1, max: 128, }), }, ai: loadAiConfig(env), }) as WorkerRuntimeConfig; } /** 日志只保留诊断所需的非敏感字段,绝不序列化密码、Token 或 API Key。 */ export function toSafeRuntimeSummary(config: RuntimeConfig): Readonly> { const summary: Record = { processName: config.processName, appEnv: config.appEnv, logLevel: config.logLevel, redis: { host: config.redis.host, port: config.redis.port, database: config.redis.database, tls: config.redis.tls, }, }; if (config.processName === 'api') summary.api = config.api; if (config.processName === 'scheduler') summary.scheduler = config.scheduler; if (config.processName === 'worker') summary.marketData = { mode: config.marketData.mode }; return deepFreeze(summary); }