fix(GOV-009): 完成脚手架审查整改
This commit is contained in:
@@ -1,29 +1,44 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { type DynamicModule, Module } from '@nestjs/common';
|
||||
|
||||
import { loadApiProcessConfig } from '../../../libs/config/src/load-process-env';
|
||||
import type { ApiRuntimeConfig } from '../../../libs/config/src/runtime-config';
|
||||
import { DisabledHealthClient } from '../../../libs/infrastructure/src/disabled-health.client';
|
||||
import { createMysqlHealthClient } from '../../../libs/infrastructure/src/mysql-health.client';
|
||||
import { createRedisHealthClient } from '../../../libs/infrastructure/src/redis-health.client';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService, MYSQL_HEALTH_PORT, REDIS_HEALTH_PORT } from './health.service';
|
||||
|
||||
const apiConfig = loadApiProcessConfig();
|
||||
/** API 进程的只读配置注入令牌,确保测试和启动入口使用同一份显式配置。 */
|
||||
export const API_RUNTIME_CONFIG = Symbol('API_RUNTIME_CONFIG');
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
{
|
||||
provide: REDIS_HEALTH_PORT,
|
||||
useFactory: () => createRedisHealthClient(apiConfig.redis),
|
||||
},
|
||||
{
|
||||
provide: MYSQL_HEALTH_PORT,
|
||||
useFactory: () =>
|
||||
apiConfig.mysql.enabled
|
||||
? createMysqlHealthClient(apiConfig.mysql)
|
||||
: new DisabledHealthClient('MySQL 未启用'),
|
||||
},
|
||||
],
|
||||
})
|
||||
export class ApiModule {}
|
||||
@Module({})
|
||||
export class ApiModule {
|
||||
/**
|
||||
* 由启动入口传入已经校验和冻结的运行配置,禁止模块加载阶段隐式读取环境变量,
|
||||
* 从而让 API 测试、OpenAPI 生成和生产启动之间不存在隐藏配置差异。
|
||||
*/
|
||||
static register(config: ApiRuntimeConfig): DynamicModule {
|
||||
return {
|
||||
module: ApiModule,
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
{ provide: API_RUNTIME_CONFIG, useValue: config },
|
||||
{
|
||||
provide: REDIS_HEALTH_PORT,
|
||||
inject: [API_RUNTIME_CONFIG],
|
||||
useFactory: (runtimeConfig: ApiRuntimeConfig) =>
|
||||
createRedisHealthClient(runtimeConfig.redis),
|
||||
},
|
||||
{
|
||||
provide: MYSQL_HEALTH_PORT,
|
||||
inject: [API_RUNTIME_CONFIG],
|
||||
useFactory: (runtimeConfig: ApiRuntimeConfig) =>
|
||||
runtimeConfig.mysql.enabled
|
||||
? createMysqlHealthClient(runtimeConfig.mysql)
|
||||
: new DisabledHealthClient('MySQL 未启用'),
|
||||
},
|
||||
],
|
||||
exports: [API_RUNTIME_CONFIG],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { HealthService } from './health.service';
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller('health')
|
||||
/** 将内部健康结果映射为 200/503,同时保持两种响应共用同一 OpenAPI DTO。 */
|
||||
export class HealthController {
|
||||
constructor(@Inject(HealthService) private readonly healthService: HealthService) {}
|
||||
|
||||
|
||||
@@ -5,14 +5,17 @@ import type {
|
||||
HealthResponseDto,
|
||||
} from '../../../libs/http-contracts/src/health.dto';
|
||||
|
||||
/** 依赖注入令牌避免健康服务耦合 ioredis 或 mysql2 客户端。 */
|
||||
export const REDIS_HEALTH_PORT = Symbol('REDIS_HEALTH_PORT');
|
||||
export const MYSQL_HEALTH_PORT = Symbol('MYSQL_HEALTH_PORT');
|
||||
|
||||
/** 健康端口只要求只读检查,便于禁用态和测试实现遵循同一契约。 */
|
||||
export interface DependencyHealthPort {
|
||||
check(): Promise<DependencyHealthDto>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
/** 聚合依赖状态并统一清洗底层错误,禁止连接信息进入 HTTP 响应。 */
|
||||
export class HealthService {
|
||||
constructor(
|
||||
@Inject(REDIS_HEALTH_PORT) private readonly redis: DependencyHealthPort,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'reflect-metadata';
|
||||
|
||||
import { type INestApplication, type NestApplicationOptions } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadApiProcessConfig } from '../../../libs/config/src/load-process-env';
|
||||
import {
|
||||
@@ -10,14 +11,18 @@ import {
|
||||
} from '../../../libs/config/src/runtime-config';
|
||||
import { ApiModule } from './api.module';
|
||||
|
||||
/** API 引导参数只接受已完成进程级校验的配置,测试可覆盖监听端口和日志器。 */
|
||||
interface BootstrapApiOptions {
|
||||
config: ApiRuntimeConfig;
|
||||
port?: number;
|
||||
logger?: NestApplicationOptions['logger'];
|
||||
}
|
||||
|
||||
/** 启动可独立部署的 API;配置由入口显式注入,监听前已完成全部校验。 */
|
||||
export async function bootstrapApi(options: BootstrapApiOptions): Promise<INestApplication> {
|
||||
const app = await NestFactory.create(ApiModule, { logger: options.logger });
|
||||
const app = await NestFactory.create(ApiModule.register(options.config), {
|
||||
logger: options.logger,
|
||||
});
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableShutdownHooks();
|
||||
await app.listen(options.port ?? options.config.api.port, options.config.api.host);
|
||||
@@ -30,6 +35,6 @@ async function runApi(): Promise<void> {
|
||||
await bootstrapApi({ config });
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
|
||||
void runApi();
|
||||
}
|
||||
|
||||
@@ -18,12 +18,9 @@ describe('Server OpenAPI 单向发布', () => {
|
||||
path.resolve(__dirname, '../../../openapi/openapi.json'),
|
||||
'utf8',
|
||||
);
|
||||
const document = JSON.parse(content) as {
|
||||
paths: Record<string, unknown>;
|
||||
components?: { schemas?: Record<string, unknown> };
|
||||
};
|
||||
const committedDocument = JSON.parse(content) as Record<string, unknown>;
|
||||
const liveDocument = await createOpenApiDocument();
|
||||
|
||||
expect(document.paths).toHaveProperty('/api/v1/health');
|
||||
expect(document.components?.schemas).toHaveProperty('HealthResponseDto');
|
||||
expect(committedDocument).toEqual(liveDocument);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,35 +2,39 @@ import 'reflect-metadata';
|
||||
|
||||
import { type INestApplicationContext, type NestApplicationOptions } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadSchedulerProcessConfig } from '../../../libs/config/src/load-process-env';
|
||||
import {
|
||||
toSafeRuntimeSummary,
|
||||
type SchedulerRuntimeConfig,
|
||||
} from '../../../libs/config/src/runtime-config';
|
||||
import { waitForShutdownSignal } from '../../../libs/infrastructure/src/process-lifecycle';
|
||||
import { SchedulerModule } from './scheduler.module';
|
||||
|
||||
/** Scheduler 引导参数确保模块只读取本进程已校验的最小配置。 */
|
||||
interface BootstrapSchedulerOptions {
|
||||
config: SchedulerRuntimeConfig;
|
||||
logger?: NestApplicationOptions['logger'];
|
||||
}
|
||||
|
||||
/** 启动无 HTTP 端口的 Scheduler 应用上下文,生命周期由命令入口持有。 */
|
||||
export async function bootstrapScheduler(
|
||||
options: BootstrapSchedulerOptions,
|
||||
): Promise<INestApplicationContext> {
|
||||
const app = await NestFactory.createApplicationContext(SchedulerModule, {
|
||||
const app = await NestFactory.createApplicationContext(SchedulerModule.register(options.config), {
|
||||
logger: options.logger,
|
||||
});
|
||||
app.enableShutdownHooks();
|
||||
return app;
|
||||
}
|
||||
|
||||
async function runScheduler(): Promise<void> {
|
||||
const config = loadSchedulerProcessConfig();
|
||||
console.info('Scheduler 进程启动配置', toSafeRuntimeSummary(config));
|
||||
await bootstrapScheduler({ config });
|
||||
const app = await bootstrapScheduler({ config });
|
||||
await waitForShutdownSignal(app);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
|
||||
void runScheduler();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { type DynamicModule, Module } from '@nestjs/common';
|
||||
|
||||
import type { SchedulerRuntimeConfig } from '../../../libs/config/src/runtime-config';
|
||||
|
||||
/** Scheduler 的最小权限配置令牌,不向 API 或 Worker 暴露其任务调度配置。 */
|
||||
export const SCHEDULER_RUNTIME_CONFIG = Symbol('SCHEDULER_RUNTIME_CONFIG');
|
||||
|
||||
@Module({})
|
||||
export class SchedulerModule {}
|
||||
export class SchedulerModule {
|
||||
/** 将入口校验后的 Scheduler 配置注册进当前进程容器。 */
|
||||
static register(config: SchedulerRuntimeConfig): DynamicModule {
|
||||
return {
|
||||
module: SchedulerModule,
|
||||
providers: [{ provide: SCHEDULER_RUNTIME_CONFIG, useValue: config }],
|
||||
exports: [SCHEDULER_RUNTIME_CONFIG],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,35 +2,39 @@ import 'reflect-metadata';
|
||||
|
||||
import { type INestApplicationContext, type NestApplicationOptions } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadWorkerProcessConfig } from '../../../libs/config/src/load-process-env';
|
||||
import {
|
||||
toSafeRuntimeSummary,
|
||||
type WorkerRuntimeConfig,
|
||||
} from '../../../libs/config/src/runtime-config';
|
||||
import { waitForShutdownSignal } from '../../../libs/infrastructure/src/process-lifecycle';
|
||||
import { WorkerModule } from './worker.module';
|
||||
|
||||
/** Worker 引导参数确保模块只读取本进程已校验的最小配置。 */
|
||||
interface BootstrapWorkerOptions {
|
||||
config: WorkerRuntimeConfig;
|
||||
logger?: NestApplicationOptions['logger'];
|
||||
}
|
||||
|
||||
/** 启动无 HTTP 端口的 Worker 应用上下文,生命周期由命令入口持有。 */
|
||||
export async function bootstrapWorker(
|
||||
options: BootstrapWorkerOptions,
|
||||
): Promise<INestApplicationContext> {
|
||||
const app = await NestFactory.createApplicationContext(WorkerModule, {
|
||||
const app = await NestFactory.createApplicationContext(WorkerModule.register(options.config), {
|
||||
logger: options.logger,
|
||||
});
|
||||
app.enableShutdownHooks();
|
||||
return app;
|
||||
}
|
||||
|
||||
async function runWorker(): Promise<void> {
|
||||
const config = loadWorkerProcessConfig();
|
||||
console.info('Worker 进程启动配置', toSafeRuntimeSummary(config));
|
||||
await bootstrapWorker({ config });
|
||||
const app = await bootstrapWorker({ config });
|
||||
await waitForShutdownSignal(app);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
|
||||
void runWorker();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { type DynamicModule, Module } from '@nestjs/common';
|
||||
|
||||
import type { WorkerRuntimeConfig } from '../../../libs/config/src/runtime-config';
|
||||
|
||||
/** Worker 的最小权限配置令牌,仅包含采集、持久化、消息与 AI 处理所需配置。 */
|
||||
export const WORKER_RUNTIME_CONFIG = Symbol('WORKER_RUNTIME_CONFIG');
|
||||
|
||||
@Module({})
|
||||
export class WorkerModule {}
|
||||
export class WorkerModule {
|
||||
/** 将入口校验后的 Worker 配置注册进当前进程容器。 */
|
||||
static register(config: WorkerRuntimeConfig): DynamicModule {
|
||||
return {
|
||||
module: WorkerModule,
|
||||
providers: [{ provide: WORKER_RUNTIME_CONFIG, useValue: config }],
|
||||
exports: [WORKER_RUNTIME_CONFIG],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,14 +47,17 @@ function resolveProcessEnvironment(processName: ProcessName): NodeJS.ProcessEnv
|
||||
return process.env.APP_ENV === 'production' ? process.env : readDevelopmentEnv(processName);
|
||||
}
|
||||
|
||||
/** API 只加载自身 local/env 键并立即完成配置校验。 */
|
||||
export function loadApiProcessConfig(): ApiRuntimeConfig {
|
||||
return loadApiConfig(resolveProcessEnvironment('api'));
|
||||
}
|
||||
|
||||
/** Scheduler 只加载调度与投递所需配置,不读取数据库凭据。 */
|
||||
export function loadSchedulerProcessConfig(): SchedulerRuntimeConfig {
|
||||
return loadSchedulerConfig(resolveProcessEnvironment('scheduler'));
|
||||
}
|
||||
|
||||
/** Worker 只加载任务执行所需配置,不读取 API 监听参数。 */
|
||||
export function loadWorkerProcessConfig(): WorkerRuntimeConfig {
|
||||
return loadWorkerConfig(resolveProcessEnvironment('worker'));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/** 应用环境仅允许显式的开发或生产态,禁止隐式 test/staging 绕过生产约束。 */
|
||||
export type AppEnvironment = 'development' | 'production';
|
||||
/** 四级日志口径由进程入口统一校验,避免基础设施各自解释字符串。 */
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
/** 每个进程只接收自己的 Redis DB 与连接参数,凭据不得进入安全摘要。 */
|
||||
export interface RedisRuntimeConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -10,6 +13,7 @@ export interface RedisRuntimeConfig {
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
/** MySQL 配置固定 5.7.42 兼容口径,启用状态决定凭据是否必填。 */
|
||||
export interface MysqlRuntimeConfig {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
@@ -22,6 +26,7 @@ export interface MysqlRuntimeConfig {
|
||||
timezone: '+08:00';
|
||||
}
|
||||
|
||||
/** OpenAI 兼容接口配置;关闭 AI 时允许凭据为空,且不影响规则分析。 */
|
||||
export interface AiRuntimeConfig {
|
||||
enabled: boolean;
|
||||
baseUrl: string;
|
||||
@@ -30,6 +35,7 @@ export interface AiRuntimeConfig {
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
/** RocketMQ 公共寻址配置;实际生产/消费参数由相应进程扩展。 */
|
||||
export interface RocketMqRuntimeConfig {
|
||||
enabled: boolean;
|
||||
nameServerAddress: string;
|
||||
@@ -44,6 +50,7 @@ interface CommonRuntimeConfig {
|
||||
redis: RedisRuntimeConfig;
|
||||
}
|
||||
|
||||
/** API 仅获得 HTTP、Redis、MySQL 与可选 AI 配置,不读取调度和消费配置。 */
|
||||
export interface ApiRuntimeConfig extends CommonRuntimeConfig {
|
||||
processName: 'api';
|
||||
api: Readonly<{ host: string; port: number }>;
|
||||
@@ -51,12 +58,14 @@ export interface ApiRuntimeConfig extends CommonRuntimeConfig {
|
||||
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<{
|
||||
@@ -71,6 +80,7 @@ export interface WorkerRuntimeConfig extends CommonRuntimeConfig {
|
||||
ai: AiRuntimeConfig;
|
||||
}
|
||||
|
||||
/** 三类配置的联合类型仅用于安全摘要等跨进程基础能力。 */
|
||||
export type RuntimeConfig = ApiRuntimeConfig | SchedulerRuntimeConfig | WorkerRuntimeConfig;
|
||||
type EnvironmentSource = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
@@ -205,6 +215,7 @@ function loadRocketMqConfig(env: EnvironmentSource): RocketMqRuntimeConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/** 从 API 专属环境变量生成深冻结配置,启动后禁止被运行时代码改写。 */
|
||||
export function loadApiConfig(env: EnvironmentSource): ApiRuntimeConfig {
|
||||
return deepFreeze({
|
||||
processName: 'api',
|
||||
@@ -220,6 +231,7 @@ export function loadApiConfig(env: EnvironmentSource): ApiRuntimeConfig {
|
||||
}) as ApiRuntimeConfig;
|
||||
}
|
||||
|
||||
/** 从 Scheduler 专属变量生成深冻结配置,并保持 RocketMQ 默认禁用边界。 */
|
||||
export function loadSchedulerConfig(env: EnvironmentSource): SchedulerRuntimeConfig {
|
||||
const rocketMq = loadRocketMqConfig(env);
|
||||
return deepFreeze({
|
||||
@@ -241,6 +253,7 @@ export function loadSchedulerConfig(env: EnvironmentSource): SchedulerRuntimeCon
|
||||
}) as SchedulerRuntimeConfig;
|
||||
}
|
||||
|
||||
/** 从 Worker 专属变量生成深冻结配置,并阻止生产环境使用模拟行情。 */
|
||||
export function loadWorkerConfig(env: EnvironmentSource): WorkerRuntimeConfig {
|
||||
const appEnv = loadAppEnv(env);
|
||||
const mode = parseEnum(env, 'MARKET_DATA_MODE', ['mock', 'live']);
|
||||
@@ -275,6 +288,7 @@ export function loadWorkerConfig(env: EnvironmentSource): WorkerRuntimeConfig {
|
||||
}) as WorkerRuntimeConfig;
|
||||
}
|
||||
|
||||
/** 日志只保留诊断所需的非敏感字段,绝不序列化密码、Token 或 API Key。 */
|
||||
export function toSafeRuntimeSummary(config: RuntimeConfig): Readonly<Record<string, unknown>> {
|
||||
const summary: Record<string, unknown> = {
|
||||
processName: config.processName,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/** API 总体健康只区分可服务与不可服务,依赖禁用不等同于故障。 */
|
||||
export type HealthStatus = 'healthy' | 'unavailable';
|
||||
/** 单项依赖额外允许 disabled,用于尚未启用但符合当前配置的组件。 */
|
||||
export type DependencyStatus = HealthStatus | 'disabled';
|
||||
|
||||
/** 单项依赖健康契约只暴露安全摘要,不返回地址、账号或底层异常。 */
|
||||
export class DependencyHealthDto {
|
||||
@ApiProperty({ type: String, enum: ['healthy', 'unavailable', 'disabled'] })
|
||||
status!: DependencyStatus;
|
||||
@@ -11,6 +14,7 @@ export class DependencyHealthDto {
|
||||
detail!: string;
|
||||
}
|
||||
|
||||
/** 健康响应固定列出三类基础设施,便于运维区分故障和计划内禁用。 */
|
||||
export class HealthDependenciesDto {
|
||||
@ApiProperty({ type: () => DependencyHealthDto })
|
||||
redis!: DependencyHealthDto;
|
||||
@@ -22,6 +26,7 @@ export class HealthDependenciesDto {
|
||||
rocketMq!: DependencyHealthDto;
|
||||
}
|
||||
|
||||
/** API 健康端点的版本化 HTTP DTO,也是 OpenAPI 单向发布源。 */
|
||||
export class HealthResponseDto {
|
||||
@ApiProperty({ type: String, enum: ['healthy', 'unavailable'] })
|
||||
status!: HealthStatus;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DependencyHealthDto } from '../../http-contracts/src/health.dto';
|
||||
|
||||
/** 将计划内关闭的依赖表达为 disabled,避免误报 unavailable 或尝试建立连接。 */
|
||||
export class DisabledHealthClient {
|
||||
constructor(private readonly detail: string) {}
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@ describe('禁用消息总线 Adapter', () => {
|
||||
|
||||
await expect(
|
||||
adapter.publish({
|
||||
version: 1,
|
||||
eventId: 'event-1',
|
||||
eventType: 'market.snapshot.updated',
|
||||
schemaVersion: 1,
|
||||
idempotencyKey: 'snapshot:1',
|
||||
occurredAt: '2026-08-13T01:30:00.000Z',
|
||||
tradeDate: '2026-08-13',
|
||||
traceId: 'trace-1',
|
||||
payload: { market: 'A_SHARE' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(MessageBusDisabledError);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MessageEnvelope } from '../../message-contracts/src/message-envelope';
|
||||
import type { MessageBusAdapter, MessageHandler } from './message-bus.adapter';
|
||||
|
||||
/** RocketMQ 禁用时使用专用错误,调用方不得误以为消息已投递或已订阅。 */
|
||||
export class MessageBusDisabledError extends Error {
|
||||
constructor(operation: 'publish' | 'subscribe') {
|
||||
super(`RocketMQ 当前未启用,拒绝执行 ${operation}`);
|
||||
@@ -8,6 +9,7 @@ export class MessageBusDisabledError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** GOV-009 的显式禁用 Adapter;拒绝所有收发,不提供不可追溯的内存队列降级。 */
|
||||
export class DisabledMessageBusAdapter implements MessageBusAdapter {
|
||||
async publish<TPayload>(message: Readonly<MessageEnvelope<TPayload>>): Promise<void> {
|
||||
void message;
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('基础设施只读健康客户端', () => {
|
||||
|
||||
await expect(client.check()).resolves.toEqual({
|
||||
status: 'healthy',
|
||||
detail: 'MySQL 5.7 / a_share_analysis_dev / +08:00',
|
||||
detail: 'MySQL 5.7.42 / a_share_analysis_dev / +08:00',
|
||||
});
|
||||
expect(queries).toEqual([MYSQL_HEALTH_QUERY]);
|
||||
});
|
||||
@@ -77,6 +77,7 @@ describe('基础设施只读健康客户端', () => {
|
||||
);
|
||||
|
||||
await expect(createClient({ version: '8.0.40' }).check()).rejects.toThrow(/MySQL 5.7/);
|
||||
await expect(createClient({ version: '5.7.41' }).check()).rejects.toThrow(/5\.7\.42/);
|
||||
await expect(createClient({ databaseName: 'a_share_analysis' }).check()).rejects.toThrow(
|
||||
/a_share_analysis_dev/,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { MessageEnvelope } from '../../message-contracts/src/message-envelope';
|
||||
|
||||
/** 消息处理器只消费项目内信封,禁止业务层依赖具体 RocketMQ SDK 类型。 */
|
||||
export type MessageHandler<TPayload = unknown> = (
|
||||
message: Readonly<MessageEnvelope<TPayload>>,
|
||||
) => Promise<void>;
|
||||
|
||||
/** 消息总线端口隔离客户端选型,并固定发布、订阅和资源关闭的最小语义。 */
|
||||
export interface MessageBusAdapter {
|
||||
publish<TPayload>(message: Readonly<MessageEnvelope<TPayload>>): Promise<void>;
|
||||
subscribe<TPayload>(eventType: string, handler: MessageHandler<TPayload>): Promise<void>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { OnModuleDestroy } from '@nestjs/common';
|
||||
import type { MysqlRuntimeConfig } from '../../config/src/runtime-config';
|
||||
import type { DependencyHealthDto } from '../../http-contracts/src/health.dto';
|
||||
|
||||
/** 固定的只读元数据查询,用于验证服务端版本、库名和会话字符口径。 */
|
||||
export const MYSQL_HEALTH_QUERY = `SELECT
|
||||
VERSION() AS version,
|
||||
DATABASE() AS databaseName,
|
||||
@@ -24,6 +25,7 @@ interface MysqlMetadataRow {
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
/** MySQL 健康客户端只校验元数据,不读取表数据或执行写入。 */
|
||||
export class MysqlHealthClient implements OnModuleDestroy {
|
||||
constructor(
|
||||
private readonly client: MysqlQueryPort,
|
||||
@@ -34,7 +36,9 @@ export class MysqlHealthClient implements OnModuleDestroy {
|
||||
const [rows] = await this.client.query(MYSQL_HEALTH_QUERY);
|
||||
const row = (rows as unknown as MysqlMetadataRow[])[0];
|
||||
if (!row) throw new Error('MySQL 未返回元数据');
|
||||
if (!row.version.startsWith('5.7.')) throw new Error('MySQL 5.7.x 版本要求未满足');
|
||||
if (!/^5\.7\.42(?:[-+.]|$)/.test(row.version)) {
|
||||
throw new Error('MySQL 5.7.42 版本要求未满足');
|
||||
}
|
||||
if (row.databaseName !== this.expectedDatabase) {
|
||||
throw new Error(`MySQL 健康检查必须连接 ${this.expectedDatabase}`);
|
||||
}
|
||||
@@ -46,7 +50,7 @@ export class MysqlHealthClient implements OnModuleDestroy {
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
detail: `MySQL 5.7 / ${row.databaseName} / ${row.timezone}`,
|
||||
detail: `MySQL 5.7.42 / ${row.databaseName} / ${row.timezone}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +63,7 @@ export class MysqlHealthClient implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建最多两个连接的只读健康池,并显式应用 MySQL 5.7.42 会话时区。 */
|
||||
export function createMysqlHealthClient(config: MysqlRuntimeConfig): MysqlHealthClient {
|
||||
const pool: Pool = createPool({
|
||||
host: config.host,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 非 HTTP 进程没有监听套接字,必须显式等待 systemd/Docker 的退出信号才能持续运行。
|
||||
* 收到首个信号后先移除监听器,再等待 Nest 完成资源释放,保证正常退出码为 0。
|
||||
*/
|
||||
export async function waitForShutdownSignal(app: INestApplicationContext): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let shuttingDown = false;
|
||||
// Signal listener 本身不保证 Node 事件循环存活;该句柄只承担进程存活锚点,
|
||||
// 不执行调度业务,真正退出时会被同步清理。
|
||||
const keepAliveHandle = setInterval(() => undefined, 2_147_483_647);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(keepAliveHandle);
|
||||
process.removeListener('SIGINT', shutdown);
|
||||
process.removeListener('SIGTERM', shutdown);
|
||||
};
|
||||
const shutdown = () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
cleanup();
|
||||
void app.close().then(resolve, reject);
|
||||
};
|
||||
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ interface RedisPingPort {
|
||||
quit(): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Redis 健康客户端只执行连接与 PING,不读写任何业务键。 */
|
||||
export class RedisHealthClient implements OnModuleDestroy {
|
||||
constructor(private readonly client: RedisPingPort) {}
|
||||
|
||||
@@ -42,6 +43,7 @@ export class RedisHealthClient implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用 lazyConnect 创建最小连接池,只有健康请求触发真实连接。 */
|
||||
export function createRedisHealthClient(config: RedisRuntimeConfig): RedisHealthClient {
|
||||
const client = new Redis({
|
||||
host: config.host,
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { MysqlConnectionOptions } from 'typeorm/driver/mysql/MysqlConnectio
|
||||
|
||||
import type { MysqlRuntimeConfig } from '../../config/src/runtime-config';
|
||||
|
||||
/**
|
||||
* 生成 MySQL 5.7.42 TypeORM 基线选项;迁移未落地前不自动运行,且永久禁止 synchronize。
|
||||
*/
|
||||
export function buildTypeOrmOptions(config: MysqlRuntimeConfig): MysqlConnectionOptions {
|
||||
return {
|
||||
type: 'mysql',
|
||||
|
||||
@@ -3,20 +3,26 @@ import { describe, expect, it } from 'vitest';
|
||||
import { createMessageEnvelope } from './message-envelope';
|
||||
|
||||
describe('RocketMQ 消息信封', () => {
|
||||
it('规范化并冻结版本 1 消息', () => {
|
||||
it('规范化并冻结带完整追溯字段的版本 1 消息', () => {
|
||||
const envelope = createMessageEnvelope({
|
||||
version: 1,
|
||||
eventId: 'event-20260813-001',
|
||||
eventType: 'market.snapshot.updated',
|
||||
schemaVersion: 1,
|
||||
idempotencyKey: 'snapshot:2026-08-13T09:30:00+08:00',
|
||||
occurredAt: '2026-08-13T01:30:00.000Z',
|
||||
tradeDate: '2026-08-13',
|
||||
traceId: 'trace-20260813-001',
|
||||
payload: { market: 'A_SHARE' },
|
||||
});
|
||||
|
||||
expect(envelope).toEqual({
|
||||
version: 1,
|
||||
eventId: 'event-20260813-001',
|
||||
eventType: 'market.snapshot.updated',
|
||||
schemaVersion: 1,
|
||||
idempotencyKey: 'snapshot:2026-08-13T09:30:00+08:00',
|
||||
occurredAt: '2026-08-13T01:30:00.000Z',
|
||||
tradeDate: '2026-08-13',
|
||||
traceId: 'trace-20260813-001',
|
||||
payload: { market: 'A_SHARE' },
|
||||
});
|
||||
expect(Object.isFrozen(envelope)).toBe(true);
|
||||
@@ -24,17 +30,23 @@ describe('RocketMQ 消息信封', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ version: 2 }, /version/],
|
||||
[{ eventId: '' }, /eventId/],
|
||||
[{ eventType: ' ' }, /eventType/],
|
||||
[{ schemaVersion: 2 }, /schemaVersion/],
|
||||
[{ idempotencyKey: '' }, /idempotencyKey/],
|
||||
[{ occurredAt: 'not-a-date' }, /occurredAt/],
|
||||
[{ tradeDate: '2026-02-30' }, /tradeDate/],
|
||||
[{ traceId: ' ' }, /traceId/],
|
||||
])('拒绝非法信封字段 %#', (override, expectedError) => {
|
||||
expect(() =>
|
||||
createMessageEnvelope({
|
||||
version: 1,
|
||||
eventId: 'event-1',
|
||||
eventType: 'market.snapshot.updated',
|
||||
schemaVersion: 1,
|
||||
idempotencyKey: 'snapshot:1',
|
||||
occurredAt: '2026-08-13T01:30:00.000Z',
|
||||
tradeDate: '2026-08-13',
|
||||
traceId: 'trace-1',
|
||||
payload: { market: 'A_SHARE' },
|
||||
...override,
|
||||
}),
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
/** Server 内部消息统一信封;字段同时满足幂等、版本演进和跨进程链路追踪。 */
|
||||
export interface MessageEnvelope<TPayload> {
|
||||
version: 1;
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
schemaVersion: 1;
|
||||
idempotencyKey: string;
|
||||
occurredAt: string;
|
||||
tradeDate: string;
|
||||
traceId: string;
|
||||
payload: TPayload;
|
||||
}
|
||||
|
||||
type MessageEnvelopeInput<TPayload> = Omit<MessageEnvelope<TPayload>, 'version'> & {
|
||||
version: number;
|
||||
type MessageEnvelopeInput<TPayload> = Omit<MessageEnvelope<TPayload>, 'schemaVersion'> & {
|
||||
schemaVersion: number;
|
||||
};
|
||||
|
||||
function deepFreeze<TValue>(value: TValue): Readonly<TValue> {
|
||||
@@ -18,22 +22,36 @@ function deepFreeze<TValue>(value: TValue): Readonly<TValue> {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 校验、规范化并深冻结消息信封,防止投递后字段被异步处理链路改写。 */
|
||||
export function createMessageEnvelope<TPayload>(
|
||||
input: MessageEnvelopeInput<TPayload>,
|
||||
): Readonly<MessageEnvelope<TPayload>> {
|
||||
if (input.version !== 1) throw new Error('version 只支持 1');
|
||||
if (!input.eventId.trim()) throw new Error('eventId 不能为空');
|
||||
if (!input.eventType.trim()) throw new Error('eventType 不能为空');
|
||||
if (input.schemaVersion !== 1) throw new Error('schemaVersion 只支持 1');
|
||||
if (!input.idempotencyKey.trim()) throw new Error('idempotencyKey 不能为空');
|
||||
if (!input.occurredAt.trim() || Number.isNaN(Date.parse(input.occurredAt))) {
|
||||
throw new Error('occurredAt 必须为合法 ISO 时间');
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.tradeDate)) {
|
||||
throw new Error('tradeDate 必须为合法 YYYY-MM-DD 日期');
|
||||
}
|
||||
const [year, month, day] = input.tradeDate.split('-').map(Number);
|
||||
const parsedTradeDate = new Date(Date.UTC(year, month - 1, day));
|
||||
if (parsedTradeDate.toISOString().slice(0, 10) !== input.tradeDate) {
|
||||
throw new Error('tradeDate 必须为合法 YYYY-MM-DD 日期');
|
||||
}
|
||||
if (!input.traceId.trim()) throw new Error('traceId 不能为空');
|
||||
if (input.payload === undefined) throw new Error('payload 不能为空');
|
||||
|
||||
return deepFreeze({
|
||||
version: 1,
|
||||
eventId: input.eventId.trim(),
|
||||
eventType: input.eventType.trim(),
|
||||
schemaVersion: 1,
|
||||
idempotencyKey: input.idempotencyKey.trim(),
|
||||
occurredAt: new Date(input.occurredAt).toISOString(),
|
||||
tradeDate: input.tradeDate,
|
||||
traceId: input.traceId.trim(),
|
||||
payload: input.payload,
|
||||
}) as Readonly<MessageEnvelope<TPayload>>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { type AddressInfo } from 'node:net';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { bootstrapApi } from '../apps/api/src/main';
|
||||
import { API_RUNTIME_CONFIG } from '../apps/api/src/api.module';
|
||||
import { bootstrapScheduler } from '../apps/scheduler/src/main';
|
||||
import { SCHEDULER_RUNTIME_CONFIG } from '../apps/scheduler/src/scheduler.module';
|
||||
import { bootstrapWorker } from '../apps/worker/src/main';
|
||||
import { WORKER_RUNTIME_CONFIG } from '../apps/worker/src/worker.module';
|
||||
import type {
|
||||
ApiRuntimeConfig,
|
||||
SchedulerRuntimeConfig,
|
||||
@@ -64,6 +69,7 @@ describe('NestJS 三进程入口', () => {
|
||||
|
||||
expect(address.address).toBe('127.0.0.1');
|
||||
expect(address.port).toBeGreaterThan(0);
|
||||
expect(app.get(API_RUNTIME_CONFIG)).toBe(config);
|
||||
|
||||
await app.close();
|
||||
expect(app.getHttpServer().listening).toBe(false);
|
||||
@@ -82,6 +88,7 @@ describe('NestJS 三进程入口', () => {
|
||||
const app = await bootstrapScheduler({ config, logger: false });
|
||||
|
||||
expect('getHttpServer' in app).toBe(false);
|
||||
expect(app.get(SCHEDULER_RUNTIME_CONFIG)).toBe(config);
|
||||
await expect(app.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -109,6 +116,76 @@ describe('NestJS 三进程入口', () => {
|
||||
const app = await bootstrapWorker({ config, logger: false });
|
||||
|
||||
expect('getHttpServer' in app).toBe(false);
|
||||
expect(app.get(WORKER_RUNTIME_CONFIG)).toBe(config);
|
||||
await expect(app.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['scheduler', 'apps/scheduler/src/main.ts'],
|
||||
['worker', 'apps/worker/src/main.ts'],
|
||||
])('%s 命令入口持续运行并在 SIGTERM 后正常退出', async (processName, entry) => {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const launcher = path.resolve(__dirname, '../node_modules/tsx/dist/cli.mjs');
|
||||
const child = spawn(process.execPath, [launcher, entry], {
|
||||
cwd: path.resolve(__dirname, '..'),
|
||||
env: {
|
||||
...process.env,
|
||||
APP_ENV: 'development',
|
||||
LOG_LEVEL: 'error',
|
||||
REDIS_HOST: '127.0.0.1',
|
||||
REDIS_PORT: '6379',
|
||||
REDIS_USERNAME: '',
|
||||
REDIS_PASSWORD: '',
|
||||
REDIS_DATABASE: processName === 'scheduler' ? '1' : '2',
|
||||
REDIS_TLS: 'false',
|
||||
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',
|
||||
AI_ENABLED: 'false',
|
||||
AI_BASE_URL: '',
|
||||
AI_API_KEY: '',
|
||||
AI_MODEL: '',
|
||||
AI_TIMEOUT_MS: '30000',
|
||||
ROCKETMQ_ENABLED: 'false',
|
||||
ROCKETMQ_NAMESRV_ADDR: '127.0.0.1:9876',
|
||||
ROCKETMQ_ENDPOINT: '127.0.0.1:8081',
|
||||
ROCKETMQ_TOPIC_PREFIX: 'a-share-analysis',
|
||||
ROCKETMQ_SEND_TIMEOUT_MS: '3000',
|
||||
ROCKETMQ_CONSUMER_GROUP: 'a-share-analysis-worker',
|
||||
ROCKETMQ_CONSUMER_CONCURRENCY: '4',
|
||||
SCHEDULER_ENABLED: 'true',
|
||||
SCHEDULER_TIMEZONE: 'Asia/Shanghai',
|
||||
MARKET_DATA_MODE: 'mock',
|
||||
MARKET_DATA_PROVIDER: 'deterministic-fixture',
|
||||
MARKET_DATA_BASE_URL: '',
|
||||
MARKET_DATA_API_KEY: '',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
child.stdout.on('data', (chunk) => output.push(String(chunk)));
|
||||
child.stderr.on('data', (chunk) => errors.push(String(chunk)));
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
expect(
|
||||
child.exitCode,
|
||||
`${processName} 不应在启动后自行退出\nlauncher=${launcher}\nstdout=${output.join('')}\nstderr=${errors.join('')}`,
|
||||
).toBeNull();
|
||||
|
||||
child.kill('SIGTERM');
|
||||
const exitCode = await new Promise<number | null>((resolve) =>
|
||||
child.once('exit', (code) => resolve(code)),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
} finally {
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ function sortObject(value: unknown): unknown {
|
||||
);
|
||||
}
|
||||
|
||||
/** 从真实 Controller/DTO 生成确定性 OpenAPI 文档,生成期间不连接外部基础设施。 */
|
||||
export async function createOpenApiDocument(): Promise<OpenAPIObject> {
|
||||
const app = await NestFactory.create(OpenApiModule, { logger: false });
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { createConnection } from 'node:net';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
const serverRoot = path.resolve(import.meta.dirname, '..');
|
||||
const launcher = path.join(serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
||||
const redisContainerName = `a-share-analysis-e2e-redis-${process.pid}`;
|
||||
let ownsRedisContainer = false;
|
||||
let redisPort = '6379';
|
||||
|
||||
const localRedisAvailable = await new Promise((resolve) => {
|
||||
const socket = createConnection({ host: '127.0.0.1', port: 6379 });
|
||||
const finish = (available) => {
|
||||
socket.destroy();
|
||||
resolve(available);
|
||||
};
|
||||
socket.setTimeout(500, () => finish(false));
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
});
|
||||
|
||||
if (!localRedisAvailable) {
|
||||
execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'--detach',
|
||||
'--name',
|
||||
redisContainerName,
|
||||
'--publish',
|
||||
'127.0.0.1::6379',
|
||||
'redis:7.4.8-alpine3.21',
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'inherit'] },
|
||||
);
|
||||
ownsRedisContainer = true;
|
||||
redisPort = execFileSync('docker', ['port', redisContainerName, '6379/tcp'], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.trim()
|
||||
.split(':')
|
||||
.at(-1);
|
||||
}
|
||||
|
||||
// 浏览器集成测试只启用 API 与禁用态 MySQL;Redis 故障会形成可验证的安全 503 响应,
|
||||
// 所有值均为公开测试夹具,不读取开发者的 *.env.local 或任何生产配置。
|
||||
const child = spawn(process.execPath, [launcher, 'apps/api/src/main.ts'], {
|
||||
cwd: serverRoot,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
APP_ENV: 'production',
|
||||
LOG_LEVEL: 'error',
|
||||
API_HOST: '127.0.0.1',
|
||||
API_PORT: '3000',
|
||||
REDIS_HOST: '127.0.0.1',
|
||||
REDIS_PORT: redisPort,
|
||||
REDIS_USERNAME: '',
|
||||
REDIS_PASSWORD: '',
|
||||
REDIS_DATABASE: '0',
|
||||
REDIS_TLS: 'false',
|
||||
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',
|
||||
AI_ENABLED: 'false',
|
||||
AI_BASE_URL: '',
|
||||
AI_API_KEY: '',
|
||||
AI_MODEL: '',
|
||||
AI_TIMEOUT_MS: '30000',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
const forwardSignal = (signal) => {
|
||||
if (ownsRedisContainer) {
|
||||
try {
|
||||
execFileSync('docker', ['stop', redisContainerName], { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Playwright 可能已因启动失败清理容器,重复清理保持幂等。
|
||||
}
|
||||
}
|
||||
if (child.exitCode === null) child.kill(signal);
|
||||
};
|
||||
process.once('SIGINT', () => forwardSignal('SIGINT'));
|
||||
process.once('SIGTERM', () => forwardSignal('SIGTERM'));
|
||||
child.once('exit', (code, signal) => {
|
||||
if (ownsRedisContainer) {
|
||||
try {
|
||||
execFileSync('docker', ['stop', redisContainerName], { stdio: 'ignore' });
|
||||
} catch {
|
||||
// 容器已由信号处理清理时无需再次报告错误。
|
||||
}
|
||||
}
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
else process.exit(code ?? 1);
|
||||
});
|
||||
Reference in New Issue
Block a user