topfans/docs/specs/2026-06-29-ai-chat-dify-integration-v2-design.md
Lenticular Studio Agent 65ce6bba12 feat: Dify 部署脚本修复 + AI 搭子 MVP 接入
主要改动:

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>
2026-07-02 12:32:34 +08:00

55 KiB
Raw Blame History

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先读这个
  • 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

Stage 2+ 必踩的 3 个坑P0 修复)和 3 个架构评审笔记已迁移到 MVP §10。 实施 V2 任何章节前,先读 MVP §10 的"演进时必踩的坑"。


一、背景与目标

1.1 现状

维度 现状
AI 角色 所有人共用"角角"默认人设,与"星"无关
LLM MiniMax M2-herllm_service.go
star_id 传递 JWT → middleware → Dubbo Attachments → AIChat 解析,链路完整
知识维度 缺失。当前 AI 无法获取所追明星的事实性资料
Dify 现状 Gateway 侧 dify_client.go 已存在,但仅服务"镭射卡"链路
会话存储 Redis context24h 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
会话存储 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                │   │
│  │  - 调 GetWelcomeMessageInitSession                                │   │
│  │  - 调 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 决策点

// 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-kbstar-wyb-kb
  3. 选择索引模式:high_qualityembedding + 向量检索)
  4. 导入资料(多种方式任选):
    • 文件上传PDF / Word / Markdown / CSV
    • Notion 同步:绑定 Notion 数据库
    • Web 同步:抓取官网、官方微博、官方粉丝站
    • API 同步Dify 提供 /v1/datasets/{id}/document/create_by_file
  5. 配置分段
    • 段落长度500-1024 tokens
    • 分段 overlap50-100
  6. 等待所有文档向量化完成Dify UI 每个文档显示 ✓ 后才继续)

Dataset ID 记录:每个 Dataset 创建后获得 UUID。

3.2 创建统一 Workflow

  1. 进入"工作室" → "创建空白应用" → 类型选 Workflow
  2. 命名:star-chat-workflow(全平台共用)
  3. 配置"开始"节点的 Input 变量:
    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 }}
    • TopK3
  5. 添加 LLM 节点
    你是一个专属 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_mappingWorkflow 不感知 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 错误示例):

# ❌ 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 新增数据

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

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

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

package service

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "time"

    "github.com/topfans/backend/services/aiChatService/model"
)

// DifyProvider 通过 Dify Workflow 提供 AI 能力
// 复合实现LLMWorkflow 内)+ RAGWorkflow 内)+ ConversationDify 管)
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

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

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

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

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 装配

// 加载 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 大幅简化

// 极简版本:只做 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_valueis_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 文档,按 MVP 实施。

Stage 2+ 演进时必踩的 3 个坑P0 修复笔记)和 3 个架构评审笔记,已迁到 MVP §10V2 不重复)。

Stage 触发条件 关键改动
Stage 2 用户量 > 1000 OR 加第 2 个星 1. 多星 Dataset 切换
2. WebSocket 端 InitSession 欢迎语动态化
3. ai_conversations 加 UNIQUE
Stage 3 Dify 偶发故障 OR SLA 要求 1. MiniMax fallback
2. Dify retry 循环
Stage 4 用户量 > 10万 OR 接 2+ AI 平台 1. AIProvider 抽象
2. ProviderFactory 策略模式
3. CozeProvider / OpenAIProvider 实现
Stage 5 用户量 > 100万 OR 业务复杂 1. ChatEngine Pipeline 化
2. AIProfile 配置化
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 相关文档

12.2 后端代码

12.3 前端

12.4 Dify 官方


十三、后续优化(不在 V2 范围)

这些是 P0 修复笔记和架构评审笔记的具体化,详见 MVP §10

  • ★ P0 修复:并发 raceRedisLock、审计后保存、组合敏感词滑动窗口
  • ★ 架构评审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 文档 实施(不要看 V2
  2. Stage 2+MVP 跑通后,参考 V2 的具体代码示例 + MVP §10 的关键坑笔记
  3. Stage 4 抽象:从 V2 复制 AIProvider interface 代码,按需调整

V2 与 MVP 是互补关系,不是替代关系。