feat: add CASL authorization and AI configuration

This commit is contained in:
2026-07-11 14:25:34 +08:00
parent 8f0991a51f
commit 1e1c476bc3
59 changed files with 7733 additions and 120 deletions

View File

@@ -0,0 +1,302 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AiConfigController } from './ai-config.controller';
import { AiConfigService } from './ai-config.service';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { AiProvider } from './ai-config.entity';
describe('AiConfigController', () => {
let controller: AiConfigController;
let service: jest.Mocked<Pick<AiConfigService, 'getConfig' | 'saveConfig' | 'testConnection' | 'clearKey'>>;
let opLog: jest.Mocked<Pick<OperationLogsService, 'log'>>;
const mockConfig = {
id: 1,
provider: AiProvider.OPENAI,
baseUrl: 'https://api.openai.com/v1',
hasApiKey: true,
hasDatabaseKey: true,
maskedApiKey: '••••1234',
keySource: 'database' as const,
defaultModel: 'gpt-4',
enabled: true,
timeoutMs: 30000,
verified: true,
lastTestedAt: '2024-01-01T00:00:00.000Z',
lastTestLatencyMs: 250,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
const mockReq = {
user: { id: 1, username: 'admin' },
headers: { 'user-agent': 'test', 'x-forwarded-for': '1.2.3.4' },
connection: { remoteAddress: '1.2.3.4' },
};
beforeEach(async () => {
service = {
getConfig: jest.fn(),
saveConfig: jest.fn(),
testConnection: jest.fn(),
clearKey: jest.fn(),
};
opLog = {
log: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AiConfigController],
providers: [
{ provide: AiConfigService, useValue: service },
{ provide: OperationLogsService, useValue: opLog },
],
}).compile();
controller = module.get<AiConfigController>(AiConfigController);
});
// ── GET /ai/config ────────────────────────────────────────────────────
describe('GET /ai/config', () => {
it('returns config with success wrapper', async () => {
service.getConfig.mockResolvedValue(mockConfig);
const result = await controller.getConfig();
expect(result.success).toBe(true);
expect(result.data).toEqual(mockConfig);
});
it('calls service.getConfig', async () => {
service.getConfig.mockResolvedValue(mockConfig);
await controller.getConfig();
expect(service.getConfig).toHaveBeenCalledTimes(1);
});
});
// ── PUT /ai/config ────────────────────────────────────────────────────
describe('PUT /ai/config', () => {
const saveDto = {
provider: AiProvider.OPENAI,
apiKey: 'sk-new-key',
enabled: true,
};
it('saves config and logs operation', async () => {
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
service.saveConfig.mockResolvedValue(saved);
const result = await controller.saveConfig(saveDto, mockReq);
expect(result.success).toBe(true);
expect(opLog.log).toHaveBeenCalledWith(
expect.objectContaining({
module: 'ai-config',
action: 'save',
userId: 1,
username: 'admin',
detail: expect.stringContaining('provider=OPENAI'),
}),
);
});
it('operation log detail does NOT contain apiKey', async () => {
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
service.saveConfig.mockResolvedValue(saved);
await controller.saveConfig(saveDto, mockReq);
const logCall = opLog.log.mock.calls[0][0];
expect(logCall.detail).not.toContain('sk-new-key');
expect(logCall.detail).not.toContain(saveDto.apiKey);
});
it('operation log detail does NOT contain full baseUrl', async () => {
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
service.saveConfig.mockResolvedValue(saved);
await controller.saveConfig(saveDto, mockReq);
const logCall = opLog.log.mock.calls[0][0];
expect(logCall.detail).not.toContain('api.openai.com/v1');
// Only hostname should be present
expect(logCall.detail).toContain('host=api.openai.com');
});
it('operation log detail logs model as configured/not-set not raw value', async () => {
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
service.saveConfig.mockResolvedValue(saved);
await controller.saveConfig(
{ provider: AiProvider.OPENAI, defaultModel: 'gpt-4' },
mockReq,
);
const logCall = opLog.log.mock.calls[0][0];
expect(logCall.detail).toContain('model=configured');
expect(logCall.detail).not.toContain('gpt-4');
});
it('operation log detail logs model=not-set when no defaultModel', async () => {
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1' } as any;
service.saveConfig.mockResolvedValue(saved);
await controller.saveConfig(
{ provider: AiProvider.OPENAI },
mockReq,
);
const logCall = opLog.log.mock.calls[0][0];
expect(logCall.detail).toContain('model=not-set');
});
});
// ── POST /ai/config/test ──────────────────────────────────────────────
describe('POST /ai/config/test', () => {
const testDto = { provider: AiProvider.OPENAI };
const testResult = {
success: true,
latencyMs: 200,
modelCount: 5,
modelAvailable: true,
testedAt: '2024-01-01T00:00:00.000Z',
message: '连接成功',
};
it('returns test result and logs operation', async () => {
service.testConnection.mockResolvedValue(testResult);
const result = await controller.testConnection(testDto, mockReq);
expect(result).toEqual(testResult);
expect(opLog.log).toHaveBeenCalledWith(
expect.objectContaining({
module: 'ai-config',
action: 'test',
detail: expect.stringContaining('success=true'),
status: 'success',
}),
);
});
it('logs status=failure on test failure', async () => {
const failResult = { ...testResult, success: false, message: '认证失败' };
service.testConnection.mockResolvedValue(failResult);
await controller.testConnection(testDto, mockReq);
expect(opLog.log).toHaveBeenCalledWith(
expect.objectContaining({
status: 'failure',
}),
);
});
it('operation log detail does NOT contain sensitive info', async () => {
const dtoWithKey = { provider: AiProvider.OPENAI, apiKey: 'sk-secret-key' };
service.testConnection.mockResolvedValue(testResult);
await controller.testConnection(dtoWithKey, mockReq);
const logCall = opLog.log.mock.calls[0][0];
expect(logCall.detail).not.toContain('sk-secret-key');
expect(logCall.detail).not.toContain('Authorization');
});
it('operation log detail does NOT contain modelCount', async () => {
service.testConnection.mockResolvedValue(testResult);
await controller.testConnection(testDto, mockReq);
const logCall = opLog.log.mock.calls[0][0];
expect(logCall.detail).not.toContain('modelCount');
});
});
// ── POST /ai/config/clear-key ─────────────────────────────────────────
describe('POST /ai/config/clear-key', () => {
it('clears key and logs operation', async () => {
service.clearKey.mockResolvedValue({
...mockConfig,
hasApiKey: false,
hasDatabaseKey: false,
maskedApiKey: null,
keySource: 'none',
});
const result = await controller.clearKey(mockReq);
expect(result.success).toBe(true);
expect(result.data.keySource).toBe('none');
expect(result.data.hasDatabaseKey).toBe(false);
expect(opLog.log).toHaveBeenCalledWith(
expect.objectContaining({
module: 'ai-config',
action: 'clear-key',
}),
);
});
});
// ── Permission decorators ─────────────────────────────────────────────
describe('route permissions', () => {
it('GET /ai/config requires ai:config:read', () => {
const permissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.getConfig,
);
expect(permissions).toContain('ai:config:read');
});
it('PUT /ai/config requires ai:config:write', () => {
const permissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.saveConfig,
);
expect(permissions).toContain('ai:config:write');
});
it('POST /ai/config/test requires ai:config:test', () => {
const permissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.testConnection,
);
expect(permissions).toContain('ai:config:test');
});
it('POST /ai/config/clear-key requires ai:config:write', () => {
const permissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.clearKey,
);
expect(permissions).toContain('ai:config:write');
});
it('read permission cannot write', () => {
const getPermissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.getConfig,
);
const savePermissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.saveConfig,
);
expect(getPermissions).not.toEqual(savePermissions);
});
it('write and test permissions are distinct', () => {
const writePermissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.saveConfig,
);
const testPermissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.testConnection,
);
expect(writePermissions).not.toEqual(testPermissions);
});
});
});

