# API Runtime Foundation Implementation Plan > **Execution note:** Execute this plan task-by-task with the KT-local workflow and use the checkboxes to track plan state. **Goal:** Implement Phase 1 of the approved API runtime foundation: typed runtime config, classified runtime errors, structured runtime evidence, and a lightweight `GET /health/runtime` endpoint that can be used by Jenkins/K8s/ktWorkflow in the next plan. **Post-review amendment:** The final public `GET /health/runtime` contract intentionally returns only `service`, `checkedAt`, `status`, and `checks`. Earlier code snippets in this plan that included `config` or described the endpoint as a safe config view were superseded during code review to avoid exposing runtime topology on an unauthenticated health endpoint. The internal `RuntimeConfigService` still owns the safe snapshot used to derive checks. **Architecture:** Add a focused `src/runtime` Nest module. It depends on `ConfigModule` and `CommonModule`, exports typed runtime primitives, and keeps business modules unchanged during this phase. The endpoint returns plain machine-readable JSON, not a Vben response wrapper, so deployment tooling can consume it directly. **Tech Stack:** NestJS 11, TypeScript 5.9, Jest 29 with `ts-jest`, existing `@nestjs/config`, existing `ToolsService`, existing Swagger/Knife4j setup. --- ## Scope Boundary This plan implements the runtime foundation skeleton only. It does not refactor NapCat login, Docker device persistence, Jenkins observation scripts, or ktWorkflow automation. Those follow as separate plans after this endpoint and evidence shape are verified. The implementation must start from a clean API worktree except committed docs. If `git status --short` shows uncommitted source changes, inspect them before editing and do not overwrite user work. ## File Structure Create these files: ```text src/runtime/ index.ts runtime.module.ts config/ runtime-config.service.ts runtime-config.types.ts errors/ runtime-error.types.ts evidence/ runtime-evidence.service.ts runtime-evidence.types.ts health/ runtime-health.controller.ts runtime-health.service.ts runtime-health.types.ts test/runtime/ runtime-config.service.spec.ts runtime-evidence.service.spec.ts runtime-health.controller.spec.ts runtime-health.service.spec.ts ``` Modify these files: ```text src/app.module.ts src/main.ts README.md API.md ``` --- ## Task 1: Add Runtime Error and Config Primitives - [ ] Confirm repo state and package metadata before edits: ```powershell git status --short --branch git rev-parse --is-inside-work-tree node -v pnpm -v if (Test-Path .node-version) { Get-Content .node-version } ``` - [ ] Add `src/runtime/errors/runtime-error.types.ts`: ```ts export type RuntimeErrorCategory = | 'config_error' | 'dependency_unavailable' | 'operation_failed' | 'cleanup_failed'; export interface RuntimeClassifiedError { category: RuntimeErrorCategory; operation: string; message: string; cause?: string; retryable: boolean; } ``` - [ ] Add `src/runtime/config/runtime-config.types.ts`: ```ts export type RuntimeConfigCheckLevel = 'required' | 'optional'; export interface RuntimeConfigCheck { key: string; level: RuntimeConfigCheckLevel; present: boolean; maskedValue?: string; message?: string; } export interface RuntimeAppConfig { nodeEnv: string; port: number; } export interface RuntimeDatabaseConfig { host: string; port: number; database: string; username: string; synchronize: boolean; } export interface RuntimeLokiConfig { transportEnabled: boolean; httpRequestPushEnabled: boolean; queryConfigured: boolean; host: string; queryHost: string; environment: string; tenantId: string; username: string; passwordConfigured: boolean; } export interface RuntimeMinioConfig { endpoint: string; port: number; useSSL: boolean; accessKey: string; bucket: string; } export interface RuntimeWordpressConfig { baseUrl: string; hostHeader: string; adminUsername: string; passwordConfigured: boolean; timeoutMs: number; loginTimeoutMs: number; availabilityTtlMs: number; } export interface RuntimeQqbotConfig { reverseWsPath: string; reverseWsToken: string; napcatRoot: string; napcatContainerMode: string; napcatSshTarget: string; napcatSshPort: number; napcatSshKeyPath: string; napcatReverseWsBase: string; napcatWebuiBaseUrl: string; napcatWebuiToken: string; } export interface RuntimeSafeConfigSnapshot { app: RuntimeAppConfig; database: RuntimeDatabaseConfig; loki: RuntimeLokiConfig; minio: RuntimeMinioConfig; wordpress: RuntimeWordpressConfig; qqbot: RuntimeQqbotConfig; checks: RuntimeConfigCheck[]; } ``` - [ ] Add `src/runtime/config/runtime-config.service.ts`: ```ts import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { ToolsService } from '../../common'; import { RuntimeAppConfig, RuntimeConfigCheck, RuntimeConfigCheckLevel, RuntimeDatabaseConfig, RuntimeLokiConfig, RuntimeMinioConfig, RuntimeQqbotConfig, RuntimeSafeConfigSnapshot, RuntimeWordpressConfig, } from './runtime-config.types'; const REQUIRED_CONFIG_KEYS = [ 'DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_DATABASE', 'ADMIN_TOKEN_SECRET', ] as const; const OPTIONAL_CONFIG_CHECKS: ReadonlyArray = [ 'MINIO_ENDPOINT', 'MINIO_PORT', 'MINIO_ACCESS_KEY', 'MINIO_SECRET_KEY', 'MINIO_BUCKET', ['LOKI_HOST', 'LOKI_URL'], ['LOKI_QUERY_HOST', 'LOKI_HOST', 'LOKI_URL'], 'LOKI_ENV', 'LOKI_HTTP_REQUEST_PUSH_ENABLED', 'LOKI_TENANT_ID', 'LOKI_USERNAME', 'LOKI_PASSWORD', 'LOKI_PUSH_ENDPOINT', 'LOKI_QUERY_ENDPOINT', 'LOKI_PUSH_TIMEOUT_MS', 'LOKI_QUERY_TIMEOUT_MS', 'LOKI_BATCH_INTERVAL_SECONDS', 'LOKI_BATCH_MAX_BUFFER_SIZE', 'WORDPRESS_BASE_URL', 'WORDPRESS_HOST_HEADER', 'WORDPRESS_ADMIN_USERNAME', 'WORDPRESS_ADMIN_PASSWORD', 'WORDPRESS_TIMEOUT_MS', 'WORDPRESS_LOGIN_TIMEOUT_MS', 'WORDPRESS_AVAILABILITY_TTL_MS', 'QQBOT_REVERSE_WS_PATH', 'QQBOT_REVERSE_WS_TOKEN', 'QQBOT_NAPCAT_ROOT', 'QQBOT_NAPCAT_CONTAINER_MODE', 'QQBOT_NAPCAT_SSH_TARGET', 'QQBOT_NAPCAT_SSH_PORT', 'QQBOT_NAPCAT_SSH_KEY_PATH', ['QQBOT_NAPCAT_REVERSE_WS_URL', 'QQBOT_NAPCAT_REVERSE_WS_BASE'], ['NAPCAT_WEBUI_BASE_URL', 'QQBOT_NAPCAT_WEBUI_URL'], ['NAPCAT_WEBUI_TOKEN', 'QQBOT_NAPCAT_WEBUI_TOKEN'], ]; @Injectable() export class RuntimeConfigService { constructor( private readonly configService: ConfigService, private readonly toolsService: ToolsService, ) {} readAppProfile(): RuntimeAppConfig { return { nodeEnv: this.getString('NODE_ENV', 'development'), port: 48085, }; } readDatabaseProfile(): RuntimeDatabaseConfig { return { host: this.getString('DB_HOST'), port: this.getPositiveNumber('DB_PORT', 3306), database: this.getString('DB_DATABASE'), username: this.getString('DB_USERNAME'), synchronize: this.getBoolean('DB_SYNC', false), }; } readLokiProfile(): RuntimeLokiConfig { const host = this.getFirstString(['LOKI_HOST', 'LOKI_URL']); const queryHost = this.getFirstString([ 'LOKI_QUERY_HOST', 'LOKI_HOST', 'LOKI_URL', ]); return { transportEnabled: !!host, httpRequestPushEnabled: !!host && this.getBoolean('LOKI_HTTP_REQUEST_PUSH_ENABLED', true), queryConfigured: !!queryHost, host, queryHost, environment: this.getString( 'LOKI_ENV', this.getString('NODE_ENV', 'development'), ), tenantId: this.getString('LOKI_TENANT_ID'), username: this.getString('LOKI_USERNAME'), passwordConfigured: !!this.getString('LOKI_PASSWORD'), }; } readMinioProfile(): RuntimeMinioConfig { return { endpoint: this.getString('MINIO_ENDPOINT'), port: this.getPositiveNumber('MINIO_PORT', 9000), useSSL: false, accessKey: this.maskSecret(this.configService.get('MINIO_ACCESS_KEY')), bucket: this.getString('MINIO_BUCKET', 'kt-template-online'), }; } readWordpressProfile(): RuntimeWordpressConfig { const timeoutMs = this.getPositiveNumber('WORDPRESS_TIMEOUT_MS', 15000); return { baseUrl: this.getString('WORDPRESS_BASE_URL'), hostHeader: this.getString('WORDPRESS_HOST_HEADER'), adminUsername: this.getString('WORDPRESS_ADMIN_USERNAME'), passwordConfigured: !!this.getString('WORDPRESS_ADMIN_PASSWORD'), timeoutMs, loginTimeoutMs: this.getPositiveNumber( 'WORDPRESS_LOGIN_TIMEOUT_MS', this.getPositiveNumber('WORDPRESS_TIMEOUT_MS', 3000), ), availabilityTtlMs: this.getPositiveNumber( 'WORDPRESS_AVAILABILITY_TTL_MS', 60_000, ), }; } readQqbotProfile(): RuntimeQqbotConfig { return { reverseWsPath: this.getString( 'QQBOT_REVERSE_WS_PATH', '/qqbot/onebot/reverse', ), reverseWsToken: this.maskSecret( this.configService.get('QQBOT_REVERSE_WS_TOKEN'), ), napcatRoot: this.getString( 'QQBOT_NAPCAT_ROOT', '/vol1/docker/kt-qqbot/napcat-instances', ), napcatContainerMode: this.getString('QQBOT_NAPCAT_CONTAINER_MODE'), napcatSshTarget: this.getString('QQBOT_NAPCAT_SSH_TARGET', 'nas'), napcatSshPort: this.getPositiveNumber('QQBOT_NAPCAT_SSH_PORT', 22), napcatSshKeyPath: this.getString('QQBOT_NAPCAT_SSH_KEY_PATH'), napcatReverseWsBase: this.getFirstString([ 'QQBOT_NAPCAT_REVERSE_WS_URL', 'QQBOT_NAPCAT_REVERSE_WS_BASE', ]), napcatWebuiBaseUrl: this.getFirstString([ 'NAPCAT_WEBUI_BASE_URL', 'QQBOT_NAPCAT_WEBUI_URL', ]), napcatWebuiToken: this.maskSecret( this.getFirstString(['NAPCAT_WEBUI_TOKEN', 'QQBOT_NAPCAT_WEBUI_TOKEN']), ), }; } getSafeSnapshot(): RuntimeSafeConfigSnapshot { return { app: this.readAppProfile(), database: this.readDatabaseProfile(), loki: this.readLokiProfile(), minio: this.readMinioProfile(), wordpress: this.readWordpressProfile(), qqbot: this.readQqbotProfile(), checks: this.getConfigChecks(), }; } getConfigChecks(): RuntimeConfigCheck[] { return [ ...REQUIRED_CONFIG_KEYS.map((key) => this.createCheck(key, 'required')), ...OPTIONAL_CONFIG_CHECKS.map((check) => typeof check === 'string' ? this.createCheck(check, 'optional') : this.createAnyCheck([...check], 'optional'), ), ]; } maskSecret(value: unknown): string { const text = this.toolsService.toSecretText(value); if (!text) return ''; if (text.length <= 4) return '****'; return `${text.slice(0, 2)}***${text.slice(-2)}`; } private createCheck( key: string, level: RuntimeConfigCheckLevel, ): RuntimeConfigCheck { const value = this.configService.get(key); const text = this.toolsService.toSecretText(value); const present = !!text; return { key, level, present, maskedValue: present ? this.maskSecret(value) : undefined, message: present ? undefined : `${key} is not configured`, }; } private createAnyCheck( keys: string[], level: RuntimeConfigCheckLevel, ): RuntimeConfigCheck { const key = keys.join('|'); const value = this.getFirstString(keys); const present = !!value; return { key, level, present, maskedValue: present ? this.maskSecret(value) : undefined, message: present ? undefined : `${key} is not configured`, }; } private getString(key: string, fallback = '') { const value = this.toolsService.toTrimmedString(this.configService.get(key)); return value || fallback; } private getFirstString(keys: string[], fallback = '') { for (const key of keys) { const value = this.getString(key); if (value) return value; } return fallback; } private getPositiveNumber(key: string, fallback: number) { return this.toolsService.toPositiveNumber( this.configService.get(key), fallback, ); } private getBoolean(key: string, fallback: boolean) { return this.toolsService.normalizeBoolean( this.configService.get(key), fallback, ); } } ``` - [ ] Add `test/runtime/runtime-config.service.spec.ts`: ```ts import { ConfigService } from '@nestjs/config'; import { ToolsService } from '../../src/common'; import { RuntimeConfigService } from '../../src/runtime/config/runtime-config.service'; function createService(values: Record) { const configService = { get: jest.fn((key: string) => values[key]), } as unknown as ConfigService; return new RuntimeConfigService(configService, new ToolsService()); } describe('RuntimeConfigService', () => { it('parses app and database profiles with stable defaults', () => { const service = createService({ DB_HOST: '127.0.0.1', DB_PORT: '3307', DB_DATABASE: 'kt', DB_USERNAME: 'admin', DB_SYNC: 'true', NODE_ENV: 'test', PORT: '12345', }); expect(service.readAppProfile()).toEqual({ nodeEnv: 'test', port: 48085, }); expect(service.readDatabaseProfile()).toEqual({ host: '127.0.0.1', port: 3307, database: 'kt', username: 'admin', synchronize: true, }); }); it('masks secrets in checks and snapshots', () => { const service = createService({ ADMIN_TOKEN_SECRET: 'abcdef123456', DB_HOST: 'mysql', DB_PORT: '3306', DB_USERNAME: 'root', DB_PASSWORD: 'password-value', DB_DATABASE: 'kt', MINIO_ACCESS_KEY: 'minio-access-key', MINIO_USE_SSL: 'true', WORDPRESS_ADMIN_USERNAME: 'wordpress-user', WORDPRESS_ADMIN_PASSWORD: 'wordpress-password', LOKI_PASSWORD: 'loki-password', QQBOT_REVERSE_WS_TOKEN: 'qq-reverse-token', NAPCAT_WEBUI_TOKEN: 'napcat-webui-token', }); const snapshot = service.getSafeSnapshot(); const adminSecretCheck = snapshot.checks.find( (check) => check.key === 'ADMIN_TOKEN_SECRET', ); expect(adminSecretCheck).toEqual( expect.objectContaining({ present: true, maskedValue: 'ab***56', }), ); expect(JSON.stringify(snapshot)).not.toContain('abcdef123456'); expect(JSON.stringify(snapshot)).not.toContain('password-value'); expect(JSON.stringify(snapshot)).not.toContain('wordpress-password'); expect(JSON.stringify(snapshot)).not.toContain('loki-password'); expect(JSON.stringify(snapshot)).not.toContain('qq-reverse-token'); expect(JSON.stringify(snapshot)).not.toContain('napcat-webui-token'); expect(snapshot.minio.accessKey).toBe('mi***ey'); expect(snapshot.minio.useSSL).toBe(false); expect(snapshot.wordpress.adminUsername).toBe('wordpress-user'); expect(snapshot.wordpress.passwordConfigured).toBe(true); expect(snapshot.loki.passwordConfigured).toBe(true); expect(snapshot.qqbot.reverseWsToken).toBe('qq***en'); expect(snapshot.qqbot.napcatWebuiToken).toBe('na***en'); }); it('reads current WordPress, Loki, and NapCat runtime keys without leaking secrets', () => { const service = createService({ WORDPRESS_BASE_URL: 'https://blog.example.test', WORDPRESS_HOST_HEADER: 'blog.example.test', WORDPRESS_ADMIN_USERNAME: 'wordpress-admin', WORDPRESS_ADMIN_PASSWORD: 'wordpress-password', WORDPRESS_TIMEOUT_MS: '16000', WORDPRESS_LOGIN_TIMEOUT_MS: '4000', WORDPRESS_AVAILABILITY_TTL_MS: '70000', LOKI_URL: 'https://loki-push.example.test', LOKI_QUERY_HOST: 'https://loki-query.example.test', LOKI_ENV: 'production', LOKI_HTTP_REQUEST_PUSH_ENABLED: 'false', LOKI_USERNAME: 'loki-user', LOKI_PASSWORD: 'loki-password', QQBOT_NAPCAT_ROOT: '/vol1/docker/napcat', QQBOT_NAPCAT_CONTAINER_MODE: 'ssh', QQBOT_NAPCAT_SSH_TARGET: 'nas', QQBOT_NAPCAT_SSH_PORT: '2202', QQBOT_NAPCAT_SSH_KEY_PATH: '/home/kt/.ssh/napcat', QQBOT_NAPCAT_REVERSE_WS_BASE: 'ws://api.example.test/onebot', QQBOT_REVERSE_WS_PATH: '/qqbot/reverse', QQBOT_REVERSE_WS_TOKEN: 'qq-reverse-token', NAPCAT_WEBUI_BASE_URL: 'http://127.0.0.1:6099', NAPCAT_WEBUI_TOKEN: 'napcat-webui-token', }); expect(service.readWordpressProfile()).toEqual({ baseUrl: 'https://blog.example.test', hostHeader: 'blog.example.test', adminUsername: 'wordpress-admin', passwordConfigured: true, timeoutMs: 16000, loginTimeoutMs: 4000, availabilityTtlMs: 70000, }); expect(service.readLokiProfile()).toEqual({ transportEnabled: true, httpRequestPushEnabled: false, queryConfigured: true, host: 'https://loki-push.example.test', queryHost: 'https://loki-query.example.test', environment: 'production', tenantId: '', username: 'loki-user', passwordConfigured: true, }); expect(service.readQqbotProfile()).toEqual({ reverseWsPath: '/qqbot/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', }); }); it('marks missing required config as absent', () => { const service = createService({ DB_HOST: 'mysql', }); const checks = service.getConfigChecks(); expect(checks).toContainEqual( expect.objectContaining({ key: 'DB_PASSWORD', level: 'required', present: false, message: 'DB_PASSWORD is not configured', }), ); }); }); ``` - [ ] Run the first targeted test: ```powershell pnpm exec jest --runTestsByPath test/runtime/runtime-config.service.spec.ts ``` - [ ] Commit after the test passes: ```powershell git status --short git add src/runtime/errors/runtime-error.types.ts src/runtime/config/runtime-config.types.ts src/runtime/config/runtime-config.service.ts test/runtime/runtime-config.service.spec.ts git diff --cached --check git commit -m "feat: 添加API运行时配置基础" ``` --- ## Task 2: Add Structured Runtime Evidence - [ ] Add `src/runtime/evidence/runtime-evidence.types.ts`: ```ts import type { RuntimeClassifiedError } from '../errors/runtime-error.types'; export type RuntimeEvidenceStatus = | 'passed' | 'failed' | 'blocked' | 'skipped'; export interface RuntimeEvidenceCleanupResult { status: RuntimeEvidenceStatus; message: string; details?: Record; } export interface RuntimeEvidenceAssertion { name: string; passed: boolean; message: string; } export interface RuntimeEvidenceInput { title: string; taskType: string; project: string; environment: string; operation: string; target?: string; status: RuntimeEvidenceStatus; startedAt?: Date; endedAt?: Date; details?: Record; assertions?: RuntimeEvidenceAssertion[]; cleanup?: RuntimeEvidenceCleanupResult; error?: RuntimeClassifiedError; } export interface RuntimeEvidenceRecord extends RuntimeEvidenceInput { startedAt: Date; endedAt: Date; durationMs: number; schemaVersion: 1; } ``` - [ ] Add `src/runtime/evidence/runtime-evidence.service.ts`: ```ts import { Injectable } from '@nestjs/common'; import { RuntimeEvidenceInput, RuntimeEvidenceRecord, } from './runtime-evidence.types'; const REDACTED_VALUE = ''; const SENSITIVE_KEY_PATTERN = /password|secret|token|authorization|cookie|privateKey|sshKey|ticket|randstr|replyText/i; const SENSITIVE_TEXT_REPLACEMENTS: Array<[RegExp, string]> = [ [/\b(Authorization)\s*[:=]\s*Bearer\s+[^\s,;]+/gi, '$1='], [/\b(Cookie)\s*[:=]\s*[^\s,;]+/gi, '$1='], [/\b(ticket|randstr|token|replyText)\s*=\s*[^\s,;&]+/gi, '$1='], ]; @Injectable() export class RuntimeEvidenceService { createRecord(input: RuntimeEvidenceInput): RuntimeEvidenceRecord { const startedAt = input.startedAt ?? new Date(); const endedAt = input.endedAt ?? new Date(); const record: RuntimeEvidenceRecord = { ...input, startedAt, endedAt, durationMs: Math.max(0, endedAt.getTime() - startedAt.getTime()), schemaVersion: 1, }; return this.sanitizeValue(record) as RuntimeEvidenceRecord; } private sanitizeValue(value: unknown): unknown { if (value instanceof Date) return value; if (typeof value === 'string') return this.sanitizeText(value); if (Array.isArray(value)) { return value.map((item) => this.sanitizeValue(item)); } if (!this.isPlainRecord(value)) { return value; } return Object.fromEntries( Object.entries(value).map(([key, item]) => [ key, this.isSensitiveKey(key) ? REDACTED_VALUE : this.sanitizeValue(item), ]), ); } private sanitizeText(value: string) { return SENSITIVE_TEXT_REPLACEMENTS.reduce( (text, [pattern, replacement]) => text.replace(pattern, replacement), value, ); } private isPlainRecord(value: unknown): value is Record { return ( typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date) ); } private isSensitiveKey(key: string) { return SENSITIVE_KEY_PATTERN.test(key); } } ``` - [ ] Add `test/runtime/runtime-evidence.service.spec.ts`: ```ts import { RuntimeEvidenceService } from '../../src/runtime/evidence/runtime-evidence.service'; describe('RuntimeEvidenceService', () => { it('creates records with duration and schema version', () => { const service = new RuntimeEvidenceService(); const startedAt = new Date('2026-06-13T00:00:00.000Z'); const endedAt = new Date('2026-06-13T00:00:01.250Z'); const record = service.createRecord({ title: 'runtime health local request', taskType: 'api-smoke', project: 'kt-template-online-api', environment: 'local', operation: 'GET /health/runtime', status: 'passed', startedAt, endedAt, }); expect(record.durationMs).toBe(1250); expect(record.schemaVersion).toBe(1); }); it('redacts nested sensitive fields from evidence details and cleanup', () => { const service = new RuntimeEvidenceService(); const record = service.createRecord({ title: 'napcat login evidence', taskType: 'api-smoke', project: 'kt-template-online-api', environment: 'local', operation: 'NapCat PasswordLogin', status: 'blocked', details: { account: '123456', ['password']: 'plain-password', data: { ['token']: 'raw-token', output: { replyText: 'base64-payload', }, }, }, cleanup: { status: 'failed', message: 'runtime password cleanup failed', details: { sshKey: 'private-key', remoteFile: '/tmp/kt-script.sh', }, }, }); expect(record.details).toEqual({ account: '123456', password: '', data: { token: '', output: { replyText: '', }, }, }); expect(record.cleanup?.details).toEqual({ sshKey: '', remoteFile: '/tmp/kt-script.sh', }); }); }); ``` - [ ] Run the evidence test: ```powershell pnpm exec jest --runTestsByPath test/runtime/runtime-evidence.service.spec.ts ``` - [ ] Commit after the test passes: ```powershell git status --short git add src/runtime/evidence/runtime-evidence.types.ts src/runtime/evidence/runtime-evidence.service.ts test/runtime/runtime-evidence.service.spec.ts git diff --cached --check git commit -m "feat: 添加API运行时证据模型" ``` --- ## Task 3: Add Runtime Health Service, Controller, and Module Wiring - [ ] Add `src/runtime/health/runtime-health.types.ts`: ```ts 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; } ``` - [ ] Add `src/runtime/health/runtime-health.service.ts`: ```ts 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'; } } ``` - [ ] Add `src/runtime/health/runtime-health.controller.ts`: ```ts 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(); } } ``` - [ ] Add `src/runtime/runtime.module.ts`: ```ts 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 {} ``` - [ ] Add `src/runtime/index.ts`: ```ts 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'; ``` - [ ] Modify `src/app.module.ts`: ```ts import { RuntimeModule } from './runtime'; ``` Add `RuntimeModule` to `imports` after `CommonModule`: ```ts CommonModule, RuntimeModule, AdminModule, ``` - [ ] Modify `src/main.ts` so `/health/runtime` appears in the basic Swagger group: ```ts { matcher: (path) => path === '/' || path.startsWith('/minio') || path.startsWith('/health'), name: '基础能力', path: 'api/basic', }, ``` - [ ] Add `test/runtime/runtime-health.service.spec.ts`: ```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 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({ service: 'kt-template-online-api', status: 'ready', }), ); }); 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, }), ); }); 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, }), ); }); }); ``` - [ ] Add `test/runtime/runtime-health.controller.spec.ts`: ```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: 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); }); }); ``` - [ ] Run runtime tests: ```powershell pnpm exec jest --runTestsByPath test/runtime/runtime-config.service.spec.ts test/runtime/runtime-evidence.service.spec.ts test/runtime/runtime-health.service.spec.ts test/runtime/runtime-health.controller.spec.ts ``` - [ ] Run typecheck: ```powershell pnpm run typecheck ``` - [ ] Commit after tests and typecheck pass: ```powershell git status --short git add src/runtime src/app.module.ts src/main.ts test/runtime docs/plans/2026-06-13-api-runtime-foundation.md git diff --cached --check git commit -m "feat: 添加API运行时健康检查" ``` --- ## Task 4: Document Runtime Health and Verify the Local Interface - [ ] Update `README.md` with a concise runtime health section: ```md ### Runtime health The API exposes `GET /health/runtime` for deployment and local smoke checks. Post-review note: the public response no longer returns `config`; it exposes status and config check results only. It returns plain JSON with: - `status`: `live`, `ready`, `degraded`, or `blocked`. - `checks`: process and runtime config checks. The endpoint is machine-readable and intentionally does not use the Vben response wrapper. ``` - [ ] Update `API.md` with the endpoint contract: ```md ## Runtime Health | Method | Path | Auth | Description | | ------ | ----------------- | ---- | ----------------------------------- | | GET | `/health/runtime` | No | Runtime health and config check status | Status meanings: - `live`: the NestJS process answered the request. - `ready`: critical config checks are present. - `degraded`: optional runtime config is absent. - `blocked`: required runtime config is absent. ``` - [ ] Start or reuse the local API service, then make one real request to the endpoint. Use a bounded wait loop and clean up only the process started for this check: ```powershell $repo = 'D:\MyFiles\KT\Node\kt-template-online-api' $job = Start-Job -ScriptBlock { param($repoPath) Set-Location $repoPath pnpm start } -ArgumentList $repo try { $deadline = (Get-Date).AddSeconds(60) $response = $null do { try { $response = Invoke-RestMethod -Uri 'http://127.0.0.1:48085/health/runtime' -TimeoutSec 5 break } catch { Start-Sleep -Seconds 2 } } while ((Get-Date) -lt $deadline) if (-not $response) { throw 'GET /health/runtime did not answer within 60 seconds' } $response | ConvertTo-Json -Depth 8 } finally { Stop-Job $job -ErrorAction SilentlyContinue Remove-Job $job -Force -ErrorAction SilentlyContinue } ``` - [ ] If the local service cannot start because required local infrastructure is unavailable, record the start failure and do not claim interface verification. Keep the committed tests and typecheck evidence separate from the interface evidence. - [ ] Run the full lightweight verification set for this phase: ```powershell pnpm exec jest --runTestsByPath test/runtime/runtime-config.service.spec.ts test/runtime/runtime-evidence.service.spec.ts test/runtime/runtime-health.service.spec.ts test/runtime/runtime-health.controller.spec.ts pnpm run typecheck git diff --check ``` - [ ] Run KT documentation sync for the changed files and apply any required doc update it reports. Prefer the `kt_change_doc_sync` MCP tool with these inputs: ```json { "project": "D:\\MyFiles\\KT\\Node\\kt-template-online-api", "changedFiles": [ "README.md", "API.md", "src/runtime", "src/app.module.ts", "src/main.ts", "test/runtime" ] } ``` - [ ] Update root `D:\MyFiles\KT\TASKS.md` with a short implementation evidence record because source files changed. Keep the record to scope, keywords, verification commands, and any remaining uncertainty. If `TASKS.md` already has pre-existing user changes, inspect the diff and do not commit unrelated lines. - [ ] Run KT global review scoped to this implementation. Fix important findings before continuing: ```powershell pnpm --dir D:\MyFiles\KT\mcp\ktWorkflow run global-review -- --project D:\MyFiles\KT\Node\kt-template-online-api --changed-files README.md API.md src/runtime src/app.module.ts src/main.ts test/runtime ``` - [ ] Run the KT global review gate after implementation: ```text Use `KT global review`, then address actionable findings with `KT review follow-up` if findings are returned. ``` - [ ] Commit API docs after verification and review pass: ```powershell git status --short git add README.md API.md git diff --cached --check git commit -m "docs: 补充API运行时健康说明" ``` - [ ] Commit the root task record only when the staged `TASKS.md` diff contains only this implementation record: ```powershell git -C D:\MyFiles\KT status --short TASKS.md git -C D:\MyFiles\KT diff -- TASKS.md git -C D:\MyFiles\KT add TASKS.md git -C D:\MyFiles\KT diff --cached --check git -C D:\MyFiles\KT commit -m "docs: 记录API运行时基础验证" ``` --- ## Final Verification Gate - [ ] Confirm API repo status is clean apart from expected ahead commits: ```powershell git status --short --branch ``` - [ ] Confirm root repo only contains intentional KT record changes: ```powershell git -C D:\MyFiles\KT status --short --branch ``` - [ ] Report exact evidence: ```text Jest command and result typecheck command and result local GET /health/runtime result or explicit local-start blocker KT doc sync result KT global review result KT global review result cleanup action for any started local process ``` ## Handoff to the Next Plan After this phase is verified, the next plan should extend the same evidence shape into ktWorkflow/Jenkins/K8s deployment observation. The NapCat adapter plan should start only after the deploy observation plan has a reusable read-only observation path.