feat: 添加API运行时健康检查

This commit is contained in:
sunlei 2026-06-13 17:47:58 +08:00
parent ce20c350cd
commit 0f47644fd6
10 changed files with 494 additions and 98 deletions

View File

@ -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运行时健康检查"
```

View File

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

View File

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

View File

@ -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();
}
}

View File

@ -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';
}
}

View File

@ -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<string, unknown>;
}
export interface RuntimeHealthReport {
service: 'kt-template-online-api';
checkedAt: string;
status: RuntimeHealthStatus;
checks: RuntimeHealthCheck[];
config: RuntimeSafeConfigSnapshot;
}

9
src/runtime/index.ts Normal file
View File

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

View File

@ -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 {}

View File

@ -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);
});
});

View File

@ -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',
}),
);
});
});