feat: 添加API运行时配置基础

This commit is contained in:
sunlei 2026-06-13 16:22:37 +08:00
parent 6433790cf5
commit 5feb22d56b
4 changed files with 317 additions and 0 deletions

View File

@ -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<string | number>(key),
fallback,
);
}
private getBoolean(key: string, fallback: boolean) {
return this.toolsService.normalizeBoolean(
this.configService.get<string | boolean | number>(key),
fallback,
);
}
}

View File

@ -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[];
}

View File

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

View File

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