feat(ai): 完成 DeepSeek 大模型官方 API 接入、双模调用、高可用平滑降级与管理后台连通性测试
这个提交包含在:
@@ -0,0 +1,74 @@
|
||||
import { AdminDeepSeekController } from '../../../src/modules/ai/controller/admin/deepseek';
|
||||
import { DeepSeekService } from '../../../src/modules/ai/service/deepseek';
|
||||
|
||||
describe('T32 AdminDeepSeekController 后台接口测试', () => {
|
||||
let controller: AdminDeepSeekController;
|
||||
let mockService: jest.Mocked<DeepSeekService>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockService = {
|
||||
getConfig: jest.fn().mockReturnValue({
|
||||
apiKey: 'sk-1234567890abcdef',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
timeoutMs: 30000,
|
||||
enabled: true,
|
||||
}),
|
||||
updateConfig: jest.fn(),
|
||||
isConfigured: jest.fn().mockReturnValue(true),
|
||||
testConnection: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
latencyMs: 120,
|
||||
model: 'deepseek-chat',
|
||||
message: 'DeepSeek API 连通成功',
|
||||
}),
|
||||
chatCompletion: jest.fn().mockResolvedValue('你好!我是 DeepSeek 智能模型。'),
|
||||
} as any;
|
||||
|
||||
controller = new AdminDeepSeekController();
|
||||
controller.deepSeekService = mockService;
|
||||
// mock controller.ok
|
||||
(controller as any).ok = (data: any) => ({ code: 1000, message: 'success', data });
|
||||
});
|
||||
|
||||
it('1. POST /config 获取配置且正确脱敏 API Key', async () => {
|
||||
const res: any = await controller.config();
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data.isConfigured).toBe(true);
|
||||
expect(res.data.apiKey).toBe('sk-1****cdef');
|
||||
expect(res.data.baseUrl).toBe('https://api.deepseek.com');
|
||||
});
|
||||
|
||||
it('2. POST /config 提交更新配置并返回新状态', async () => {
|
||||
const res: any = await controller.config({
|
||||
apiKey: 'sk-new-key-abcdef123456',
|
||||
model: 'deepseek-reasoner',
|
||||
});
|
||||
expect(res.code).toBe(1000);
|
||||
expect(mockService.updateConfig).toHaveBeenCalledWith({
|
||||
apiKey: 'sk-new-key-abcdef123456',
|
||||
model: 'deepseek-reasoner',
|
||||
});
|
||||
});
|
||||
|
||||
it('3. POST /test-connection 连通性测试', async () => {
|
||||
const res: any = await controller.testConnection('sk-test-pass');
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data.success).toBe(true);
|
||||
expect(res.data.latencyMs).toBe(120);
|
||||
expect(mockService.testConnection).toHaveBeenCalledWith('sk-test-pass');
|
||||
});
|
||||
|
||||
it('4. POST /chat 调试对话调用', async () => {
|
||||
const res: any = await controller.chat({
|
||||
messages: [{ role: 'user', content: '测试' }],
|
||||
});
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data.reply).toBe('你好!我是 DeepSeek 智能模型。');
|
||||
expect(mockService.chatCompletion).toHaveBeenCalledWith({
|
||||
messages: [{ role: 'user', content: '测试' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,24 @@ describe('T36: 名片 AI 顾问对话 (modules/ai chat)', () => {
|
||||
expect(result.shouldCollectLead).toBe(true);
|
||||
expect(/[\u4e00-\u9fff]/.test(result.reply)).toBe(true);
|
||||
});
|
||||
|
||||
it('当 DeepSeekService 已配置时优先调用大模型,失败时平滑降级', async () => {
|
||||
const mockDeepSeek = {
|
||||
isConfigured: jest.fn().mockReturnValue(true),
|
||||
chatCompletion: jest.fn().mockResolvedValue('这是 DeepSeek 大模型生成的拟人顾问回复'),
|
||||
};
|
||||
service.deepSeekService = mockDeepSeek as any;
|
||||
|
||||
const res = await service.ask(userAsk('你们都有什么服务?'));
|
||||
expect(res.reply).toBe('这是 DeepSeek 大模型生成的拟人顾问回复');
|
||||
expect(mockDeepSeek.chatCompletion).toHaveBeenCalled();
|
||||
|
||||
// 测试异常平滑降级
|
||||
mockDeepSeek.chatCompletion.mockRejectedValue(new Error('Network error'));
|
||||
const fallbackRes = await service.ask(userAsk('你好', { locale: 'zh-CN' }));
|
||||
expect(fallbackRes.reply).toBeTruthy();
|
||||
expect(fallbackRes.reply).toContain('智能顾问');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. OpenAiChatController /open/ai/chat', () => {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { DeepSeekService } from '../../../src/modules/ai/service/deepseek';
|
||||
import { DeepSeekConfig } from '../../../src/comm/types/omni';
|
||||
|
||||
describe('T31 DeepSeek 大模型集成与适配服务 (deepseek.ts)', () => {
|
||||
let service: DeepSeekService;
|
||||
|
||||
beforeEach(() => {
|
||||
// 先清理环境变量,防止跨测试用例污染
|
||||
delete process.env.DEEPSEEK_API_KEY;
|
||||
delete process.env.DEEPSEEK_BASE_URL;
|
||||
delete process.env.DEEPSEEK_MODEL;
|
||||
service = new DeepSeekService();
|
||||
});
|
||||
|
||||
describe('1. 配置管理与状态检测 (Config & Readiness)', () => {
|
||||
it('1.1 初始状态默认配置验证', () => {
|
||||
const config = service.getConfig();
|
||||
expect(config.baseUrl).toBe('https://api.deepseek.com');
|
||||
expect(config.model).toBe('deepseek-chat');
|
||||
expect(config.temperature).toBe(0.7);
|
||||
expect(config.timeoutMs).toBe(30000);
|
||||
expect(service.isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('1.2 优先从环境变量读取 DEEPSEEK_API_KEY', () => {
|
||||
process.env.DEEPSEEK_API_KEY = 'sk-env-test-key-123';
|
||||
process.env.DEEPSEEK_BASE_URL = 'https://custom.deepseek.com';
|
||||
process.env.DEEPSEEK_MODEL = 'deepseek-reasoner';
|
||||
|
||||
const envService = new DeepSeekService();
|
||||
expect(envService.isConfigured()).toBe(true);
|
||||
expect(envService.getConfig().apiKey).toBe('sk-env-test-key-123');
|
||||
expect(envService.getConfig().baseUrl).toBe('https://custom.deepseek.com');
|
||||
expect(envService.getConfig().model).toBe('deepseek-reasoner');
|
||||
});
|
||||
|
||||
it('1.3 updateConfig 动态更新与合并配置', () => {
|
||||
service.updateConfig({
|
||||
apiKey: 'sk-dynamic-key-456',
|
||||
model: 'deepseek-reasoner',
|
||||
temperature: 0.2,
|
||||
});
|
||||
|
||||
expect(service.isConfigured()).toBe(true);
|
||||
const cfg = service.getConfig();
|
||||
expect(cfg.apiKey).toBe('sk-dynamic-key-456');
|
||||
expect(cfg.model).toBe('deepseek-reasoner');
|
||||
expect(cfg.temperature).toBe(0.2);
|
||||
expect(cfg.baseUrl).toBe('https://api.deepseek.com'); // 保留默认值
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 提示词动态拼装 (Prompt Builders)', () => {
|
||||
it('2.1 buildSystemPrompt 组装名片主、人设与知识库切片', () => {
|
||||
const card = {
|
||||
name: '张总',
|
||||
company: '华强出海科技',
|
||||
title: '海外商务总监',
|
||||
};
|
||||
const concierge = {
|
||||
personaType: 'trade_expert' as const,
|
||||
toneStyle: 'professional' as const,
|
||||
promptTemplate: '你是一名资深出海贸易专家,精通中东与东南亚市场。',
|
||||
};
|
||||
const chunks = [
|
||||
{ index: 1, content: '支持信用证结算与 FOB 迪拜港条款。' },
|
||||
{ index: 2, content: '最小起订量 (MOQ) 为 500 台。' },
|
||||
];
|
||||
|
||||
const systemPrompt = service.buildSystemPrompt(card, concierge, chunks);
|
||||
|
||||
expect(systemPrompt).toContain('张总');
|
||||
expect(systemPrompt).toContain('华强出海科技');
|
||||
expect(systemPrompt).toContain('海外商务总监');
|
||||
expect(systemPrompt).toContain('你是一名资深出海贸易专家');
|
||||
expect(systemPrompt).toContain('支持信用证结算与 FOB 迪拜港条款。');
|
||||
expect(systemPrompt).toContain('最小起订量 (MOQ) 为 500 台。');
|
||||
});
|
||||
|
||||
it('2.2 buildLocalizePrompt 组装多语言润色与术语库指令', () => {
|
||||
const prompt = service.buildLocalizePrompt(
|
||||
'欢迎参观我们位于迪拜的展位',
|
||||
'en-US',
|
||||
'professional',
|
||||
['展位 -> Booth', '迪拜 -> Dubai']
|
||||
);
|
||||
|
||||
expect(prompt).toContain('欢迎参观我们位于迪拜的展位');
|
||||
expect(prompt).toContain('en-US');
|
||||
expect(prompt).toContain('professional');
|
||||
expect(prompt).toContain('Booth');
|
||||
expect(prompt).toContain('Dubai');
|
||||
});
|
||||
|
||||
it('2.3 buildCopilotPrompt 组装销售破冰首语生成指令', () => {
|
||||
const lead = {
|
||||
name: 'Ahmed',
|
||||
company: 'Gulf Energy',
|
||||
phone: '+971501234567',
|
||||
demand: '储能集装箱报价',
|
||||
};
|
||||
const tracks = [
|
||||
{ module: 'case_energy', duration: 180 },
|
||||
{ module: 'price_table', duration: 90 },
|
||||
];
|
||||
|
||||
const prompt = service.buildCopilotPrompt(lead, tracks);
|
||||
|
||||
expect(prompt).toContain('Ahmed');
|
||||
expect(prompt).toContain('Gulf Energy');
|
||||
expect(prompt).toContain('储能集装箱报价');
|
||||
expect(prompt).toContain('case_energy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 文本补全调用与高可用降级 (chatCompletion & Fallback)', () => {
|
||||
it('3.1 未配置 API Key 时,提供 fallbackReply 则安全返回兜底内容', async () => {
|
||||
expect(service.isConfigured()).toBe(false);
|
||||
|
||||
const result = await service.chatCompletion(
|
||||
{
|
||||
messages: [{ role: 'user', content: '你们的产品价格是多少?' }],
|
||||
},
|
||||
'这是本地规则引擎的兜底回复'
|
||||
);
|
||||
|
||||
expect(result).toBe('这是本地规则引擎的兜底回复');
|
||||
});
|
||||
|
||||
it('3.2 未配置 API Key 且未提供 fallbackReply 时安全降级或抛出清晰提示', async () => {
|
||||
await expect(
|
||||
service.chatCompletion({
|
||||
messages: [{ role: 'user', content: '你好' }],
|
||||
})
|
||||
).rejects.toThrow('DeepSeek API Key 未配置');
|
||||
});
|
||||
|
||||
it('3.3 模拟正常调用 DeepSeek 官方接口返回生成文本', async () => {
|
||||
service.updateConfig({ apiKey: 'sk-mock-valid-key' });
|
||||
|
||||
// 模拟底层 HTTP client
|
||||
const mockPost = jest.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
id: 'chatcmpl-test-123',
|
||||
model: 'deepseek-chat',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '您好!我是华强科技的 AI 顾问,很高兴为您服务。',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
(service as any).httpClient = { post: mockPost };
|
||||
|
||||
const reply = await service.chatCompletion({
|
||||
messages: [{ role: 'user', content: '您好' }],
|
||||
});
|
||||
|
||||
expect(reply).toBe('您好!我是华强科技的 AI 顾问,很高兴为您服务。');
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'https://api.deepseek.com/chat/completions',
|
||||
expect.objectContaining({
|
||||
model: 'deepseek-chat',
|
||||
messages: [{ role: 'user', content: '您好' }],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer sk-mock-valid-key',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('3.4 API 调用异常且提供 fallbackReply 时平滑降级', async () => {
|
||||
service.updateConfig({ apiKey: 'sk-mock-valid-key' });
|
||||
const mockPost = jest.fn().mockRejectedValue(new Error('Network Timeout'));
|
||||
(service as any).httpClient = { post: mockPost };
|
||||
|
||||
const reply = await service.chatCompletion(
|
||||
{ messages: [{ role: 'user', content: '报价' }] },
|
||||
'网络异常时的平滑降级回复'
|
||||
);
|
||||
|
||||
expect(reply).toBe('网络异常时的平滑降级回复');
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 连通性测试 (testConnection)', () => {
|
||||
it('4.1 未配置 API Key 且未传入临时 Key 时测试失败', async () => {
|
||||
const res = await service.testConnection();
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.message).toContain('API Key 未提供');
|
||||
});
|
||||
|
||||
it('4.2 传入测试 Key 成功握手并统计延迟', async () => {
|
||||
const mockPost = jest.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
choices: [
|
||||
{ message: { content: 'pong' }, finish_reason: 'stop' },
|
||||
],
|
||||
},
|
||||
});
|
||||
(service as any).httpClient = { post: mockPost };
|
||||
|
||||
const res = await service.testConnection('sk-test-tmp-key');
|
||||
expect(res.success).toBe(true);
|
||||
expect(typeof res.latencyMs).toBe('number');
|
||||
expect(res.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
expect(res.model).toBe('deepseek-chat');
|
||||
});
|
||||
|
||||
it('4.3 官方 API 返回 401 鉴权失败捕获', async () => {
|
||||
const mockPost = jest.fn().mockRejectedValue({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: { message: 'Authentication Fails (invalid api key)' } },
|
||||
},
|
||||
});
|
||||
(service as any).httpClient = { post: mockPost };
|
||||
|
||||
const res = await service.testConnection('sk-invalid-key');
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.message).toContain('Authentication Fails');
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户