From 0f47644fd69b6c1af84f4aea09f855aac64a7da3 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 13 Jun 2026 17:47:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0API=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-13-api-runtime-foundation.md | 231 ++++++++++-------- src/app.module.ts | 2 + src/main.ts | 3 +- .../health/runtime-health.controller.ts | 16 ++ src/runtime/health/runtime-health.service.ts | 64 +++++ src/runtime/health/runtime-health.types.ts | 19 ++ src/runtime/index.ts | 9 + src/runtime/runtime.module.ts | 19 ++ .../runtime/runtime-health.controller.spec.ts | 68 ++++++ test/runtime/runtime-health.service.spec.ts | 161 ++++++++++++ 10 files changed, 494 insertions(+), 98 deletions(-) create mode 100644 src/runtime/health/runtime-health.controller.ts create mode 100644 src/runtime/health/runtime-health.service.ts create mode 100644 src/runtime/health/runtime-health.types.ts create mode 100644 src/runtime/index.ts create mode 100644 src/runtime/runtime.module.ts create mode 100644 test/runtime/runtime-health.controller.spec.ts create mode 100644 test/runtime/runtime-health.service.spec.ts diff --git a/docs/superpowers/plans/2026-06-13-api-runtime-foundation.md b/docs/superpowers/plans/2026-06-13-api-runtime-foundation.md index f6d6c3d..8d5745f 100644 --- a/docs/superpowers/plans/2026-06-13-api-runtime-foundation.md +++ b/docs/superpowers/plans/2026-06-13-api-runtime-foundation.md @@ -1009,50 +1009,77 @@ Add `RuntimeModule` to `imports` after `CommonModule`: ```ts import { RuntimeHealthService } from '../../src/runtime/health/runtime-health.service'; +import type { RuntimeSafeConfigSnapshot } from '../../src/runtime/config/runtime-config.types'; + +function createSnapshot( + checks: RuntimeSafeConfigSnapshot['checks'], +): RuntimeSafeConfigSnapshot { + return { + app: { nodeEnv: 'test', port: 48085 }, + database: { + host: 'mysql', + port: 3306, + database: 'kt', + username: 'root', + synchronize: false, + }, + loki: { + transportEnabled: true, + httpRequestPushEnabled: true, + queryConfigured: true, + host: 'https://loki-push.example.test', + queryHost: 'https://loki-query.example.test', + environment: 'test', + tenantId: 'kt', + username: 'loki-user', + passwordConfigured: true, + }, + minio: { + endpoint: 'minio', + port: 9000, + useSSL: false, + accessKey: 'mi***ey', + bucket: 'kt-template-online', + }, + wordpress: { + baseUrl: 'https://blog.example.test', + hostHeader: 'blog.example.test', + adminUsername: 'wordpress-admin', + passwordConfigured: true, + timeoutMs: 15000, + loginTimeoutMs: 3000, + availabilityTtlMs: 60000, + }, + qqbot: { + reverseWsPath: '/qqbot/onebot/reverse', + reverseWsToken: 'qq***en', + napcatRoot: '/vol1/docker/napcat', + napcatContainerMode: 'ssh', + napcatSshTarget: 'nas', + napcatSshPort: 2202, + napcatSshKeyPath: '/home/kt/.ssh/napcat', + napcatReverseWsBase: 'ws://api.example.test/onebot', + napcatWebuiBaseUrl: 'http://127.0.0.1:6099', + napcatWebuiToken: 'na***en', + }, + checks, + }; +} + +function createService(snapshot: RuntimeSafeConfigSnapshot) { + return new RuntimeHealthService({ + getSafeSnapshot: jest.fn(() => snapshot), + } as any); +} describe('RuntimeHealthService', () => { - it('returns ready when required config is present and optional config is present', () => { - const service = new RuntimeHealthService({ - getSafeSnapshot: () => ({ - app: { nodeEnv: 'test', port: 48085 }, - database: { - host: 'mysql', - port: 3306, - database: 'kt', - username: 'root', - synchronize: false, - }, - loki: { - transportEnabled: false, - httpRequestPushEnabled: false, - queryConfigured: false, - host: '', - queryHost: '', - environment: 'test', - tenantId: '', - username: '', - passwordConfigured: false, - }, - minio: { - endpoint: 'minio', - port: 9000, - useSSL: false, - accessKey: 'mi***ey', - }, - wordpress: { endpoint: 'https://example.test', username: 'wo***er' }, - qqbot: { - reverseWsUrl: 'ws://127.0.0.1:3001', - napcatDataRoot: '/data/napcat', - napcatSshHost: 'nas', - napcatSshPort: 22, - napcatSshUser: 'root', - }, - checks: [ - { key: 'DB_HOST', level: 'required', present: true }, - { key: 'MINIO_ENDPOINT', level: 'optional', present: true }, - ], - }), - } as any); + it('returns ready when required and optional checks are present', () => { + const service = createService( + createSnapshot([ + { key: 'DB_HOST', level: 'required', present: true }, + { key: 'MINIO_ENDPOINT', level: 'optional', present: true }, + ]), + ); expect(service.getRuntimeHealth()).toEqual( expect.objectContaining({ @@ -1063,52 +1090,17 @@ describe('RuntimeHealthService', () => { }); it('returns blocked when required config is missing', () => { - const service = new RuntimeHealthService({ - getSafeSnapshot: () => ({ - app: { nodeEnv: 'test', port: 48085 }, - database: { - host: '', - port: 3306, - database: '', - username: '', - synchronize: false, + const service = createService( + createSnapshot([ + { + key: 'DB_PASSWORD', + level: 'required', + present: false, + message: 'DB_PASSWORD is not configured', }, - loki: { - transportEnabled: false, - httpRequestPushEnabled: false, - queryConfigured: false, - host: '', - queryHost: '', - environment: 'test', - tenantId: '', - username: '', - passwordConfigured: false, - }, - minio: { endpoint: '', port: 9000, useSSL: false, accessKey: '' }, - wordpress: { endpoint: '', username: '' }, - qqbot: { - reverseWsUrl: '', - napcatDataRoot: '', - napcatSshHost: '', - napcatSshPort: 22, - napcatSshUser: '', - }, - checks: [ - { - key: 'DB_PASSWORD', - level: 'required', - present: false, - message: 'DB_PASSWORD is not configured', - }, - { - key: 'MINIO_ENDPOINT', - level: 'optional', - present: false, - message: 'MINIO_ENDPOINT is not configured', - }, - ], - }), - } as any); + { key: 'MINIO_ENDPOINT', level: 'optional', present: true }, + ]), + ); const report = service.getRuntimeHealth(); @@ -1121,6 +1113,31 @@ describe('RuntimeHealthService', () => { }), ); }); + + it('returns degraded when only optional config is missing', () => { + const service = createService( + createSnapshot([ + { key: 'DB_HOST', level: 'required', present: true }, + { + key: 'LOKI_PASSWORD', + level: 'optional', + present: false, + message: 'LOKI_PASSWORD is not configured', + }, + ]), + ); + + const report = service.getRuntimeHealth(); + + expect(report.status).toBe('degraded'); + expect(report.checks).toContainEqual( + expect.objectContaining({ + name: 'config:LOKI_PASSWORD', + status: 'degraded', + critical: false, + }), + ); + }); }); ``` @@ -1128,10 +1145,11 @@ describe('RuntimeHealthService', () => { ```ts import { RuntimeHealthController } from '../../src/runtime/health/runtime-health.controller'; +import type { RuntimeHealthReport } from '../../src/runtime/health/runtime-health.types'; describe('RuntimeHealthController', () => { it('returns the runtime health report from the service', () => { - const report = { + const report: RuntimeHealthReport = { service: 'kt-template-online-api', checkedAt: '2026-06-13T00:00:00.000Z', status: 'ready', @@ -1156,18 +1174,37 @@ describe('RuntimeHealthController', () => { username: '', passwordConfigured: false, }, - minio: { endpoint: '', port: 9000, useSSL: false, accessKey: '' }, - wordpress: { endpoint: '', username: '' }, + minio: { + endpoint: 'minio', + port: 9000, + useSSL: false, + accessKey: 'mi***ey', + bucket: 'kt-template-online', + }, + wordpress: { + baseUrl: 'https://blog.example.test', + hostHeader: 'blog.example.test', + adminUsername: 'wordpress-admin', + passwordConfigured: true, + timeoutMs: 15000, + loginTimeoutMs: 3000, + availabilityTtlMs: 60000, + }, qqbot: { - reverseWsUrl: '', - napcatDataRoot: '', - napcatSshHost: '', - napcatSshPort: 22, - napcatSshUser: '', + reverseWsPath: '/qqbot/onebot/reverse', + reverseWsToken: 'qq***en', + napcatRoot: '/vol1/docker/napcat', + napcatContainerMode: 'ssh', + napcatSshTarget: 'nas', + napcatSshPort: 2202, + napcatSshKeyPath: '/home/kt/.ssh/napcat', + napcatReverseWsBase: 'ws://api.example.test/onebot', + napcatWebuiBaseUrl: 'http://127.0.0.1:6099', + napcatWebuiToken: 'na***en', }, checks: [], }, - } as const; + }; const service = { getRuntimeHealth: jest.fn(() => report) }; const controller = new RuntimeHealthController(service as any); @@ -1193,7 +1230,7 @@ pnpm run typecheck ```powershell git status --short -git add src/runtime src/app.module.ts src/main.ts test/runtime +git add src/runtime src/app.module.ts src/main.ts test/runtime docs/superpowers/plans/2026-06-13-api-runtime-foundation.md git diff --cached --check git commit -m "feat: 添加API运行时健康检查" ``` diff --git a/src/app.module.ts b/src/app.module.ts index 5bfc5e3..4d1a33e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { AdminModule } from './admin/admin.module'; import { BlogModule } from './blog/blog.module'; import { WordpressModule } from './wordpress/wordpress.module'; import { QqbotModule } from './qqbot/qqbot.module'; +import { RuntimeModule } from './runtime'; @Module({ imports: [ @@ -64,6 +65,7 @@ import { QqbotModule } from './qqbot/qqbot.module'; }), MinioClientModule, CommonModule, + RuntimeModule, AdminModule, BlogModule, WordpressModule, diff --git a/src/main.ts b/src/main.ts index b35f4be..1778b0b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,7 +41,8 @@ const swaggerGroups: SwaggerDocumentGroup[] = [ path: 'api/wordpress', }, { - matcher: (path) => path === '/' || path.startsWith('/minio'), + matcher: (path) => + path === '/' || path.startsWith('/minio') || path.startsWith('/health'), name: '基础能力', path: 'api/basic', }, diff --git a/src/runtime/health/runtime-health.controller.ts b/src/runtime/health/runtime-health.controller.ts new file mode 100644 index 0000000..324b174 --- /dev/null +++ b/src/runtime/health/runtime-health.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuntimeHealthService } from './runtime-health.service'; +import type { RuntimeHealthReport } from './runtime-health.types'; + +@ApiTags('Runtime Health') +@Controller('health') +export class RuntimeHealthController { + constructor(private readonly runtimeHealthService: RuntimeHealthService) {} + + @Get('runtime') + @ApiOperation({ summary: 'Get machine-readable API runtime health' }) + getRuntimeHealth(): RuntimeHealthReport { + return this.runtimeHealthService.getRuntimeHealth(); + } +} diff --git a/src/runtime/health/runtime-health.service.ts b/src/runtime/health/runtime-health.service.ts new file mode 100644 index 0000000..712e512 --- /dev/null +++ b/src/runtime/health/runtime-health.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { RuntimeConfigService } from '../config/runtime-config.service'; +import { + RuntimeHealthCheck, + RuntimeHealthReport, + RuntimeHealthStatus, +} from './runtime-health.types'; + +@Injectable() +export class RuntimeHealthService { + constructor(private readonly runtimeConfigService: RuntimeConfigService) {} + + getRuntimeHealth(): RuntimeHealthReport { + const config = this.runtimeConfigService.getSafeSnapshot(); + const checks: RuntimeHealthCheck[] = [ + { + name: 'process', + status: 'live', + critical: true, + message: 'NestJS process answered runtime health request', + }, + ...config.checks.map((check) => ({ + name: `config:${check.key}`, + status: this.getConfigCheckStatus(check.present, check.level), + critical: check.level === 'required', + message: check.present + ? `${check.key} is configured` + : check.message ?? `${check.key} is not configured`, + })), + ]; + + return { + service: 'kt-template-online-api', + checkedAt: new Date().toISOString(), + status: this.aggregateStatus(checks), + checks, + config, + }; + } + + private getConfigCheckStatus( + present: boolean, + level: 'required' | 'optional', + ): RuntimeHealthStatus { + if (present) return 'ready'; + return level === 'required' ? 'blocked' : 'degraded'; + } + + private aggregateStatus(checks: RuntimeHealthCheck[]): RuntimeHealthStatus { + if (checks.some((check) => check.critical && check.status === 'blocked')) { + return 'blocked'; + } + + if (checks.some((check) => check.status === 'degraded')) { + return 'degraded'; + } + + if (checks.every((check) => check.status === 'live')) { + return 'live'; + } + + return 'ready'; + } +} diff --git a/src/runtime/health/runtime-health.types.ts b/src/runtime/health/runtime-health.types.ts new file mode 100644 index 0000000..b035e23 --- /dev/null +++ b/src/runtime/health/runtime-health.types.ts @@ -0,0 +1,19 @@ +import type { RuntimeSafeConfigSnapshot } from '../config/runtime-config.types'; + +export type RuntimeHealthStatus = 'live' | 'ready' | 'degraded' | 'blocked'; + +export interface RuntimeHealthCheck { + name: string; + status: RuntimeHealthStatus; + critical: boolean; + message: string; + detail?: Record; +} + +export interface RuntimeHealthReport { + service: 'kt-template-online-api'; + checkedAt: string; + status: RuntimeHealthStatus; + checks: RuntimeHealthCheck[]; + config: RuntimeSafeConfigSnapshot; +} diff --git a/src/runtime/index.ts b/src/runtime/index.ts new file mode 100644 index 0000000..57e5ecb --- /dev/null +++ b/src/runtime/index.ts @@ -0,0 +1,9 @@ +export * from './config/runtime-config.service'; +export * from './config/runtime-config.types'; +export * from './errors/runtime-error.types'; +export * from './evidence/runtime-evidence.service'; +export * from './evidence/runtime-evidence.types'; +export * from './health/runtime-health.controller'; +export * from './health/runtime-health.service'; +export * from './health/runtime-health.types'; +export * from './runtime.module'; diff --git a/src/runtime/runtime.module.ts b/src/runtime/runtime.module.ts new file mode 100644 index 0000000..51618c6 --- /dev/null +++ b/src/runtime/runtime.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { CommonModule } from '../common'; +import { RuntimeConfigService } from './config/runtime-config.service'; +import { RuntimeEvidenceService } from './evidence/runtime-evidence.service'; +import { RuntimeHealthController } from './health/runtime-health.controller'; +import { RuntimeHealthService } from './health/runtime-health.service'; + +@Module({ + imports: [ConfigModule, CommonModule], + controllers: [RuntimeHealthController], + providers: [ + RuntimeConfigService, + RuntimeEvidenceService, + RuntimeHealthService, + ], + exports: [RuntimeConfigService, RuntimeEvidenceService, RuntimeHealthService], +}) +export class RuntimeModule {} diff --git a/test/runtime/runtime-health.controller.spec.ts b/test/runtime/runtime-health.controller.spec.ts new file mode 100644 index 0000000..47c1d40 --- /dev/null +++ b/test/runtime/runtime-health.controller.spec.ts @@ -0,0 +1,68 @@ +import { RuntimeHealthController } from '../../src/runtime/health/runtime-health.controller'; +import type { RuntimeHealthReport } from '../../src/runtime/health/runtime-health.types'; + +describe('RuntimeHealthController', () => { + it('returns the service report and calls the service once', () => { + const report: RuntimeHealthReport = { + service: 'kt-template-online-api', + checkedAt: '2026-06-13T00:00:00.000Z', + status: 'ready', + checks: [], + config: { + app: { nodeEnv: 'test', port: 48085 }, + database: { + host: 'mysql', + port: 3306, + database: 'kt', + username: 'root', + synchronize: false, + }, + loki: { + transportEnabled: false, + httpRequestPushEnabled: false, + queryConfigured: false, + host: '', + queryHost: '', + environment: 'test', + tenantId: '', + username: '', + passwordConfigured: false, + }, + minio: { + endpoint: 'minio', + port: 9000, + useSSL: false, + accessKey: 'mi***ey', + bucket: 'kt-template-online', + }, + wordpress: { + baseUrl: 'https://blog.example.test', + hostHeader: 'blog.example.test', + adminUsername: 'wordpress-admin', + passwordConfigured: true, + timeoutMs: 15000, + loginTimeoutMs: 3000, + availabilityTtlMs: 60000, + }, + qqbot: { + reverseWsPath: '/qqbot/onebot/reverse', + reverseWsToken: 'qq***en', + napcatRoot: '/vol1/docker/napcat', + napcatContainerMode: 'ssh', + napcatSshTarget: 'nas', + napcatSshPort: 2202, + napcatSshKeyPath: '/home/kt/.ssh/napcat', + napcatReverseWsBase: 'ws://api.example.test/onebot', + napcatWebuiBaseUrl: 'http://127.0.0.1:6099', + napcatWebuiToken: 'na***en', + }, + checks: [], + }, + }; + const service = { getRuntimeHealth: jest.fn(() => report) }; + const controller = new RuntimeHealthController(service as any); + + expect(controller.getRuntimeHealth()).toBe(report); + expect(service.getRuntimeHealth).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/runtime/runtime-health.service.spec.ts b/test/runtime/runtime-health.service.spec.ts new file mode 100644 index 0000000..f940e34 --- /dev/null +++ b/test/runtime/runtime-health.service.spec.ts @@ -0,0 +1,161 @@ +import { RuntimeHealthService } from '../../src/runtime/health/runtime-health.service'; +import type { RuntimeSafeConfigSnapshot } from '../../src/runtime/config/runtime-config.types'; + +function createSnapshot( + checks: RuntimeSafeConfigSnapshot['checks'], +): RuntimeSafeConfigSnapshot { + return { + app: { nodeEnv: 'test', port: 48085 }, + database: { + host: 'mysql', + port: 3306, + database: 'kt', + username: 'root', + synchronize: false, + }, + loki: { + transportEnabled: true, + httpRequestPushEnabled: true, + queryConfigured: true, + host: 'https://loki-push.example.test', + queryHost: 'https://loki-query.example.test', + environment: 'test', + tenantId: 'kt', + username: 'loki-user', + passwordConfigured: true, + }, + minio: { + endpoint: 'minio', + port: 9000, + useSSL: false, + accessKey: 'mi***ey', + bucket: 'kt-template-online', + }, + wordpress: { + baseUrl: 'https://blog.example.test', + hostHeader: 'blog.example.test', + adminUsername: 'wordpress-admin', + passwordConfigured: true, + timeoutMs: 15000, + loginTimeoutMs: 3000, + availabilityTtlMs: 60000, + }, + qqbot: { + reverseWsPath: '/qqbot/onebot/reverse', + reverseWsToken: 'qq***en', + napcatRoot: '/vol1/docker/napcat', + napcatContainerMode: 'ssh', + napcatSshTarget: 'nas', + napcatSshPort: 2202, + napcatSshKeyPath: '/home/kt/.ssh/napcat', + napcatReverseWsBase: 'ws://api.example.test/onebot', + napcatWebuiBaseUrl: 'http://127.0.0.1:6099', + napcatWebuiToken: 'na***en', + }, + checks, + }; +} + +function createService(snapshot: RuntimeSafeConfigSnapshot) { + return new RuntimeHealthService({ + getSafeSnapshot: jest.fn(() => snapshot), + } as any); +} + +describe('RuntimeHealthService', () => { + it('returns ready when required and optional checks are present', () => { + const service = createService( + createSnapshot([ + { key: 'DB_HOST', level: 'required', present: true }, + { key: 'MINIO_ENDPOINT', level: 'optional', present: true }, + ]), + ); + + const report = service.getRuntimeHealth(); + + expect(report).toEqual( + expect.objectContaining({ + service: 'kt-template-online-api', + status: 'ready', + config: expect.objectContaining({ + minio: expect.objectContaining({ bucket: 'kt-template-online' }), + }), + }), + ); + expect(new Date(report.checkedAt).toISOString()).toBe(report.checkedAt); + expect(report.checks).toContainEqual({ + name: 'process', + status: 'live', + critical: true, + message: 'NestJS process answered runtime health request', + }); + expect(report.checks).toContainEqual( + expect.objectContaining({ + name: 'config:DB_HOST', + status: 'ready', + critical: true, + message: 'DB_HOST is configured', + }), + ); + expect(report.checks).toContainEqual( + expect.objectContaining({ + name: 'config:MINIO_ENDPOINT', + status: 'ready', + critical: false, + message: 'MINIO_ENDPOINT is configured', + }), + ); + }); + + it('returns blocked when required config is missing', () => { + const service = createService( + createSnapshot([ + { + key: 'DB_PASSWORD', + level: 'required', + present: false, + message: 'DB_PASSWORD is not configured', + }, + { key: 'MINIO_ENDPOINT', level: 'optional', present: true }, + ]), + ); + + const report = service.getRuntimeHealth(); + + expect(report.status).toBe('blocked'); + expect(report.checks).toContainEqual( + expect.objectContaining({ + name: 'config:DB_PASSWORD', + status: 'blocked', + critical: true, + message: 'DB_PASSWORD is not configured', + }), + ); + }); + + it('returns degraded when only optional config is missing', () => { + const service = createService( + createSnapshot([ + { key: 'DB_HOST', level: 'required', present: true }, + { + key: 'LOKI_PASSWORD', + level: 'optional', + present: false, + message: 'LOKI_PASSWORD is not configured', + }, + ]), + ); + + const report = service.getRuntimeHealth(); + + expect(report.status).toBe('degraded'); + expect(report.checks).toContainEqual( + expect.objectContaining({ + name: 'config:LOKI_PASSWORD', + status: 'degraded', + critical: false, + message: 'LOKI_PASSWORD is not configured', + }), + ); + }); +});