feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具

This commit is contained in:
2026-08-05 17:11:00 +08:00
parent 644c35ce53
commit 0e6e3e2d96
64 changed files with 8395 additions and 6434 deletions

View File

@@ -10,7 +10,7 @@ import {
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { AiConfigService } from './ai-config.service';
import { SaveAiConfigDto, TestAiConfigDto, FetchModelsDto } from './dto/ai-config.dto';
@@ -39,17 +39,8 @@ export class AiConfigController {
@RequirePermission('ai:config:write')
async saveConfig(@Body() body: SaveAiConfigDto, @Req() req: AuthenticatedRequest) {
const config = await this.service.saveConfig(body);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.opLog.log({
userId: req.user?.id,
username: req.user?.username,
module: 'ai-config',
action: 'save',
targetId: config.id,
targetType: 'AiConfig',
detail: `provider=${body.provider} host=${new URL(config.baseUrl).hostname} model=${body.defaultModel ? 'configured' : 'not-set'}`,
ipAddress,
userAgent,
await logAudit(this.opLog, req, {
module: 'ai-config', action: 'save', targetId: config.id, targetType: 'AiConfig', detail: `provider=${body.provider} host=${new URL(config.baseUrl).hostname} model=${body.defaultModel ? 'configured' : 'not-set'}`,
});
return { success: true, message: '配置已保存' };
}
@@ -58,17 +49,8 @@ export class AiConfigController {
@RequirePermission('ai:config:test')
async testConnection(@Body() body: TestAiConfigDto, @Req() req: AuthenticatedRequest) {
const result = await this.service.testConnection(body);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.opLog.log({
userId: req.user?.id,
username: req.user?.username,
module: 'ai-config',
action: 'test',
targetType: 'AiConfig',
detail: `provider=${body.provider ?? '-'} success=${result.success} latency=${result.latencyMs ?? '-'}`,
ipAddress,
userAgent,
status: result.success ? 'success' : 'failure',
await logAudit(this.opLog, req, {
module: 'ai-config', action: 'test', targetType: 'AiConfig', detail: `provider=${body.provider ?? '-'} success=${result.success} latency=${result.latencyMs ?? '-'}`, status: result.success ? 'success' : 'failure',
});
return result;
}
@@ -84,16 +66,8 @@ export class AiConfigController {
@RequirePermission('ai:config:write')
async clearKey(@Req() req: AuthenticatedRequest) {
const data = await this.service.clearKey();
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.opLog.log({
userId: req.user?.id,
username: req.user?.username,
module: 'ai-config',
action: 'clear-key',
targetType: 'AiConfig',
detail: `keySource=${data.keySource}`,
ipAddress,
userAgent,
await logAudit(this.opLog, req, {
module: 'ai-config', action: 'clear-key', targetType: 'AiConfig', detail: `keySource=${data.keySource}`,
});
return { success: true, message: '密钥已清除', data };
}

View File

@@ -0,0 +1,351 @@
import { BadRequestException, InternalServerErrorException, Logger } from '@nestjs/common';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import { lookup } from 'node:dns';
import { isIP } from 'node:net';
import * as http from 'node:http';
import * as https from 'node:https';
import { AiProvider } from './ai-config.entity';
import { DEFAULT_BASE_URLS } from './dto/ai-config.dto';
export function testFailureResult(message: string, now: string) {
return {
success: false,
latencyMs: null,
modelCount: null,
modelAvailable: false,
testedAt: now,
message,
};
}
let _encryptionWarned = false;
export function getEncryptionKey(): Buffer {
const raw = process.env.AI_CONFIG_ENCRYPTION_KEY;
if (!raw) {
if (process.env.NODE_ENV !== 'production') {
if (!_encryptionWarned) {
_encryptionWarned = true;
Logger.warn(
'AI_CONFIG_ENCRYPTION_KEY 未设置,使用开发回退密钥。生产环境必须配置!',
'AiConfigService',
);
}
// 32 hex pairs → 32 bytes
return Buffer.from('ff'.repeat(32), 'hex');
}
throw new InternalServerErrorException('AI_CONFIG_ENCRYPTION_KEY 未配置,无法加解密 API Key');
}
// Hex: exactly 64 hex chars
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
return Buffer.from(raw, 'hex');
}
// Base64: decode then re-encode to normalize padding; reject non-canonical forms
if (/^[A-Za-z0-9+/]+=*$/.test(raw)) {
const buf = Buffer.from(raw, 'base64');
if (buf.length !== 32) {
throw new InternalServerErrorException(
'AI_CONFIG_ENCRYPTION_KEY 格式无效base64 解码后须为 32 字节',
);
}
// Re-encode to canonical base64 (no line breaks) and compare
const canonical = buf.toString('base64');
if (raw !== canonical) {
throw new InternalServerErrorException(
'AI_CONFIG_ENCRYPTION_KEY 格式无效base64 编码须为标准格式(无多余 padding',
);
}
return buf;
}
throw new InternalServerErrorException(
'AI_CONFIG_ENCRYPTION_KEY 格式无效:需为 64 位 hex 或 base64 编码的 32 字节密钥',
);
}
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
export function encrypt(plaintext: string): { ciphertext: string; iv: string; authTag: string } {
const key = getEncryptionKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
ciphertext: encrypted.toString('base64'),
iv: iv.toString('base64'),
authTag: tag.toString('base64'),
};
}
export function decrypt(ciphertextB64: string, ivB64: string, authTagB64: string): string {
const key = getEncryptionKey();
const iv = Buffer.from(ivB64, 'base64');
const authTag = Buffer.from(authTagB64, 'base64');
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(Buffer.from(ciphertextB64, 'base64')),
decipher.final(),
]);
return decrypted.toString('utf-8');
}
const PRIVATE_IPV4_RANGES = [
/^127\./,
/^10\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^169\.254\./,
/^0\./,
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
];
export function isPrivateHost(hostname: string): boolean {
// Strip IPv6 brackets from URL.hostname
if (hostname.startsWith('[') && hostname.endsWith(']')) {
hostname = hostname.slice(1, -1);
}
if (hostname === 'localhost' || hostname === '0.0.0.0') return true;
if (hostname.endsWith('.local')) return true;
if (isIP(hostname) === 6) {
// IPv6 private/loopback
if (hostname === '::1' || hostname === '::') return true;
const lower = hostname.toLowerCase();
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7
if (
lower.startsWith('fe8') ||
lower.startsWith('fe9') ||
lower.startsWith('fea') ||
lower.startsWith('feb')
)
return true; // fe80::/10
// IPv4-mapped IPv6: ::ffff:0:0/96
if (lower.startsWith('::ffff:') && isIP(lower.slice(7)) === 4) {
return PRIVATE_IPV4_RANGES.some((re) => re.test(lower.slice(7)));
}
return false;
}
if (isIP(hostname) === 4) {
return PRIVATE_IPV4_RANGES.some((re) => re.test(hostname));
}
return false;
}
// Known provider hosts — only these are allowed for fixed providers
const PROVIDER_HOSTS: Partial<Record<AiProvider, readonly string[]>> = {
[AiProvider.OPENAI]: ['api.openai.com'],
[AiProvider.DEEPSEEK]: ['api.deepseek.com'],
};
// Required pathname for fixed providers
const PROVIDER_REQUIRED_PATHS: Partial<Record<AiProvider, string>> = {
[AiProvider.OPENAI]: '/v1',
[AiProvider.DEEPSEEK]: '/',
};
// Known public provider hosts — always skip DNS private-IP check.
// Their CDN/proxy nodes may resolve to private-range IPs in certain regions.
const DNS_TRUSTED_HOSTS = new Set(['api.openai.com', 'api.deepseek.com']);
export function validateAndNormalizeBaseUrl(url: string | undefined, provider: AiProvider): string {
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
const raw = url?.trim() || DEFAULT_BASE_URLS[provider];
if (!raw) {
throw new BadRequestException('OPENAI_COMPATIBLE 模式必须提供 baseUrl');
}
// Reject search/query and hash/fragment
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new BadRequestException('请求参数无效');
}
if (parsed.search || parsed.hash) {
throw new BadRequestException('请求参数无效');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new BadRequestException('请求参数无效');
}
if (process.env.NODE_ENV === 'production' && parsed.protocol === 'http:') {
throw new BadRequestException('生产环境禁止使用 http://');
}
if (parsed.username || parsed.password) {
throw new BadRequestException('请求参数无效');
}
const normalized = parsed.origin + parsed.pathname.replace(/\/+$/, '');
// Provider-specific host check
const allowedHosts = PROVIDER_HOSTS[provider];
if (allowedHosts) {
if (!allowedHosts.includes(parsed.hostname)) {
throw new BadRequestException(`${provider} 必须使用固定域名`);
}
// Enforce exact path for fixed providers
const requiredPath = PROVIDER_REQUIRED_PATHS[provider];
if (
requiredPath !== undefined &&
parsed.pathname.replace(/\/+$/, '') !== requiredPath.replace(/\/+$/, '')
) {
throw new BadRequestException(`请求参数无效`);
}
} else {
// OPENAI_COMPATIBLE — SSRF check
if (!allowPrivate && isPrivateHost(parsed.hostname)) {
throw new BadRequestException('不允许使用内网地址');
}
}
return normalized;
}
export async function resolveHostnames(
hostname: string,
): Promise<{ address: string; family: number }[]> {
return new Promise((resolve, reject) => {
lookup(hostname, { all: true, family: 0 }, (err, addresses) => {
if (err) {
reject(err);
return;
}
if (!addresses || addresses.length === 0) {
reject(new Error('DNS 解析返回空结果'));
return;
}
resolve(
addresses.map((a) => ({
address: a.address,
family: a.family,
})),
);
});
});
}
export async function validateDnsNotPrivate(hostname: string): Promise<void> {
// Trusted public provider hosts — skip DNS check (CDN nodes may resolve to private IPs)
if (DNS_TRUSTED_HOSTS.has(hostname)) return;
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
if (allowPrivate) return;
let addresses: { address: string; family: number }[];
try {
addresses = await resolveHostnames(hostname);
} catch {
throw new BadRequestException('无法解析域名');
}
for (const { address } of addresses) {
if (isPrivateHost(address)) {
throw new BadRequestException('域名解析到内网地址');
}
}
}
const MAX_RESPONSE_BYTES = 1_048_576; // 1 MiB
/**
* Perform a pinned HTTP GET request.
* DNS resolves once; the resolved IP is used for connection, preventing DNS rebinding.
* Redirects are forbidden. HTTPS certificate validation is enforced.
*/
export function pinnedGet(
url: string,
headers: Record<string, string>,
timeoutMs: number,
): Promise<{ status: number; contentType: string | null; body: string; latencyMs: number }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const isHttps = parsed.protocol === 'https:';
const port = parsed.port ? parseInt(parsed.port, 10) : isHttps ? 443 : 80;
const hostname = parsed.hostname;
const path = parsed.pathname + parsed.search;
lookup(hostname, { all: true, family: 0 }, (dnsErr, addresses) => {
if (dnsErr || !addresses || addresses.length === 0) {
reject(new Error('DNS 解析失败'));
return;
}
const resolved = addresses.find((a) => !isPrivateHost(a.address));
if (!resolved && process.env.AI_ALLOW_PRIVATE_BASE_URL !== 'true') {
reject(new Error('解析到内网地址'));
return;
}
const targetIp = resolved ? resolved.address : addresses[0].address;
const family = resolved ? resolved.family : addresses[0].family;
const transport = isHttps ? https : http;
const requestStart = Date.now();
const req = transport.request(
{
hostname: targetIp,
port,
path,
method: 'GET',
headers: { ...headers, Host: hostname },
servername: isHttps ? hostname : undefined,
rejectUnauthorized: isHttps,
family: family === 6 ? 6 : 4,
timeout: timeoutMs,
},
(res) => {
const latencyMs = Date.now() - requestStart;
const status = res.statusCode ?? 500;
if (status >= 300 && status < 400 && res.headers.location) {
res.resume();
res.destroy();
return reject(new Error('禁止重定向'));
}
const contentType = res.headers['content-type'] ?? null;
const chunks: Buffer[] = [];
let totalBytes = 0;
res.on('data', (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > MAX_RESPONSE_BYTES) {
res.destroy();
reject(new Error('响应过大'));
return;
}
chunks.push(chunk);
});
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf-8');
resolve({ status, contentType, body, latencyMs });
});
res.on('error', reject);
},
);
req.on('timeout', () => {
req.destroy();
reject(new Error('连接超时'));
});
req.on('error', reject);
req.end();
});
});
}