View File

@@ -0,0 +1,93 @@
import {
Controller,
Get,
Put,
Post,
Body,
UseGuards,
Req,
} from '@nestjs/common';
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 { AiConfigService } from './ai-config.service';
import { SaveAiConfigDto, TestAiConfigDto } from './dto/ai-config.dto';
interface AuthenticatedRequest {
user?: { id: number; username: string };
headers: Record<string, string | string[] | undefined>;
connection?: { remoteAddress?: string };
}
@Controller('ai/config')
@UseGuards(JwtAuthGuard)
export class AiConfigController {
constructor(
private readonly service: AiConfigService,
private readonly opLog: OperationLogsService,
) {}
@Get()
@RequirePermission('ai:config:read')
async getConfig() {
const data = await this.service.getConfig();
return { success: true, data };
}
@Put()
@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'} enabled=${body.enabled ?? config.enabled}`,
ipAddress,
userAgent,
});
return { success: true, message: '配置已保存' };
}
@Post('test')
@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',
});
return result;
}
@Post('clear-key')
@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,
});
return { success: true, message: '密钥已清除', data };
}
}

View File

@@ -0,0 +1,68 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum AiProvider {
OPENAI = 'OPENAI',
DEEPSEEK = 'DEEPSEEK',
OPENAI_COMPATIBLE = 'OPENAI_COMPATIBLE',
}
export const SINGLETON_KEY = 'GLOBAL';
@Entity('ai_config')
@Index('uq_ai_config_singleton', ['singletonKey'], { unique: true })
export class AiConfig {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'singleton_key', type: 'varchar', length: 20, default: SINGLETON_KEY })
singletonKey: string;
@Column({ type: 'varchar', length: 50, default: AiProvider.OPENAI })
provider: AiProvider;
@Column({ name: 'base_url', type: 'varchar', length: 500, nullable: true })
baseUrl: string;
@Column({ name: 'encrypted_api_key', type: 'text', nullable: true })
encryptedApiKey: string | null;
@Column({ name: 'api_key_iv', type: 'varchar', length: 50, nullable: true })
apiKeyIv: string | null;
@Column({ name: 'api_key_auth_tag', type: 'varchar', length: 50, nullable: true })
apiKeyAuthTag: string | null;
@Column({ name: 'key_last4', type: 'varchar', length: 4, nullable: true })
keyLast4: string | null;
@Column({ name: 'default_model', type: 'varchar', length: 100, nullable: true })
defaultModel: string | null;
@Column({ type: 'boolean', default: false })
enabled: boolean;
@Column({ name: 'timeout_ms', type: 'int', default: 30000 })
timeoutMs: number;
@Column({ type: 'boolean', default: false })
verified: boolean;
@Column({ name: 'last_tested_at', type: 'datetime', nullable: true })
lastTestedAt: Date | null;
@Column({ name: 'last_test_latency_ms', type: 'int', nullable: true })
lastTestLatencyMs: number | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiConfig } from './ai-config.entity';
import { AiConfigService } from './ai-config.service';
import { AiConfigController } from './ai-config.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([AiConfig]), OperationLogsModule],
controllers: [AiConfigController],
providers: [AiConfigService],
exports: [AiConfigService],
})
export class AiConfigModule {}

