主要改动: 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>
55 KiB
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 大类):
- 缺乏 RAG 能力:现有 AI 只能用通用 LLM 知识,无明星专属数据
- 会话无持久化:当前仅 Redis 缓存 24h,用户次日回来会失忆
- AI 平台耦合:未来要接 Coze/FastGPT/OpenAI 等
- 运营成本失控:扩到 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-her(llm_service.go) |
| star_id 传递 | JWT → middleware → Dubbo Attachments → AIChat 解析,链路完整 |
| 知识维度 | 缺失。当前 AI 无法获取所追明星的事实性资料 |
| Dify 现状 | Gateway 侧 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 目标
- ✅ 运营成本 O(1):改 prompt 只改 1 处
- ✅ 会话永久保留:用户 1 周后重连,能继续上次对话
- ✅ 职责清晰:Dify 路径 Backend 不参与 prompt 组装
- ✅ 可扩展:未来接 Coze/FastGPT,只需实现
AIProvider接口 - ✅ 风险可控:渐进式上线,配置回滚
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 │ │
│ │ - 调 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 决策点
// 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:
- 登录 Dify → "知识库" → "创建知识库"
- 命名规范:
star-{identity_id}-kb(如star-xz-kb、star-wyb-kb) - 选择索引模式:
high_quality(embedding + 向量检索) - 导入资料(多种方式任选):
- 文件上传:PDF / Word / Markdown / CSV
- Notion 同步:绑定 Notion 数据库
- Web 同步:抓取官网、官方微博、官方粉丝站
- API 同步:Dify 提供
/v1/datasets/{id}/document/create_by_file
- 配置分段:
- 段落长度:500-1024 tokens
- 分段 overlap:50-100
- 等待所有文档向量化完成(Dify UI 每个文档显示 ✓ 后才继续)
Dataset ID 记录:每个 Dataset 创建后获得 UUID。
3.2 创建统一 Workflow
- 进入"工作室" → "创建空白应用" → 类型选 Workflow
- 命名:
star-chat-workflow(全平台共用) - 配置"开始"节点的 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 - 添加 知识检索节点 (Knowledge Retrieval):
- Knowledge:选"运行时根据输入选择"
- 变量:
{{ dataset_id }} - Query:
{{ query }} - TopK:3
- 添加 LLM 节点:
你是一个专属 AI 搭子,名字叫"角角"。 【用户称呼】请称呼用户为"{{ user_nickname }}" 【说话风格】{{ user_style }} 【语言】{{ user_language }} # 用户原始问题 {{ query }} # 用户长期记忆 {{ user_memory }} # 知识库检索结果 {{#knowledge_retrieval_node.result#}} 请基于以上信息回答用户问题。 - 添加"内容审核"节点:
- 类型:Dify 原生"内容审核"节点
- 位置:LLM 节点 → 内容审核节点 → 结束节点
- 配置:
- moderation_type:
keywords - keywords: 敏感词列表(每行一个)
- on_failure:
replace_with_text= "抱歉,这个话题我无法继续,我们换个话题聊聊吧。"
- moderation_type:
- 添加"结束"节点
- 调试:传
inputs={dataset_id, query, user_*}验证 RAG 召回 - 发布 → 复制 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 错误示例):
# ❌ 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 能力
// 复合实现: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:
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_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_configsDIFY_API_BASE:走 ai_chat_configs
十、Stage 2+ 演进路径
MVP 优先:MVP 阶段不实施本文档的复杂设计。先读 MVP 文档,按 MVP 实施。
Stage 2+ 演进时必踩的 3 个坑(P0 修复笔记)和 3 个架构评审笔记,已迁到 MVP §10(V2 不重复)。
| 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 相关文档
- MVP 实施级方案:2026-06-29-ai-chat-dify-mvp-design.md(先读)
- AI Chat 整体设计:backend/docs/AI-Chat-Service设计方案.md
12.2 后端代码
- AIChat Provider:backend/services/aiChatService/provider/ai_chat_provider.go
- AIChat LLM Service:backend/services/aiChatService/service/llm_service.go
- AIChat Memory Service:backend/services/aiChatService/service/memory_service.go
- AIChat 装配:backend/services/aiChatService/main.go
- 模型定义:backend/services/aiChatService/model/ai_chat_models.go
- Star 模型:backend/pkg/models/user.go
- Gateway WebSocket:backend/gateway/socket/ai_chat_socket.go
- Dify 现有(镭射卡):backend/gateway/service/dify_client.go
12.3 前端
- 页面:frontend/pages/ai-dazi/index.vue
- WebSocket 封装:frontend/utils/socket/AiChatSocket.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 周可落地)
实施路径:
- MVP 阶段:按 MVP 文档 实施(不要看 V2)
- Stage 2+:MVP 跑通后,参考 V2 的具体代码示例 + MVP §10 的关键坑笔记
- Stage 4 抽象:从 V2 复制
AIProviderinterface 代码,按需调整
V2 与 MVP 是互补关系,不是替代关系。