feat(GOV-009): 建立 NestJS 三进程基线
This commit is contained in:
@@ -24,7 +24,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **a-share-analysis** (1076 symbols, 1271 relationships, 22 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **a-share-analysis** (1220 symbols, 1576 relationships, 36 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
Generated
+2489
-34
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Module({})
|
||||||
|
export class ApiModule {}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import { type INestApplication, type NestApplicationOptions } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
|
import { loadApiProcessConfig } from '../../../libs/config/src/load-process-env';
|
||||||
|
import {
|
||||||
|
toSafeRuntimeSummary,
|
||||||
|
type ApiRuntimeConfig,
|
||||||
|
} from '../../../libs/config/src/runtime-config';
|
||||||
|
import { ApiModule } from './api.module';
|
||||||
|
|
||||||
|
interface BootstrapApiOptions {
|
||||||
|
config: ApiRuntimeConfig;
|
||||||
|
port?: number;
|
||||||
|
logger?: NestApplicationOptions['logger'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapApi(options: BootstrapApiOptions): Promise<INestApplication> {
|
||||||
|
const app = await NestFactory.create(ApiModule, { logger: options.logger });
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
await app.listen(options.port ?? options.config.api.port, options.config.api.host);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runApi(): Promise<void> {
|
||||||
|
const config = loadApiProcessConfig();
|
||||||
|
console.info('API 进程启动配置', toSafeRuntimeSummary(config));
|
||||||
|
await bootstrapApi({ config });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
void runApi();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.build.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/apps/api"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "../../libs/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import { type INestApplicationContext, type NestApplicationOptions } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
|
import { loadSchedulerProcessConfig } from '../../../libs/config/src/load-process-env';
|
||||||
|
import {
|
||||||
|
toSafeRuntimeSummary,
|
||||||
|
type SchedulerRuntimeConfig,
|
||||||
|
} from '../../../libs/config/src/runtime-config';
|
||||||
|
import { SchedulerModule } from './scheduler.module';
|
||||||
|
|
||||||
|
interface BootstrapSchedulerOptions {
|
||||||
|
config: SchedulerRuntimeConfig;
|
||||||
|
logger?: NestApplicationOptions['logger'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapScheduler(
|
||||||
|
options: BootstrapSchedulerOptions,
|
||||||
|
): Promise<INestApplicationContext> {
|
||||||
|
const app = await NestFactory.createApplicationContext(SchedulerModule, {
|
||||||
|
logger: options.logger,
|
||||||
|
});
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScheduler(): Promise<void> {
|
||||||
|
const config = loadSchedulerProcessConfig();
|
||||||
|
console.info('Scheduler 进程启动配置', toSafeRuntimeSummary(config));
|
||||||
|
await bootstrapScheduler({ config });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
void runScheduler();
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Module({})
|
||||||
|
export class SchedulerModule {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.build.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/apps/scheduler"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "../../libs/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import { type INestApplicationContext, type NestApplicationOptions } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
|
import { loadWorkerProcessConfig } from '../../../libs/config/src/load-process-env';
|
||||||
|
import {
|
||||||
|
toSafeRuntimeSummary,
|
||||||
|
type WorkerRuntimeConfig,
|
||||||
|
} from '../../../libs/config/src/runtime-config';
|
||||||
|
import { WorkerModule } from './worker.module';
|
||||||
|
|
||||||
|
interface BootstrapWorkerOptions {
|
||||||
|
config: WorkerRuntimeConfig;
|
||||||
|
logger?: NestApplicationOptions['logger'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrapWorker(
|
||||||
|
options: BootstrapWorkerOptions,
|
||||||
|
): Promise<INestApplicationContext> {
|
||||||
|
const app = await NestFactory.createApplicationContext(WorkerModule, {
|
||||||
|
logger: options.logger,
|
||||||
|
});
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWorker(): Promise<void> {
|
||||||
|
const config = loadWorkerProcessConfig();
|
||||||
|
console.info('Worker 进程启动配置', toSafeRuntimeSummary(config));
|
||||||
|
await bootstrapWorker({ config });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
void runWorker();
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Module({})
|
||||||
|
export class WorkerModule {}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.build.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/apps/worker"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "../../libs/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"monorepo": true,
|
||||||
|
"root": "apps/api",
|
||||||
|
"sourceRoot": "apps/api/src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": false,
|
||||||
|
"webpack": true,
|
||||||
|
"tsConfigPath": "apps/api/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"api": {
|
||||||
|
"type": "application",
|
||||||
|
"root": "apps/api",
|
||||||
|
"entryFile": "main",
|
||||||
|
"sourceRoot": "apps/api/src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "apps/api/tsconfig.app.json",
|
||||||
|
"webpack": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scheduler": {
|
||||||
|
"type": "application",
|
||||||
|
"root": "apps/scheduler",
|
||||||
|
"entryFile": "main",
|
||||||
|
"sourceRoot": "apps/scheduler/src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "apps/scheduler/tsconfig.app.json",
|
||||||
|
"webpack": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"worker": {
|
||||||
|
"type": "application",
|
||||||
|
"root": "apps/worker",
|
||||||
|
"entryFile": "main",
|
||||||
|
"sourceRoot": "apps/worker/src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "apps/worker/tsconfig.app.json",
|
||||||
|
"webpack": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@a-share-analysis/server",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev:api": "tsx apps/api/src/main.ts",
|
||||||
|
"dev:scheduler": "tsx apps/scheduler/src/main.ts",
|
||||||
|
"dev:worker": "tsx apps/worker/src/main.ts",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"build": "node tools/clean-build-output.mjs && nest build api && nest build scheduler && nest build worker && node tools/verify-build-output.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "11.1.29",
|
||||||
|
"@nestjs/core": "11.1.29",
|
||||||
|
"@nestjs/platform-express": "11.1.29",
|
||||||
|
"dotenv": "16.6.1",
|
||||||
|
"reflect-metadata": "0.2.2",
|
||||||
|
"rxjs": "7.8.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "11.0.24",
|
||||||
|
"@nestjs/testing": "11.1.29",
|
||||||
|
"@types/node": "22.20.0",
|
||||||
|
"ts-loader": "9.6.2",
|
||||||
|
"tsx": "4.20.6",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "3.2.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { type AddressInfo } from 'node:net';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { bootstrapApi } from '../apps/api/src/main';
|
||||||
|
import { bootstrapScheduler } from '../apps/scheduler/src/main';
|
||||||
|
import { bootstrapWorker } from '../apps/worker/src/main';
|
||||||
|
import type {
|
||||||
|
ApiRuntimeConfig,
|
||||||
|
SchedulerRuntimeConfig,
|
||||||
|
WorkerRuntimeConfig,
|
||||||
|
} from '../libs/config/src/runtime-config';
|
||||||
|
|
||||||
|
const redisConfig = Object.freeze({
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 6379,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
database: 0,
|
||||||
|
tls: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mysqlConfig = Object.freeze({
|
||||||
|
enabled: false,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 3306,
|
||||||
|
database: 'a_share_analysis_dev',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
charset: 'utf8mb4' as const,
|
||||||
|
collation: 'utf8mb4_general_ci' as const,
|
||||||
|
timezone: '+08:00' as const,
|
||||||
|
});
|
||||||
|
|
||||||
|
const aiConfig = Object.freeze({
|
||||||
|
enabled: false,
|
||||||
|
baseUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
model: '',
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rocketMqConfig = Object.freeze({
|
||||||
|
enabled: false,
|
||||||
|
nameServerAddress: '127.0.0.1:9876',
|
||||||
|
endpoint: '127.0.0.1:8081',
|
||||||
|
topicPrefix: 'a-share-analysis',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NestJS 三进程入口', () => {
|
||||||
|
it('API 使用真实 HTTP 应用监听注入端口并可关闭', async () => {
|
||||||
|
const config: ApiRuntimeConfig = {
|
||||||
|
processName: 'api',
|
||||||
|
appEnv: 'development',
|
||||||
|
logLevel: 'debug',
|
||||||
|
api: Object.freeze({ host: '127.0.0.1', port: 3000 }),
|
||||||
|
redis: redisConfig,
|
||||||
|
mysql: mysqlConfig,
|
||||||
|
ai: aiConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = await bootstrapApi({ config, port: 0, logger: false });
|
||||||
|
const address = app.getHttpServer().address() as AddressInfo;
|
||||||
|
|
||||||
|
expect(address.address).toBe('127.0.0.1');
|
||||||
|
expect(address.port).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
expect(app.getHttpServer().listening).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Scheduler 使用无 HTTP 端口的应用上下文并可关闭', async () => {
|
||||||
|
const config: SchedulerRuntimeConfig = {
|
||||||
|
processName: 'scheduler',
|
||||||
|
appEnv: 'development',
|
||||||
|
logLevel: 'debug',
|
||||||
|
scheduler: Object.freeze({ enabled: true, timezone: 'Asia/Shanghai' }),
|
||||||
|
redis: Object.freeze({ ...redisConfig, database: 1 }),
|
||||||
|
rocketMq: Object.freeze({ ...rocketMqConfig, sendTimeoutMs: 3000 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = await bootstrapScheduler({ config, logger: false });
|
||||||
|
|
||||||
|
expect('getHttpServer' in app).toBe(false);
|
||||||
|
await expect(app.close()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Worker 使用无 HTTP 端口的应用上下文并可关闭', async () => {
|
||||||
|
const config: WorkerRuntimeConfig = {
|
||||||
|
processName: 'worker',
|
||||||
|
appEnv: 'development',
|
||||||
|
logLevel: 'debug',
|
||||||
|
marketData: Object.freeze({
|
||||||
|
mode: 'mock',
|
||||||
|
provider: 'deterministic-fixture',
|
||||||
|
baseUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
}),
|
||||||
|
redis: Object.freeze({ ...redisConfig, database: 2 }),
|
||||||
|
mysql: mysqlConfig,
|
||||||
|
rocketMq: Object.freeze({
|
||||||
|
...rocketMqConfig,
|
||||||
|
consumerGroup: 'a-share-analysis-worker',
|
||||||
|
consumerConcurrency: 4,
|
||||||
|
}),
|
||||||
|
ai: aiConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = await bootstrapWorker({ config, logger: false });
|
||||||
|
|
||||||
|
expect('getHttpServer' in app).toBe(false);
|
||||||
|
await expect(app.close()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { rm } from 'node:fs/promises';
|
||||||
|
|
||||||
|
// 构建前只清理 Server 自己的派生产物,避免历史 main.js 让产物门禁误判为成功。
|
||||||
|
await rm(new URL('../dist/', import.meta.url), { recursive: true, force: true });
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { access, readdir } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const expectedOutputs = [
|
||||||
|
'dist/apps/api/main.js',
|
||||||
|
'dist/apps/scheduler/main.js',
|
||||||
|
'dist/apps/worker/main.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(expectedOutputs.map((output) => access(output)));
|
||||||
|
|
||||||
|
const sourceDirectories = ['apps', 'libs', 'test'];
|
||||||
|
const leakedOutputs = [];
|
||||||
|
|
||||||
|
async function findLeakedOutputs(directory) {
|
||||||
|
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||||
|
const target = `${directory}/${entry.name}`;
|
||||||
|
if (entry.isDirectory()) await findLeakedOutputs(target);
|
||||||
|
if (entry.isFile() && /\.(?:js|js\.map|d\.ts)$/.test(entry.name)) leakedOutputs.push(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(sourceDirectories.map(findLeakedOutputs));
|
||||||
|
if (leakedOutputs.length > 0) {
|
||||||
|
throw new Error(`构建产物不得写入源码目录:${leakedOutputs.join('、')}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": false
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "test", "**/*.test.ts", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"removeComments": false,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["apps/**/*.ts", "libs/**/*.ts", "test/**/*.ts", "tools/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['apps/**/*.test.ts', 'apps/**/*.spec.ts', 'libs/**/*.test.ts', 'test/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user