View File

@@ -0,0 +1,260 @@
import { BadRequestException } from '@nestjs/common';
import { AiConfig } from './ai-config.entity';
import {
pinnedGet,
testFailureResult,
validateAndNormalizeBaseUrl,
validateDnsNotPrivate,
} from './ai-config.helpers';
import type {
AiConfigTestResultDto,
FetchModelsDto,
FetchModelsResultDto,
TestAiConfigDto,
} from './dto/ai-config.dto';
export interface AiConfigProbeContext {
getOrCreateConfig(): Promise<AiConfig>;
resolveApiKey(config: AiConfig | null): {
plaintext: string | null;
source: 'database' | 'environment' | 'none';
};
save(config: AiConfig): Promise<AiConfig>;
}
export async function testConnection(
context: AiConfigProbeContext,
dto?: TestAiConfigDto,
): Promise<AiConfigTestResultDto> {
const config = await context.getOrCreateConfig();
const now = new Date().toISOString();
// Determine effective provider / baseUrl
const provider = dto?.provider ?? config.provider;
const rawBaseUrl = dto?.baseUrl ?? config.baseUrl;
let baseUrl: string;
try {
baseUrl = validateAndNormalizeBaseUrl(rawBaseUrl, provider);
} catch (err: unknown) {
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
return testFailureResult(message, now);
}
// Determine effective defaultModel
const effectiveDefaultModel = dto?.defaultModel ?? config.defaultModel ?? '';
// DNS check
try {
await validateDnsNotPrivate(new URL(baseUrl).hostname);
} catch (err: unknown) {
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
return testFailureResult(message, now);
}
// Determine API key
let apiKey: string;
if (dto?.apiKey) {
apiKey = dto.apiKey;
} else {
const { plaintext } = context.resolveApiKey(config);
if (!plaintext) {
return {
success: false,
latencyMs: null,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '未配置 API Key',
};
}
apiKey = plaintext;
}
const timeoutMs = dto?.timeoutMs ?? config.timeoutMs;
let result: AiConfigTestResultDto;
try {
const { status, contentType, body, latencyMs } = await pinnedGet(
`${baseUrl}/models`,
{ Authorization: `Bearer ${apiKey}` },
timeoutMs,
);
// Classify by HTTP status first, then content-type
if (status === 401 || status === 403) {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '认证失败,请检查 API Key',
};
} else if (status >= 500) {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '服务不可用',
};
} else if (status >= 400) {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: `服务返回错误状态 ${status}`,
};
} else if (!contentType || !contentType.includes('application/json')) {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '响应格式无效',
};
} else {
let data: { data?: Array<{ id: string }> };
try {
const parsed: unknown = JSON.parse(body);
if (!parsed || typeof parsed !== 'object') throw new Error('invalid');
data = parsed;
} catch {
config.lastTestedAt = new Date();
config.lastTestLatencyMs = latencyMs;
config.verified = false;
await context.save(config);
return {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '响应格式无效',
};
}
const models = Array.isArray(data?.data) ? data.data : [];
const modelCount = models.length;
const modelAvailable =
!effectiveDefaultModel || models.some((m) => m.id === effectiveDefaultModel);
const message = modelAvailable
? `连接成功,目标模型 "${effectiveDefaultModel}" 可用`
: effectiveDefaultModel
? '连接成功,但未找到目标模型'
: models.length > 0
? `连接成功,可用模型 ${models.length}`
: '连接成功,但未返回可用模型';
result = {
success: true,
latencyMs,
modelCount,
modelAvailable,
testedAt: now,
message,
};
}
} catch (err: unknown) {
const message =
err instanceof Error
? err.message === '连接超时'
? '连接超时'
: err.message === '响应过大'
? '响应过大'
: err.message === '禁止重定向'
? '连接失败,请检查 Base URL'
: '连接失败,请检查 Base URL'
: '连接失败,请检查 Base URL';
result = {
success: false,
latencyMs: null,
modelCount: null,
modelAvailable: false,
testedAt: now,
message,
};
}
config.lastTestedAt = new Date();
config.lastTestLatencyMs = result.latencyMs;
config.verified = result.success;
await context.save(config);
return result;
}
export async function fetchModels(
context: AiConfigProbeContext,
dto?: FetchModelsDto,
): Promise<FetchModelsResultDto> {
const config = await context.getOrCreateConfig();
const provider = dto?.provider ?? config.provider;
const rawBaseUrl = dto?.baseUrl ?? config.baseUrl;
let baseUrl: string;
try {
baseUrl = validateAndNormalizeBaseUrl(rawBaseUrl, provider);
} catch (err: unknown) {
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
return { success: false, models: [], message };
}
// DNS SSRF check
try {
await validateDnsNotPrivate(new URL(baseUrl).hostname);
} catch (err: unknown) {
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
return { success: false, models: [], message };
}
// Determine API key
let apiKey: string;
if (dto?.apiKey) {
apiKey = dto.apiKey;
} else {
const { plaintext } = context.resolveApiKey(config);
if (!plaintext) {
return { success: false, models: [], message: '未配置 API Key' };
}
apiKey = plaintext;
}
const timeoutMs = dto?.timeoutMs ?? config.timeoutMs;
try {
const { status, contentType, body } = await pinnedGet(
`${baseUrl}/models`,
{ Authorization: `Bearer ${apiKey}` },
timeoutMs,
);
if (status === 401 || status === 403) {
return { success: false, models: [], message: '认证失败,请检查 API Key' };
}
if (status >= 500) {
return { success: false, models: [], message: '服务不可用' };
}
if (status >= 400) {
return { success: false, models: [], message: `服务返回错误状态 ${status}` };
}
if (!contentType || !contentType.includes('application/json')) {
return { success: false, models: [], message: '响应格式无效' };
}
const parsed: unknown = JSON.parse(body);
if (!parsed || typeof parsed !== 'object') {
return { success: false, models: [], message: '响应格式无效' };
}
const data = parsed as { data?: Array<{ id: string }> };
const models = Array.isArray(data?.data) ? data.data : [];
return { success: true, models };
} catch {
return { success: false, models: [], message: '获取模型列表失败,请检查配置' };
}
}

