feat(GOV-009): 建立后端契约与健康检查
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
# 自动生成或不可变发布产物
|
||||||
|
AGENTS.md
|
||||||
|
pnpm-lock.yaml
|
||||||
|
database/schema-baselines/
|
||||||
|
|
||||||
|
# 历史功能档案与人类维护文档不由代码格式器改写,追溯内容保持原样。
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# 已有治理脚本保留原历史格式;其行为由专项测试门禁覆盖。
|
||||||
|
tools/database/
|
||||||
|
tools/governance/
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# GitNexus — Code Intelligence
|
||||||
|
|
||||||
This project is indexed by GitNexus as **a-share-analysis** (1220 symbols, 1576 relationships, 36 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
This project is indexed by GitNexus as **a-share-analysis** (1372 symbols, 1907 relationships, 42 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||||
|
|
||||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7.2.5-alpine
|
image: redis:7.2.5-alpine
|
||||||
container_name: a-share-analysis-dev-redis
|
container_name: a-share-analysis-dev-redis
|
||||||
command: ["redis-server", "--appendonly", "yes"]
|
command: ['redis-server', '--appendonly', 'yes']
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:6379:6379"
|
- '127.0.0.1:6379:6379'
|
||||||
volumes:
|
volumes:
|
||||||
- ../../.local/redis:/data
|
- ../../.local/redis:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
+1
@@ -22,6 +22,7 @@ MYSQL_USERNAME=
|
|||||||
MYSQL_PASSWORD=
|
MYSQL_PASSWORD=
|
||||||
MYSQL_CHARSET=utf8mb4
|
MYSQL_CHARSET=utf8mb4
|
||||||
MYSQL_COLLATION=utf8mb4_general_ci
|
MYSQL_COLLATION=utf8mb4_general_ci
|
||||||
|
MYSQL_TIMEZONE=+08:00
|
||||||
|
|
||||||
# 第一阶段关闭付费 AI;启用后 BASE_URL、API_KEY 和 MODEL 变为必填。
|
# 第一阶段关闭付费 AI;启用后 BASE_URL、API_KEY 和 MODEL 变为必填。
|
||||||
AI_ENABLED=false
|
AI_ENABLED=false
|
||||||
|
|||||||
+1
@@ -21,6 +21,7 @@ MYSQL_USERNAME=
|
|||||||
MYSQL_PASSWORD=
|
MYSQL_PASSWORD=
|
||||||
MYSQL_CHARSET=utf8mb4
|
MYSQL_CHARSET=utf8mb4
|
||||||
MYSQL_COLLATION=utf8mb4_general_ci
|
MYSQL_COLLATION=utf8mb4_general_ci
|
||||||
|
MYSQL_TIMEZONE=+08:00
|
||||||
|
|
||||||
ROCKETMQ_ENABLED=false
|
ROCKETMQ_ENABLED=false
|
||||||
ROCKETMQ_NAMESRV_ADDR=127.0.0.1:9876
|
ROCKETMQ_NAMESRV_ADDR=127.0.0.1:9876
|
||||||
|
|||||||
+1
@@ -20,6 +20,7 @@ MYSQL_USERNAME=
|
|||||||
MYSQL_PASSWORD=
|
MYSQL_PASSWORD=
|
||||||
MYSQL_CHARSET=utf8mb4
|
MYSQL_CHARSET=utf8mb4
|
||||||
MYSQL_COLLATION=utf8mb4_general_ci
|
MYSQL_COLLATION=utf8mb4_general_ci
|
||||||
|
MYSQL_TIMEZONE=+08:00
|
||||||
|
|
||||||
AI_ENABLED=false
|
AI_ENABLED=false
|
||||||
AI_BASE_URL=
|
AI_BASE_URL=
|
||||||
|
|||||||
+1
@@ -21,6 +21,7 @@ MYSQL_USERNAME=
|
|||||||
MYSQL_PASSWORD=
|
MYSQL_PASSWORD=
|
||||||
MYSQL_CHARSET=utf8mb4
|
MYSQL_CHARSET=utf8mb4
|
||||||
MYSQL_COLLATION=utf8mb4_general_ci
|
MYSQL_COLLATION=utf8mb4_general_ci
|
||||||
|
MYSQL_TIMEZONE=+08:00
|
||||||
|
|
||||||
ROCKETMQ_ENABLED=false
|
ROCKETMQ_ENABLED=false
|
||||||
ROCKETMQ_NAMESRV_ADDR=
|
ROCKETMQ_NAMESRV_ADDR=
|
||||||
|
|||||||
@@ -25,4 +25,10 @@ export default [
|
|||||||
'vue/multi-word-component-names': 'off',
|
'vue/multi-word-component-names': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
|
||||||
|
rules: {
|
||||||
|
'vue/one-component-per-file': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+726
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,29 @@
|
|||||||
import { Module } from '@nestjs/common';
|
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 {}
|
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> {
|
export async function bootstrapApi(options: BootstrapApiOptions): Promise<INestApplication> {
|
||||||
const app = await NestFactory.create(ApiModule, { logger: options.logger });
|
const app = await NestFactory.create(ApiModule, { logger: options.logger });
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
await app.listen(options.port ?? options.config.api.port, options.config.api.host);
|
await app.listen(options.port ?? options.config.api.port, options.config.api.host);
|
||||||
return app;
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -157,4 +157,19 @@ describe('进程级运行配置', () => {
|
|||||||
expect(serialized).not.toContain('password');
|
expect(serialized).not.toContain('password');
|
||||||
expect(serialized).not.toContain('apiKey');
|
expect(serialized).not.toContain('apiKey');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('RocketMQ 在未落地已验证 Node 客户端前禁止启用', () => {
|
||||||
|
expect(() =>
|
||||||
|
loadSchedulerConfig({
|
||||||
|
APP_ENV: 'development',
|
||||||
|
LOG_LEVEL: 'debug',
|
||||||
|
SCHEDULER_TIMEZONE: 'Asia/Shanghai',
|
||||||
|
SCHEDULER_ENABLED: 'true',
|
||||||
|
...baseRedisEnv,
|
||||||
|
...disabledRocketMqEnv,
|
||||||
|
ROCKETMQ_ENABLED: 'true',
|
||||||
|
ROCKETMQ_SEND_TIMEOUT_MS: '3000',
|
||||||
|
}),
|
||||||
|
).toThrow(/RocketMQ Node 客户端/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -191,7 +191,8 @@ function loadRocketMqConfig(env: EnvironmentSource): RocketMqRuntimeConfig {
|
|||||||
const nameServerAddress = optionalText(env, 'ROCKETMQ_NAMESRV_ADDR');
|
const nameServerAddress = optionalText(env, 'ROCKETMQ_NAMESRV_ADDR');
|
||||||
const endpoint = optionalText(env, 'ROCKETMQ_ENDPOINT');
|
const endpoint = optionalText(env, 'ROCKETMQ_ENDPOINT');
|
||||||
|
|
||||||
// GOV-009 尚不选择 Node RocketMQ 客户端;启用配置只在后续 Adapter 落地时允许启动。
|
// GOV-009 尚未选定通过真实链路验证的 Node 客户端,禁止以内存队列或空实现伪装启用。
|
||||||
|
if (enabled) throw new Error('RocketMQ Node 客户端尚未落地验证,当前禁止启用');
|
||||||
if (enabled && !nameServerAddress && !endpoint) {
|
if (enabled && !nameServerAddress && !endpoint) {
|
||||||
throw new Error('ROCKETMQ_NAMESRV_ADDR 或 ROCKETMQ_ENDPOINT 至少填写一个');
|
throw new Error('ROCKETMQ_NAMESRV_ADDR 或 ROCKETMQ_ENDPOINT 至少填写一个');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export type HealthStatus = 'healthy' | 'unavailable';
|
||||||
|
export type DependencyStatus = HealthStatus | 'disabled';
|
||||||
|
|
||||||
|
export class DependencyHealthDto {
|
||||||
|
@ApiProperty({ type: String, enum: ['healthy', 'unavailable', 'disabled'] })
|
||||||
|
status!: DependencyStatus;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
detail!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HealthDependenciesDto {
|
||||||
|
@ApiProperty({ type: () => DependencyHealthDto })
|
||||||
|
redis!: DependencyHealthDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: () => DependencyHealthDto })
|
||||||
|
mysql!: DependencyHealthDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: () => DependencyHealthDto })
|
||||||
|
rocketMq!: DependencyHealthDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HealthResponseDto {
|
||||||
|
@ApiProperty({ type: String, enum: ['healthy', 'unavailable'] })
|
||||||
|
status!: HealthStatus;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, enum: ['api'] })
|
||||||
|
process!: 'api';
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, format: 'date-time' })
|
||||||
|
checkedAt!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: () => HealthDependenciesDto })
|
||||||
|
dependencies!: HealthDependenciesDto;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createMessageEnvelope } from './message-envelope';
|
||||||
|
|
||||||
|
describe('RocketMQ 消息信封', () => {
|
||||||
|
it('规范化并冻结版本 1 消息', () => {
|
||||||
|
const envelope = createMessageEnvelope({
|
||||||
|
version: 1,
|
||||||
|
eventType: 'market.snapshot.updated',
|
||||||
|
idempotencyKey: 'snapshot:2026-08-13T09:30:00+08:00',
|
||||||
|
occurredAt: '2026-08-13T01:30:00.000Z',
|
||||||
|
payload: { market: 'A_SHARE' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(envelope).toEqual({
|
||||||
|
version: 1,
|
||||||
|
eventType: 'market.snapshot.updated',
|
||||||
|
idempotencyKey: 'snapshot:2026-08-13T09:30:00+08:00',
|
||||||
|
occurredAt: '2026-08-13T01:30:00.000Z',
|
||||||
|
payload: { market: 'A_SHARE' },
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(envelope)).toBe(true);
|
||||||
|
expect(Object.isFrozen(envelope.payload)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ version: 2 }, /version/],
|
||||||
|
[{ eventType: ' ' }, /eventType/],
|
||||||
|
[{ idempotencyKey: '' }, /idempotencyKey/],
|
||||||
|
[{ occurredAt: 'not-a-date' }, /occurredAt/],
|
||||||
|
])('拒绝非法信封字段 %#', (override, expectedError) => {
|
||||||
|
expect(() =>
|
||||||
|
createMessageEnvelope({
|
||||||
|
version: 1,
|
||||||
|
eventType: 'market.snapshot.updated',
|
||||||
|
idempotencyKey: 'snapshot:1',
|
||||||
|
occurredAt: '2026-08-13T01:30:00.000Z',
|
||||||
|
payload: { market: 'A_SHARE' },
|
||||||
|
...override,
|
||||||
|
}),
|
||||||
|
).toThrow(expectedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export interface MessageEnvelope<TPayload> {
|
||||||
|
version: 1;
|
||||||
|
eventType: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
occurredAt: string;
|
||||||
|
payload: TPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageEnvelopeInput<TPayload> = Omit<MessageEnvelope<TPayload>, 'version'> & {
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function deepFreeze<TValue>(value: TValue): Readonly<TValue> {
|
||||||
|
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||||
|
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
|
||||||
|
Object.freeze(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMessageEnvelope<TPayload>(
|
||||||
|
input: MessageEnvelopeInput<TPayload>,
|
||||||
|
): Readonly<MessageEnvelope<TPayload>> {
|
||||||
|
if (input.version !== 1) throw new Error('version 只支持 1');
|
||||||
|
if (!input.eventType.trim()) throw new Error('eventType 不能为空');
|
||||||
|
if (!input.idempotencyKey.trim()) throw new Error('idempotencyKey 不能为空');
|
||||||
|
if (!input.occurredAt.trim() || Number.isNaN(Date.parse(input.occurredAt))) {
|
||||||
|
throw new Error('occurredAt 必须为合法 ISO 时间');
|
||||||
|
}
|
||||||
|
if (input.payload === undefined) throw new Error('payload 不能为空');
|
||||||
|
|
||||||
|
return deepFreeze({
|
||||||
|
version: 1,
|
||||||
|
eventType: input.eventType.trim(),
|
||||||
|
idempotencyKey: input.idempotencyKey.trim(),
|
||||||
|
occurredAt: new Date(input.occurredAt).toISOString(),
|
||||||
|
payload: input.payload,
|
||||||
|
}) as Readonly<MessageEnvelope<TPayload>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"DependencyHealthDto": {
|
||||||
|
"properties": {
|
||||||
|
"detail": { "type": "string" },
|
||||||
|
"status": {
|
||||||
|
"enum": ["healthy", "unavailable", "disabled"],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["status", "detail"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"HealthDependenciesDto": {
|
||||||
|
"properties": {
|
||||||
|
"mysql": { "$ref": "#/components/schemas/DependencyHealthDto" },
|
||||||
|
"redis": { "$ref": "#/components/schemas/DependencyHealthDto" },
|
||||||
|
"rocketMq": { "$ref": "#/components/schemas/DependencyHealthDto" }
|
||||||
|
},
|
||||||
|
"required": ["redis", "mysql", "rocketMq"],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"HealthResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"checkedAt": { "format": "date-time", "type": "string" },
|
||||||
|
"dependencies": {
|
||||||
|
"$ref": "#/components/schemas/HealthDependenciesDto"
|
||||||
|
},
|
||||||
|
"process": { "enum": ["api"], "type": "string" },
|
||||||
|
"status": { "enum": ["healthy", "unavailable"], "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["status", "process", "checkedAt", "dependencies"],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"contact": {},
|
||||||
|
"description": "",
|
||||||
|
"title": "A 股市场分析工具 API",
|
||||||
|
"version": "0.0.8-draft"
|
||||||
|
},
|
||||||
|
"openapi": "3.0.0",
|
||||||
|
"paths": {
|
||||||
|
"/api/v1/health": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "HealthController_getHealth",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/HealthResponseDto" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/HealthResponseDto" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": ["health"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"servers": [],
|
||||||
|
"tags": []
|
||||||
|
}
|
||||||
+10
-1
@@ -6,6 +6,7 @@
|
|||||||
"dev:api": "tsx apps/api/src/main.ts",
|
"dev:api": "tsx apps/api/src/main.ts",
|
||||||
"dev:scheduler": "tsx apps/scheduler/src/main.ts",
|
"dev:scheduler": "tsx apps/scheduler/src/main.ts",
|
||||||
"dev:worker": "tsx apps/worker/src/main.ts",
|
"dev:worker": "tsx apps/worker/src/main.ts",
|
||||||
|
"openapi:generate": "tsx tools/export-openapi.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -16,14 +17,22 @@
|
|||||||
"@nestjs/common": "11.1.29",
|
"@nestjs/common": "11.1.29",
|
||||||
"@nestjs/core": "11.1.29",
|
"@nestjs/core": "11.1.29",
|
||||||
"@nestjs/platform-express": "11.1.29",
|
"@nestjs/platform-express": "11.1.29",
|
||||||
|
"@nestjs/swagger": "11.4.6",
|
||||||
|
"@nestjs/typeorm": "11.0.3",
|
||||||
"dotenv": "16.6.1",
|
"dotenv": "16.6.1",
|
||||||
|
"ioredis": "5.11.1",
|
||||||
|
"mysql2": "3.23.3",
|
||||||
"reflect-metadata": "0.2.2",
|
"reflect-metadata": "0.2.2",
|
||||||
"rxjs": "7.8.2"
|
"rxjs": "7.8.2",
|
||||||
|
"typeorm": "0.3.31"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "11.0.24",
|
"@nestjs/cli": "11.0.24",
|
||||||
"@nestjs/testing": "11.1.29",
|
"@nestjs/testing": "11.1.29",
|
||||||
"@types/node": "22.20.0",
|
"@types/node": "22.20.0",
|
||||||
|
"@types/supertest": "7.2.1",
|
||||||
|
"prettier": "3.9.6",
|
||||||
|
"supertest": "7.2.2",
|
||||||
"ts-loader": "9.6.2",
|
"ts-loader": "9.6.2",
|
||||||
"tsx": "4.20.6",
|
"tsx": "4.20.6",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DocumentBuilder, SwaggerModule, type OpenAPIObject } from '@nestjs/swagger';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { format } from 'prettier';
|
||||||
|
|
||||||
|
import { HealthController } from '../apps/api/src/health.controller';
|
||||||
|
import {
|
||||||
|
HealthService,
|
||||||
|
MYSQL_HEALTH_PORT,
|
||||||
|
REDIS_HEALTH_PORT,
|
||||||
|
} from '../apps/api/src/health.service';
|
||||||
|
import { DisabledHealthClient } from '../libs/infrastructure/src/disabled-health.client';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [
|
||||||
|
HealthService,
|
||||||
|
{ provide: REDIS_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 Redis') },
|
||||||
|
{ provide: MYSQL_HEALTH_PORT, useValue: new DisabledHealthClient('文档生成不连接 MySQL') },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
class OpenApiModule {}
|
||||||
|
|
||||||
|
function sortObject(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(sortObject);
|
||||||
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, child]) => [key, sortObject(child)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOpenApiDocument(): Promise<OpenAPIObject> {
|
||||||
|
const app = await NestFactory.create(OpenApiModule, { logger: false });
|
||||||
|
app.setGlobalPrefix('api/v1');
|
||||||
|
const options = new DocumentBuilder()
|
||||||
|
.setTitle('A 股市场分析工具 API')
|
||||||
|
.setVersion('0.0.8-draft')
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, options);
|
||||||
|
await app.close();
|
||||||
|
return sortObject(document) as OpenAPIObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeOpenApiDocument(): Promise<void> {
|
||||||
|
const serverDirectory = path.resolve(__dirname, '..');
|
||||||
|
const output = path.join(serverDirectory, 'openapi', 'openapi.json');
|
||||||
|
const serialized = await format(JSON.stringify(await createOpenApiDocument()), {
|
||||||
|
parser: 'json',
|
||||||
|
});
|
||||||
|
await mkdir(path.dirname(output), { recursive: true });
|
||||||
|
await writeFile(output, serialized, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
void writeOpenApiDocument().catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,12 @@ import { defineConfig } from 'vitest/config';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['apps/**/*.test.ts', 'apps/**/*.spec.ts', 'libs/**/*.test.ts', 'test/**/*.test.ts'],
|
include: [
|
||||||
|
'apps/**/*.test.ts',
|
||||||
|
'apps/**/*.spec.ts',
|
||||||
|
'apps/**/*.e2e-spec.ts',
|
||||||
|
'libs/**/*.test.ts',
|
||||||
|
'test/**/*.test.ts',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user