feat(GOV-009): 建立后端契约与健康检查
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import type { DependencyHealthDto } from '../../http-contracts/src/health.dto';
|
||||
|
||||
export class DisabledHealthClient {
|
||||
constructor(private readonly detail: string) {}
|
||||
|
||||
async check(): Promise<DependencyHealthDto> {
|
||||
return { status: 'disabled', detail: this.detail };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DisabledMessageBusAdapter, MessageBusDisabledError } from './disabled-message-bus.adapter';
|
||||
|
||||
describe('禁用消息总线 Adapter', () => {
|
||||
it('拒绝发布和订阅且不提供内存队列降级', async () => {
|
||||
const adapter = new DisabledMessageBusAdapter();
|
||||
|
||||
await expect(
|
||||
adapter.publish({
|
||||
version: 1,
|
||||
eventType: 'market.snapshot.updated',
|
||||
idempotencyKey: 'snapshot:1',
|
||||
occurredAt: '2026-08-13T01:30:00.000Z',
|
||||
payload: { market: 'A_SHARE' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(MessageBusDisabledError);
|
||||
await expect(
|
||||
adapter.subscribe('market.snapshot.updated', async () => undefined),
|
||||
).rejects.toBeInstanceOf(MessageBusDisabledError);
|
||||
await expect(adapter.close()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MessageEnvelope } from '../../message-contracts/src/message-envelope';
|
||||
import type { MessageBusAdapter, MessageHandler } from './message-bus.adapter';
|
||||
|
||||
export class MessageBusDisabledError extends Error {
|
||||
constructor(operation: 'publish' | 'subscribe') {
|
||||
super(`RocketMQ 当前未启用,拒绝执行 ${operation}`);
|
||||
this.name = 'MessageBusDisabledError';
|
||||
}
|
||||
}
|
||||
|
||||
export class DisabledMessageBusAdapter implements MessageBusAdapter {
|
||||
async publish<TPayload>(message: Readonly<MessageEnvelope<TPayload>>): Promise<void> {
|
||||
void message;
|
||||
throw new MessageBusDisabledError('publish');
|
||||
}
|
||||
|
||||
async subscribe<TPayload>(eventType: string, handler: MessageHandler<TPayload>): Promise<void> {
|
||||
void eventType;
|
||||
void handler;
|
||||
throw new MessageBusDisabledError('subscribe');
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MysqlHealthClient, MYSQL_HEALTH_QUERY } from './mysql-health.client';
|
||||
import { RedisHealthClient } from './redis-health.client';
|
||||
import { buildTypeOrmOptions } from './typeorm-options';
|
||||
|
||||
describe('基础设施只读健康客户端', () => {
|
||||
it('Redis 仅执行 PING 并返回健康状态', async () => {
|
||||
const commands: string[] = [];
|
||||
const client = new RedisHealthClient({
|
||||
status: 'wait',
|
||||
connect: async () => {
|
||||
commands.push('CONNECT');
|
||||
},
|
||||
ping: async () => {
|
||||
commands.push('PING');
|
||||
return 'PONG';
|
||||
},
|
||||
quit: async () => 'OK',
|
||||
});
|
||||
|
||||
await expect(client.check()).resolves.toEqual({ status: 'healthy', detail: 'PONG' });
|
||||
expect(commands).toEqual(['CONNECT', 'PING']);
|
||||
});
|
||||
|
||||
it('MySQL 使用固定元数据查询并校验 5.7 与数据库口径', async () => {
|
||||
const queries: string[] = [];
|
||||
const client = new MysqlHealthClient(
|
||||
{
|
||||
query: async (sql: string) => {
|
||||
queries.push(sql);
|
||||
return [
|
||||
[
|
||||
{
|
||||
version: '5.7.42-log',
|
||||
databaseName: 'a_share_analysis_dev',
|
||||
charset: 'utf8mb4',
|
||||
collation: 'utf8mb4_general_ci',
|
||||
timezone: '+08:00',
|
||||
},
|
||||
],
|
||||
[],
|
||||
];
|
||||
},
|
||||
end: async () => undefined,
|
||||
},
|
||||
'a_share_analysis_dev',
|
||||
);
|
||||
|
||||
await expect(client.check()).resolves.toEqual({
|
||||
status: 'healthy',
|
||||
detail: 'MySQL 5.7 / a_share_analysis_dev / +08:00',
|
||||
});
|
||||
expect(queries).toEqual([MYSQL_HEALTH_QUERY]);
|
||||
});
|
||||
|
||||
it('MySQL 拒绝版本、库名或连接口径漂移', async () => {
|
||||
const createClient = (override: Record<string, string>) =>
|
||||
new MysqlHealthClient(
|
||||
{
|
||||
query: async () => [
|
||||
[
|
||||
{
|
||||
version: '5.7.42',
|
||||
databaseName: 'a_share_analysis_dev',
|
||||
charset: 'utf8mb4',
|
||||
collation: 'utf8mb4_general_ci',
|
||||
timezone: '+08:00',
|
||||
...override,
|
||||
},
|
||||
],
|
||||
[],
|
||||
],
|
||||
end: async () => undefined,
|
||||
},
|
||||
'a_share_analysis_dev',
|
||||
);
|
||||
|
||||
await expect(createClient({ version: '8.0.40' }).check()).rejects.toThrow(/MySQL 5.7/);
|
||||
await expect(createClient({ databaseName: 'a_share_analysis' }).check()).rejects.toThrow(
|
||||
/a_share_analysis_dev/,
|
||||
);
|
||||
await expect(createClient({ collation: 'utf8mb4_0900_ai_ci' }).check()).rejects.toThrow(
|
||||
/utf8mb4_general_ci/,
|
||||
);
|
||||
});
|
||||
|
||||
it('MySQL 按当前配置校验生产库名', async () => {
|
||||
const client = new MysqlHealthClient(
|
||||
{
|
||||
query: async () => [
|
||||
[
|
||||
{
|
||||
version: '5.7.42',
|
||||
databaseName: 'a_share_analysis',
|
||||
charset: 'utf8mb4',
|
||||
collation: 'utf8mb4_general_ci',
|
||||
timezone: '+08:00',
|
||||
},
|
||||
],
|
||||
[],
|
||||
],
|
||||
end: async () => undefined,
|
||||
},
|
||||
'a_share_analysis',
|
||||
);
|
||||
|
||||
await expect(client.check()).resolves.toMatchObject({ status: 'healthy' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('TypeORM MySQL 5.7 选项', () => {
|
||||
it('禁止自动同步并固定字符集、排序规则和东八区', () => {
|
||||
const options = buildTypeOrmOptions({
|
||||
enabled: true,
|
||||
host: '127.0.0.1',
|
||||
port: 3306,
|
||||
database: 'a_share_analysis_dev',
|
||||
username: 'dev-user',
|
||||
password: 'TEST_SENSITIVE_VALUE',
|
||||
charset: 'utf8mb4',
|
||||
collation: 'utf8mb4_general_ci',
|
||||
timezone: '+08:00',
|
||||
});
|
||||
|
||||
expect(options).toMatchObject({
|
||||
type: 'mysql',
|
||||
charset: 'utf8mb4',
|
||||
timezone: '+08:00',
|
||||
synchronize: false,
|
||||
migrationsRun: false,
|
||||
});
|
||||
expect(options.extra).toMatchObject({ collation: 'utf8mb4_general_ci' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MessageEnvelope } from '../../message-contracts/src/message-envelope';
|
||||
|
||||
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>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createPool, type Pool } from 'mysql2/promise';
|
||||
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,
|
||||
@@character_set_database AS charset,
|
||||
@@collation_database AS collation,
|
||||
@@session.time_zone AS timezone`;
|
||||
|
||||
interface MysqlQueryPort {
|
||||
query(sql: string): Promise<[unknown, unknown]>;
|
||||
end(): Promise<void>;
|
||||
}
|
||||
|
||||
interface MysqlMetadataRow {
|
||||
version: string;
|
||||
databaseName: string;
|
||||
charset: string;
|
||||
collation: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export class MysqlHealthClient implements OnModuleDestroy {
|
||||
constructor(
|
||||
private readonly client: MysqlQueryPort,
|
||||
private readonly expectedDatabase: string,
|
||||
) {}
|
||||
|
||||
async check(): Promise<DependencyHealthDto> {
|
||||
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 (row.databaseName !== this.expectedDatabase) {
|
||||
throw new Error(`MySQL 健康检查必须连接 ${this.expectedDatabase}`);
|
||||
}
|
||||
if (row.charset !== 'utf8mb4') throw new Error('MySQL 字符集必须为 utf8mb4');
|
||||
if (row.collation !== 'utf8mb4_general_ci') {
|
||||
throw new Error('MySQL 排序规则必须为 utf8mb4_general_ci');
|
||||
}
|
||||
if (row.timezone !== '+08:00') throw new Error('MySQL 会话时区必须为 +08:00');
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
detail: `MySQL 5.7 / ${row.databaseName} / ${row.timezone}`,
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.client.end();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function createMysqlHealthClient(config: MysqlRuntimeConfig): MysqlHealthClient {
|
||||
const pool: Pool = createPool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.username,
|
||||
password: config.password,
|
||||
charset: config.charset,
|
||||
timezone: config.timezone,
|
||||
connectionLimit: 2,
|
||||
enableKeepAlive: false,
|
||||
});
|
||||
return new MysqlHealthClient(pool, config.database);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Redis from 'ioredis';
|
||||
import type { OnModuleDestroy } from '@nestjs/common';
|
||||
|
||||
import type { DependencyHealthDto } from '../../http-contracts/src/health.dto';
|
||||
import type { RedisRuntimeConfig } from '../../config/src/runtime-config';
|
||||
|
||||
interface RedisPingPort {
|
||||
status: string;
|
||||
connect(): Promise<void>;
|
||||
ping(): Promise<string>;
|
||||
quit(): Promise<unknown>;
|
||||
}
|
||||
|
||||
export class RedisHealthClient implements OnModuleDestroy {
|
||||
constructor(private readonly client: RedisPingPort) {}
|
||||
|
||||
async check(): Promise<DependencyHealthDto> {
|
||||
if (
|
||||
this.client.status === 'wait' ||
|
||||
this.client.status === 'end' ||
|
||||
this.client.status === 'close'
|
||||
) {
|
||||
await this.client.connect();
|
||||
}
|
||||
const response = await this.client.ping();
|
||||
if (response !== 'PONG') throw new Error('Redis PING 未返回 PONG');
|
||||
return { status: 'healthy', detail: 'PONG' };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (
|
||||
this.client.status !== 'wait' &&
|
||||
this.client.status !== 'end' &&
|
||||
this.client.status !== 'close'
|
||||
) {
|
||||
await this.client.quit();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function createRedisHealthClient(config: RedisRuntimeConfig): RedisHealthClient {
|
||||
const client = new Redis({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username || undefined,
|
||||
password: config.password || undefined,
|
||||
db: config.database,
|
||||
tls: config.tls ? {} : undefined,
|
||||
lazyConnect: true,
|
||||
enableOfflineQueue: false,
|
||||
maxRetriesPerRequest: 0,
|
||||
});
|
||||
return new RedisHealthClient(client);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MysqlConnectionOptions } from 'typeorm/driver/mysql/MysqlConnectionOptions';
|
||||
|
||||
import type { MysqlRuntimeConfig } from '../../config/src/runtime-config';
|
||||
|
||||
export function buildTypeOrmOptions(config: MysqlRuntimeConfig): MysqlConnectionOptions {
|
||||
return {
|
||||
type: 'mysql',
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
charset: config.charset,
|
||||
timezone: config.timezone,
|
||||
synchronize: false,
|
||||
migrationsRun: false,
|
||||
entities: [],
|
||||
migrations: [],
|
||||
extra: {
|
||||
collation: config.collation,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user