forked from wangziqi/gongxue-base
272 lines
8.8 KiB
TypeScript
272 lines
8.8 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
InternalServerErrorException,
|
|
Logger,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
|
|
import {
|
|
AiConfigResponseDto,
|
|
AiRuntimeConfig,
|
|
DEFAULT_BASE_URLS,
|
|
FetchModelsDto,
|
|
FetchModelsResultDto,
|
|
SaveAiConfigDto,
|
|
TestAiConfigDto,
|
|
AiConfigTestResultDto,
|
|
} from './dto/ai-config.dto';
|
|
import { decrypt, encrypt, validateAndNormalizeBaseUrl, validateDnsNotPrivate } from './ai-config.helpers';
|
|
import { fetchModels, testConnection } from './ai-config.probe';
|
|
import type { AiConfigProbeContext } from './ai-config.probe';
|
|
|
|
@Injectable()
|
|
export class AiConfigService implements AiConfigProbeContext {
|
|
private readonly logger = new Logger(AiConfigService.name);
|
|
|
|
constructor(
|
|
@InjectRepository(AiConfig)
|
|
private readonly repo: Repository<AiConfig>,
|
|
) {}
|
|
|
|
save(config: AiConfig): Promise<AiConfig> {
|
|
return this.repo.save(config);
|
|
}
|
|
|
|
/** Resolve the effective API key: DB first, then env, then none */
|
|
resolveApiKey(config: AiConfig | null): {
|
|
plaintext: string | null;
|
|
source: 'database' | 'environment' | 'none';
|
|
} {
|
|
// DB stored key
|
|
if (config?.encryptedApiKey && config?.apiKeyIv && config?.apiKeyAuthTag) {
|
|
try {
|
|
const plaintext = decrypt(config.encryptedApiKey, config.apiKeyIv, config.apiKeyAuthTag);
|
|
return { plaintext, source: 'database' };
|
|
} catch {
|
|
this.logger.error('解密数据库 API Key 失败,密文可能已损坏');
|
|
throw new InternalServerErrorException('无法解密 API Key');
|
|
}
|
|
}
|
|
|
|
// Environment fallback
|
|
const envKey = process.env.AI_API_KEY;
|
|
if (envKey) {
|
|
return { plaintext: envKey, source: 'environment' };
|
|
}
|
|
|
|
return { plaintext: null, source: 'none' };
|
|
}
|
|
|
|
/** Load or create the singleton config row */
|
|
async getOrCreateConfig(): Promise<AiConfig> {
|
|
let config = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
|
|
if (!config) {
|
|
config = this.repo.create({
|
|
singletonKey: SINGLETON_KEY,
|
|
provider: AiProvider.DEEPSEEK,
|
|
baseUrl: DEFAULT_BASE_URLS[AiProvider.DEEPSEEK],
|
|
enabled: true,
|
|
timeoutMs: 30000,
|
|
});
|
|
try {
|
|
config = await this.repo.save(config);
|
|
} catch (err: unknown) {
|
|
// Unique constraint violation → another request created it first
|
|
const isErrWithCode = err !== null && typeof err === 'object' && 'code' in err;
|
|
const code = isErrWithCode ? (err as Record<string, unknown>).code : undefined;
|
|
const errno = isErrWithCode ? (err as Record<string, unknown>).errno : undefined;
|
|
// MySQL: ER_DUP_ENTRY (code 'ER_DUP_ENTRY') or errno 1062
|
|
if (code === 'ER_DUP_ENTRY' || errno === 1062) {
|
|
const existing = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
|
|
if (existing) return existing;
|
|
}
|
|
throw err;
|
|
}
|
|
return config;
|
|
}
|
|
|
|
// Migrate old defaults: if provider is still OPENAI (old default) and config was never
|
|
// explicitly configured (no API key, never verified), switch to DeepSeek silently.
|
|
if (
|
|
config.provider === AiProvider.OPENAI &&
|
|
config.baseUrl === DEFAULT_BASE_URLS[AiProvider.OPENAI] &&
|
|
!config.encryptedApiKey &&
|
|
!config.verified
|
|
) {
|
|
config.provider = AiProvider.DEEPSEEK;
|
|
config.baseUrl = DEFAULT_BASE_URLS[AiProvider.DEEPSEEK];
|
|
await this.repo.save(config);
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
/** Build masked key display */
|
|
private buildMaskedKey(keyLast4: string | null): string | null {
|
|
if (keyLast4 && keyLast4.length === 4) {
|
|
return `••••${keyLast4}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** GET response */
|
|
async getConfig(): Promise<AiConfigResponseDto> {
|
|
const config = await this.getOrCreateConfig();
|
|
const { source } = this.resolveApiKey(config);
|
|
|
|
const hasDbKey = !!(config.encryptedApiKey && config.apiKeyIv && config.apiKeyAuthTag);
|
|
|
|
return {
|
|
id: config.id,
|
|
provider: config.provider,
|
|
baseUrl: config.baseUrl,
|
|
hasApiKey: source !== 'none',
|
|
hasDatabaseKey: hasDbKey,
|
|
maskedApiKey: config.keyLast4
|
|
? this.buildMaskedKey(config.keyLast4)
|
|
: source !== 'none'
|
|
? '••••'
|
|
: null,
|
|
keySource: source,
|
|
defaultModel: config.defaultModel ?? null,
|
|
enabled: config.enabled,
|
|
supportsVision: config.supportsVision,
|
|
timeoutMs: config.timeoutMs,
|
|
reasoningEffort: config.reasoningEffort ?? null,
|
|
verified: config.verified,
|
|
lastTestedAt: config.lastTestedAt?.toISOString() ?? null,
|
|
lastTestLatencyMs: config.lastTestLatencyMs ?? null,
|
|
createdAt: config.createdAt.toISOString(),
|
|
updatedAt: config.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
/** PUT / save */
|
|
async saveConfig(dto: SaveAiConfigDto): Promise<AiConfig> {
|
|
const config = await this.getOrCreateConfig();
|
|
|
|
const normalizedBaseUrl = validateAndNormalizeBaseUrl(dto.baseUrl, dto.provider);
|
|
|
|
// DNS SSRF check for all providers
|
|
await validateDnsNotPrivate(new URL(normalizedBaseUrl).hostname);
|
|
|
|
config.provider = dto.provider;
|
|
config.baseUrl = normalizedBaseUrl;
|
|
|
|
if (dto.defaultModel !== undefined) {
|
|
config.defaultModel = dto.defaultModel || null;
|
|
}
|
|
|
|
if (dto.timeoutMs !== undefined) {
|
|
config.timeoutMs = dto.timeoutMs;
|
|
}
|
|
|
|
if (dto.reasoningEffort !== undefined) {
|
|
config.reasoningEffort = dto.reasoningEffort || null;
|
|
}
|
|
|
|
// Handle apiKey — empty/undefined = keep existing
|
|
if (dto.apiKey !== undefined && dto.apiKey !== '') {
|
|
const { ciphertext, iv, authTag } = encrypt(dto.apiKey);
|
|
config.encryptedApiKey = ciphertext;
|
|
config.apiKeyIv = iv;
|
|
config.apiKeyAuthTag = authTag;
|
|
config.keyLast4 = dto.apiKey.slice(-4);
|
|
}
|
|
|
|
// AI is always enabled by default — the enable switch has been removed
|
|
if (dto.enabled !== undefined) {
|
|
config.enabled = dto.enabled;
|
|
} else {
|
|
config.enabled = true;
|
|
}
|
|
|
|
if (dto.supportsVision !== undefined) {
|
|
config.supportsVision = dto.supportsVision;
|
|
}
|
|
|
|
if (dto.enabled === true) {
|
|
const { plaintext } = this.resolveApiKey(config);
|
|
if (!plaintext) throw new BadRequestException('启用 AI 服务前必须配置 API Key');
|
|
if (!config.defaultModel?.trim()) {
|
|
throw new BadRequestException('启用 AI 服务前必须配置默认模型');
|
|
}
|
|
}
|
|
|
|
return this.repo.save(config);
|
|
}
|
|
|
|
/** Clear DB key only */
|
|
async clearKey(): Promise<AiConfigResponseDto> {
|
|
const config = await this.getOrCreateConfig();
|
|
config.encryptedApiKey = null;
|
|
config.apiKeyIv = null;
|
|
config.apiKeyAuthTag = null;
|
|
config.keyLast4 = null;
|
|
// If no env key either, disable
|
|
const envKey = process.env.AI_API_KEY;
|
|
if (!envKey) {
|
|
config.enabled = false;
|
|
}
|
|
await this.repo.save(config);
|
|
|
|
return this.getConfig();
|
|
}
|
|
|
|
/** Test connection — uses saved config or request body overrides */
|
|
async testConnection(dto?: TestAiConfigDto): Promise<AiConfigTestResultDto> {
|
|
return testConnection(this, dto);
|
|
}
|
|
|
|
/** Fetch available model list from the configured provider */
|
|
async fetchModels(dto?: FetchModelsDto): Promise<FetchModelsResultDto> {
|
|
return fetchModels(this, dto);
|
|
}
|
|
|
|
/**
|
|
* Server-only runtime config — for future AI adapters.
|
|
* Re-validates the stored base URL and DNS at runtime to guard
|
|
* against config-table tampering or DNS record changes.
|
|
* Future adapters should still use a restricted transport helper.
|
|
*/
|
|
async getRuntimeConfig(): Promise<AiRuntimeConfig> {
|
|
const config = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
|
|
if (!config) {
|
|
throw new InternalServerErrorException('AI 配置未初始化');
|
|
}
|
|
|
|
if (!config.enabled) {
|
|
throw new BadRequestException('AI 服务未启用');
|
|
}
|
|
|
|
// Re-validate and normalize the stored base URL
|
|
const normalizedBaseUrl = validateAndNormalizeBaseUrl(config.baseUrl, config.provider);
|
|
|
|
// Re-check DNS at runtime
|
|
await validateDnsNotPrivate(new URL(normalizedBaseUrl).hostname);
|
|
|
|
const { plaintext } = this.resolveApiKey(config);
|
|
if (!plaintext) {
|
|
throw new BadRequestException('未配置 API Key');
|
|
}
|
|
|
|
// defaultModel is required for actual AI calls
|
|
if (!config.defaultModel) {
|
|
throw new BadRequestException('未配置默认模型');
|
|
}
|
|
|
|
return {
|
|
provider: config.provider,
|
|
baseUrl: normalizedBaseUrl,
|
|
apiKey: plaintext,
|
|
defaultModel: config.defaultModel,
|
|
timeoutMs: config.timeoutMs,
|
|
enabled: config.enabled,
|
|
supportsVision: config.supportsVision,
|
|
reasoningEffort: config.reasoningEffort ?? null,
|
|
};
|
|
}
|
|
}
|