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
@@ -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',