fix(GOV-009): 完成脚手架审查整改
This commit is contained in:
@@ -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>>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user