311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
/** 应用环境仅允许显式的开发或生产态,禁止隐式 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<Record<string, string | undefined>>;
|
|
|
|
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<TValue extends string>(
|
|
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<TValue>(value: TValue): Readonly<TValue> {
|
|
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
for (const child of Object.values(value as Record<string, unknown>)) 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<Record<string, unknown>> {
|
|
const summary: Record<string, unknown> = {
|
|
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);
|
|
}
|