View File

@@ -0,0 +1,781 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
randomBytes,
} from 'node:crypto';
import { AiConfigService } from './ai-config.service';
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
// ---------------------------------------------------------------------------
// Helpers for testing encryption directly
// ---------------------------------------------------------------------------
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
function encryptWithKey(key: Buffer, plaintext: string) {
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 decryptWithKey(
key: Buffer,
ciphertextB64: string,
ivB64: string,
authTagB64: string,
): string {
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');
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AiConfigService', () => {
let service: AiConfigService;
let repo: jest.Mocked<Pick<Repository<AiConfig>, 'findOne' | 'save' | 'create'>>;
// Use a known encryption key so tests are deterministic
const TEST_KEY_BYTES_32 = Buffer.alloc(32, 'a'); // 32 bytes of 'a'
const TEST_KEY_HEX = TEST_KEY_BYTES_32.toString('hex'); // 64 hex chars
function makeConfig(overrides: Partial<AiConfig> = {}): AiConfig {
return {
id: 1,
singletonKey: SINGLETON_KEY,
provider: AiProvider.OPENAI,
baseUrl: 'https://api.openai.com/v1',
encryptedApiKey: null,
apiKeyIv: null,
apiKeyAuthTag: null,
keyLast4: null,
defaultModel: null,
enabled: false,
timeoutMs: 30000,
verified: false,
lastTestedAt: null,
lastTestLatencyMs: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
beforeEach(async () => {
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
delete process.env.AI_API_KEY;
repo = {
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
service = module.get<AiConfigService>(AiConfigService);
});
afterEach(() => {
delete process.env.AI_CONFIG_ENCRYPTION_KEY;
delete process.env.AI_API_KEY;
});
// ── Encryption ────────────────────────────────────────────────────────
describe('encryption', () => {
it('roundtrip: encrypt then decrypt returns original text', () => {
const plaintext = 'sk-test-key-1234567890abcdef';
const { ciphertext, iv, authTag } = encryptWithKey(TEST_KEY_BYTES_32, plaintext);
const decrypted = decryptWithKey(TEST_KEY_BYTES_32, ciphertext, iv, authTag);
expect(decrypted).toBe(plaintext);
});
it('random IV: same key + same plaintext produces different ciphertexts', () => {
const plaintext = 'sk-test-key-1234567890abcdef';
const key = TEST_KEY_BYTES_32;
const r1 = encryptWithKey(key, plaintext);
const r2 = encryptWithKey(key, plaintext);
expect(r1.iv).not.toBe(r2.iv);
expect(r1.ciphertext).not.toBe(r2.ciphertext);
expect(decryptWithKey(key, r1.ciphertext, r1.iv, r1.authTag)).toBe(plaintext);
expect(decryptWithKey(key, r2.ciphertext, r2.iv, r2.authTag)).toBe(plaintext);
});
it('wrong key fails decryption', () => {
const plaintext = 'sk-test-key-1234567890abcdef';
const correctKey = TEST_KEY_BYTES_32;
const wrongKey = Buffer.alloc(32, 'b');
const { ciphertext, iv, authTag } = encryptWithKey(correctKey, plaintext);
expect(() =>
decryptWithKey(wrongKey, ciphertext, iv, authTag),
).toThrow();
});
it('tampered auth tag fails decryption', () => {
const plaintext = 'sk-test-key-1234567890abcdef';
const key = TEST_KEY_BYTES_32;
const { ciphertext, iv, authTag } = encryptWithKey(key, plaintext);
const tamperedTag = Buffer.from(authTag, 'base64');
tamperedTag[0] ^= 1;
expect(() =>
decryptWithKey(key, ciphertext, iv, tamperedTag.toString('base64')),
).toThrow();
});
});
// ── Encryption key validation ─────────────────────────────────────────
describe('encryption key validation', () => {
it('rejects plain 32-char string (not hex or base64)', async () => {
process.env.AI_CONFIG_ENCRYPTION_KEY = 'a'.repeat(32);
repo.findOne.mockResolvedValue(makeConfig());
// Re-create service with new key
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
await expect(
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('rejects invalid base64 input', async () => {
process.env.AI_CONFIG_ENCRYPTION_KEY = '!!!invalid!!!';
repo.findOne.mockResolvedValue(makeConfig());
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
await expect(
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('accepts valid base64 32-byte key', async () => {
const b64key = TEST_KEY_BYTES_32.toString('base64');
process.env.AI_CONFIG_ENCRYPTION_KEY = b64key;
const existing = makeConfig();
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
const result = await svc.saveConfig({ provider: AiProvider.OPENAI });
expect(result).toBeDefined();
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('rejects non-canonical base64 (extra padding)', async () => {
const canonicalB64 = TEST_KEY_BYTES_32.toString('base64');
// Non-canonical: add extra padding
const badB64 = canonicalB64 + '==';
process.env.AI_CONFIG_ENCRYPTION_KEY = badB64;
repo.findOne.mockResolvedValue(makeConfig());
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
await expect(
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('rejects base64 with invalid characters', async () => {
process.env.AI_CONFIG_ENCRYPTION_KEY = '!!!!aaaa';
repo.findOne.mockResolvedValue(makeConfig());
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
await expect(
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('throws in production when no key set', async () => {
process.env.NODE_ENV = 'production';
delete process.env.AI_CONFIG_ENCRYPTION_KEY;
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
delete process.env.NODE_ENV;
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
});
// ── Config management ─────────────────────────────────────────────────
describe('getOrCreateConfig', () => {
it('returns existing config when found', async () => {
const existing = makeConfig();
repo.findOne.mockResolvedValue(existing);
const result = await service.getOrCreateConfig();
expect(result).toBe(existing);
expect(repo.findOne).toHaveBeenCalledWith({ where: { singletonKey: SINGLETON_KEY } });
expect(repo.create).not.toHaveBeenCalled();
});
it('creates default config when none exists', async () => {
repo.findOne.mockResolvedValue(null);
const created = makeConfig();
repo.create.mockReturnValue(created);
repo.save.mockResolvedValue(created);
const result = await service.getOrCreateConfig();
expect(repo.create).toHaveBeenCalled();
expect(result.provider).toBe(AiProvider.OPENAI);
expect(result.enabled).toBe(false);
});
});
describe('getConfig', () => {
it('returns masked key info with source=none when no key', async () => {
repo.findOne.mockResolvedValue(makeConfig());
const result = await service.getConfig();
expect(result.hasApiKey).toBe(false);
expect(result.hasDatabaseKey).toBe(false);
expect(result.maskedApiKey).toBeNull();
expect(result.keySource).toBe('none');
});
it('returns hasApiKey=true and hasDatabaseKey=true when DB key exists', async () => {
const key = 'sk-abcdefghij1234';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
key,
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: '1234',
}),
);
const result = await service.getConfig();
expect(result.hasApiKey).toBe(true);
expect(result.hasDatabaseKey).toBe(true);
expect(result.maskedApiKey).toBe('••••1234');
expect(result.keySource).toBe('database');
});
it('never returns plaintext key in GET response', async () => {
const key = 'sk-topsecret1234';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
key,
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: '1234',
}),
);
const result = await service.getConfig();
const json = JSON.stringify(result);
expect(json).not.toContain('topsecret');
expect(json).not.toContain('sk-');
});
it('env key fallback: source=environment, hasDatabaseKey=false', async () => {
process.env.AI_API_KEY = 'sk-env-key-1234';
repo.findOne.mockResolvedValue(makeConfig());
const result = await service.getConfig();
expect(result.hasApiKey).toBe(true);
expect(result.hasDatabaseKey).toBe(false);
expect(result.keySource).toBe('environment');
delete process.env.AI_API_KEY;
});
});
// ── Save config ───────────────────────────────────────────────────────
describe('saveConfig', () => {
it('saves provider and baseUrl', async () => {
const existing = makeConfig();
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.DEEPSEEK,
baseUrl: 'https://api.deepseek.com',
});
expect(result.provider).toBe(AiProvider.DEEPSEEK);
expect(result.baseUrl).toBe('https://api.deepseek.com');
});
it('encrypts and saves apiKey', async () => {
const existing = makeConfig();
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const apiKey = 'sk-saved-key-5678';
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
apiKey,
});
expect(result.encryptedApiKey).toBeTruthy();
expect(result.apiKeyIv).toBeTruthy();
expect(result.apiKeyAuthTag).toBeTruthy();
expect(result.keyLast4).toBe('5678');
const encKey = result.encryptedApiKey;
const encIv = result.apiKeyIv;
const encTag = result.apiKeyAuthTag;
expect(encKey).toBeTruthy();
expect(encIv).toBeTruthy();
expect(encTag).toBeTruthy();
if (!encKey || !encIv || !encTag) throw new Error('encrypted fields missing');
const decrypted = decryptWithKey(TEST_KEY_BYTES_32, encKey, encIv, encTag);
expect(decrypted).toBe(apiKey);
});
it('empty apiKey preserves existing key', async () => {
const existingKey = 'sk-existing-9999';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
existingKey,
);
const existing = makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: '9999',
});
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
apiKey: '',
});
expect(result.encryptedApiKey).toBe(ciphertext);
expect(result.keyLast4).toBe('9999');
});
it('undefined apiKey preserves existing key', async () => {
const existingKey = 'sk-existing-9999';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
existingKey,
);
const existing = makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: '9999',
});
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
});
expect(result.encryptedApiKey).toBe(ciphertext);
});
it('rejects enabled=true without any key', async () => {
repo.findOne.mockResolvedValue(makeConfig());
repo.save.mockImplementation((c) => Promise.resolve(c));
await expect(
service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
}),
).rejects.toThrow(BadRequestException);
});
it('allows enabled=true when DB key exists and defaultModel is set', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-existing-key',
);
repo.findOne.mockResolvedValue(
makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag, defaultModel: 'gpt-4' }),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
});
expect(result.enabled).toBe(true);
});
it('allows enabled=true with env key fallback and defaultModel', async () => {
process.env.AI_API_KEY = 'sk-env-key';
repo.findOne.mockResolvedValue(makeConfig({ defaultModel: 'gpt-4' }));
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
});
expect(result.enabled).toBe(true);
delete process.env.AI_API_KEY;
});
it('provider switch replaces default baseUrl', async () => {
repo.findOne.mockResolvedValue(makeConfig({ baseUrl: 'https://api.openai.com/v1' }));
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.DEEPSEEK,
});
expect(result.baseUrl).toBe('https://api.deepseek.com');
});
it('OPENAI_COMPATIBLE requires baseUrl', async () => {
repo.findOne.mockResolvedValue(makeConfig());
repo.save.mockImplementation((c) => Promise.resolve(c));
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
}),
).rejects.toThrow(BadRequestException);
});
it('enabled=true with defaultModel in DTO works even if config has none', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-existing-key',
);
repo.findOne.mockResolvedValue(
makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag }),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
defaultModel: 'gpt-4',
});
expect(result.enabled).toBe(true);
});
it('enabled=true rejects when defaultModel is empty everywhere', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-existing-key',
);
repo.findOne.mockResolvedValue(
makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag }),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
await expect(
service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
}),
).rejects.toThrow(BadRequestException);
});
});
// ── Clear key ─────────────────────────────────────────────────────────
describe('clearKey', () => {
it('clears DB key and disables when no env key', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-to-clear',
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: 'lear',
enabled: true,
}),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.clearKey();
expect(result.hasApiKey).toBe(false);
expect(result.hasDatabaseKey).toBe(false);
expect(result.keySource).toBe('none');
expect(result.maskedApiKey).toBeNull();
});
it('clear DB key falls back to env source', async () => {
process.env.AI_API_KEY = 'sk-env-after-clear';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-to-clear',
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: 'lear',
}),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.clearKey();
expect(result.keySource).toBe('environment');
expect(result.hasDatabaseKey).toBe(false);
delete process.env.AI_API_KEY;
});
});
// ── URL / SSRF validation ──────────────────────────────────────────────
describe('baseUrl validation', () => {
it('rejects non-http protocol', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'ftp://evil.com',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects URL with username/password', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://user:pass@evil.com',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects URL with search/query string', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://evil.com/v1?proxy=internal',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects URL with hash/fragment', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://evil.com/v1#section',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects localhost for OPENAI_COMPATIBLE', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'http://localhost:8080/v1',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects 127.0.0.1 for OPENAI_COMPATIBLE', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://127.0.0.1:8080',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects .local hostname', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://myservice.local/v1',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects IPv6 loopback ::1', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'http://[::1]:8080/v1',
}),
).rejects.toThrow(BadRequestException);
});
it('allows private IP when AI_ALLOW_PRIVATE_BASE_URL=true', async () => {
process.env.AI_ALLOW_PRIVATE_BASE_URL = 'true';
repo.findOne.mockResolvedValue(makeConfig());
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'http://localhost:8080/v1',
});
expect(result.baseUrl).toBe('http://localhost:8080/v1');
delete process.env.AI_ALLOW_PRIVATE_BASE_URL;
});
it('OPENAI rejects non-openai hostname', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI,
baseUrl: 'https://evil.com/v1',
}),
).rejects.toThrow(BadRequestException);
});
it('OPENAI rejects wrong pathname', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI,
baseUrl: 'https://api.openai.com/evil-proxy',
}),
).rejects.toThrow(BadRequestException);
});
it('normalizes trailing slash', async () => {
repo.findOne.mockResolvedValue(makeConfig());
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
baseUrl: 'https://api.openai.com/v1/',
});
expect(result.baseUrl).toBe('https://api.openai.com/v1');
});
});
// ── getRuntimeConfig ──────────────────────────────────────────────────
describe('getRuntimeConfig', () => {
it('returns config with plaintext key', async () => {
const apiKey = 'sk-runtime-key';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
apiKey,
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
enabled: true,
defaultModel: 'gpt-4',
}),
);
const runtime = await service.getRuntimeConfig();
expect(runtime.apiKey).toBe(apiKey);
expect(runtime.enabled).toBe(true);
});
it('throws when not enabled', async () => {
repo.findOne.mockResolvedValue(makeConfig({ enabled: false }));
await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
});
it('throws when no key available', async () => {
repo.findOne.mockResolvedValue(makeConfig({ enabled: true }));
await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
});
it('throws when defaultModel is empty', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-runtime-key',
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
enabled: true,
defaultModel: null,
}),
);
await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
});
it('throws when no config row exists', async () => {
repo.findOne.mockResolvedValue(null);
await expect(service.getRuntimeConfig()).rejects.toThrow(InternalServerErrorException);
});
});
});

