fix(GOV-009): 完成脚手架审查整改

This commit is contained in:
2026-08-13 18:30:57 +08:00
parent dcdf634457
commit 3a8120a33a
47 changed files with 752 additions and 101 deletions
+36 -21
View File
@@ -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],
};
}
}
+1
View File
@@ -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) {}
+3
View File
@@ -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,
+7 -2
View File
@@ -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();
}
+3 -6
View File
@@ -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);
});
});
+8 -4
View File
@@ -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();
}
+16 -2
View File
@@ -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],
};
}
}
+8 -4
View File
@@ -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();
}
+16 -2
View File
@@ -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'));
}
+14
View File
@@ -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>>;
}
+77
View File
@@ -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');
}
});
});
+1
View File
@@ -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');
+103
View File
@@ -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);
});