From 5feb22d56bcd2d1c2c38e2ad4f7eedd5f519ef4d Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 13 Jun 2026 16:22:37 +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=E9=85=8D=E7=BD=AE=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/runtime/config/runtime-config.service.ts | 158 +++++++++++++++++++ src/runtime/config/runtime-config.types.ts | 58 +++++++ src/runtime/errors/runtime-error.types.ts | 13 ++ test/runtime/runtime-config.service.spec.ts | 88 +++++++++++ 4 files changed, 317 insertions(+) create mode 100644 src/runtime/config/runtime-config.service.ts create mode 100644 src/runtime/config/runtime-config.types.ts create mode 100644 src/runtime/errors/runtime-error.types.ts create mode 100644 test/runtime/runtime-config.service.spec.ts diff --git a/src/runtime/config/runtime-config.service.ts b/src/runtime/config/runtime-config.service.ts new file mode 100644 index 0000000..e2783da --- /dev/null +++ b/src/runtime/config/runtime-config.service.ts @@ -0,0 +1,158 @@ +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_KEYS = [ + 'MINIO_ENDPOINT', + 'MINIO_PORT', + 'MINIO_ACCESS_KEY', + 'MINIO_SECRET_KEY', + 'LOKI_HOST', + 'WORDPRESS_API_URL', + 'QQBOT_REVERSE_WS_URL', + 'NAPCAT_DATA_ROOT', + 'NAPCAT_SSH_HOST', + 'NAPCAT_SSH_PORT', + 'NAPCAT_SSH_USER', +] as const; + +@Injectable() +export class RuntimeConfigService { + constructor( + private readonly configService: ConfigService, + private readonly toolsService: ToolsService, + ) {} + + readAppProfile(): RuntimeAppConfig { + return { + nodeEnv: this.getString('NODE_ENV', 'development'), + port: this.getPositiveNumber('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 { + return { + enabled: this.getBoolean('LOKI_ENABLED', false), + host: this.getString('LOKI_HOST'), + basicAuth: this.maskSecret(this.configService.get('LOKI_BASIC_AUTH')), + }; + } + + readMinioProfile(): RuntimeMinioConfig { + return { + endpoint: this.getString('MINIO_ENDPOINT'), + port: this.getPositiveNumber('MINIO_PORT', 9000), + useSSL: this.getBoolean('MINIO_USE_SSL', false), + accessKey: this.maskSecret(this.configService.get('MINIO_ACCESS_KEY')), + }; + } + + readWordpressProfile(): RuntimeWordpressConfig { + return { + endpoint: this.getString('WORDPRESS_API_URL'), + username: this.maskSecret(this.configService.get('WORDPRESS_USERNAME')), + }; + } + + readQqbotProfile(): RuntimeQqbotConfig { + return { + reverseWsUrl: this.getString('QQBOT_REVERSE_WS_URL'), + napcatDataRoot: this.getString('NAPCAT_DATA_ROOT'), + napcatSshHost: this.getString('NAPCAT_SSH_HOST'), + napcatSshPort: this.getPositiveNumber('NAPCAT_SSH_PORT', 22), + napcatSshUser: this.getString('NAPCAT_SSH_USER'), + }; + } + + 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_KEYS.map((key) => this.createCheck(key, '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 getString(key: string, fallback = '') { + const value = this.toolsService.toTrimmedString(this.configService.get(key)); + return value || 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, + ); + } +} diff --git a/src/runtime/config/runtime-config.types.ts b/src/runtime/config/runtime-config.types.ts new file mode 100644 index 0000000..4622505 --- /dev/null +++ b/src/runtime/config/runtime-config.types.ts @@ -0,0 +1,58 @@ +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 { + enabled: boolean; + host: string; + basicAuth: string; +} + +export interface RuntimeMinioConfig { + endpoint: string; + port: number; + useSSL: boolean; + accessKey: string; +} + +export interface RuntimeWordpressConfig { + endpoint: string; + username: string; +} + +export interface RuntimeQqbotConfig { + reverseWsUrl: string; + napcatDataRoot: string; + napcatSshHost: string; + napcatSshPort: number; + napcatSshUser: string; +} + +export interface RuntimeSafeConfigSnapshot { + app: RuntimeAppConfig; + database: RuntimeDatabaseConfig; + loki: RuntimeLokiConfig; + minio: RuntimeMinioConfig; + wordpress: RuntimeWordpressConfig; + qqbot: RuntimeQqbotConfig; + checks: RuntimeConfigCheck[]; +} diff --git a/src/runtime/errors/runtime-error.types.ts b/src/runtime/errors/runtime-error.types.ts new file mode 100644 index 0000000..5328988 --- /dev/null +++ b/src/runtime/errors/runtime-error.types.ts @@ -0,0 +1,13 @@ +export type RuntimeErrorCategory = + | 'config_error' + | 'dependency_unavailable' + | 'operation_failed' + | 'cleanup_failed'; + +export interface RuntimeClassifiedError { + category: RuntimeErrorCategory; + operation: string; + message: string; + cause?: string; + retryable: boolean; +} diff --git a/test/runtime/runtime-config.service.spec.ts b/test/runtime/runtime-config.service.spec.ts new file mode 100644 index 0000000..10a3f4e --- /dev/null +++ b/test/runtime/runtime-config.service.spec.ts @@ -0,0 +1,88 @@ +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', + }); + + 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', + WORDPRESS_USERNAME: 'wordpress-user', + }); + + const snapshot = service.getSafeSnapshot(); + const snapshotJson = JSON.stringify(snapshot); + const adminSecretCheck = snapshot.checks.find( + (check) => check.key === 'ADMIN_TOKEN_SECRET', + ); + + expect(service.maskSecret('abcdef123456')).toBe('ab***56'); + expect(service.maskSecret('')).toBe(''); + expect(service.maskSecret('abcd')).toBe('****'); + expect(adminSecretCheck).toEqual( + expect.objectContaining({ + present: true, + maskedValue: 'ab***56', + }), + ); + expect(snapshotJson).not.toContain('abcdef123456'); + expect(snapshotJson).not.toContain('password-value'); + expect(snapshotJson).not.toContain('minio-access-key'); + expect(snapshotJson).not.toContain('wordpress-user'); + expect(snapshot.minio.accessKey).toBe('mi***ey'); + expect(snapshot.wordpress.username).toBe('wo***er'); + }); + + 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', + }), + ); + }); +});