View File

@@ -0,0 +1,753 @@
import {
Injectable,
Logger,
BadRequestException,
InternalServerErrorException,
} 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,
AiConfigResponseDto,
AiConfigTestResultDto,
AiRuntimeConfig,
DEFAULT_BASE_URLS,
} 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]: '/',
};
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> {
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
// ---------------------------------------------------------------------------
@Injectable()
export class AiConfigService {
private readonly logger = new Logger(AiConfigService.name);
constructor(
@InjectRepository(AiConfig)
private readonly repo: Repository<AiConfig>,
) {}
/** Resolve the effective API key: DB first, then env, then none */
private 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.OPENAI,
baseUrl: DEFAULT_BASE_URLS[AiProvider.OPENAI],
enabled: false,
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
// SQLite: SQLITE_CONSTRAINT (code 'SQLITE_CONSTRAINT')
if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') {
const existing = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
if (existing) return existing;
}
throw err;
}
}
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,
timeoutMs: config.timeoutMs,
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();
// Validate and normalize baseUrl
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;
}
// 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);
}
// enabled validation
if (dto.enabled !== undefined) {
if (dto.enabled) {
const { plaintext } = this.resolveApiKey(config);
if (!plaintext) {
throw new BadRequestException('未配置 API Key无法启用。请先保存 API Key 再启用');
}
// defaultModel is required when enabled
const effectiveDefaultModel =
dto.defaultModel !== undefined ? dto.defaultModel : config.defaultModel;
if (!effectiveDefaultModel) {
throw new BadRequestException('启用 AI 服务时必须配置默认模型');
}
}
config.enabled = dto.enabled;
}
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> {
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;
}
/**
* 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,
};
}
}

View File

@@ -0,0 +1,129 @@
import { validate } from 'class-validator';
import { SaveAiConfigDto, TestAiConfigDto } from './ai-config.dto';
import { AiProvider } from '../ai-config.entity';
describe('SaveAiConfigDto', () => {
it('validates a correct OPENAI config', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('validates a correct DEEPSEEK config', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.DEEPSEEK;
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('fixed provider can omit baseUrl', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('fixed provider can provide baseUrl (valid string)', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
dto.baseUrl = 'https://api.openai.com/v1';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('OPENAI_COMPATIBLE must provide baseUrl', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI_COMPATIBLE;
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
});
it('OPENAI_COMPATIBLE with baseUrl passes', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI_COMPATIBLE;
dto.baseUrl = 'https://custom.api.com/v1';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('OPENAI_COMPATIBLE with empty baseUrl fails', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI_COMPATIBLE;
dto.baseUrl = '';
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
});
it('invalid provider fails', async () => {
const dto = new SaveAiConfigDto();
(dto as Record<string, unknown>).provider = 'INVALID';
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].constraints).toHaveProperty('isIn');
});
it('timeoutMs outside range fails', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
dto.timeoutMs = 500;
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('apiKey is optional string', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
dto.apiKey = 'sk-test-1234';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('defaultModel is optional string', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
dto.defaultModel = 'gpt-4';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('enabled is optional boolean', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
dto.enabled = true;
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe('TestAiConfigDto', () => {
it('empty DTO is valid (all fields optional)', async () => {
const dto = new TestAiConfigDto();
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('partial fields validate', async () => {
const dto = new TestAiConfigDto();
dto.provider = AiProvider.OPENAI_COMPATIBLE;
dto.baseUrl = 'https://custom.api.com/v1';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('invalid provider fails', async () => {
const dto = new TestAiConfigDto();
(dto as Record<string, unknown>).provider = 'INVALID';
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('timeoutMs outside range fails', async () => {
const dto = new TestAiConfigDto();
dto.timeoutMs = 0;
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,116 @@
import {
IsString,
IsBoolean,
IsOptional,
IsInt,
Min,
Max,
IsIn,
IsNotEmpty,
ValidateIf,
} from 'class-validator';
import { AiProvider } from '../ai-config.entity';
const PROVIDERS = [AiProvider.OPENAI, AiProvider.DEEPSEEK, AiProvider.OPENAI_COMPATIBLE] as const;
const DEFAULT_BASE_URLS: Record<AiProvider, string> = {
[AiProvider.OPENAI]: 'https://api.openai.com/v1',
[AiProvider.DEEPSEEK]: 'https://api.deepseek.com',
[AiProvider.OPENAI_COMPATIBLE]: '',
};
/** DTO for PUT /api/ai/config — all fields required or validated */
export class SaveAiConfigDto {
@IsIn(PROVIDERS)
provider!: AiProvider;
@ValidateIf((o: SaveAiConfigDto) => o.provider === AiProvider.OPENAI_COMPATIBLE || o.baseUrl !== undefined)
@IsNotEmpty({ message: 'OPENAI_COMPATIBLE 模式必须提供 baseUrl' })
@IsString()
baseUrl?: string;
/** Raw API key — never returned by GET; empty / undefined = keep existing */
@IsOptional()
@IsString()
apiKey?: string;
@IsOptional()
@IsString()
defaultModel?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsInt()
@Min(1000)
@Max(120000)
timeoutMs?: number;
}
/** DTO for POST /api/ai/config/test — all fields optional, validate only when provided */
export class TestAiConfigDto {
@IsOptional()
@IsIn(PROVIDERS)
provider?: AiProvider;
@IsOptional()
@IsString()
baseUrl?: string;
@IsOptional()
@IsString()
apiKey?: string;
@IsOptional()
@IsString()
defaultModel?: string;
@IsOptional()
@IsInt()
@Min(1000)
@Max(120000)
timeoutMs?: number;
}
/** Response shape for GET /api/ai/config — NEVER includes plaintext key */
export interface AiConfigResponseDto {
id: number;
provider: AiProvider;
baseUrl: string;
hasApiKey: boolean;
hasDatabaseKey: boolean;
maskedApiKey: string | null;
keySource: 'database' | 'environment' | 'none';
defaultModel: string | null;
enabled: boolean;
timeoutMs: number;
verified: boolean;
lastTestedAt: string | null;
lastTestLatencyMs: number | null;
createdAt: string;
updatedAt: string;
}
/** Response shape for POST /api/ai/config/test */
export interface AiConfigTestResultDto {
success: boolean;
latencyMs: number | null;
modelCount: number | null;
modelAvailable: boolean;
testedAt: string;
message: string;
}
/** Server-only runtime config — NEVER exported via controller DTO */
export interface AiRuntimeConfig {
provider: AiProvider;
baseUrl: string;
apiKey: string;
defaultModel: string;
timeoutMs: number;
enabled: boolean;
}
export { DEFAULT_BASE_URLS };