diff --git a/admin/src/pages/ai/concierge/index.tsx b/admin/src/pages/ai/concierge/index.tsx index c94cc42..ab5d3c0 100644 --- a/admin/src/pages/ai/concierge/index.tsx +++ b/admin/src/pages/ai/concierge/index.tsx @@ -12,6 +12,8 @@ import { Message, Spin, Grid, + Tag, + Divider, } from '@arco-design/web-react'; import axios from 'axios'; import useLocale from '@/utils/useLocale'; @@ -20,6 +22,8 @@ import type { AiConciergeConfig, ConciergePersonaType, ConciergeToneStyle, + DeepSeekConfig, + DeepSeekTestConnectionResult, } from '@/types/omni'; import locale from './locale'; @@ -28,6 +32,8 @@ const { Row, Col } = Grid; const GET_CONFIG_API = '/api/admin/ai/concierge/getByCardId'; const SAVE_CONFIG_API = '/api/admin/ai/concierge/saveByCardId'; const CARD_PAGE_API = '/api/admin/card/info/page'; +const DEEPSEEK_CONFIG_API = '/api/admin/ai/deepseek/config'; +const DEEPSEEK_TEST_API = '/api/admin/ai/deepseek/test-connection'; interface CoolResponse { code: number; @@ -98,6 +104,100 @@ export default function AiConciergePage(): JSX.Element { const [selectedCardId, setSelectedCardId] = useState(DEFAULT_CARD_ID); const [cardOptions, setCardOptions] = useState([]); + const [deepSeekForm] = Form.useForm(); + const [deepSeekLoading, setDeepSeekLoading] = useState(false); + const [deepSeekSaving, setDeepSeekSaving] = useState(false); + const [deepSeekTesting, setDeepSeekTesting] = useState(false); + const [deepSeekConfig, setDeepSeekConfig] = useState(null); + const [testResult, setTestResult] = useState(null); + + const loadDeepSeekConfig = async (): Promise => { + setDeepSeekLoading(true); + try { + const res = await axios.post>( + DEEPSEEK_CONFIG_API, + {} + ); + if (res.data && res.data.code === 1000 && res.data.data) { + const d = res.data.data; + setDeepSeekConfig(d); + deepSeekForm.setFieldsValue({ + apiKey: d.apiKey || '', + baseUrl: d.baseUrl || 'https://api.deepseek.com', + model: d.model || 'deepseek-chat', + temperature: d.temperature ?? 0.7, + maxTokens: d.maxTokens ?? 2048, + enabled: d.enabled !== false, + }); + } + } catch { + // 保持静默优雅降级 + } finally { + setDeepSeekLoading(false); + } + }; + + const handleTestDeepSeek = async (): Promise => { + setDeepSeekTesting(true); + setTestResult(null); + try { + const values = deepSeekForm.getFieldsValue(); + const res = await axios.post>( + DEEPSEEK_TEST_API, + { apiKey: values.apiKey } + ); + if (res.data && res.data.code === 1000 && res.data.data) { + setTestResult(res.data.data); + if (res.data.data.success) { + Message.success( + `${t['aiDeepSeek.test.success']} (${res.data.data.latencyMs ?? 0}ms)` + ); + } else { + Message.error(res.data.data.message || t['aiDeepSeek.test.fail']); + } + } else { + Message.error(res.data?.message || t['aiDeepSeek.test.fail']); + } + } catch { + Message.error(t['aiDeepSeek.test.fail']); + } finally { + setDeepSeekTesting(false); + } + }; + + const handleSaveDeepSeek = async (values: DeepSeekConfig): Promise => { + setDeepSeekSaving(true); + try { + const payload: Partial = { + baseUrl: values.baseUrl, + model: values.model, + temperature: Number(values.temperature), + maxTokens: Number(values.maxTokens), + enabled: Boolean(values.enabled), + }; + if (values.apiKey && !values.apiKey.includes('****')) { + payload.apiKey = values.apiKey; + } + const res = await axios.post>( + DEEPSEEK_CONFIG_API, + payload + ); + if (res.data && res.data.code === 1000 && res.data.data) { + Message.success(t['aiDeepSeek.save.success']); + setDeepSeekConfig(res.data.data); + if (res.data.data.apiKey) { + deepSeekForm.setFieldValue('apiKey', res.data.data.apiKey); + } + } else { + Message.error(res.data?.message || t['aiDeepSeek.save.fail']); + } + } catch { + Message.error(t['aiDeepSeek.save.fail']); + } finally { + setDeepSeekSaving(false); + } + }; + const fetchCardOptions = async (): Promise => { try { const res = await axios.post>( @@ -171,6 +271,7 @@ export default function AiConciergePage(): JSX.Element { useEffect(() => { fetchCardOptions(); loadConciergeConfig(selectedCardId); + loadDeepSeekConfig(); }, []); const handleCardChange = (cardId: number): void => { @@ -401,6 +502,178 @@ export default function AiConciergePage(): JSX.Element { + + + {t['aiDeepSeek.card.title']} + + {deepSeekConfig?.isConfigured + ? t['aiDeepSeek.status.configured'] + : t['aiDeepSeek.status.unconfigured']} + + + } + bordered={false} + style={{ + marginTop: spacing[24], + borderRadius: radius.card, + border: `1px solid ${lightColors.border}`, + backgroundColor: lightColors.bg, + }} + > +
+ {t['aiDeepSeek.card.desc']} +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {testResult && ( +
+ + {testResult.message || (testResult.success ? '连通成功' : '连通失败')} + {testResult.latencyMs !== undefined && ` | ${testResult.latencyMs}ms`} + {testResult.model && ` | ${testResult.model}`} + +
+ )} + +
+ + + + +
+
+
+
); } diff --git a/admin/src/pages/ai/concierge/locale/index.ts b/admin/src/pages/ai/concierge/locale/index.ts index 093c111..915144f 100644 --- a/admin/src/pages/ai/concierge/locale/index.ts +++ b/admin/src/pages/ai/concierge/locale/index.ts @@ -43,6 +43,31 @@ const i18n = { 'aiConcierge.request.success': 'Concierge settings saved successfully', 'aiConcierge.request.fail': 'Failed to load concierge configuration', 'aiConcierge.request.saveFail': 'Failed to save configuration', + 'aiDeepSeek.card.title': 'DeepSeek Official LLM Configuration', + 'aiDeepSeek.card.desc': + 'Configure DeepSeek API Key, supporting deepseek-chat and deepseek-reasoner dual models for 24/7 AI concierge and cross-border localization.', + 'aiDeepSeek.form.apiKey': 'API Key', + 'aiDeepSeek.form.apiKey.placeholder': + 'Enter official API Key starting with sk- (leave blank to keep current)', + 'aiDeepSeek.form.baseUrl': 'API Base URL', + 'aiDeepSeek.form.model': 'Model Version', + 'aiDeepSeek.model.chat': 'DeepSeek-V3 Chat (deepseek-chat)', + 'aiDeepSeek.model.reasoner': 'DeepSeek-R1 Reasoner (deepseek-reasoner)', + 'aiDeepSeek.form.temperature': 'Temperature', + 'aiDeepSeek.form.temperature.desc': + 'Lower values are deterministic; higher values are creative.', + 'aiDeepSeek.form.status': 'Enable DeepSeek LLM', + 'aiDeepSeek.form.status.desc': + 'When enabled, requests prioritize DeepSeek official API with smooth fallback.', + 'aiDeepSeek.action.test': 'Test Connection', + 'aiDeepSeek.action.testing': 'Testing connection...', + 'aiDeepSeek.action.save': 'Save Model Config', + 'aiDeepSeek.status.configured': 'Configured', + 'aiDeepSeek.status.unconfigured': 'Not Configured', + 'aiDeepSeek.test.success': 'DeepSeek API connection successful', + 'aiDeepSeek.test.fail': 'Connection test failed', + 'aiDeepSeek.save.success': 'DeepSeek configuration updated', + 'aiDeepSeek.save.fail': 'Failed to save DeepSeek configuration', }, 'zh-CN': { 'menu.ai.concierge': 'AI 顾问配置', @@ -88,6 +113,31 @@ const i18n = { 'aiConcierge.request.success': 'AI 顾问配置已保存', 'aiConcierge.request.fail': 'AI 顾问配置加载失败', 'aiConcierge.request.saveFail': '保存配置失败', + 'aiDeepSeek.card.title': 'DeepSeek 官方大模型底座配置', + 'aiDeepSeek.card.desc': + '配置 DeepSeek API Key,支持 deepseek-chat 与 deepseek-reasoner 双模型接入,赋能 24/7 AI 顾问对话与出海多语言润色。', + 'aiDeepSeek.form.apiKey': 'API Key 密钥', + 'aiDeepSeek.form.apiKey.placeholder': + '输入 sk- 开头的官方密钥 (留空则保留当前已配置密钥)', + 'aiDeepSeek.form.baseUrl': 'API 接口基地址 (Base URL)', + 'aiDeepSeek.form.model': '接入模型版本', + 'aiDeepSeek.model.chat': 'DeepSeek-V3 对话模型 (deepseek-chat)', + 'aiDeepSeek.model.reasoner': 'DeepSeek-R1 深度推理模型 (deepseek-reasoner)', + 'aiDeepSeek.form.temperature': '采样温度 (Temperature)', + 'aiDeepSeek.form.temperature.desc': + '较低值输出严谨稳定,较高值回复生动多样。', + 'aiDeepSeek.form.status': '启用 DeepSeek 大模型', + 'aiDeepSeek.form.status.desc': + '开启后系统优先直连 DeepSeek 官方 API,网络异常或未配置时自动平滑降级。', + 'aiDeepSeek.action.test': '测试连通性', + 'aiDeepSeek.action.testing': '连通性测试中...', + 'aiDeepSeek.action.save': '保存大模型配置', + 'aiDeepSeek.status.configured': '已配置', + 'aiDeepSeek.status.unconfigured': '未配置', + 'aiDeepSeek.test.success': 'DeepSeek API 连通测试成功', + 'aiDeepSeek.test.fail': '连通性测试失败', + 'aiDeepSeek.save.success': 'DeepSeek 大模型配置已保存', + 'aiDeepSeek.save.fail': '保存大模型配置失败', }, 'ar-SA': { 'menu.ai.concierge': 'إعدادات المساعد الذكي', @@ -133,6 +183,31 @@ const i18n = { 'aiConcierge.request.success': 'تم حفظ الإعدادات بنجاح', 'aiConcierge.request.fail': 'تعذر تحميل الإعدادات', 'aiConcierge.request.saveFail': 'فشل حفظ الإعدادات', + 'aiDeepSeek.card.title': 'إعدادات نموذج DeepSeek الرسمي', + 'aiDeepSeek.card.desc': + 'تكوين مفتاح واجهة DeepSeek البرمجية لدعم محادثات المساعد الذكي وترجمة المحتوى.', + 'aiDeepSeek.form.apiKey': 'مفتاح API', + 'aiDeepSeek.form.apiKey.placeholder': + 'أدخل المفتاح الرسمي الذي يبدأ بـ sk-', + 'aiDeepSeek.form.baseUrl': 'عنوان الواجهة الأساسي', + 'aiDeepSeek.form.model': 'إصدار النموذج', + 'aiDeepSeek.model.chat': 'نموذج محادثة DeepSeek-V3', + 'aiDeepSeek.model.reasoner': 'نموذج استدلال DeepSeek-R1', + 'aiDeepSeek.form.temperature': 'درجة الحرارة', + 'aiDeepSeek.form.temperature.desc': + 'القيم المنخفضة أكثر دقة والصلبة أكثر إبداعا.', + 'aiDeepSeek.form.status': 'تفعيل نموذج DeepSeek', + 'aiDeepSeek.form.status.desc': + 'عند التفعيل، تعطى الأولوية لواجهة DeepSeek مع خفض سلس للأداء عند الانقطاع.', + 'aiDeepSeek.action.test': 'اختبار الاتصال', + 'aiDeepSeek.action.testing': 'جاري اختبار الاتصال...', + 'aiDeepSeek.action.save': 'حفظ إعدادات النموذج', + 'aiDeepSeek.status.configured': 'تم التكوين', + 'aiDeepSeek.status.unconfigured': 'غير مكوّن', + 'aiDeepSeek.test.success': 'نجح اختبار الاتصال بـ DeepSeek', + 'aiDeepSeek.test.fail': 'فشل اختبار الاتصال', + 'aiDeepSeek.save.success': 'تم حفظ إعدادات DeepSeek بنجاح', + 'aiDeepSeek.save.fail': 'فشل حفظ إعدادات DeepSeek', }, }; diff --git a/admin/src/types/omni.ts b/admin/src/types/omni.ts index 71c638f..526f988 100644 --- a/admin/src/types/omni.ts +++ b/admin/src/types/omni.ts @@ -476,6 +476,42 @@ export interface AiConciergeConfig { isEnabled: boolean; } +// ========================================== +// 5.1 DeepSeek 大模型集成契约 (DeepSeek AI Suite) +// ========================================== + +export type DeepSeekModel = 'deepseek-chat' | 'deepseek-reasoner'; + +export interface DeepSeekConfig { + apiKey?: string; + baseUrl?: string; + model?: DeepSeekModel; + temperature?: number; + maxTokens?: number; + timeoutMs?: number; + enabled?: boolean; +} + +export interface DeepSeekChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface DeepSeekChatCompletionRequest { + messages: DeepSeekChatMessage[]; + model?: DeepSeekModel; + temperature?: number; + maxTokens?: number; + stream?: boolean; +} + +export interface DeepSeekTestConnectionResult { + success: boolean; + latencyMs?: number; + model?: string; + message?: string; +} + export type LocalizeTone = 'professional' | 'direct' | 'storytelling'; export interface LocalizeRequest { @@ -725,4 +761,117 @@ export interface TeamRankBroadcastPayload { meta?: TeamRankBroadcastMeta; } +// ========================================== +// 12. AI 顾问对话与 DeepSeek 大模型契约 (AI Concierge & DeepSeek Suite) +// ========================================== + +export type AiChatRole = 'user' | 'assistant' | 'system'; + +export interface AiChatMessage { + role: AiChatRole; + content: string; +} + +export interface AiChatRequest { + cardId: string | number; + locale?: string; + messages: AiChatMessage[]; +} + +export interface AiChatReply { + reply: string; + locale: string; + shouldCollectLead: boolean; +} + +export interface AiKnowledgeDoc { + id?: string | number; + cardId: string | number; + title: string; + content: string; + locale?: string; +} + +export type ConciergePersonaType = + | 'trade_expert' + | 'business_rep' + | 'tech_advisor' + | 'custom'; + +export type ConciergeToneStyle = + | 'professional' + | 'direct' + | 'enthusiastic' + | 'friendly'; + +export interface AiConciergeConfig { + id?: string | number; + cardId: string | number; + personaType: ConciergePersonaType; + welcomeMsg: string; + triggerLeadThreshold: number; + promptTemplate: string; + toneStyle: ConciergeToneStyle; + isEnabled: boolean; +} + +export interface KnowledgeChunk { + id?: string | number; + index: number; + content: string; + length: number; + score?: number; +} + +export type DeepSeekModel = 'deepseek-chat' | 'deepseek-reasoner'; + +export interface DeepSeekConfig { + apiKey?: string; + baseUrl?: string; + model?: DeepSeekModel; + temperature?: number; + maxTokens?: number; + timeoutMs?: number; + enabled?: boolean; + isConfigured?: boolean; +} + +export interface DeepSeekChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface DeepSeekChatCompletionRequest { + messages: DeepSeekChatMessage[]; + model?: DeepSeekModel; + temperature?: number; + maxTokens?: number; + stream?: boolean; +} + +export interface DeepSeekChatCompletionResponse { + id: string; + model: string; + choices: Array<{ + index: number; + message: { + role: 'assistant'; + content: string; + }; + finish_reason: string; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +export interface DeepSeekTestConnectionResult { + success: boolean; + latencyMs?: number; + model?: string; + message?: string; +} + diff --git a/admin/tests/ai-deepseek.test.ts b/admin/tests/ai-deepseek.test.ts new file mode 100644 index 0000000..a63826f --- /dev/null +++ b/admin/tests/ai-deepseek.test.ts @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const conciergePagePath = path.join(adminRoot, 'src/pages/ai/concierge/index.tsx'); +const conciergeLocalePath = path.join(adminRoot, 'src/pages/ai/concierge/locale/index.ts'); +const omniTypesPath = path.join(adminRoot, 'src/types/omni.ts'); + +test('1. DeepSeek 大模型后台界面必须接入标准 API 与连通性测试端点', () => { + assert.ok(fs.existsSync(conciergePagePath), 'AI 页面文件必须存在'); + const content = fs.readFileSync(conciergePagePath, 'utf-8'); + + assert.ok( + content.includes('/api/admin/ai/deepseek/config'), + '必须包含 /api/admin/ai/deepseek/config 配置端点' + ); + assert.ok( + content.includes('/api/admin/ai/deepseek/test-connection'), + '必须包含 /api/admin/ai/deepseek/test-connection 连通性测试端点' + ); + assert.ok(content.includes('deepseek-chat'), '必须支持 deepseek-chat (V3)'); + assert.ok(content.includes('deepseek-reasoner'), '必须支持 deepseek-reasoner (R1)'); + assert.ok(content.includes('handleTestDeepSeek'), '必须提供连通性测试处理函数'); + assert.ok(content.includes('handleSaveDeepSeek'), '必须提供保存配置处理函数'); +}); + +test('2. DeepSeek 大模型跨端类型契约必须在 admin/src/types/omni.ts 中严密定义', () => { + assert.ok(fs.existsSync(omniTypesPath), 'omni.ts 类型契约必须存在'); + const content = fs.readFileSync(omniTypesPath, 'utf-8'); + + assert.ok(content.includes('DeepSeekModel'), '必须导出 DeepSeekModel 类型'); + assert.ok(content.includes('DeepSeekConfig'), '必须导出 DeepSeekConfig 接口'); + assert.ok(content.includes('DeepSeekChatMessage'), '必须导出 DeepSeekChatMessage 接口'); + assert.ok( + content.includes('DeepSeekTestConnectionResult'), + '必须导出 DeepSeekTestConnectionResult 接口' + ); +}); + +test('3. DeepSeek 多语言国际化必须覆盖中英阿且无 emoji 无 any', () => { + assert.ok(fs.existsSync(conciergeLocalePath), '语言包必须存在'); + const localeContent = fs.readFileSync(conciergeLocalePath, 'utf-8'); + + assert.ok(localeContent.includes("'aiDeepSeek.card.title'"), '必须包含卡片标题词条'); + assert.ok(localeContent.includes("'aiDeepSeek.form.apiKey'"), '必须包含 API Key 表单项'); + assert.ok(localeContent.includes("'aiDeepSeek.form.model'"), '必须包含模型选择项'); + assert.ok(localeContent.includes("'aiDeepSeek.action.test'"), '必须包含测试连通性按钮词条'); + assert.ok(localeContent.includes("'aiDeepSeek.test.success'"), '必须包含测试成功提示'); + + // 三语检查 + assert.ok(localeContent.includes("'en-US'"), '必须支持 en-US'); + assert.ok(localeContent.includes("'zh-CN'"), '必须支持 zh-CN'); + assert.ok(localeContent.includes("'ar-SA'"), '必须支持 ar-SA'); + + // 禁止 emoji + const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u; + assert.ok(!emojiRegex.test(localeContent), '语言包严禁使用 emoji'); + assert.ok(!/\bany\b/.test(localeContent), '语言包严禁使用 any'); +}); diff --git a/backend/src/comm/entity/base.ts b/backend/src/comm/entity/base.ts index c0aa25c..c021b8a 100644 --- a/backend/src/comm/entity/base.ts +++ b/backend/src/comm/entity/base.ts @@ -1,85 +1,9 @@ -import { - Index, - PrimaryGeneratedColumn, - Column, - DeleteDateColumn, -} from 'typeorm'; -import * as moment from 'moment'; -import { CoolBaseEntity } from '@cool-midway/core'; - /** - * 时间转换器 + * 统一实体基类重导出 (兼容历史路径) + * 复用 Cool-Admin 官方 modules/base/entity/base */ -export const transformerTime = { - to(value?: Date | string | null) { - return value - ? moment(value).format('YYYY-MM-DD HH:mm:ss') - : moment().format('YYYY-MM-DD HH:mm:ss'); - }, - from(value?: string | null) { - return value; - }, -}; - -/** - * Json 转换器 - */ -export const transformerJson = { - to: (value: unknown) => value, - from: (value: unknown) => { - if (typeof value === 'string') { - try { - return JSON.parse(value); - } catch (e) { - return value; - } - } - return value; - }, -}; - -/** - * 通用实体基类 (BaseEntity) - * 提供主键自增、多租户隔离 (tenantId)、审计时间戳 (createTime/updateTime) 及软删除 (deletedAt) - */ -export abstract class BaseEntity extends CoolBaseEntity { - @PrimaryGeneratedColumn('increment', { - name: 'id', - comment: 'ID', - }) - id: number; - - @Index() - @Column({ - name: 'tenant_id', - comment: '企业租户ID', - nullable: true, - }) - tenantId: number; - - @Index() - @Column({ - name: 'create_time', - comment: '创建时间', - type: 'varchar', - transformer: transformerTime, - }) - createTime: Date; - - @Index() - @Column({ - name: 'update_time', - comment: '更新时间', - type: 'varchar', - transformer: transformerTime, - }) - updateTime: Date; - - @Index() - @DeleteDateColumn({ - name: 'deleted_at', - comment: '软删除时间', - nullable: true, - }) - deletedAt?: Date | null; -} +export { + BaseEntity, + transformerJson, + transformerTime, +} from '../../modules/base/entity/base'; diff --git a/backend/src/comm/types/omni.ts b/backend/src/comm/types/omni.ts index d33a7ad..5184f63 100644 --- a/backend/src/comm/types/omni.ts +++ b/backend/src/comm/types/omni.ts @@ -599,6 +599,60 @@ export interface KnowledgeChunk { score?: number; } +// ========================================== +// 5.1 DeepSeek 大模型集成契约 (DeepSeek AI Suite) +// ========================================== + +export type DeepSeekModel = 'deepseek-chat' | 'deepseek-reasoner'; + +export interface DeepSeekConfig { + apiKey?: string; + baseUrl?: string; + model?: DeepSeekModel; + temperature?: number; + maxTokens?: number; + timeoutMs?: number; + enabled?: boolean; +} + +export interface DeepSeekChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface DeepSeekChatCompletionRequest { + messages: DeepSeekChatMessage[]; + model?: DeepSeekModel; + temperature?: number; + maxTokens?: number; + stream?: boolean; +} + +export interface DeepSeekChatCompletionResponse { + id: string; + model: string; + choices: Array<{ + index: number; + message: { + role: 'assistant'; + content: string; + }; + finish_reason: string; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +export interface DeepSeekTestConnectionResult { + success: boolean; + latencyMs?: number; + model?: string; + message?: string; +} + /** * 知识库沙盒调试请求契约 */ diff --git a/backend/src/modules/ai/controller/admin/deepseek.ts b/backend/src/modules/ai/controller/admin/deepseek.ts new file mode 100644 index 0000000..ecbe8ba --- /dev/null +++ b/backend/src/modules/ai/controller/admin/deepseek.ts @@ -0,0 +1,65 @@ +import { Body, Inject, Post } from '@midwayjs/core'; +import { BaseController, CoolController } from '@cool-midway/core'; +import { DeepSeekService } from '../../service/deepseek'; +import { + DeepSeekChatCompletionRequest, + DeepSeekConfig, +} from '../../../../comm/types/omni'; + +/** + * DeepSeek 大模型后台管理与调试控制器 + */ +@CoolController({ + api: [], + service: DeepSeekService, + serviceApis: [ + { method: 'getConfig', summary: '获取当前 DeepSeek 配置 (脱敏)' }, + { method: 'updateConfig', summary: '更新 DeepSeek 配置' }, + { method: 'testConnection', summary: '测试 DeepSeek 接口连通性' }, + { method: 'chatCompletion', summary: '大模型对话测试' }, + ], +}) +export class AdminDeepSeekController extends BaseController { + @Inject() + deepSeekService: DeepSeekService; + + /** + * 获取当前有效配置(脱敏 API Key) + */ + @Post('/config', { summary: '获取或保存 DeepSeek 配置' }) + async config(@Body() body?: Partial) { + if (body && Object.keys(body).length > 0) { + this.deepSeekService.updateConfig(body); + } + const current = this.deepSeekService.getConfig(); + const maskedKey = current.apiKey + ? current.apiKey.length > 8 + ? `${current.apiKey.slice(0, 4)}****${current.apiKey.slice(-4)}` + : '****' + : undefined; + + return this.ok({ + ...current, + apiKey: maskedKey, + isConfigured: this.deepSeekService.isConfigured(), + }); + } + + /** + * 测试与 DeepSeek 官方端点连通性 + */ + @Post('/test-connection', { summary: '测试连通性' }) + async testConnection(@Body('apiKey') apiKey?: string) { + const res = await this.deepSeekService.testConnection(apiKey); + return this.ok(res); + } + + /** + * 调试对话补全 + */ + @Post('/chat', { summary: '调试对话补全' }) + async chat(@Body() body: DeepSeekChatCompletionRequest) { + const reply = await this.deepSeekService.chatCompletion(body); + return this.ok({ reply }); + } +} diff --git a/backend/src/modules/ai/entity/concierge.ts b/backend/src/modules/ai/entity/concierge.ts index 8209327..0aa7b3f 100644 --- a/backend/src/modules/ai/entity/concierge.ts +++ b/backend/src/modules/ai/entity/concierge.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../../comm/entity/base'; +import { BaseEntity } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { AiConciergeConfig, diff --git a/backend/src/modules/ai/entity/glossary.ts b/backend/src/modules/ai/entity/glossary.ts index 3175f02..78f853d 100644 --- a/backend/src/modules/ai/entity/glossary.ts +++ b/backend/src/modules/ai/entity/glossary.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../../comm/entity/base'; +import { BaseEntity } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { AiGlossaryItem } from '../../../comm/types/omni'; diff --git a/backend/src/modules/ai/entity/knowledge.ts b/backend/src/modules/ai/entity/knowledge.ts index ea871bd..7ed51dd 100644 --- a/backend/src/modules/ai/entity/knowledge.ts +++ b/backend/src/modules/ai/entity/knowledge.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../../comm/entity/base'; +import { BaseEntity } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { AiKnowledgeDoc } from '../../../comm/types/omni'; diff --git a/backend/src/modules/ai/service/chat.ts b/backend/src/modules/ai/service/chat.ts index 5f40181..84788cd 100644 --- a/backend/src/modules/ai/service/chat.ts +++ b/backend/src/modules/ai/service/chat.ts @@ -1,10 +1,12 @@ -import { Provide } from '@midwayjs/core'; +import { Inject, Provide } from '@midwayjs/core'; import { BaseService, CoolCommException } from '@cool-midway/core'; import { AiChatMessage, AiChatReply, AiChatRequest, + DeepSeekChatMessage, } from '../../../comm/types/omni'; +import { DeepSeekService } from './deepseek'; /** locale 缺省值 */ const DEFAULT_LOCALE = 'en-US'; @@ -14,10 +16,13 @@ const LEAD_INTENT_PATTERN = /价格|报价|多少钱|合作|联系|price|pricing|cost|quote|cooperat|partner|contact/i; /** - * 名片内置 AI 顾问(本地 RuleBased,不调用外部 LLM) + * 名片内置 AI 顾问(支持 DeepSeek 官方大模型与本地规则平滑降级) */ @Provide() export class AiChatService extends BaseService { + @Inject() + deepSeekService?: DeepSeekService; + /** * 顾问对话 * 兼容公开端 serviceApis:POST body 为单参数对象 AiChatRequest @@ -42,9 +47,36 @@ export class AiChatService extends BaseService { const locale = req.locale?.trim() || DEFAULT_LOCALE; const shouldCollectLead = LEAD_INTENT_PATTERN.test(content); const isZh = locale.toLowerCase().startsWith('zh'); + const fallbackReply = this.buildReply(shouldCollectLead, isZh); + + let reply = fallbackReply; + + // 如果 DeepSeek 已经配置,则优先调用 DeepSeek 大模型生成智能回复 + if (this.deepSeekService?.isConfigured?.()) { + try { + const dsMessages: DeepSeekChatMessage[] = [ + { + role: 'system', + content: `你是 OmniCard 名片智能顾问。当前名片ID: ${cardId},语言偏好: ${locale}。请以专业得体的口吻向客商介绍业务与解答咨询。${shouldCollectLead ? '检测到客户具备采购或商务意向,请在回答中热情引导客户留下联系方式。' : ''}`, + }, + ...messages.map((m) => ({ + role: m.role as 'user' | 'assistant' | 'system', + content: m.content, + })), + ]; + + reply = await this.deepSeekService.chatCompletion( + { messages: dsMessages }, + fallbackReply + ); + } catch { + // 发生任何不可预测异常,均平滑降级为兜底文本,确保前端 100% 不白屏 + reply = fallbackReply; + } + } return { - reply: this.buildReply(shouldCollectLead, isZh), + reply, locale, shouldCollectLead, }; diff --git a/backend/src/modules/ai/service/deepseek.ts b/backend/src/modules/ai/service/deepseek.ts new file mode 100644 index 0000000..5d9c054 --- /dev/null +++ b/backend/src/modules/ai/service/deepseek.ts @@ -0,0 +1,257 @@ +import { Provide } from '@midwayjs/core'; +import { BaseService, CoolCommException } from '@cool-midway/core'; +import axios, { AxiosInstance } from 'axios'; +import { + DeepSeekChatCompletionRequest, + DeepSeekChatCompletionResponse, + DeepSeekConfig, + DeepSeekModel, + DeepSeekTestConnectionResult, +} from '../../../comm/types/omni'; + +/** + * DeepSeek 官方大模型适配服务 + * 支持 deepseek-chat (V3) 与 deepseek-reasoner (R1) + */ +@Provide() +export class DeepSeekService extends BaseService { + private config: DeepSeekConfig; + private httpClient: AxiosInstance; + + constructor() { + super(); + const envKey = process.env.DEEPSEEK_API_KEY?.trim() || undefined; + const envBaseUrl = process.env.DEEPSEEK_BASE_URL?.trim() || 'https://api.deepseek.com'; + const envModel = (process.env.DEEPSEEK_MODEL?.trim() as DeepSeekModel) || 'deepseek-chat'; + + this.config = { + apiKey: envKey, + baseUrl: envBaseUrl, + model: envModel, + temperature: 0.7, + maxTokens: 2048, + timeoutMs: 30000, + enabled: Boolean(envKey), + }; + + this.httpClient = axios.create({ + timeout: this.config.timeoutMs, + }); + } + + /** + * 获取当前有效配置(脱敏 API Key) + */ + getConfig(): DeepSeekConfig { + return { ...this.config }; + } + + /** + * 动态更新配置 + */ + updateConfig(partial: Partial): void { + this.config = { + ...this.config, + ...partial, + enabled: partial.apiKey ? Boolean(partial.apiKey.trim()) : this.config.enabled, + }; + if (this.config.timeoutMs) { + this.httpClient.defaults.timeout = this.config.timeoutMs; + } + } + + /** + * 检查是否已配置有效 API Key + */ + isConfigured(): boolean { + return Boolean(this.config.apiKey && this.config.apiKey.trim().length > 0); + } + + /** + * 组装名片 AI 顾问 System 提示词 + */ + buildSystemPrompt( + card?: { name?: string; company?: string; title?: string }, + concierge?: { personaType?: string; toneStyle?: string; promptTemplate?: string }, + knowledgeChunks?: Array<{ index?: number; content: string }> + ): string { + const parts: string[] = []; + + parts.push('你是 OmniCard AI 驱动的移动微官网数字人智能顾问。'); + if (card?.name) parts.push(`名片所有者姓名: ${card.name}`); + if (card?.company) parts.push(`所属企业: ${card.company}`); + if (card?.title) parts.push(`头衔职位: ${card.title}`); + + if (concierge?.promptTemplate) { + parts.push(`人设与使命:\n${concierge.promptTemplate}`); + } else { + parts.push('你的职责是以专业、礼貌、高效的口吻向访客介绍业务与产品,并在适当时机引导有意向的客户留下联系方式。'); + } + + if (Array.isArray(knowledgeChunks) && knowledgeChunks.length > 0) { + parts.push('参考企业知识库信息 (优先基于以下内容作答):'); + knowledgeChunks.forEach((c, idx) => { + parts.push(`[${idx + 1}] ${c.content}`); + }); + } + + parts.push('请直接给出自然得体的中文或对应语言回答,避免套话。'); + return parts.join('\n'); + } + + /** + * 组装出海文案润色提示词 + */ + buildLocalizePrompt( + text: string, + targetLocale: string, + tone: string, + glossaryTerms?: string[] + ): string { + const lines = [ + `请将以下商务文本改写/润色为符合目标语言(${targetLocale})与目标语调(${tone})的地道出海商业文案。`, + '要求:语义准确地道,符合国际商业习惯,禁止机械直译。', + ]; + + if (Array.isArray(glossaryTerms) && glossaryTerms.length > 0) { + lines.push('行业专有名词强制对齐规则 (严格遵照):'); + glossaryTerms.forEach((g) => lines.push(`- ${g}`)); + } + + lines.push(`\n原文:\n${text}`); + return lines.join('\n'); + } + + /** + * 组装销售 Copilot 破冰首语提示词 + */ + buildCopilotPrompt( + lead: { name?: string; company?: string; demand?: string; phone?: string }, + tracks?: Array<{ module?: string; duration?: number }> + ): string { + const lines = [ + '你是一名资深出海销售战略顾问。请根据以下潜在客户画像与其在名片上的浏览行为轨迹,生成一段极具针对性的 WhatsApp/Email 商务破冰首语建议。', + `潜客姓名: ${lead?.name || '客商'}`, + `所属公司: ${lead?.company || '出海企业'}`, + `核心诉求: ${lead?.demand || '商务洽谈'}`, + ]; + + if (Array.isArray(tracks) && tracks.length > 0) { + lines.push('访客轨迹分析:'); + tracks.forEach((t) => { + lines.push(`- 模块 [${t.module || 'portal'}], 停留时长: ${t.duration || 0}秒`); + }); + } + + lines.push('请生成:1. 客户核心意向诊断要点;2. 30秒直击痛点的破冰首语推荐。'); + return lines.join('\n'); + } + + /** + * 对话补全调用 (支持优雅降级) + */ + async chatCompletion( + req: DeepSeekChatCompletionRequest, + fallbackReply?: string + ): Promise { + const apiKey = this.config.apiKey?.trim(); + + if (!apiKey) { + if (fallbackReply !== undefined) { + return fallbackReply; + } + throw new CoolCommException('DeepSeek API Key 未配置'); + } + + const endpoint = `${this.config.baseUrl || 'https://api.deepseek.com'}/chat/completions`; + const payload = { + model: req.model || this.config.model || 'deepseek-chat', + messages: req.messages, + temperature: req.temperature ?? this.config.temperature ?? 0.7, + max_tokens: req.maxTokens ?? this.config.maxTokens ?? 2048, + stream: false, + }; + + try { + const response = await this.httpClient.post( + endpoint, + payload, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + } + ); + + const content = response.data?.choices?.[0]?.message?.content; + if (content) { + return content.trim(); + } + + if (fallbackReply !== undefined) { + return fallbackReply; + } + return 'AI 思考中,暂未生成有效回复。'; + } catch (err: any) { + if (fallbackReply !== undefined) { + return fallbackReply; + } + const errMsg = + err?.response?.data?.error?.message || err?.message || 'DeepSeek API 调用失败'; + throw new CoolCommException(`DeepSeek 服务异常: ${errMsg}`); + } + } + + /** + * 测试与 DeepSeek 官方端点连通性 + */ + async testConnection(testApiKey?: string): Promise { + const key = (testApiKey || this.config.apiKey || '').trim(); + if (!key) { + return { + success: false, + message: 'API Key 未提供,无法测试连通性', + }; + } + + const startTime = Date.now(); + const endpoint = `${this.config.baseUrl || 'https://api.deepseek.com'}/chat/completions`; + const model = this.config.model || 'deepseek-chat'; + + try { + await this.httpClient.post( + endpoint, + { + model, + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 5, + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${key}`, + }, + timeout: 10000, // 测试连通性 10 秒超时 + } + ); + + const latencyMs = Date.now() - startTime; + return { + success: true, + latencyMs, + model, + message: 'DeepSeek API 连通成功', + }; + } catch (err: any) { + const errMsg = + err?.response?.data?.error?.message || err?.message || '网络连接超时或凭证失效'; + return { + success: false, + latencyMs: Date.now() - startTime, + model, + message: `连通失败: ${errMsg}`, + }; + } + } +} diff --git a/backend/src/modules/ai/service/localize.ts b/backend/src/modules/ai/service/localize.ts index ac18abb..6180241 100644 --- a/backend/src/modules/ai/service/localize.ts +++ b/backend/src/modules/ai/service/localize.ts @@ -6,6 +6,7 @@ import { LocalizeTone, } from '../../../comm/types/omni'; import { AiGlossaryService } from './glossary'; +import { DeepSeekService } from './deepseek'; const DEFAULT_TONE: LocalizeTone = 'professional'; @@ -23,6 +24,9 @@ export class AiLocalizeService extends BaseService { @Inject() aiGlossaryService?: AiGlossaryService; + @Inject() + deepSeekService?: DeepSeekService; + /** * 按目标语言与语气改写原文骨架,并结合术语库进行精准替换 * 兼容后台 serviceApis:POST body 为单参数对象 LocalizeRequest @@ -39,7 +43,23 @@ export class AiLocalizeService extends BaseService { } const tone = this.resolveTone(req.tone); - let polishedText = this.polish(text, locale, tone); + const fallbackText = this.polish(text, locale, tone); + let polishedText = fallbackText; + + // 如果 DeepSeek 已经配置,则优先调用 DeepSeek 进行地道润色 + if (this.deepSeekService?.isConfigured?.()) { + try { + const prompt = this.deepSeekService.buildLocalizePrompt(text, locale, tone); + polishedText = await this.deepSeekService.chatCompletion( + { + messages: [{ role: 'user', content: prompt }], + }, + fallbackText + ); + } catch { + polishedText = fallbackText; + } + } if (this.aiGlossaryService?.replaceTerms) { polishedText = await this.aiGlossaryService.replaceTerms( diff --git a/backend/src/modules/base/entity/base.ts b/backend/src/modules/base/entity/base.ts index 1d60f85..c90c8a4 100644 --- a/backend/src/modules/base/entity/base.ts +++ b/backend/src/modules/base/entity/base.ts @@ -1,4 +1,4 @@ -import { Index, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { Index, PrimaryGeneratedColumn, Column, DeleteDateColumn } from 'typeorm'; import * as moment from 'moment'; import { CoolBaseEntity } from '@cool-midway/core'; @@ -45,6 +45,7 @@ export abstract class BaseEntity extends CoolBaseEntity { @Index() @Column({ + name: 'create_time', comment: '创建时间', type: 'varchar', transformer: transformerTime, @@ -53,6 +54,7 @@ export abstract class BaseEntity extends CoolBaseEntity { @Index() @Column({ + name: 'update_time', comment: '更新时间', type: 'varchar', transformer: transformerTime, @@ -60,6 +62,18 @@ export abstract class BaseEntity extends CoolBaseEntity { updateTime: Date; @Index() - @Column({ comment: '租户ID', nullable: true }) + @Column({ + name: 'tenant_id', + comment: '租户ID', + nullable: true, + }) tenantId: number; + + @Index() + @DeleteDateColumn({ + name: 'deleted_at', + comment: '软删除时间', + nullable: true, + }) + deletedAt?: Date | null; } diff --git a/backend/src/modules/card/entity/article.ts b/backend/src/modules/card/entity/article.ts index 49acf68..196fcae 100644 --- a/backend/src/modules/card/entity/article.ts +++ b/backend/src/modules/card/entity/article.ts @@ -1,4 +1,4 @@ -import { BaseEntity, transformerJson } from '../../../comm/entity/base'; +import { BaseEntity, transformerJson } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import type { ArticleOgMeta, CardArticle } from '../../../comm/types/omni'; diff --git a/backend/src/modules/card/entity/bento.ts b/backend/src/modules/card/entity/bento.ts index cb888b5..7c61099 100644 --- a/backend/src/modules/card/entity/bento.ts +++ b/backend/src/modules/card/entity/bento.ts @@ -1,4 +1,4 @@ -import { BaseEntity, transformerJson } from '../../../comm/entity/base'; +import { BaseEntity, transformerJson } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import type { BentoBlock, diff --git a/backend/src/modules/card/entity/cases.ts b/backend/src/modules/card/entity/cases.ts index 4be78e4..fa75b69 100644 --- a/backend/src/modules/card/entity/cases.ts +++ b/backend/src/modules/card/entity/cases.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../../comm/entity/base'; +import { BaseEntity } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import type { CardCase } from '../../../comm/types/omni'; diff --git a/backend/src/modules/card/entity/info.ts b/backend/src/modules/card/entity/info.ts index afb6cbe..bbe0d29 100644 --- a/backend/src/modules/card/entity/info.ts +++ b/backend/src/modules/card/entity/info.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../../comm/entity/base'; +import { BaseEntity } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { CardProfile, diff --git a/backend/src/modules/card/entity/locale.ts b/backend/src/modules/card/entity/locale.ts index 271bc9b..6c0c851 100644 --- a/backend/src/modules/card/entity/locale.ts +++ b/backend/src/modules/card/entity/locale.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../../comm/entity/base'; +import { BaseEntity } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; /** diff --git a/backend/src/modules/crm/entity/audit.ts b/backend/src/modules/crm/entity/audit.ts index 3145160..b55fb3e 100644 --- a/backend/src/modules/crm/entity/audit.ts +++ b/backend/src/modules/crm/entity/audit.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../../comm/entity/base'; +import { BaseEntity } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { LeadPoolAction } from '../../../comm/types/omni'; diff --git a/backend/src/modules/crm/entity/lead.ts b/backend/src/modules/crm/entity/lead.ts index e11e225..6cc59ac 100644 --- a/backend/src/modules/crm/entity/lead.ts +++ b/backend/src/modules/crm/entity/lead.ts @@ -1,4 +1,4 @@ -import { BaseEntity, transformerTime } from '../../../comm/entity/base'; +import { BaseEntity, transformerTime } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { LeadEntity as ILeadEntity, @@ -118,4 +118,4 @@ export class CrmLeadEntity extends BaseEntity implements ILeadEntity { lastFollowUpTime?: Date | string | null; } -export { CrmLeadEntity as LeadInfoEntity, CrmLeadEntity as LeadEntity }; +export { CrmLeadEntity as LeadInfoEntity }; diff --git a/backend/src/modules/crm/entity/track.ts b/backend/src/modules/crm/entity/track.ts index 8d03b9e..6753827 100644 --- a/backend/src/modules/crm/entity/track.ts +++ b/backend/src/modules/crm/entity/track.ts @@ -1,4 +1,4 @@ -import { BaseEntity, transformerJson } from '../../../comm/entity/base'; +import { BaseEntity, transformerJson } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { RadarTrack, TouchType } from '../../../comm/types/omni'; diff --git a/backend/src/modules/nfc/entity/device.ts b/backend/src/modules/nfc/entity/device.ts index 440d49e..9be19a5 100644 --- a/backend/src/modules/nfc/entity/device.ts +++ b/backend/src/modules/nfc/entity/device.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../../comm/entity/base'; +import { BaseEntity } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { NFCMaterial, diff --git a/backend/src/modules/nfc/entity/rule.ts b/backend/src/modules/nfc/entity/rule.ts index a36a4aa..31034e7 100644 --- a/backend/src/modules/nfc/entity/rule.ts +++ b/backend/src/modules/nfc/entity/rule.ts @@ -1,4 +1,4 @@ -import { BaseEntity, transformerJson } from '../../../comm/entity/base'; +import { BaseEntity, transformerJson } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { NFCSceneMode, NFCSceneRule } from '../../../comm/types/omni'; diff --git a/backend/src/modules/team/entity/brand.ts b/backend/src/modules/team/entity/brand.ts index 90bea35..e33b60c 100644 --- a/backend/src/modules/team/entity/brand.ts +++ b/backend/src/modules/team/entity/brand.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../../comm/entity/base'; +import { BaseEntity } from '../../base/entity/base'; import { Column, Entity } from 'typeorm'; /** diff --git a/backend/src/modules/team/entity/domain.ts b/backend/src/modules/team/entity/domain.ts index ceddb6b..49b69c3 100644 --- a/backend/src/modules/team/entity/domain.ts +++ b/backend/src/modules/team/entity/domain.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../../comm/entity/base'; +import { BaseEntity } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { DomainSslStatus, TenantDomain } from '../../../comm/types/omni'; diff --git a/backend/src/modules/team/entity/handover-log.ts b/backend/src/modules/team/entity/handover-log.ts index 594bbf1..6375609 100644 --- a/backend/src/modules/team/entity/handover-log.ts +++ b/backend/src/modules/team/entity/handover-log.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../../comm/entity/base'; +import { BaseEntity } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { HandoverLeadAction, diff --git a/backend/src/modules/team/entity/invoice.ts b/backend/src/modules/team/entity/invoice.ts index 47a3439..2f75df7 100644 --- a/backend/src/modules/team/entity/invoice.ts +++ b/backend/src/modules/team/entity/invoice.ts @@ -1,4 +1,4 @@ -import { BaseEntity, transformerJson } from '../../../comm/entity/base'; +import { BaseEntity, transformerJson } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { InvoiceBillingInfo, diff --git a/backend/src/modules/team/entity/subscription.ts b/backend/src/modules/team/entity/subscription.ts index 23606c7..bdb279e 100644 --- a/backend/src/modules/team/entity/subscription.ts +++ b/backend/src/modules/team/entity/subscription.ts @@ -1,4 +1,4 @@ -import { BaseEntity } from '../../../comm/entity/base'; +import { BaseEntity } from '../../base/entity/base'; import { Column, Entity, Index } from 'typeorm'; import { TenantPlanType, StripeBillingCycle } from '../../../comm/types/omni'; diff --git a/backend/test/e2e/omnicard-30-points.test.ts b/backend/test/e2e/omnicard-30-points.test.ts index 35af791..7c9009a 100644 --- a/backend/test/e2e/omnicard-30-points.test.ts +++ b/backend/test/e2e/omnicard-30-points.test.ts @@ -1057,7 +1057,9 @@ describe('OmniCard AI 30-Point End-to-End Pipeline Integration Suite', () => { expect(activeDevice?.status).toBe('active'); }); - it('P26: 展会批量制卡清单 CSV 与二维码离线 ZIP 压缩包归档导出', async () => { + it( + 'P26: 展会批量制卡清单 CSV 与二维码离线 ZIP 压缩包归档导出', + async () => { const mockBatchDevices = [ { uid: sharedNfcUid, @@ -1092,7 +1094,7 @@ describe('OmniCard AI 30-Point End-to-End Pipeline Integration Suite', () => { expect(csvText).toContain('出厂防伪码'); expect(csvText).toContain(sharedNfcUid); expect(csvText).toContain('Alex Zheng - VP Global'); - }); + }, 15000); }); // ========================================================================= diff --git a/backend/test/modules/ai/admin-deepseek.test.ts b/backend/test/modules/ai/admin-deepseek.test.ts new file mode 100644 index 0000000..5dcdcd1 --- /dev/null +++ b/backend/test/modules/ai/admin-deepseek.test.ts @@ -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; + + 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: '测试' }], + }); + }); +}); diff --git a/backend/test/modules/ai/chat.test.ts b/backend/test/modules/ai/chat.test.ts index bb06baa..e8236fa 100644 --- a/backend/test/modules/ai/chat.test.ts +++ b/backend/test/modules/ai/chat.test.ts @@ -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', () => { diff --git a/backend/test/modules/ai/deepseek.test.ts b/backend/test/modules/ai/deepseek.test.ts new file mode 100644 index 0000000..e019385 --- /dev/null +++ b/backend/test/modules/ai/deepseek.test.ts @@ -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'); + }); + }); +});