主要改动: fix(docker/dify-deploy): 修复脚本核心功能 - heredoc 单引号 bug: 'ENVEOF' 改为 ENVEOF,变量正确展开 - 端口默认值 8083/8084/8085 对齐 .env.prod 生产配置 - 加 dc_cmd() 兼容 docker-compose v1/v2 plugin - openssl rand 生成强随机密码与 SECRET_KEY(42 字符) - install 跳过已存在 .env,保护用户配置(管理员密码/SECRET_KEY) - read -p < /dev/tty 兼容非 tty 环境(CI/CD) - show-config 改用 DIFY_NGINX_PORT(nginx 入口)而非 APP_WEB_PORT docs(mvp-design): 修正 §3.2 workflow inputs 描述 - 实际只有 query,删除错误的 user_id input 声明 - 节点序列图同步更新 feat(aiChatService): 新增 Dify 客户端与适配器 - service/dify_client.go: Dify Workflow 调用 + SSE 解析 - service/dify_adapter.go: 与现有 chat_service 桥接 - provider/ai_chat_provider.go: Dubbo 入口简化 - main.go: 装配 ConversationRepository + DifyClient feat(migrations): 新增 AI 搭子会话表 ai_chat.sql - ai_conversations / ai_messages 表 + 索引 docs: 新增 Dify 集成设计文档 - 2026-06-29-ai-chat-dify-mvp-design.md (MVP 实施级) - 2026-06-29-ai-chat-dify-integration-v2-design.md (V2 演进路线图) - docs/dify/角角.yml (Workflow DSL 导出) config: 更新 env 模板与 docker 配置 - backend/.env.example: DIFY_* 环境变量声明 - docker/.env.prod: DIFY_API_BASE 对齐 8083 - docker/build.sh: 微调 - CLAUDE.md: 项目规范补充 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1432 lines
55 KiB
Markdown
1432 lines
55 KiB
Markdown
# AI 搭子对接 Dify 知识库 - V2 完整架构设计
|
||
|
||
> ⚠️ **本文档状态(2026-06-29 重建)**:
|
||
> - V2 是"100 明星 + 多 AI 平台"的**完整架构设计**
|
||
> - **MVP 阶段不实施**——MVP 阶段只做 1 个明星(肖战)+ 1 个 Dify Workflow
|
||
> - **MVP 实施级方案**:[2026-06-29-ai-chat-dify-mvp-design.md](2026-06-29-ai-chat-dify-mvp-design.md)(**先读这个**)
|
||
> - V2 文档保留作为 Stage 2+ 的**演进路线图**和**详细代码参考**
|
||
|
||
> ★ **MVP 优先原则**:MVP 阶段不实施本文档中"为未来 100 明星 + 多 AI 平台"准备的设计。请先按 MVP 实施(约 1 周),跑通后再按本文档的 §10 演进。
|
||
|
||
---
|
||
|
||
## 〇、文档说明
|
||
|
||
- **V2 适用范围**:所有 star_id,所有 AI 平台对接(Dify 先行)
|
||
- **V2 工作量**:约 4-5 周(含 Dify 端 + 后端 + 测试)—— **不要分配实施时间,按业务节奏推进**
|
||
- **目标状态**:长期可维护、可扩展(30+ 明星 + 多 AI 平台)
|
||
- **V1 → V2 演进**:V1 评审发现"一星一 App / API Key 散落 / Prompt 边界混乱 / 上下文重复 / 会话仅 Redis / Fallback 副作用 / 抽象太浅 / if-else 工厂" 8 大问题,V2 全部解决(详见 §1.2 和 §14)
|
||
|
||
### 〇.1 方案概述(★ 必读)
|
||
|
||
#### 〇.1.1 我们要解决的问题
|
||
|
||
**业务问题**:追星 App 用户与"角角"AI 搭子聊天时,AI 没有**专属知识库**,无法针对所追明星(默认肖战)给出有针对性的回答。
|
||
|
||
**技术问题**(4 大类):
|
||
1. **缺乏 RAG 能力**:现有 AI 只能用通用 LLM 知识,无明星专属数据
|
||
2. **会话无持久化**:当前仅 Redis 缓存 24h,用户次日回来会失忆
|
||
3. **AI 平台耦合**:未来要接 Coze/FastGPT/OpenAI 等
|
||
4. **运营成本失控**:扩到 100 明星时,配置/版本/审计会爆炸
|
||
|
||
#### 〇.1.2 整体实现路径
|
||
|
||
```
|
||
数据迁移 Dify 端准备 后端代码 联调测试
|
||
├ 建 2 张表 ├ 准备知识库 ├ model ├ 内部账号测试
|
||
├ ├ 创 Workflow ├ repository ├ WebSocket 联调
|
||
└ └ 不需要 Code 节点 └ service └ 错误注入
|
||
└ provider
|
||
└ main.go 装配
|
||
```
|
||
|
||
**不要分配实施时间**——按业务节奏推进。
|
||
|
||
#### 〇.1.3 关键决策
|
||
|
||
| 决策项 | V2 选择 | 理由 |
|
||
|--------|---------|------|
|
||
| Dify 架构 | **Workflow**(不是 Chat App) | Workflow 支持 Knowledge Retrieval Node 动态选 dataset_id |
|
||
| 多星隔离 | Backend 维护 `star_id → dataset_id` 映射 | Dify 端 Workflow 内部用变量查 |
|
||
| API Key | **全局 1 个** | App 重建/删除不影响 Backend |
|
||
| Prompt 责任 | **Dify 路径:Dify 负责;MiniMax 路径:Backend 拼 system prompt** | Dify 端用 Workflow 模板组装;MiniMax 端无 Dify 替代 |
|
||
| 会话存储 | PostgreSQL 主,Redis 1h 缓存 | 永久保留,跨设备同步 |
|
||
| Backend context 维护 | **删除** SaveContext 给 LLM 用 | Dify 内部管 LLM 上下文 |
|
||
| Fallback 策略 | **仅无 conversation 时**允许 | 已开 conversation 失败则报错,不切 LLM |
|
||
| Provider 抽象 | `AIProvider` 复合接口 | LLM + RAG + Conversation 一起实现 |
|
||
|
||
#### 〇.1.4 核心架构图(TL;DR)
|
||
|
||
```
|
||
Mobile App
|
||
│ WebSocket
|
||
▼
|
||
Gateway (不感知 Provider)
|
||
│ Dubbo Triple
|
||
▼
|
||
AIChatService
|
||
│
|
||
├─ ChatEngine (核心编排)
|
||
│ ├─ AuditService (前置 + 后置 + 滑动窗口)
|
||
│ ├─ MemoryStore (长期记忆)
|
||
│ ├─ RedisLock (防并发 race)
|
||
│ └─ ProviderFactory (按 star_id 选 Provider)
|
||
│
|
||
├─ DifyProvider (协调整合)
|
||
│ ├─ DatasetResolver (star_id → dataset_id)
|
||
│ ├─ WorkflowClient (Dify HTTP + SSE)
|
||
│ ├─ HistoryClient (Dify /v1/messages)
|
||
│ └─ ConversationStore (缓存 + 持久化)
|
||
│
|
||
└─ MiniMaxProvider (直接 LLM + 本地 Conversation)
|
||
|
||
外部:Dify Workflow (开始 → 知识检索 → LLM → 内容审核 → 结束)
|
||
dataset_id 由 Backend 传入,Workflow 不再维护 mapping
|
||
```
|
||
|
||
#### 〇.1.5 演进路径(参见 [MVP §10](2026-06-29-ai-chat-dify-mvp-design.md#十stage-2-演进路径))
|
||
|
||
> ★ **Stage 2+ 必踩的 3 个坑**(P0 修复)和 **3 个架构评审笔记**已迁移到 MVP §10。
|
||
> 实施 V2 任何章节前,**先读 MVP §10** 的"演进时必踩的坑"。
|
||
|
||
---
|
||
|
||
## 一、背景与目标
|
||
|
||
### 1.1 现状
|
||
|
||
| 维度 | 现状 |
|
||
|------|------|
|
||
| AI 角色 | 所有人共用"角角"默认人设,与"星"无关 |
|
||
| LLM | MiniMax `M2-her`([llm_service.go](backend/services/aiChatService/service/llm_service.go)) |
|
||
| star_id 传递 | JWT → middleware → Dubbo Attachments → AIChat 解析,链路完整 |
|
||
| 知识维度 | **缺失**。当前 AI 无法获取所追明星的事实性资料 |
|
||
| Dify 现状 | Gateway 侧 [dify_client.go](backend/gateway/service/dify_client.go) 已存在,但仅服务"镭射卡"链路 |
|
||
| 会话存储 | Redis context,24h TTL(**无持久化**) |
|
||
|
||
### 1.2 V1 方案已识别的设计缺陷(架构评审结论)
|
||
|
||
> V1 评审打分:工程完整度 9/10,可维护性 6.5/10,长期扩展性 5.5/10,与 Dify 结合方式 6/10
|
||
|
||
| 缺陷 | V1 做法 | 后果 |
|
||
|------|---------|------|
|
||
| **一星一 App** | 每个明星独立 Chat App + Dataset + API Key | 100 星 = 100 个 App,运营改 prompt 要改 100 次 |
|
||
| **API Key 数量爆炸** | `star_app_mapping: {87: app-xxx, 88: app-yyy}` | Key 跟业务身份绑定,App 重建/删除会全失效 |
|
||
| **Prompt 边界混乱** | Dify system prompt + Backend persona 双写 | 两边都在管"人设",冲突不可避免 |
|
||
| **上下文重复维护** | Dify Conversation + Backend Redis Context 双份 | 必然漂移 |
|
||
| **会话仅在 Redis** | conversation_id 24h TTL | 用户次日重连会失忆 |
|
||
| **Fallback 有副作用** | 失败即切 MiniMax | 中途切会丢上下文 |
|
||
| **Provider 抽象太浅** | 只抽 LLMProvider | 接 Coze/FastGPT 时发现 Knowledge/Conversation 硬编码了 Dify |
|
||
| **if-else 工厂** | Provider 内部 `if useDify { ... } else { ... }` | 扩展性差 |
|
||
|
||
### 1.3 V2 目标
|
||
|
||
1. ✅ **运营成本 O(1)**:改 prompt 只改 1 处
|
||
2. ✅ **会话永久保留**:用户 1 周后重连,能继续上次对话
|
||
3. ✅ **职责清晰**:Dify 路径 Backend 不参与 prompt 组装
|
||
4. ✅ **可扩展**:未来接 Coze/FastGPT,只需实现 `AIProvider` 接口
|
||
5. ✅ **风险可控**:渐进式上线,配置回滚
|
||
|
||
### 1.4 V2 关键决策
|
||
|
||
| 决策项 | V2 选择 | 理由 |
|
||
|--------|---------|------|
|
||
| Dify 架构 | **Workflow**(不是 Chat App) | Workflow 支持 Knowledge Retrieval Node 动态选 dataset_id |
|
||
| 多星隔离 | Backend 维护 `star_id → dataset_id` 映射 | Dify 端 Workflow 内部用变量查 |
|
||
| API Key | **全局 1 个** | App 重建/删除不影响 Backend |
|
||
| Prompt 责任 | **Dify 路径:Dify 负责;MiniMax 路径:Backend 拼 system prompt** | Dify 端用 Workflow 模板组装;MiniMax 端无 Dify 替代,Backend 沿用 [prompt_builder.go](backend/services/aiChatService/service/prompt_builder.go) |
|
||
| 会话存储 | PostgreSQL 主,Redis 1h 缓存 | 永久保留,跨设备同步 |
|
||
| Backend context 维护 | **删除** SaveContext 给 LLM 用 | Dify 内部管 LLM 上下文 |
|
||
| Fallback 策略 | **仅无 conversation 时**允许 | 已开 conversation 失败则报错,不切 LLM |
|
||
| Provider 抽象 | `AIProvider` 复合接口 | LLM + RAG + Conversation 一起实现 |
|
||
|
||
---
|
||
|
||
## 二、整体架构
|
||
|
||
### 2.1 V2 分层架构
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Mobile App (UniApp) │
|
||
│ WebSocket /ai-chat?token=Bearer_xxx │
|
||
│ 协议:保持不变(仅服务端内部切换 Provider) │
|
||
└─────────────────────────────────┬───────────────────────────────────────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Gateway (:8080) │
|
||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ AIChatWebSocketHandler (Hub) │ │
|
||
│ │ 透传到 AIChatService,不感知 AI Provider │ │
|
||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||
│ │ Dubbo Triple │
|
||
└──────────────────────────────┼──────────────────────────────────────────────┘
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ AIChatService (:20008) │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ Chat Engine (核心编排) │ │
|
||
│ │ 职责: │ │
|
||
│ │ - 鉴权 / 审计 / 限流(横切关注点) │ │
|
||
│ │ - 选 AIProvider(按 star_id) │ │
|
||
│ │ - 调 MemoryStore 召回长期记忆 │ │
|
||
│ │ - 调 AIProvider.GetConversation / StreamChat │ │
|
||
│ │ - 流式审核 + 推 WebSocket │ │
|
||
│ │ - 调 AIProvider.AppendMessage 保存 │ │
|
||
│ │ - 调 AIProvider.UpdateExternalID 持久化 Dify conv_id │ │
|
||
│ │ - 调 GetWelcomeMessage(InitSession) │ │
|
||
│ │ - 调 MemoryStore.ExtractMemory(每 N 轮) │ │
|
||
│ │ Chat Engine 不知道 Dify / MiniMax 是什么,只跟 AIProvider 打交道 │ │
|
||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ AIProvider (复合接口) │ │
|
||
│ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │
|
||
│ │ │ DifyProvider │ │ MiniMaxProvider │ │ CozeProvider │ │ │
|
||
│ │ │ (当前实现) │ │ (当前实现) │ │ (未来) │ │ │
|
||
│ │ │ LLM ✓ │ │ LLM ✓ │ │ LLM ✓ │ │ │
|
||
│ │ │ RAG ✓ (Dify 知识)│ │ RAG ✗ (无) │ │ RAG ✓ (Coze KB) │ │ │
|
||
│ │ │ Conversation ✓ │ │ Conversation ✓ │ │ Conversation ✓ │ │ │
|
||
│ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ │
|
||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||
└────────────────────────┬────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────────────┐
|
||
│ Dify Server │
|
||
│ POST /v1/workflows/run │
|
||
│ { │
|
||
│ inputs: { │
|
||
│ dataset_id, │
|
||
│ query, │
|
||
│ user_*, │
|
||
│ }, │
|
||
│ user, response_mode, │
|
||
│ conversation_id │
|
||
│ } │
|
||
│ │
|
||
│ Workflow 内部: │
|
||
│ 1. Knowledge Retrieval 节点 │
|
||
│ 2. LLM 节点 │
|
||
│ 3. 内容审核节点 │
|
||
│ 4. 输出 │
|
||
└──────────────────────────────┘
|
||
```
|
||
|
||
### 2.2 核心数据流(一次完整对话)
|
||
|
||
```
|
||
[Mobile]
|
||
│ WebSocket send {action: "send_message", session_id, message, persona_id}
|
||
│
|
||
▼
|
||
[Gateway Hub]
|
||
│ 鉴权 (JWT → user_id, star_id)
|
||
│
|
||
▼
|
||
[AIChatService Provider.SendMessage]
|
||
│
|
||
├─ 1. 前置审核 (AuditService.AuditText)
|
||
│
|
||
├─ 2. 选 AIProvider (ProviderFactory.Select(starID))
|
||
│ ├─ dify.star_dataset_mapping 命中 → DifyProvider
|
||
│ └─ 未命中 → MiniMaxProvider
|
||
│
|
||
├─ 3. 长期记忆召回 (MemoryStore.Recall)
|
||
│
|
||
├─ 4. 加分布式锁 (RedisLock.Acquire)
|
||
│ └─ conv_lock:{userId}:{starId} 30s TTL
|
||
│
|
||
├─ 5. 获取/创建会话
|
||
│ └─ DifyProvider.GetOrCreateConversation(key)
|
||
│ └─ ★ ConversationStore 内部封装
|
||
│
|
||
├─ 6. 构造请求 (ChatRequest)
|
||
│
|
||
├─ 7. 调 Provider.StreamChat
|
||
│ ├─ Dify 路径: DatasetResolver + WorkflowClient.StreamRun
|
||
│ └─ MiniMax 路径: 调 MiniMax API
|
||
│
|
||
├─ 8. 流式返回 + 逐 token 后置审核
|
||
│
|
||
├─ 9. 更新 Dify conv_id
|
||
│
|
||
├─ 10. 保存会话消息
|
||
│
|
||
└─ 11. 记忆提取 (每 5 轮)
|
||
```
|
||
|
||
### 2.3 Provider 决策点
|
||
|
||
```go
|
||
// ProviderFactory 单例,按 star_id 选 AIProvider
|
||
type ProviderFactory struct {
|
||
difyConfig model.DifyConfig
|
||
difyProvider AIProvider
|
||
miniMaxProvider AIProvider
|
||
}
|
||
|
||
func (f *ProviderFactory) Select(starID int64) AIProvider {
|
||
if !f.difyConfig.Enable {
|
||
return f.miniMaxProvider
|
||
}
|
||
if _, ok := f.difyConfig.StarDatasetMapping[starID]; ok {
|
||
return f.difyProvider
|
||
}
|
||
return f.miniMaxProvider
|
||
}
|
||
|
||
func (f *ProviderFactory) SelectFallback(currentName string) AIProvider {
|
||
if !f.difyConfig.FallbackToMiniMax {
|
||
return nil
|
||
}
|
||
if currentName == "dify" {
|
||
return f.miniMaxProvider
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 三、Dify 端准备工作
|
||
|
||
> **前置条件**:需要 1 名 Dify 管理员账号(产品/运营),负责 Datasets、Workflow 维护。
|
||
|
||
### 3.1 准备数据集(Datasets)
|
||
|
||
**为每个明星创建一个 Dataset**:
|
||
|
||
1. 登录 Dify → "知识库" → "创建知识库"
|
||
2. 命名规范:`star-{identity_id}-kb`(如 `star-xz-kb`、`star-wyb-kb`)
|
||
3. 选择索引模式:`high_quality`(embedding + 向量检索)
|
||
4. 导入资料(多种方式任选):
|
||
- **文件上传**:PDF / Word / Markdown / CSV
|
||
- **Notion 同步**:绑定 Notion 数据库
|
||
- **Web 同步**:抓取官网、官方微博、官方粉丝站
|
||
- **API 同步**:Dify 提供 `/v1/datasets/{id}/document/create_by_file`
|
||
5. **配置分段**:
|
||
- 段落长度:500-1024 tokens
|
||
- 分段 overlap:50-100
|
||
6. **等待所有文档向量化完成**(Dify UI 每个文档显示 ✓ 后才继续)
|
||
|
||
**Dataset ID 记录**:每个 Dataset 创建后获得 UUID。
|
||
|
||
### 3.2 创建统一 Workflow
|
||
|
||
1. 进入"工作室" → "创建空白应用" → 类型选 **Workflow**
|
||
2. 命名:`star-chat-workflow`(全平台共用)
|
||
3. 配置"开始"节点的 Input 变量:
|
||
```yaml
|
||
inputs:
|
||
- name: dataset_id # string, ★ Backend 传入
|
||
type: text
|
||
- name: query # string, 用户原始消息
|
||
type: text
|
||
- name: user_nickname # string
|
||
type: text
|
||
- name: user_memory # string, optional
|
||
type: paragraph
|
||
- name: user_style # string, optional
|
||
type: text
|
||
- name: user_language # string, default "zh-CN"
|
||
type: text
|
||
```
|
||
4. 添加 **知识检索节点 (Knowledge Retrieval)**:
|
||
- Knowledge:选"运行时根据输入选择"
|
||
- 变量:`{{ dataset_id }}`
|
||
- Query:`{{ query }}`
|
||
- TopK:3
|
||
5. 添加 **LLM 节点**:
|
||
```markdown
|
||
你是一个专属 AI 搭子,名字叫"角角"。
|
||
|
||
【用户称呼】请称呼用户为"{{ user_nickname }}"
|
||
【说话风格】{{ user_style }}
|
||
【语言】{{ user_language }}
|
||
|
||
# 用户原始问题
|
||
{{ query }}
|
||
|
||
# 用户长期记忆
|
||
{{ user_memory }}
|
||
|
||
# 知识库检索结果
|
||
{{#knowledge_retrieval_node.result#}}
|
||
|
||
请基于以上信息回答用户问题。
|
||
```
|
||
6. 添加"内容审核"节点:
|
||
- 类型:Dify 原生"内容审核"节点
|
||
- 位置:LLM 节点 → 内容审核节点 → 结束节点
|
||
- 配置:
|
||
- moderation_type: `keywords`
|
||
- keywords: 敏感词列表(每行一个)
|
||
- on_failure: `replace_with_text` = "抱歉,这个话题我无法继续,我们换个话题聊聊吧。"
|
||
7. 添加"结束"节点
|
||
8. **调试**:传 `inputs={dataset_id, query, user_*}` 验证 RAG 召回
|
||
9. **发布** → 复制 API Key
|
||
|
||
### 3.3 单一数据源原则
|
||
|
||
> **★ 关键设计原则**:dataset_id 的"star → dataset"映射**只在 Backend 维护**(`ai_chat_configs.dify.star_dataset_mapping`),Workflow 不感知 star 概念。
|
||
|
||
```
|
||
[Backend DifyProvider] [Dify Workflow]
|
||
│ │
|
||
│ 1. SELECT dify.star_dataset_mapping │
|
||
│ WHERE star_id = 87 │
|
||
│ → dataset_id = "ds-xxxx" │
|
||
│ │
|
||
│ 2. POST /v1/workflows/run │
|
||
│ inputs: { │
|
||
│ dataset_id: "ds-xxxx", ←────────┼──→ 开始节点
|
||
│ query, user_* │
|
||
│ } │
|
||
```
|
||
|
||
**拒绝的做法**(V1 错误示例):
|
||
```python
|
||
# ❌ Workflow 内部维护映射(数据冗余)
|
||
STAR_DATASET_MAP = {
|
||
"87": "ds-aaaa-xxxx",
|
||
"88": "ds-bbbb-yyyy",
|
||
}
|
||
```
|
||
|
||
**为什么错**:
|
||
- Backend 改 mapping → 忘改 Workflow → 用户问"肖战"答"王一博"的资料
|
||
- 运营修改 Dataset ID → 必须同步两个地方
|
||
|
||
**Workflow 内部节点串联**:
|
||
```
|
||
[开始] inputs:{dataset_id, query, user_*}
|
||
↓
|
||
[知识检索] dataset_id 直接使用
|
||
↓
|
||
[LLM] prompt 模板
|
||
↓
|
||
[内容审核]
|
||
↓
|
||
[结束]
|
||
```
|
||
|
||
### 3.4 关于"换星新建会话"
|
||
|
||
- `(user_id, star_id)` 是 ai_conversations 表的唯一键
|
||
- 用户从 star=87 切到 star=88:自动建新会话
|
||
- 用户从 star=88 切回 star=87:恢复原会话
|
||
- **这是预期行为**
|
||
|
||
### 3.5 知识库自动同步(未来项目)
|
||
|
||
> V2 不在 AIChat 范围,但留出接口。
|
||
|
||
- 独立 Worker 负责:拉取 RSS / 微博 / 公众号 / Notion;去重;调 Dify API
|
||
- AIChat 端不感知此 Worker 存在
|
||
|
||
---
|
||
|
||
## 四、配置与数据层
|
||
|
||
### 4.1 ai_chat_configs 新增数据
|
||
|
||
```sql
|
||
INSERT INTO ai_chat_configs (config_key, config_value, config_type, category, description, is_encrypted) VALUES
|
||
('dify.enable', 'false', 'boolean', 'dify', 'Dify 集成总开关', FALSE),
|
||
('dify.api_base', 'https://api.dify.ai/v1', 'string', 'dify', 'Dify API 基础 URL', FALSE),
|
||
('dify.workflow_url', '/workflows/run', 'string', 'dify', 'Workflow API 路径', FALSE),
|
||
('dify.star_dataset_mapping', '{"87":"ds-aaaa-xxxx"}', 'json', 'dify', 'star_id → Dataset ID 映射', FALSE),
|
||
('dify.api_key', 'app-xxxxxxxxxxxx', 'string', 'dify', 'Dify Workflow API Key', TRUE),
|
||
('dify.timeout_sec', '60', 'number', 'dify', 'Dify 单次请求超时(秒)', FALSE),
|
||
('dify.user_id_salt', 'topfans-default-salt-CHANGE-ME', 'string', 'dify', 'Dify user 字段哈希盐值', TRUE),
|
||
('dify.retry_count', '2', 'number', 'dify', 'Dify 单次失败重试次数', FALSE),
|
||
('dify.fallback_to_minimax', 'false', 'boolean', 'dify', 'Dify 失败后是否 fallback', FALSE);
|
||
```
|
||
|
||
### 4.2 PostgreSQL 新增表:ai_conversations
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS ai_conversations (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
user_id BIGINT NOT NULL,
|
||
star_id BIGINT NOT NULL,
|
||
provider_name VARCHAR(32) NOT NULL,
|
||
external_conversation_id VARCHAR(128),
|
||
message_count INT DEFAULT 0,
|
||
last_active_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
|
||
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
|
||
updated_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT,
|
||
CONSTRAINT uk_ai_conv_user_star UNIQUE (user_id, star_id)
|
||
);
|
||
CREATE INDEX idx_ai_conversations_user_star ON ai_conversations(user_id, star_id);
|
||
CREATE INDEX idx_ai_conversations_last_active ON ai_conversations(last_active_at DESC);
|
||
COMMENT ON TABLE ai_conversations IS 'AI 会话元数据';
|
||
|
||
-- AI 消息表(仅 MiniMax 路径使用)
|
||
CREATE TABLE IF NOT EXISTS ai_messages (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
conversation_id BIGINT NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
|
||
role VARCHAR(16) NOT NULL,
|
||
content TEXT NOT NULL,
|
||
created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT
|
||
);
|
||
CREATE INDEX idx_ai_messages_conversation ON ai_messages(conversation_id, created_at);
|
||
COMMENT ON TABLE ai_messages IS 'AI 消息表';
|
||
```
|
||
|
||
### 4.3 Redis 角色
|
||
|
||
| Key 模式 | Value | TTL | 角色 |
|
||
|----------|-------|-----|------|
|
||
| `conv_meta:{userId}:{starId}` | JSON 会话元数据 | 1h | ai_conversations 读缓存 |
|
||
| `msg_cache:{conversation_id}` | JSON 消息列表 | 10min | GetHistory 响应缓存 |
|
||
| `ai_user_memories` 相关 | 不变 | 24h | 长期记忆召回缓存 |
|
||
| `conv_lock:{userId}:{starId}` | token | 30s | 分布式锁(★ P0-1) |
|
||
|
||
> **关键**:`conv_meta` 缓存**只是性能优化**。Redis flush 不影响数据完整性。
|
||
|
||
---
|
||
|
||
## 五、代码改造
|
||
|
||
### 5.1 改造总览
|
||
|
||
| 层级 | 改动 | 工作量 |
|
||
|------|------|--------|
|
||
| `service/ai_provider.go` | **新增**:`AIProvider` 复合接口 + 数据结构 | 中 |
|
||
| `service/dify_provider.go` | **新增**:`DifyProvider` | 中 |
|
||
| `service/dify_workflow_client.go` | **新增**:Dify HTTP + SSE | 中 |
|
||
| `service/dify_history_client.go` | **新增**:Dify /v1/messages | 中 |
|
||
| `service/dataset_resolver.go` | **新增**:star_id → dataset_id | 小 |
|
||
| `service/conversation_store.go` | **新增**:ConversationStore 接口 + CachedConversationStore | 中 |
|
||
| `service/minimax_provider.go` | **新增**:`MiniMaxProvider` | 中 |
|
||
| `service/chat_engine.go` | **新增**:核心编排 | 中 |
|
||
| `service/provider_factory.go` | **新建**:按 star_id 选 Provider | 中 |
|
||
| `service/redis_lock.go` | **新建**:RedisLock | 中 |
|
||
| `model/ai_chat_models.go` | **新增**:`DifyConfig` / `Conversation` / `AIMessage` | 小 |
|
||
| `repository/conversation_repository.go` | **新建**:ai_conversations GORM 仓储 | 中 |
|
||
| `repository/message_repository.go` | **新建**:ai_messages GORM 仓储 | 中 |
|
||
| `provider/ai_chat_provider.go` | **大幅简化**:只做参数解析 + 鉴权 + 调 ChatEngine | 小 |
|
||
| `main.go` | **改造**:装配 ProviderFactory + ChatEngine | 小 |
|
||
| `migrations/ai_conversations.sql` | **新增**:上述 DDL | 小 |
|
||
| **前端** | **无改动** ✅ | 0 |
|
||
|
||
**总工作量估计**:核心代码 ~1500-2000 行
|
||
|
||
### 5.2 AIProvider 复合接口定义
|
||
|
||
**新文件** `service/ai_provider.go`:
|
||
|
||
```go
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"github.com/topfans/backend/services/aiChatService/model"
|
||
)
|
||
|
||
// AIProvider 复合 AI 后端能力
|
||
// 每个实现负责:LLM 调用 + RAG 检索 + 会话管理(Composite Pattern)
|
||
type AIProvider interface {
|
||
Name() string
|
||
|
||
// LLM 能力
|
||
StreamChat(ctx context.Context, req *ChatRequest) (StreamReader, error)
|
||
StreamChatWithBackup(ctx context.Context, req *ChatRequest) (StreamReader, error)
|
||
|
||
// RAG 能力
|
||
Retrieve(ctx context.Context, req *RetrieveRequest) (*RetrieveResult, error)
|
||
|
||
// Conversation 能力
|
||
GetOrCreateConversation(ctx context.Context, key ConversationKey) (*Conversation, error)
|
||
GetConversationMessages(ctx context.Context, conv *Conversation, limit int) ([]*ChatMessage, error)
|
||
AppendConversationMessages(ctx context.Context, conv *Conversation, msgs []*ChatMessage) error
|
||
UpdateConversationExternalID(ctx context.Context, conv *Conversation, externalID string) error
|
||
}
|
||
|
||
// 数据结构
|
||
type ChatRequest struct {
|
||
UserID int64
|
||
StarID int64
|
||
SessionID string
|
||
Message string
|
||
Conversation *Conversation
|
||
SystemInputs *SystemInputs
|
||
}
|
||
|
||
type SystemInputs struct {
|
||
UserMemory string
|
||
UserStyle string
|
||
UserNickname string
|
||
UserLanguage string
|
||
}
|
||
|
||
type ConversationKey struct {
|
||
UserID int64
|
||
StarID int64
|
||
}
|
||
|
||
type Conversation struct {
|
||
ID int64
|
||
UserID int64
|
||
StarID int64
|
||
ProviderName string
|
||
ExternalConvID string
|
||
MessageCount int
|
||
LastActiveAt int64
|
||
}
|
||
|
||
type ChatMessage struct {
|
||
Role string
|
||
Content string
|
||
}
|
||
|
||
type RetrieveRequest struct {
|
||
StarID int64
|
||
Query string
|
||
TopK int
|
||
DatasetID string
|
||
}
|
||
|
||
type RetrieveResult struct {
|
||
Documents []*Document
|
||
}
|
||
|
||
type Document struct {
|
||
ID string
|
||
Content string
|
||
Score float64
|
||
Metadata map[string]string
|
||
}
|
||
|
||
// MemoryStore 长期记忆接口
|
||
type MemoryItem struct {
|
||
ID uint
|
||
Content string
|
||
Keywords []string
|
||
Weight int
|
||
}
|
||
|
||
type MemoryStore interface {
|
||
Recall(ctx context.Context, userID int64, query string, limit int) ([]*MemoryItem, error)
|
||
Extract(ctx context.Context, userID int64, recentMessages []*ChatMessage) error
|
||
GetUserMemories(ctx context.Context, userID int64) ([]*MemoryItem, error)
|
||
}
|
||
|
||
// ConversationStore 抽象
|
||
type ConversationStore interface {
|
||
GetOrCreate(ctx context.Context, key ConversationKey, providerName string) (*Conversation, error)
|
||
GetMessages(ctx context.Context, conv *Conversation, limit int) ([]*ChatMessage, error)
|
||
AppendMessages(ctx context.Context, conv *Conversation, msgs []*ChatMessage) error
|
||
UpdateExternalID(ctx context.Context, conv *Conversation, externalID string) error
|
||
}
|
||
```
|
||
|
||
### 5.3 DifyProvider 实现
|
||
|
||
**新文件** `service/dify_provider.go`:
|
||
|
||
```go
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/topfans/backend/services/aiChatService/model"
|
||
)
|
||
|
||
// DifyProvider 通过 Dify Workflow 提供 AI 能力
|
||
// 复合实现:LLM(Workflow 内)+ RAG(Workflow 内)+ Conversation(Dify 管)
|
||
type DifyProvider struct {
|
||
workflowClient WorkflowClient
|
||
historyClient HistoryClient
|
||
convStore ConversationStore
|
||
datasetResolver DatasetResolver
|
||
userIDSalt string
|
||
sensitiveWords []string
|
||
}
|
||
|
||
func NewDifyProvider(
|
||
cfg model.DifyConfig,
|
||
convStore ConversationStore,
|
||
workflowClient WorkflowClient,
|
||
historyClient HistoryClient,
|
||
datasetResolver DatasetResolver,
|
||
sensitiveWords []string,
|
||
) *DifyProvider {
|
||
return &DifyProvider{
|
||
workflowClient: workflowClient,
|
||
historyClient: historyClient,
|
||
convStore: convStore,
|
||
datasetResolver: datasetResolver,
|
||
userIDSalt: cfg.UserIDSalt,
|
||
sensitiveWords: sensitiveWords,
|
||
}
|
||
}
|
||
|
||
func (p *DifyProvider) Name() string { return "dify" }
|
||
|
||
func (p *DifyProvider) StreamChat(ctx context.Context, req *ChatRequest) (StreamReader, error) {
|
||
return p.workflowClient.StreamRun(ctx, WorkflowRunParams{
|
||
DatasetID: p.datasetResolver.Resolve(req.StarID),
|
||
Query: req.Message,
|
||
UserNickname: req.SystemInputs.UserNickname,
|
||
UserMemory: req.SystemInputs.UserMemory,
|
||
UserStyle: req.SystemInputs.UserStyle,
|
||
UserLanguage: req.SystemInputs.UserLanguage,
|
||
UserHashed: p.hashUserID(req.UserID),
|
||
ExternalConvID: req.Conversation.ExternalConvID,
|
||
SensitiveWords: p.sensitiveWords,
|
||
})
|
||
}
|
||
|
||
func (p *DifyProvider) GetOrCreateConversation(ctx context.Context, key ConversationKey) (*Conversation, error) {
|
||
return p.convStore.GetOrCreate(ctx, key, p.Name())
|
||
}
|
||
|
||
func (p *DifyProvider) GetConversationMessages(ctx context.Context, conv *Conversation, limit int) ([]*ChatMessage, error) {
|
||
if conv.ExternalConvID == "" {
|
||
return nil, nil
|
||
}
|
||
return p.historyClient.FetchMessages(ctx, HistoryFetchParams{
|
||
ExternalConvID: conv.ExternalConvID,
|
||
UserHashed: p.hashUserID(conv.UserID),
|
||
Limit: limit,
|
||
})
|
||
}
|
||
|
||
func (p *DifyProvider) AppendConversationMessages(ctx context.Context, conv *Conversation, msgs []*ChatMessage) error {
|
||
conv.MessageCount += len(msgs)
|
||
conv.LastActiveAt = time.Now().UnixMilli()
|
||
return p.convStore.AppendMessages(ctx, conv, msgs)
|
||
}
|
||
|
||
func (p *DifyProvider) UpdateConversationExternalID(ctx context.Context, conv *Conversation, externalID string) error {
|
||
if externalID == "" || externalID == conv.ExternalConvID {
|
||
return nil
|
||
}
|
||
return p.convStore.UpdateExternalID(ctx, conv, externalID)
|
||
}
|
||
|
||
func (p *DifyProvider) hashUserID(userID int64) string {
|
||
h := sha256.Sum256([]byte(fmt.Sprintf("%d:%s", userID, p.userIDSalt)))
|
||
return "aichat-" + hex.EncodeToString(h[:8])
|
||
}
|
||
```
|
||
|
||
### 5.4 DifyClient 组件
|
||
|
||
**新文件** `service/dify_workflow_client.go`:
|
||
|
||
```go
|
||
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/topfans/backend/pkg/logger"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
type DifyWorkflowClientConfig struct {
|
||
APIBase string
|
||
WorkflowURL string
|
||
APIKey string
|
||
TimeoutSec int
|
||
RetryCount int
|
||
}
|
||
|
||
type DifyWorkflowClient struct {
|
||
cfg DifyWorkflowClientConfig
|
||
httpClient *http.Client
|
||
}
|
||
|
||
func NewDifyWorkflowClient(cfg DifyWorkflowClientConfig) *DifyWorkflowClient {
|
||
return &DifyWorkflowClient{
|
||
cfg: cfg,
|
||
httpClient: &http.Client{
|
||
Timeout: time.Duration(cfg.TimeoutSec) * time.Second,
|
||
},
|
||
}
|
||
}
|
||
|
||
func (c *DifyWorkflowClient) StreamRun(ctx context.Context, params WorkflowRunParams) (StreamReader, error) {
|
||
inputs := map[string]interface{}{
|
||
"dataset_id": params.DatasetID,
|
||
"query": params.Query,
|
||
"user_nickname": params.UserNickname,
|
||
"user_memory": params.UserMemory,
|
||
"user_style": params.UserStyle,
|
||
"user_language": params.UserLanguage,
|
||
}
|
||
body := map[string]interface{}{
|
||
"inputs": inputs,
|
||
"response_mode": "streaming",
|
||
"conversation_id": params.ExternalConvID,
|
||
"user": params.UserHashed,
|
||
}
|
||
|
||
var lastErr error
|
||
attempts := c.cfg.RetryCount
|
||
if attempts < 1 {
|
||
attempts = 1
|
||
}
|
||
for i := 0; i < attempts; i++ {
|
||
jsonData, _ := json.Marshal(body)
|
||
httpReq, _ := http.NewRequestWithContext(ctx, "POST",
|
||
c.cfg.APIBase+c.cfg.WorkflowURL, bytes.NewReader(jsonData))
|
||
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := c.httpClient.Do(httpReq)
|
||
if err != nil {
|
||
lastErr = fmt.Errorf("dify request: %w", err)
|
||
time.Sleep(500 * time.Millisecond)
|
||
continue
|
||
}
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
lastErr = fmt.Errorf("dify returned HTTP %d", resp.StatusCode)
|
||
time.Sleep(500 * time.Millisecond)
|
||
continue
|
||
}
|
||
|
||
return NewDifyStreamReader(resp.Body, params.SensitiveWords), nil
|
||
}
|
||
return nil, lastErr
|
||
}
|
||
```
|
||
|
||
**新文件** `service/dify_history_client.go`:
|
||
|
||
```go
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"time"
|
||
)
|
||
|
||
type DifyHistoryClientConfig struct {
|
||
APIBase string
|
||
APIKey string
|
||
TimeoutSec int
|
||
}
|
||
|
||
type DifyHistoryClient struct {
|
||
cfg DifyHistoryClientConfig
|
||
httpClient *http.Client
|
||
}
|
||
|
||
func NewDifyHistoryClient(cfg DifyHistoryClientConfig) *DifyHistoryClient {
|
||
return &DifyHistoryClient{
|
||
cfg: cfg,
|
||
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second},
|
||
}
|
||
}
|
||
|
||
func (c *DifyHistoryClient) FetchMessages(ctx context.Context, params HistoryFetchParams) ([]*ChatMessage, error) {
|
||
url := fmt.Sprintf("%s/messages?conversation_id=%s&user=%s&limit=%d",
|
||
c.cfg.APIBase, params.ExternalConvID, params.UserHashed, params.Limit)
|
||
httpReq, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
|
||
|
||
resp, err := c.httpClient.Do(httpReq)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("dify fetch history: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
var result struct {
|
||
Data []struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return nil, fmt.Errorf("dify decode history: %w", err)
|
||
}
|
||
|
||
msgs := make([]*ChatMessage, len(result.Data))
|
||
for i, m := range result.Data {
|
||
msgs[i] = &ChatMessage{Role: m.Role, Content: m.Content}
|
||
}
|
||
return msgs, nil
|
||
}
|
||
```
|
||
|
||
### 5.5 model 包新增
|
||
|
||
**修改文件** `model/ai_chat_models.go`:
|
||
|
||
```go
|
||
package model
|
||
|
||
type DifyConfig struct {
|
||
Enable bool
|
||
APIBase string
|
||
WorkflowURL string
|
||
APIKey string
|
||
TimeoutSec int
|
||
UserIDSalt string
|
||
RetryCount int
|
||
FallbackToMiniMax bool
|
||
StarDatasetMapping map[int64]string
|
||
}
|
||
|
||
type Conversation struct {
|
||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||
UserID int64 `gorm:"index;not null"`
|
||
StarID int64 `gorm:"index;not null"`
|
||
ProviderName string `gorm:"type:varchar(32);not null"`
|
||
ExternalConvID string `gorm:"type:varchar(128);default:''"`
|
||
MessageCount int `gorm:"default:0"`
|
||
LastActiveAt int64 `gorm:"autoUpdateTime:milli"`
|
||
CreatedAt int64 `gorm:"autoCreateTime:milli"`
|
||
UpdatedAt int64 `gorm:"autoUpdateTime:milli"`
|
||
}
|
||
|
||
func (Conversation) TableName() string { return "ai_conversations" }
|
||
|
||
type AIMessage struct {
|
||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||
ConversationID int64 `gorm:"index;not null"`
|
||
Role string `gorm:"type:varchar(16);not null"`
|
||
Content string `gorm:"type:text;not null"`
|
||
CreatedAt int64 `gorm:"autoCreateTime:milli"`
|
||
}
|
||
|
||
func (AIMessage) TableName() string { return "ai_messages" }
|
||
```
|
||
|
||
### 5.6 ChatEngine 编排
|
||
|
||
**新文件** `service/chat_engine.go`:
|
||
|
||
```go
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/redis/go-redis/v9"
|
||
"github.com/topfans/backend/pkg/logger"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
type ChatEngine struct {
|
||
providers *ProviderFactory
|
||
memoryStore MemoryStore
|
||
auditService *AuditService
|
||
redisLock RedisLock
|
||
redisClient *redis.Client
|
||
triggerTurns int
|
||
}
|
||
|
||
func NewChatEngine(
|
||
providers *ProviderFactory,
|
||
memory MemoryStore,
|
||
audit *AuditService,
|
||
redisLock RedisLock,
|
||
redisClient *redis.Client,
|
||
triggerTurns int,
|
||
) *ChatEngine {
|
||
return &ChatEngine{
|
||
providers: providers,
|
||
memoryStore: memory,
|
||
auditService: audit,
|
||
redisLock: redisLock,
|
||
redisClient: redisClient,
|
||
triggerTurns: triggerTurns,
|
||
}
|
||
}
|
||
|
||
type RedisLock interface {
|
||
Acquire(ctx context.Context, key string, ttl time.Duration) (acquired bool, token string, err error)
|
||
Release(ctx context.Context, key string, token string) error
|
||
}
|
||
|
||
type redisLockImpl struct{ client *redis.Client }
|
||
|
||
func NewRedisLock(client *redis.Client) *redisLockImpl { return &redisLockImpl{client} }
|
||
func (l *redisLockImpl) Acquire(ctx context.Context, key string, ttl time.Duration) (bool, string, error) {
|
||
token := uuid.New().String()
|
||
ok, err := l.client.SetNX(ctx, key, token, ttl).Result()
|
||
return ok, token, err
|
||
}
|
||
func (l *redisLockImpl) Release(ctx context.Context, key, token string) error {
|
||
const script = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`
|
||
return l.client.Eval(ctx, script, []string{key}, token).Err()
|
||
}
|
||
|
||
func (e *ChatEngine) SendMessage(ctx context.Context, userID, starID int64, personaID, message string) (<-chan *StreamChunk, error) {
|
||
out := make(chan *StreamChunk, 16)
|
||
go func() {
|
||
defer close(out)
|
||
|
||
if !e.auditService.AuditText(message) {
|
||
e.sendSafeResponse(out)
|
||
return
|
||
}
|
||
|
||
provider := e.providers.Select(starID)
|
||
providerName := provider.Name()
|
||
|
||
memories, _ := e.memoryStore.Recall(ctx, userID, message, 5)
|
||
memoryText := formatMemories(memories)
|
||
|
||
lockKey := fmt.Sprintf("conv_lock:%d:%d", userID, starID)
|
||
lockAcquired, lockToken, _ := e.redisLock.Acquire(ctx, lockKey, 30*time.Second)
|
||
if !lockAcquired {
|
||
time.Sleep(200 * time.Millisecond)
|
||
lockAcquired, lockToken, _ = e.redisLock.Acquire(ctx, lockKey, 30*time.Second)
|
||
if !lockAcquired {
|
||
out <- &StreamChunk{Type: "error", Error: "系统繁忙"}
|
||
return
|
||
}
|
||
}
|
||
defer e.redisLock.Release(ctx, lockKey, lockToken)
|
||
|
||
conv, _ := provider.GetOrCreateConversation(ctx, ConversationKey{UserID: userID, StarID: starID})
|
||
if conv == nil {
|
||
out <- &StreamChunk{Type: "error", Error: "会话创建失败"}
|
||
return
|
||
}
|
||
|
||
req := &ChatRequest{
|
||
UserID: userID,
|
||
StarID: starID,
|
||
Message: message,
|
||
Conversation: conv,
|
||
SystemInputs: &SystemInputs{UserMemory: memoryText, UserLanguage: "zh-CN"},
|
||
}
|
||
|
||
streamReader, err := provider.StreamChat(ctx, req)
|
||
if err != nil {
|
||
if handled := e.handleLLMFailure(ctx, provider, req, out); !handled {
|
||
out <- &StreamChunk{Type: "error", Error: "服务暂不可用"}
|
||
}
|
||
return
|
||
}
|
||
defer streamReader.Close()
|
||
|
||
var fullResponse string
|
||
for {
|
||
content, done, err := streamReader.Next()
|
||
if err != nil {
|
||
out <- &StreamChunk{Type: "error", Error: "服务异常"}
|
||
return
|
||
}
|
||
if content != "" && !e.auditService.AuditResponse(content) {
|
||
safeMsg := e.auditService.DefaultSafeResponse()
|
||
out <- &StreamChunk{Type: "message", Content: safeMsg, IsEnd: false}
|
||
out <- &StreamChunk{Type: "message", IsEnd: true}
|
||
provider.AppendConversationMessages(ctx, conv, []*ChatMessage{
|
||
{Role: "user", Content: message},
|
||
{Role: "assistant", Content: safeMsg},
|
||
})
|
||
return
|
||
}
|
||
fullResponse += content
|
||
out <- &StreamChunk{Type: "message", Content: content, IsEnd: done}
|
||
if done { break }
|
||
}
|
||
|
||
if newConvID := streamReader.GetConversationID(); newConvID != "" && newConvID != conv.ExternalConvID {
|
||
provider.UpdateConversationExternalID(ctx, conv, newConvID)
|
||
conv.ExternalConvID = newConvID
|
||
}
|
||
provider.AppendConversationMessages(ctx, conv, []*ChatMessage{
|
||
{Role: "user", Content: message},
|
||
{Role: "assistant", Content: fullResponse},
|
||
})
|
||
|
||
newTurns := conv.MessageCount / 2
|
||
if newTurns >= e.triggerTurns && newTurns%e.triggerTurns == 0 {
|
||
extractCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||
go func() {
|
||
defer cancel()
|
||
e.memoryStore.Extract(extractCtx, userID, []*ChatMessage{
|
||
{Role: "user", Content: message},
|
||
{Role: "assistant", Content: fullResponse},
|
||
})
|
||
}()
|
||
}
|
||
}()
|
||
return out, nil
|
||
}
|
||
|
||
func (e *ChatEngine) sendSafeResponse(out chan<- *StreamChunk) {
|
||
safeMsg := e.auditService.DefaultSafeResponse()
|
||
out <- &StreamChunk{Type: "message", Content: safeMsg, IsEnd: false}
|
||
out <- &StreamChunk{Type: "message", IsEnd: true}
|
||
}
|
||
|
||
func (e *ChatEngine) handleLLMFailure(ctx context.Context, provider AIProvider, req *ChatRequest, out chan<- *StreamChunk) bool {
|
||
if req.Conversation.MessageCount > 0 {
|
||
out <- &StreamChunk{Type: "error", Error: "服务暂不可用,请稍后重试"}
|
||
return true
|
||
}
|
||
fallback := e.providers.SelectFallback(provider.Name())
|
||
if fallback != nil {
|
||
streamReader, _ := fallback.StreamChat(ctx, req)
|
||
if streamReader != nil {
|
||
defer streamReader.Close()
|
||
for {
|
||
content, done, _ := streamReader.Next()
|
||
if content != "" {
|
||
out <- &StreamChunk{Type: "message", Content: content, IsEnd: done}
|
||
}
|
||
if done { break }
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
```
|
||
|
||
### 5.7 main.go 装配
|
||
|
||
```go
|
||
// 加载 Dify 配置
|
||
difyConfigs, _ := configRepo.GetByCategory(loadCtx, "dify")
|
||
difyConfig, _ := loadDifyConfig(difyConfigs)
|
||
|
||
// 创建 ConversationStore
|
||
convStore := service.NewCachedConversationStore(convRepo, msgRepo, redisClient)
|
||
|
||
// 创建 Dify 客户端组件
|
||
workflowClient := service.NewDifyWorkflowClient(service.DifyWorkflowClientConfig{
|
||
APIBase: difyConfig.APIBase,
|
||
WorkflowURL: difyConfig.WorkflowURL,
|
||
APIKey: difyConfig.APIKey,
|
||
TimeoutSec: difyConfig.TimeoutSec,
|
||
RetryCount: difyConfig.RetryCount,
|
||
})
|
||
historyClient := service.NewDifyHistoryClient(service.DifyHistoryClientConfig{
|
||
APIBase: difyConfig.APIBase,
|
||
APIKey: difyConfig.APIKey,
|
||
})
|
||
datasetResolver := service.NewDatasetResolver(difyConfig.StarDatasetMapping)
|
||
|
||
// 创建 Provider
|
||
difyProvider := service.NewDifyProvider(difyConfig, convStore, workflowClient, historyClient, datasetResolver, nil)
|
||
miniMaxProvider := service.NewMiniMaxProvider(convRepo, msgRepo)
|
||
|
||
// 创建 ProviderFactory
|
||
factory := service.NewProviderFactory(difyConfig, difyProvider, miniMaxProvider)
|
||
|
||
// 创建 Redis 锁
|
||
redisLock := service.NewRedisLock(redisClient)
|
||
|
||
// 创建 ChatEngine
|
||
chatEngine := service.NewChatEngine(factory, memoryService, auditService, redisLock, redisClient, 5)
|
||
|
||
// Provider 大幅简化
|
||
aiChatProvider := provider.NewAIChatProvider(chatEngine)
|
||
```
|
||
|
||
### 5.8 Provider 大幅简化
|
||
|
||
```go
|
||
// 极简版本:只做 Dubbo 入口 + 参数解析
|
||
func (p *AIChatProvider) SendMessage(ctx context.Context, req *pb.ChatMessageRequest, stream pb.AIChatService_SendMessageServer) error {
|
||
userID, _, err := extractUserInfoFromDubboAttachments(ctx)
|
||
if err != nil { return err }
|
||
|
||
chunks, err := p.chatEngine.SendMessage(ctx, userID, 87, "", req.Message)
|
||
if err != nil { return err }
|
||
|
||
for chunk := range chunks {
|
||
if chunk.Type == "error" {
|
||
stream.Send(&pb.ChatMessageResponse{Content: chunk.Error, IsEnd: true})
|
||
} else {
|
||
stream.Send(&pb.ChatMessageResponse{Content: chunk.Content, IsEnd: chunk.IsEnd})
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
> **从此 Provider 不再有 `if useDify` 分支**。
|
||
|
||
---
|
||
|
||
## 六、消息流时序
|
||
|
||
### 6.1 首次对话(Dify 路径)
|
||
|
||
```
|
||
[Mobile] → [Gateway] → [Provider] → [ChatEngine]
|
||
│
|
||
├─ 1. 前置审核
|
||
├─ 2. 选 DifyProvider
|
||
├─ 3. 长期记忆召回
|
||
├─ 4. 加锁 (RedisLock)
|
||
├─ 5. GetOrCreateConv
|
||
│ └─ 查 Redis 缓存 → 查 PG → 创建
|
||
│
|
||
├─ 6. StreamChat
|
||
│ ├─ DatasetResolver.Resolve(87) → dataset_id
|
||
│ ├─ WorkflowClient.StreamRun(params)
|
||
│ └─ POST /v1/workflows/run
|
||
│ └─ {dataset_id, query, user_*}
|
||
│
|
||
│ ↓ Dify 返回 SSE
|
||
│ ↓
|
||
│ audit + stream.Send
|
||
│ ↓
|
||
│ message{content, is_end}
|
||
│ ↓
|
||
├─ 7. UpdateExternalID
|
||
│ └─ convStore.UpdateExternalID
|
||
│
|
||
├─ 8. AppendMessages
|
||
│ └─ 更新 message_count
|
||
│
|
||
└─ 9. 释放锁 + 记忆提取
|
||
```
|
||
|
||
### 6.2 续接对话(次日重连)
|
||
|
||
```
|
||
1. ChatEngine.SendMessage
|
||
2. GetOrCreateConv → ConvStore.GetOrCreate → 查 Redis 缓存命中
|
||
3. StreamChat → WorkflowClient.StreamRun → POST {conversation_id: "uuid-xxx"}
|
||
4. 流式返回
|
||
5. AppendMessages
|
||
```
|
||
|
||
**关键**:conversation_id 在 DB 中永久保留,跨天/跨设备都能续上。
|
||
|
||
### 6.3 Fallback 场景
|
||
|
||
```
|
||
DifyProvider.StreamChat
|
||
↓ 失败
|
||
DifyProvider.StreamChatWithBackup // 网络层重试一次
|
||
↓ 失败
|
||
├─ conv.MessageCount == 0 && dify.fallback_to_minimax=true
|
||
│ → 切到 MiniMaxProvider
|
||
└─ 其他情况
|
||
→ 返回错误给前端
|
||
```
|
||
|
||
---
|
||
|
||
## 七、错误处理与降级
|
||
|
||
### 7.1 错误分类与处理
|
||
|
||
| 错误类型 | 处理 |
|
||
|---------|------|
|
||
| **Dify 401/403** | 直接返回错误(不重试、不 fallback) |
|
||
| **Dify 5xx** | 重试 N 次(dify.retry_count),仍失败按 fallback 规则 |
|
||
| **Dify timeout** | 同 5xx |
|
||
| **Dify 流中断** | 后置 audit 拦截 + 返回安全回复 |
|
||
| **PostgreSQL 不可用** | GetOrCreateConv 失败,返回错误 |
|
||
| **Redis 不可用** | 降级到直接读 PostgreSQL |
|
||
|
||
### 7.2 监控指标(建议)
|
||
|
||
| 指标 | 类型 | 来源 |
|
||
|------|------|------|
|
||
| `aichat_provider_request_total{provider, status}` | counter | ChatEngine |
|
||
| `aichat_provider_fallback_total{from, reason}` | counter | ChatEngine |
|
||
| `aichat_dify_stream_duration_ms` | histogram | DifyProvider |
|
||
|
||
---
|
||
|
||
## 八、安全与审核
|
||
|
||
### 8.1 审核
|
||
|
||
- **前置审核**:`auditService.AuditText(message)` 拦截敏感词
|
||
- **后置审核**:`auditService.AuditResponse(token)` 逐 token 拦截
|
||
- **Dify 流中 error 事件**:ChatEngine 捕获 err 后发安全回复
|
||
|
||
### 8.2 知识库内容安全
|
||
|
||
- Dify Dataset 导入时由 Dify 端做内容审核
|
||
- 建议 Workflow 内部加"内容审核节点"(Dify 原生能力)
|
||
|
||
### 8.3 Dify API Key 安全
|
||
|
||
- 存储:`ai_chat_configs.config_value`,`is_encrypted=TRUE`
|
||
- 实际加解密:依赖 PostgreSQL 访问控制
|
||
- 不记录到日志
|
||
- 不返回给前端
|
||
|
||
### 8.4 user 字段隐私
|
||
|
||
- 哈希化:`aichat-{sha256(userId + salt)[:16]}`
|
||
- 盐值:`dify.user_id_salt` 环境变量
|
||
- Dify 端运营无法反推 user_id
|
||
|
||
---
|
||
|
||
## 九、配置文件清单
|
||
|
||
### 9.1 环境变量
|
||
|
||
| 变量 | 用途 | 必填 |
|
||
|------|------|------|
|
||
| `DIFY_USER_ID_SALT` | 哈希 user_id 的盐 | 是 |
|
||
|
||
### 9.2 ai_chat_configs 数据
|
||
|
||
9 个 `dify.*` 配置项(详见 §4.1)
|
||
|
||
### 9.3 不需要的环境变量
|
||
|
||
- `DIFY_API_KEY`:走 ai_chat_configs
|
||
- `DIFY_API_BASE`:走 ai_chat_configs
|
||
|
||
---
|
||
|
||
## 十、Stage 2+ 演进路径
|
||
|
||
> **MVP 优先**:MVP 阶段不实施本文档的复杂设计。先读 [MVP 文档](2026-06-29-ai-chat-dify-mvp-design.md),按 MVP 实施。
|
||
|
||
**Stage 2+ 演进时必踩的 3 个坑**(P0 修复笔记)和 **3 个架构评审笔记**,已迁到 MVP §10(V2 不重复)。
|
||
|
||
| Stage | 触发条件 | 关键改动 |
|
||
|-------|---------|---------|
|
||
| **Stage 2** | 用户量 > 1000 OR 加第 2 个星 | 1. 多星 Dataset 切换<br>2. WebSocket 端 InitSession 欢迎语动态化<br>3. ai_conversations 加 UNIQUE |
|
||
| **Stage 3** | Dify 偶发故障 OR SLA 要求 | 1. MiniMax fallback<br>2. Dify retry 循环 |
|
||
| **Stage 4** | 用户量 > 10万 OR 接 2+ AI 平台 | 1. AIProvider 抽象<br>2. ProviderFactory 策略模式<br>3. CozeProvider / OpenAIProvider 实现 |
|
||
| **Stage 5** | 用户量 > 100万 OR 业务复杂 | 1. ChatEngine Pipeline 化<br>2. AIProfile 配置化<br>3. 长期记忆提取 |
|
||
|
||
---
|
||
|
||
## 十一、目录与文件改动
|
||
|
||
```
|
||
backend/services/aiChatService/
|
||
├── main.go ← 改
|
||
├── model/
|
||
│ ├── ai_chat_models.go ← 改(新增 DifyConfig / Conversation / AIMessage)
|
||
│ └── ai_chat_errors.go
|
||
├── provider/
|
||
│ ├── ai_chat_provider.go ← 大幅简化
|
||
├── service/
|
||
│ ├── ai_provider.go ← 新建:AIProvider interface
|
||
│ ├── dify_provider.go ← 新建:DifyProvider
|
||
│ ├── dify_workflow_client.go ← 新建
|
||
│ ├── dify_history_client.go ← 新建
|
||
│ ├── dataset_resolver.go ← 新建
|
||
│ ├── conversation_store.go ← 新建
|
||
│ ├── minimax_provider.go ← 新建
|
||
│ ├── chat_engine.go ← 新建
|
||
│ ├── provider_factory.go ← 新建
|
||
│ ├── redis_lock.go ← 新建
|
||
│ ├── chat_service.go ← 删除
|
||
│ ├── llm_service.go ← 保留
|
||
│ ├── prompt_builder.go ← 保留
|
||
│ ├── memory_service.go ← 改
|
||
│ ├── audit_service.go ← 改(加 GetSensitiveWords)
|
||
│ ├── persona_service.go ← 不改
|
||
└── repository/
|
||
├── conversation_repository.go ← 新建
|
||
├── message_repository.go ← 新建
|
||
├── memory_repository.go ← 不改
|
||
├── persona_repository.go ← 不改
|
||
└── config_repository.go ← 不改
|
||
|
||
migrations/
|
||
└── ai_conversations.sql ← 新建
|
||
```
|
||
|
||
**新建文件**:11 个
|
||
**修改文件**:5 个
|
||
**删除文件**:1 个
|
||
|
||
---
|
||
|
||
## 十二、关键文件参考索引
|
||
|
||
### 12.1 相关文档
|
||
|
||
- **MVP 实施级方案**:[2026-06-29-ai-chat-dify-mvp-design.md](2026-06-29-ai-chat-dify-mvp-design.md)(**先读**)
|
||
- AI Chat 整体设计:[backend/docs/AI-Chat-Service设计方案.md](backend/docs/AI-Chat-Service设计方案.md)
|
||
|
||
### 12.2 后端代码
|
||
|
||
- AIChat Provider:[backend/services/aiChatService/provider/ai_chat_provider.go](backend/services/aiChatService/provider/ai_chat_provider.go)
|
||
- AIChat LLM Service:[backend/services/aiChatService/service/llm_service.go](backend/services/aiChatService/service/llm_service.go)
|
||
- AIChat Memory Service:[backend/services/aiChatService/service/memory_service.go](backend/services/aiChatService/service/memory_service.go)
|
||
- AIChat 装配:[backend/services/aiChatService/main.go](backend/services/aiChatService/main.go)
|
||
- 模型定义:[backend/services/aiChatService/model/ai_chat_models.go](backend/services/aiChatService/model/ai_chat_models.go)
|
||
- Star 模型:[backend/pkg/models/user.go](backend/pkg/models/user.go)
|
||
- Gateway WebSocket:[backend/gateway/socket/ai_chat_socket.go](backend/gateway/socket/ai_chat_socket.go)
|
||
- Dify 现有(镭射卡):[backend/gateway/service/dify_client.go](backend/gateway/service/dify_client.go)
|
||
|
||
### 12.3 前端
|
||
|
||
- 页面:[frontend/pages/ai-dazi/index.vue](frontend/pages/ai-dazi/index.vue)
|
||
- WebSocket 封装:[frontend/utils/socket/AiChatSocket.js](frontend/utils/socket/AiChatSocket.js)
|
||
- 全局管理:[frontend/utils/socket/GlobalSocketManager.js](frontend/utils/socket/GlobalSocketManager.js)
|
||
|
||
### 12.4 Dify 官方
|
||
|
||
- Workflow API:https://docs.dify.ai/api-reference/workflow/run-workflow
|
||
- Knowledge Retrieval 节点:https://docs.dify.ai/guides/workflow/node/knowledge-retrieval
|
||
|
||
---
|
||
|
||
## 十三、后续优化(不在 V2 范围)
|
||
|
||
> 这些是 P0 修复笔记和架构评审笔记的具体化,**详见 MVP §10**:
|
||
> - ★ P0 修复:并发 race(RedisLock)、审计后保存、组合敏感词(滑动窗口)
|
||
> - ★ 架构评审:Provider God Class、Provider 依赖存储、Workflow 重复 mapping
|
||
|
||
其他:
|
||
- Coze / FastGPT 接入(Stage 4)
|
||
- 知识库自动同步 Worker(独立项目)
|
||
- ConfigWatcher 热加载
|
||
- 用户 persona 完全自定义
|
||
- 对话质量评估
|
||
- A/B 测试框架
|
||
- 多轮摘要压缩
|
||
- 跨星记忆
|
||
|
||
---
|
||
|
||
## 十四、总结
|
||
|
||
V2 是"100 明星 + 多 AI 平台"的完整架构设计。
|
||
|
||
**V2 vs MVP**:
|
||
- V2 完整(约 1500-2000 行代码、5 周工作量)
|
||
- MVP 精简(约 300-500 行代码、1 周可落地)
|
||
|
||
**实施路径**:
|
||
1. **MVP 阶段**:按 [MVP 文档](2026-06-29-ai-chat-dify-mvp-design.md) 实施(**不要看 V2**)
|
||
2. **Stage 2+**:MVP 跑通后,参考 V2 的具体代码示例 + MVP §10 的关键坑笔记
|
||
3. **Stage 4 抽象**:从 V2 复制 `AIProvider` interface 代码,按需调整
|
||
|
||
V2 与 MVP 是**互补**关系,不是替代关系。
|