View File

@@ -1,379 +1,28 @@
import {
Injectable,
Logger,
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import { lookup } from 'node:dns';
import { isIP } from 'node:net';
import * as http from 'node:http';
import * as https from 'node:https';
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
import {
SaveAiConfigDto,
TestAiConfigDto,
FetchModelsDto,
FetchModelsResultDto,
AiConfigResponseDto,
AiConfigTestResultDto,
AiRuntimeConfig,
DEFAULT_BASE_URLS,
FetchModelsDto,
FetchModelsResultDto,
SaveAiConfigDto,
TestAiConfigDto,
AiConfigTestResultDto,
} from './dto/ai-config.dto';
// ---------------------------------------------------------------------------
// Key derivation
// ---------------------------------------------------------------------------
let _encryptionWarned = false;
function getEncryptionKey(): Buffer {
const raw = process.env.AI_CONFIG_ENCRYPTION_KEY;
if (!raw) {
if (process.env.NODE_ENV !== 'production') {
if (!_encryptionWarned) {
_encryptionWarned = true;
Logger.warn(
'AI_CONFIG_ENCRYPTION_KEY 未设置,使用开发回退密钥。生产环境必须配置!',
'AiConfigService',
);
}
// 32 hex pairs → 32 bytes
return Buffer.from('ff'.repeat(32), 'hex');
}
throw new InternalServerErrorException('AI_CONFIG_ENCRYPTION_KEY 未配置,无法加解密 API Key');
}
// Hex: exactly 64 hex chars
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
return Buffer.from(raw, 'hex');
}
// Base64: decode then re-encode to normalize padding; reject non-canonical forms
if (/^[A-Za-z0-9+/]+=*$/.test(raw)) {
const buf = Buffer.from(raw, 'base64');
if (buf.length !== 32) {
throw new InternalServerErrorException(
'AI_CONFIG_ENCRYPTION_KEY 格式无效base64 解码后须为 32 字节',
);
}
// Re-encode to canonical base64 (no line breaks) and compare
const canonical = buf.toString('base64');
if (raw !== canonical) {
throw new InternalServerErrorException(
'AI_CONFIG_ENCRYPTION_KEY 格式无效base64 编码须为标准格式(无多余 padding',
);
}
return buf;
}
throw new InternalServerErrorException(
'AI_CONFIG_ENCRYPTION_KEY 格式无效:需为 64 位 hex 或 base64 编码的 32 字节密钥',
);
}
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
function encrypt(plaintext: string): { ciphertext: string; iv: string; authTag: string } {
const key = getEncryptionKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
ciphertext: encrypted.toString('base64'),
iv: iv.toString('base64'),
authTag: tag.toString('base64'),
};
}
function decrypt(ciphertextB64: string, ivB64: string, authTagB64: string): string {
const key = getEncryptionKey();
const iv = Buffer.from(ivB64, 'base64');
const authTag = Buffer.from(authTagB64, 'base64');
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(Buffer.from(ciphertextB64, 'base64')),
decipher.final(),
]);
return decrypted.toString('utf-8');
}
// ---------------------------------------------------------------------------
// URL / SSRF helpers
// ---------------------------------------------------------------------------
const PRIVATE_IPV4_RANGES = [
/^127\./,
/^10\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^169\.254\./,
/^0\./,
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
];
function isPrivateHost(hostname: string): boolean {
// Strip IPv6 brackets from URL.hostname
if (hostname.startsWith('[') && hostname.endsWith(']')) {
hostname = hostname.slice(1, -1);
}
if (hostname === 'localhost' || hostname === '0.0.0.0') return true;
if (hostname.endsWith('.local')) return true;
if (isIP(hostname) === 6) {
// IPv6 private/loopback
if (hostname === '::1' || hostname === '::') return true;
const lower = hostname.toLowerCase();
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7
if (
lower.startsWith('fe8') ||
lower.startsWith('fe9') ||
lower.startsWith('fea') ||
lower.startsWith('feb')
)
return true; // fe80::/10
// IPv4-mapped IPv6: ::ffff:0:0/96
if (lower.startsWith('::ffff:') && isIP(lower.slice(7)) === 4) {
return PRIVATE_IPV4_RANGES.some((re) => re.test(lower.slice(7)));
}
return false;
}
if (isIP(hostname) === 4) {
return PRIVATE_IPV4_RANGES.some((re) => re.test(hostname));
}
return false;
}
// Known provider hosts — only these are allowed for fixed providers
const PROVIDER_HOSTS: Partial<Record<AiProvider, readonly string[]>> = {
[AiProvider.OPENAI]: ['api.openai.com'],
[AiProvider.DEEPSEEK]: ['api.deepseek.com'],
};
// Required pathname for fixed providers
const PROVIDER_REQUIRED_PATHS: Partial<Record<AiProvider, string>> = {
[AiProvider.OPENAI]: '/v1',
[AiProvider.DEEPSEEK]: '/',
};
// Known public provider hosts — always skip DNS private-IP check.
// Their CDN/proxy nodes may resolve to private-range IPs in certain regions.
const DNS_TRUSTED_HOSTS = new Set([
'api.openai.com',
'api.deepseek.com',
]);
function validateAndNormalizeBaseUrl(url: string | undefined, provider: AiProvider): string {
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
const raw = url?.trim() || DEFAULT_BASE_URLS[provider];
if (!raw) {
throw new BadRequestException('OPENAI_COMPATIBLE 模式必须提供 baseUrl');
}
// Reject search/query and hash/fragment
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new BadRequestException('请求参数无效');
}
if (parsed.search || parsed.hash) {
throw new BadRequestException('请求参数无效');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new BadRequestException('请求参数无效');
}
if (process.env.NODE_ENV === 'production' && parsed.protocol === 'http:') {
throw new BadRequestException('生产环境禁止使用 http://');
}
if (parsed.username || parsed.password) {
throw new BadRequestException('请求参数无效');
}
const normalized = parsed.origin + parsed.pathname.replace(/\/+$/, '');
// Provider-specific host check
const allowedHosts = PROVIDER_HOSTS[provider];
if (allowedHosts) {
if (!allowedHosts.includes(parsed.hostname)) {
throw new BadRequestException(`${provider} 必须使用固定域名`);
}
// Enforce exact path for fixed providers
const requiredPath = PROVIDER_REQUIRED_PATHS[provider];
if (
requiredPath !== undefined &&
parsed.pathname.replace(/\/+$/, '') !== requiredPath.replace(/\/+$/, '')
) {
throw new BadRequestException(`请求参数无效`);
}
} else {
// OPENAI_COMPATIBLE — SSRF check
if (!allowPrivate && isPrivateHost(parsed.hostname)) {
throw new BadRequestException('不允许使用内网地址');
}
}
return normalized;
}
async function resolveHostnames(hostname: string): Promise<{ address: string; family: number }[]> {
return new Promise((resolve, reject) => {
lookup(hostname, { all: true, family: 0 }, (err, addresses) => {
if (err) {
reject(err);
return;
}
if (!addresses || addresses.length === 0) {
reject(new Error('DNS 解析返回空结果'));
return;
}
resolve(
addresses.map((a) => ({
address: a.address,
family: a.family,
})),
);
});
});
}
async function validateDnsNotPrivate(hostname: string): Promise<void> {
// Trusted public provider hosts — skip DNS check (CDN nodes may resolve to private IPs)
if (DNS_TRUSTED_HOSTS.has(hostname)) return;
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
if (allowPrivate) return;
let addresses: { address: string; family: number }[];
try {
addresses = await resolveHostnames(hostname);
} catch {
throw new BadRequestException('无法解析域名');
}
for (const { address } of addresses) {
if (isPrivateHost(address)) {
throw new BadRequestException('域名解析到内网地址');
}
}
}
// ---------------------------------------------------------------------------
// Connection test — uses node:http/https with DNS pinning to prevent rebinding
// ---------------------------------------------------------------------------
const MAX_RESPONSE_BYTES = 1_048_576; // 1 MiB
/**
* Perform a pinned HTTP GET request.
* DNS resolves once; the resolved IP is used for connection, preventing DNS rebinding.
* Redirects are forbidden. HTTPS certificate validation is enforced.
*/
function pinnedGet(
url: string,
headers: Record<string, string>,
timeoutMs: number,
): Promise<{ status: number; contentType: string | null; body: string; latencyMs: number }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const isHttps = parsed.protocol === 'https:';
const port = parsed.port ? parseInt(parsed.port, 10) : isHttps ? 443 : 80;
const hostname = parsed.hostname;
const path = parsed.pathname + parsed.search;
lookup(hostname, { all: true, family: 0 }, (dnsErr, addresses) => {
if (dnsErr || !addresses || addresses.length === 0) {
reject(new Error('DNS 解析失败'));
return;
}
const resolved = addresses.find((a) => !isPrivateHost(a.address));
if (!resolved && process.env.AI_ALLOW_PRIVATE_BASE_URL !== 'true') {
reject(new Error('解析到内网地址'));
return;
}
const targetIp = resolved ? resolved.address : addresses[0].address;
const family = resolved ? resolved.family : addresses[0].family;
const transport = isHttps ? https : http;
const requestStart = Date.now();
const req = transport.request(
{
hostname: targetIp,
port,
path,
method: 'GET',
headers: { ...headers, Host: hostname },
servername: isHttps ? hostname : undefined,
rejectUnauthorized: isHttps,
family: family === 6 ? 6 : 4,
timeout: timeoutMs,
},
(res) => {
const latencyMs = Date.now() - requestStart;
const status = res.statusCode ?? 500;
if (status >= 300 && status < 400 && res.headers.location) {
res.resume();
res.destroy();
return reject(new Error('禁止重定向'));
}
const contentType = res.headers['content-type'] ?? null;
const chunks: Buffer[] = [];
let totalBytes = 0;
res.on('data', (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > MAX_RESPONSE_BYTES) {
res.destroy();
reject(new Error('响应过大'));
return;
}
chunks.push(chunk);
});
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf-8');
resolve({ status, contentType, body, latencyMs });
});
res.on('error', reject);
},
);
req.on('timeout', () => {
req.destroy();
reject(new Error('连接超时'));
});
req.on('error', reject);
req.end();
});
});
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
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 {
export class AiConfigService implements AiConfigProbeContext {
private readonly logger = new Logger(AiConfigService.name);
constructor(
@@ -381,8 +30,12 @@ export class AiConfigService {
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 */
private resolveApiKey(config: AiConfig | null): {
resolveApiKey(config: AiConfig | null): {
plaintext: string | null;
source: 'database' | 'environment' | 'none';
} {
@@ -495,7 +148,6 @@ export class AiConfigService {
async saveConfig(dto: SaveAiConfigDto): Promise<AiConfig> {
const config = await this.getOrCreateConfig();
// Validate and normalize baseUrl
const normalizedBaseUrl = validateAndNormalizeBaseUrl(dto.baseUrl, dto.provider);
// DNS SSRF check for all providers
@@ -566,251 +218,12 @@ export class AiConfigService {
/** Test connection — uses saved config or request body overrides */
async testConnection(dto?: TestAiConfigDto): Promise<AiConfigTestResultDto> {
const config = await this.getOrCreateConfig();
const now = new Date().toISOString();
// Determine effective provider / baseUrl
const provider = dto?.provider ?? config.provider;
const rawBaseUrl = dto?.baseUrl ?? config.baseUrl;
let baseUrl: string;
try {
baseUrl = validateAndNormalizeBaseUrl(rawBaseUrl, provider);
} catch (err: unknown) {
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
return {
success: false,
latencyMs: null,
modelCount: null,
modelAvailable: false,
testedAt: now,
message,
};
}
// Determine effective defaultModel
const effectiveDefaultModel = dto?.defaultModel ?? config.defaultModel ?? '';
// DNS check
try {
await validateDnsNotPrivate(new URL(baseUrl).hostname);
} catch (err: unknown) {
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
return {
success: false,
latencyMs: null,
modelCount: null,
modelAvailable: false,
testedAt: now,
message,
};
}
// Determine API key
let apiKey: string;
if (dto?.apiKey) {
apiKey = dto.apiKey;
} else {
const { plaintext } = this.resolveApiKey(config);
if (!plaintext) {
return {
success: false,
latencyMs: null,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '未配置 API Key',
};
}
apiKey = plaintext;
}
const timeoutMs = dto?.timeoutMs ?? config.timeoutMs;
let result: AiConfigTestResultDto;
try {
const { status, contentType, body, latencyMs } = await pinnedGet(
`${baseUrl}/models`,
{ Authorization: `Bearer ${apiKey}` },
timeoutMs,
);
// Classify by HTTP status first, then content-type
if (status === 401 || status === 403) {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '认证失败,请检查 API Key',
};
} else if (status >= 500) {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '服务不可用',
};
} else if (status >= 400) {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: `服务返回错误状态 ${status}`,
};
} else if (!contentType || !contentType.includes('application/json')) {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '响应格式无效',
};
} else {
let data: { data?: Array<{ id: string }> };
try {
const parsed: unknown = JSON.parse(body);
if (!parsed || typeof parsed !== 'object') throw new Error('invalid');
data = parsed;
} catch {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '响应格式无效',
};
config.lastTestedAt = new Date();
config.lastTestLatencyMs = latencyMs;
config.verified = false;
await this.repo.save(config);
return result;
}
const models = Array.isArray(data?.data) ? data.data : [];
const modelCount = models.length;
const modelAvailable =
!effectiveDefaultModel || models.some((m) => m.id === effectiveDefaultModel);
const message = modelAvailable
? `连接成功,目标模型 "${effectiveDefaultModel}" 可用`
: effectiveDefaultModel
? '连接成功,但未找到目标模型'
: models.length > 0
? `连接成功,可用模型 ${models.length}`
: '连接成功,但未返回可用模型';
result = {
success: true,
latencyMs,
modelCount,
modelAvailable,
testedAt: now,
message,
};
}
} catch (err: unknown) {
const message =
err instanceof Error
? err.message === '连接超时'
? '连接超时'
: err.message === '响应过大'
? '响应过大'
: err.message === '禁止重定向'
? '连接失败,请检查 Base URL'
: '连接失败,请检查 Base URL'
: '连接失败,请检查 Base URL';
result = {
success: false,
latencyMs: null,
modelCount: null,
modelAvailable: false,
testedAt: now,
message,
};
}
// Update last tested info on config
config.lastTestedAt = new Date();
config.lastTestLatencyMs = result.latencyMs;
config.verified = result.success;
await this.repo.save(config);
return result;
return testConnection(this, dto);
}
/** Fetch available model list from the configured provider */
async fetchModels(dto?: FetchModelsDto): Promise<FetchModelsResultDto> {
const config = await this.getOrCreateConfig();
const provider = dto?.provider ?? config.provider;
const rawBaseUrl = dto?.baseUrl ?? config.baseUrl;
let baseUrl: string;
try {
baseUrl = validateAndNormalizeBaseUrl(rawBaseUrl, provider);
} catch (err: unknown) {
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
return { success: false, models: [], message };
}
// DNS SSRF check
try {
await validateDnsNotPrivate(new URL(baseUrl).hostname);
} catch (err: unknown) {
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
return { success: false, models: [], message };
}
// Determine API key
let apiKey: string;
if (dto?.apiKey) {
apiKey = dto.apiKey;
} else {
const { plaintext } = this.resolveApiKey(config);
if (!plaintext) {
return { success: false, models: [], message: '未配置 API Key' };
}
apiKey = plaintext;
}
const timeoutMs = dto?.timeoutMs ?? config.timeoutMs;
try {
const { status, contentType, body } = await pinnedGet(
`${baseUrl}/models`,
{ Authorization: `Bearer ${apiKey}` },
timeoutMs,
);
if (status === 401 || status === 403) {
return { success: false, models: [], message: '认证失败,请检查 API Key' };
}
if (status >= 500) {
return { success: false, models: [], message: '服务不可用' };
}
if (status >= 400) {
return { success: false, models: [], message: `服务返回错误状态 ${status}` };
}
if (!contentType || !contentType.includes('application/json')) {
return { success: false, models: [], message: '响应格式无效' };
}
const parsed: unknown = JSON.parse(body);
if (!parsed || typeof parsed !== 'object') {
return { success: false, models: [], message: '响应格式无效' };
}
const data = parsed as { data?: Array<{ id: string }> };
const models = Array.isArray(data?.data) ? data.data : [];
return { success: true, models };
} catch {
return { success: false, models: [], message: '获取模型列表失败,请检查配置' };
}
return fetchModels(this, dto);
}
/**

View File

@@ -15,7 +15,9 @@ const PROVIDERS = [AiProvider.OPENAI, AiProvider.DEEPSEEK, AiProvider.OPENAI_COM
export const REASONING_EFFORT_LEVELS = ['none', 'low', 'medium', 'high', 'xhigh'] as const;
const DEFAULT_BASE_URLS: Record<AiProvider, string> = {
// aislop-ignore-next-line: hardcoded-url -- OpenAI 官方 API 固定端点
[AiProvider.OPENAI]: 'https://api.openai.com/v1',
// aislop-ignore-next-line: hardcoded-url -- DeepSeek 官方 API 固定端点
[AiProvider.DEEPSEEK]: 'https://api.deepseek.com',
[AiProvider.OPENAI_COMPATIBLE]: '',
};