feat(GOV-009): 建立 NestJS 三进程基线

This commit is contained in:
2026-08-13 16:59:09 +08:00
parent 28d54186ad
commit 3fc703de0b
23 changed files with 3399 additions and 36 deletions
@@ -0,0 +1,60 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { parse } from 'dotenv';
import {
loadApiConfig,
loadSchedulerConfig,
loadWorkerConfig,
type ApiRuntimeConfig,
type SchedulerRuntimeConfig,
type WorkerRuntimeConfig,
} from './runtime-config';
type ProcessName = 'api' | 'scheduler' | 'worker';
function findRepositoryRoot(startDirectory: string): string {
let current = path.resolve(startDirectory);
while (true) {
if (existsSync(path.join(current, 'pnpm-workspace.yaml'))) return current;
const parent = path.dirname(current);
if (parent === current) throw new Error('无法定位项目根目录');
current = parent;
}
}
function readDevelopmentEnv(processName: ProcessName): NodeJS.ProcessEnv {
const repositoryRoot = findRepositoryRoot(__dirname);
const envFile = path.join(
repositoryRoot,
'deploy',
'env',
'development',
`${processName}.env.local`,
);
if (!existsSync(envFile)) {
throw new Error(`缺少本地开发配置:deploy/env/development/${processName}.env.local`);
}
return { ...process.env, ...parse(readFileSync(envFile)) };
}
function resolveProcessEnvironment(processName: ProcessName): NodeJS.ProcessEnv {
// 生产环境只信任 systemd/Compose 注入的 process.env,绝不读取仓库内开发配置。
return process.env.APP_ENV === 'production' ? process.env : readDevelopmentEnv(processName);
}
export function loadApiProcessConfig(): ApiRuntimeConfig {
return loadApiConfig(resolveProcessEnvironment('api'));
}
export function loadSchedulerProcessConfig(): SchedulerRuntimeConfig {
return loadSchedulerConfig(resolveProcessEnvironment('scheduler'));
}
export function loadWorkerProcessConfig(): WorkerRuntimeConfig {
return loadWorkerConfig(resolveProcessEnvironment('worker'));
}
@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest';
import {
loadApiConfig,
loadSchedulerConfig,
loadWorkerConfig,
toSafeRuntimeSummary,
} from './runtime-config';
const baseRedisEnv = {
REDIS_HOST: '127.0.0.1',
REDIS_PORT: '6379',
REDIS_USERNAME: '',
REDIS_PASSWORD: 'TEST_SENSITIVE_VALUE',
REDIS_DATABASE: '0',
REDIS_TLS: 'false',
};
const disabledMysqlEnv = {
MYSQL_ENABLED: 'false',
MYSQL_HOST: '127.0.0.1',
MYSQL_PORT: '3306',
MYSQL_DATABASE: 'a_share_analysis_dev',
MYSQL_USERNAME: '',
MYSQL_PASSWORD: '',
MYSQL_CHARSET: 'utf8mb4',
MYSQL_COLLATION: 'utf8mb4_general_ci',
MYSQL_TIMEZONE: '+08:00',
};
const disabledAiEnv = {
AI_ENABLED: 'false',
AI_BASE_URL: '',
AI_API_KEY: '',
AI_MODEL: '',
AI_TIMEOUT_MS: '30000',
};
const disabledRocketMqEnv = {
ROCKETMQ_ENABLED: 'false',
ROCKETMQ_NAMESRV_ADDR: '127.0.0.1:9876',
ROCKETMQ_ENDPOINT: '127.0.0.1:8081',
ROCKETMQ_TOPIC_PREFIX: 'a-share-analysis',
};
describe('进程级运行配置', () => {
it('API 只返回自己的配置且不要求 Scheduler 或 Worker 字段', () => {
const config = loadApiConfig({
APP_ENV: 'development',
LOG_LEVEL: 'debug',
API_HOST: '127.0.0.1',
API_PORT: '3000',
...baseRedisEnv,
...disabledMysqlEnv,
...disabledAiEnv,
SCHEDULER_TIMEZONE: 'should-not-leak',
MARKET_DATA_API_KEY: 'should-not-leak',
});
expect(config).toMatchObject({
processName: 'api',
appEnv: 'development',
api: { host: '127.0.0.1', port: 3000 },
redis: { database: 0 },
mysql: { enabled: false, timezone: '+08:00' },
});
expect(config).not.toHaveProperty('SCHEDULER_TIMEZONE');
expect(config).not.toHaveProperty('MARKET_DATA_API_KEY');
expect(Object.isFrozen(config)).toBe(true);
expect(Object.isFrozen(config.redis)).toBe(true);
});
it('Scheduler 不读取 MySQL 密码且不要求 API 端口', () => {
const config = loadSchedulerConfig({
APP_ENV: 'development',
LOG_LEVEL: 'debug',
SCHEDULER_TIMEZONE: 'Asia/Shanghai',
SCHEDULER_ENABLED: 'true',
...baseRedisEnv,
...disabledRocketMqEnv,
ROCKETMQ_SEND_TIMEOUT_MS: '3000',
MYSQL_PASSWORD: 'must-not-enter-scheduler',
});
expect(config).toMatchObject({
processName: 'scheduler',
scheduler: { enabled: true, timezone: 'Asia/Shanghai' },
rocketMq: { enabled: false, sendTimeoutMs: 3000 },
});
expect(JSON.stringify(config)).not.toContain('must-not-enter-scheduler');
expect(config).not.toHaveProperty('api');
});
it('Worker 不要求 API 端口并保留自己的行情与消费配置', () => {
const config = loadWorkerConfig({
APP_ENV: 'development',
LOG_LEVEL: 'debug',
MARKET_DATA_MODE: 'mock',
MARKET_DATA_PROVIDER: 'deterministic-fixture',
MARKET_DATA_BASE_URL: '',
MARKET_DATA_API_KEY: '',
...baseRedisEnv,
REDIS_DATABASE: '2',
...disabledMysqlEnv,
...disabledRocketMqEnv,
ROCKETMQ_CONSUMER_GROUP: 'a-share-analysis-worker',
ROCKETMQ_CONSUMER_CONCURRENCY: '4',
...disabledAiEnv,
});
expect(config).toMatchObject({
processName: 'worker',
marketData: { mode: 'mock', provider: 'deterministic-fixture' },
redis: { database: 2 },
rocketMq: { consumerConcurrency: 4 },
});
expect(config).not.toHaveProperty('api');
});
it('MySQL 启用时拒绝缺失密码或错误时区', () => {
const enabledMysql = {
...disabledMysqlEnv,
MYSQL_ENABLED: 'true',
MYSQL_USERNAME: 'dev-user',
MYSQL_PASSWORD: 'TEST_SENSITIVE_VALUE',
};
const apiEnv = {
APP_ENV: 'development',
LOG_LEVEL: 'debug',
API_HOST: '127.0.0.1',
API_PORT: '3000',
...baseRedisEnv,
...enabledMysql,
...disabledAiEnv,
};
expect(() => loadApiConfig({ ...apiEnv, MYSQL_PASSWORD: '' })).toThrow(/MYSQL_PASSWORD/);
expect(() => loadApiConfig({ ...apiEnv, MYSQL_TIMEZONE: 'SYSTEM' })).toThrow(/MYSQL_TIMEZONE/);
});
it('日志摘要不包含任何连接密码、密钥或未知字段', () => {
const config = loadApiConfig({
APP_ENV: 'development',
LOG_LEVEL: 'debug',
API_HOST: '127.0.0.1',
API_PORT: '3000',
...baseRedisEnv,
...disabledMysqlEnv,
...disabledAiEnv,
UNKNOWN_TOKEN: 'TEST_UNKNOWN_VALUE',
});
const serialized = JSON.stringify(toSafeRuntimeSummary(config));
expect(serialized).toContain('"processName":"api"');
expect(serialized).not.toContain('TEST_SENSITIVE_VALUE');
expect(serialized).not.toContain('TEST_UNKNOWN_VALUE');
expect(serialized).not.toContain('password');
expect(serialized).not.toContain('apiKey');
});
});
+295
View File
@@ -0,0 +1,295 @@
export type AppEnvironment = 'development' | 'production';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export interface RedisRuntimeConfig {
host: string;
port: number;
username: string;
password: string;
database: number;
tls: boolean;
}
export interface MysqlRuntimeConfig {
enabled: boolean;
host: string;
port: number;
database: string;
username: string;
password: string;
charset: 'utf8mb4';
collation: 'utf8mb4_general_ci';
timezone: '+08:00';
}
export interface AiRuntimeConfig {
enabled: boolean;
baseUrl: string;
apiKey: string;
model: string;
timeoutMs: number;
}
export interface RocketMqRuntimeConfig {
enabled: boolean;
nameServerAddress: string;
endpoint: string;
topicPrefix: string;
}
interface CommonRuntimeConfig {
processName: 'api' | 'scheduler' | 'worker';
appEnv: AppEnvironment;
logLevel: LogLevel;
redis: RedisRuntimeConfig;
}
export interface ApiRuntimeConfig extends CommonRuntimeConfig {
processName: 'api';
api: Readonly<{ host: string; port: number }>;
mysql: MysqlRuntimeConfig;
ai: AiRuntimeConfig;
}
export interface SchedulerRuntimeConfig extends CommonRuntimeConfig {
processName: 'scheduler';
scheduler: Readonly<{ enabled: boolean; timezone: 'Asia/Shanghai' }>;
rocketMq: RocketMqRuntimeConfig & Readonly<{ sendTimeoutMs: number }>;
}
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 RocketMQ 客户端;启用配置只在后续 Adapter 落地时允许启动。
if (enabled && !nameServerAddress && !endpoint) {
throw new Error('ROCKETMQ_NAMESRV_ADDR 或 ROCKETMQ_ENDPOINT 至少填写一个');
}
return {
enabled,
nameServerAddress,
endpoint,
topicPrefix: requireText(env, 'ROCKETMQ_TOPIC_PREFIX'),
};
}
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;
}
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;
}
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;
}
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);
}