feat(GOV-009): 建立后端契约与健康检查
This commit is contained in:
@@ -1,4 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
import { loadApiProcessConfig } from '../../../libs/config/src/load-process-env';
|
||||
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();
|
||||
|
||||
@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 {}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, HttpException, HttpStatus, Inject } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiServiceUnavailableResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { HealthResponseDto } from '../../../libs/http-contracts/src/health.dto';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(@Inject(HealthService) private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({ type: HealthResponseDto })
|
||||
@ApiServiceUnavailableResponse({ type: HealthResponseDto })
|
||||
async getHealth(): Promise<HealthResponseDto> {
|
||||
const response = await this.healthService.check();
|
||||
if (response.status === 'unavailable') {
|
||||
throw new HttpException(response, HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
DependencyHealthDto,
|
||||
HealthResponseDto,
|
||||
} from '../../../libs/http-contracts/src/health.dto';
|
||||
|
||||
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()
|
||||
export class HealthService {
|
||||
constructor(
|
||||
@Inject(REDIS_HEALTH_PORT) private readonly redis: DependencyHealthPort,
|
||||
@Inject(MYSQL_HEALTH_PORT) private readonly mysql: DependencyHealthPort,
|
||||
) {}
|
||||
|
||||
async check(): Promise<HealthResponseDto> {
|
||||
const [redis, mysql] = await Promise.all([
|
||||
this.checkSafely(this.redis),
|
||||
this.checkSafely(this.mysql),
|
||||
]);
|
||||
const status =
|
||||
redis.status === 'healthy' && mysql.status !== 'unavailable' ? 'healthy' : 'unavailable';
|
||||
|
||||
return {
|
||||
status,
|
||||
process: 'api',
|
||||
checkedAt: new Date().toISOString(),
|
||||
dependencies: {
|
||||
redis,
|
||||
mysql,
|
||||
rocketMq: { status: 'disabled', detail: 'GOV-009 尚未启用 RocketMQ 客户端' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async checkSafely(port: DependencyHealthPort): Promise<DependencyHealthDto> {
|
||||
try {
|
||||
const result = await port.check();
|
||||
if (result.status === 'healthy' || result.status === 'disabled') return result;
|
||||
} catch {
|
||||
// 外部错误可能包含 DSN、用户名或密码;HTTP 健康响应只暴露固定安全文案。
|
||||
}
|
||||
return { status: 'unavailable', detail: '依赖检查失败' };
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ interface BootstrapApiOptions {
|
||||
|
||||
export async function bootstrapApi(options: BootstrapApiOptions): Promise<INestApplication> {
|
||||
const app = await NestFactory.create(ApiModule, { logger: options.logger });
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.enableShutdownHooks();
|
||||
await app.listen(options.port ?? options.config.api.port, options.config.api.host);
|
||||
return app;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { HealthController } from '../src/health.controller';
|
||||
import {
|
||||
HealthService,
|
||||
MYSQL_HEALTH_PORT,
|
||||
REDIS_HEALTH_PORT,
|
||||
type DependencyHealthPort,
|
||||
} from '../src/health.service';
|
||||
|
||||
describe('GET /api/v1/health', () => {
|
||||
let app: INestApplication | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close();
|
||||
app = undefined;
|
||||
});
|
||||
|
||||
async function createApp(
|
||||
redis: DependencyHealthPort,
|
||||
mysql: DependencyHealthPort,
|
||||
): Promise<INestApplication> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
{ provide: REDIS_HEALTH_PORT, useValue: redis },
|
||||
{ provide: MYSQL_HEALTH_PORT, useValue: mysql },
|
||||
],
|
||||
}).compile();
|
||||
const nestApp = moduleRef.createNestApplication();
|
||||
nestApp.setGlobalPrefix('api/v1');
|
||||
await nestApp.init();
|
||||
return nestApp;
|
||||
}
|
||||
|
||||
it('依赖健康时返回 200 与 RocketMQ disabled', async () => {
|
||||
app = await createApp(
|
||||
{ check: async () => ({ status: 'healthy', detail: 'PONG' }) },
|
||||
{ check: async () => ({ status: 'healthy', detail: 'MySQL 5.7' }) },
|
||||
);
|
||||
|
||||
const response = await request(app.getHttpServer()).get('/api/v1/health').expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'healthy',
|
||||
process: 'api',
|
||||
dependencies: {
|
||||
redis: { status: 'healthy' },
|
||||
mysql: { status: 'healthy' },
|
||||
rocketMq: { status: 'disabled' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('依赖失败时返回 503 且不泄漏错误中的敏感值', async () => {
|
||||
app = await createApp(
|
||||
{ check: async () => ({ status: 'unavailable', detail: 'TEST_SENSITIVE_VALUE' }) },
|
||||
{ check: async () => ({ status: 'healthy', detail: 'MySQL 5.7' }) },
|
||||
);
|
||||
|
||||
const response = await request(app.getHttpServer()).get('/api/v1/health').expect(503);
|
||||
const serialized = JSON.stringify(response.body);
|
||||
|
||||
expect(response.body.status).toBe('unavailable');
|
||||
expect(serialized).not.toContain('TEST_SENSITIVE_VALUE');
|
||||
});
|
||||
|
||||
it('有意禁用的可选依赖不降低 API 总体状态', async () => {
|
||||
app = await createApp(
|
||||
{ check: async () => ({ status: 'healthy', detail: 'PONG' }) },
|
||||
{ check: async () => ({ status: 'disabled', detail: 'MySQL 未启用' }) },
|
||||
);
|
||||
|
||||
const response = await request(app.getHttpServer()).get('/api/v1/health').expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'healthy',
|
||||
dependencies: { mysql: { status: 'disabled' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createOpenApiDocument } from '../../../tools/export-openapi';
|
||||
|
||||
describe('Server OpenAPI 单向发布', () => {
|
||||
it('从真实 Controller 和 DTO 生成健康接口契约', async () => {
|
||||
const document = await createOpenApiDocument();
|
||||
|
||||
expect(document.paths).toHaveProperty('/api/v1/health');
|
||||
expect(document.components?.schemas).toHaveProperty('HealthResponseDto');
|
||||
});
|
||||
|
||||
it('仓库内 OpenAPI 文件是可解析的同一契约', async () => {
|
||||
const content = await readFile(
|
||||
path.resolve(__dirname, '../../../openapi/openapi.json'),
|
||||
'utf8',
|
||||
);
|
||||
const document = JSON.parse(content) as {
|
||||
paths: Record<string, unknown>;
|
||||
components?: { schemas?: Record<string, unknown> };
|
||||
};
|
||||
|
||||
expect(document.paths).toHaveProperty('/api/v1/health');
|
||||
expect(document.components?.schemas).toHaveProperty('HealthResponseDto');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user