主要改动: 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>
940 lines
33 KiB
Markdown
940 lines
33 KiB
Markdown
# AI 搭子 MVP 方案
|
||
|
||
> **本文档是实施级方案**。所有"为未来 100 明星 + 多 AI 平台"准备的复杂设计(Provider 抽象、ProviderFactory、Pipeline、ConversationStore 抽象等)**MVP 阶段不实现**。
|
||
>
|
||
> V2 完整架构文档([2026-06-29-ai-chat-dify-integration-v2-design.md](2026-06-29-ai-chat-dify-integration-v2-design.md))保留为**长期演进路线图**,MVP 阶段不实施。
|
||
|
||
> **🎉 2026-06-30 MVP 端到端跑通!**
|
||
>
|
||
> 验证结果(实测):
|
||
> - ✅ 后端 20008 端口正常 Dubbo 监听
|
||
> - ✅ Dify Workflow 流式响应 8 个 chunk 正常返回
|
||
> - ✅ Dify 内部用 minimax-m3 模型推理("嗨!我是角角~..." 完整回复)
|
||
> - ✅ ai_conversations 表写入 message_count=2
|
||
> - ✅ ai_messages 表写入 user="hi" + assistant="嗨!我是角角~..." (228 字符)
|
||
> - ✅ Gateway → aichatservice → Dify → DB 完整链路打通
|
||
|
||
---
|
||
|
||
## 〇、我们解决的问题
|
||
|
||
### 〇.1 业务问题
|
||
|
||
追星 App 用户与"角角"AI 搭子聊天时,AI **没有专属知识库**,无法针对所追明星(默认肖战)给出有针对性的回答:
|
||
|
||
- 用户:"肖战最近有什么新作品?"
|
||
- 现状:AI 只能泛泛而谈("肖战是中国男演员...")
|
||
- 期望:AI 应能基于最新资料回答"根据知识库,肖战的新剧《X》将于 X 月上映"
|
||
|
||
### 〇.2 技术问题
|
||
|
||
| # | 问题 | 严重度 | MVP 解决方式 |
|
||
|---|------|--------|------------|
|
||
| 1 | **缺乏 RAG 能力**:AI 没有专属数据 | 🔴 高 | 接入 Dify Workflow + 肖战知识库 |
|
||
| 2 | **会话无持久化**:当前仅 Redis 缓存 24h | 🟡 中 | PostgreSQL 持久化 + Redis 缓存(★ 核心) |
|
||
| 3 | **AI 平台耦合**:未来要接 Coze/FastGPT | 🟢 低 | MVP 不做,1 个 Dify 够用 |
|
||
| 4 | **运营成本失控**:100 明星时配置爆炸 | 🟢 低 | MVP 固定 1 个星 |
|
||
|
||
### 〇.3 解决方式(**业务驱动**,不是架构驱动)
|
||
|
||
| 阶段 | 做什么 | 不做什么 | 触发条件 |
|
||
|------|--------|---------|---------|
|
||
| **MVP** | Dify + PostgreSQL + Redis | Provider 抽象、Fallback、多星 | 现在 |
|
||
| Stage 2 | 多星 Dataset 切换 | Provider 抽象 | 加第 2 个星 / 用户 > 1000 |
|
||
| Stage 3 | MiniMax fallback | 抽象 | Dify 偶发故障 |
|
||
| Stage 4 | Provider 抽象 | 复杂 Pipeline | 接第 2 个 AI 平台 / 用户 > 10万 |
|
||
| Stage 5 | Pipeline / AIProfile | A/B 测试 / 灰度 | 用户 > 100万 |
|
||
|
||
**核心原则**:**业务不到不做架构**。MVP 阶段不实施"为未来 100 明星 + 多 AI 平台"准备的复杂设计。
|
||
|
||
---
|
||
|
||
## 〇、文档说明
|
||
|
||
- **MVP 范围**:只有 1 个明星(默认肖战 star_id=87)
|
||
- **AI 平台**:只有 Dify(无 MiniMax fallback,无 Provider 抽象)
|
||
- **目标**:验证业务假设(用户愿意与"角角"聊天)
|
||
- **后续演进**:见 [§十 演进路径](#十演进路径)
|
||
|
||
---
|
||
|
||
## 〇.1 方案概述(★ 必读)
|
||
|
||
### 〇.1.1 我们要做什么
|
||
|
||
**业务**:在追星 App 里加"AI 搭子"功能。用户进入后默认看到"角角"(肖战的 AI 形象),可以问"肖战最近在干嘛?"等关于肖战的问题。
|
||
|
||
**MVP 范围**:
|
||
- ✅ 1 个明星(肖战)
|
||
- ✅ 1 个 Dify Workflow(含 1 个肖战知识库)
|
||
- ✅ 1 种回复源(Dify)
|
||
- ✅ WebSocket 流式输出
|
||
- ❌ 不做:多星切换、模型 fallback、人设自定义、长期记忆提取、多 AI 平台
|
||
|
||
### 〇.1.2 整体实现路径
|
||
|
||
```
|
||
数据迁移 Dify 端准备 后端代码 联调测试
|
||
├ 建 2 张表 ├ 准备知识库 ├ model ├ 内部账号测试
|
||
├ ├ 创 Workflow ├ repository ├ WebSocket 联调
|
||
└ └ 不需要 Code 节点 └ service └ 错误注入
|
||
└ provider
|
||
└ main.go 装配
|
||
```
|
||
|
||
**不要分配实施时间**——按业务节奏推进。
|
||
|
||
### 〇.1.3 关键决策(80% 推到 Stage 2+)
|
||
|
||
| 决策项 | MVP 选择 | Stage 2+ 再考虑 |
|
||
|--------|---------|-----------------|
|
||
| Dify 架构 | **Workflow**(含 1 个 Dataset) | 多星时加 dataset 变量 |
|
||
| AI Provider | **Dify 单源**(无抽象) | Stage 3 才抽象 `AIProvider` |
|
||
| 会话存储 | **PostgreSQL 主 + Redis 缓存** | 沿用 MVP 设计 |
|
||
| Fallback | **无**(Dify 失败就报错) | Stage 2 看需求 |
|
||
| Memory 提取 | **不做**(每轮都入 ai_messages) | Stage 2+ 看需求 |
|
||
| Provider 抽象 | **不做** | Stage 3+ |
|
||
| 数据集切换 | **固定 1 个**(肖战) | Stage 2 多星时加 mapping |
|
||
|
||
### 〇.1.4 核心架构图(TL;DR)
|
||
|
||
```
|
||
用户(追星 App)
|
||
│ WebSocket
|
||
▼
|
||
Gateway (现有, 不改)
|
||
│ Dubbo Triple
|
||
▼
|
||
AIChatService.ChatService
|
||
│
|
||
├─ JWT 鉴权 (从 Dubbo attachments 取 user_id)
|
||
│
|
||
├─ 保存/读取会话: ConversationRepository
|
||
│ ├─ ai_conversations (PostgreSQL, ★ V2 关键决策保留)
|
||
│ └─ ai_messages (PostgreSQL, ★ V2 关键决策保留)
|
||
│
|
||
├─ 调 Dify (★ 唯一 AI 源)
|
||
│ └─ DifyClient.StreamChat()
|
||
│ └─ POST /v1/workflows/run
|
||
│
|
||
└─ SSE 流 → WebSocket → 客户端
|
||
|
||
外部: Dify Workflow
|
||
┌──────────────────────┐
|
||
│ 开始 │
|
||
│ ↓ │
|
||
│ Knowledge Retrieval │ ← 固定查"肖战知识库"
|
||
│ ↓ │
|
||
│ LLM │ ← 固定 Prompt (角角人设)
|
||
│ ↓ │
|
||
│ 结束 │
|
||
└──────────────────────┘
|
||
```
|
||
|
||
**关键简化**:
|
||
- ❌ 没有 Provider 抽象
|
||
- ❌ 没有 ProviderFactory
|
||
- ❌ 没有 MemoryStore
|
||
- ❌ 没有 RedisLock(并发问题 MVP 阶段不严重)
|
||
- ❌ 没有 AIProfile
|
||
- ❌ 没有 DatasetResolver
|
||
- ❌ 没有 Star→Dataset mapping(只有 1 个星)
|
||
- ❌ 没有 Fallback 逻辑
|
||
- ❌ 没有 Memory 提取循环
|
||
- ❌ 没有 Star App 多 Workflow
|
||
|
||
---
|
||
|
||
## 一、MVP 范围
|
||
|
||
### 1.1 包含
|
||
|
||
- ✅ 1 个明星(肖战 star_id=87)
|
||
- ✅ 1 个 Dify Workflow(3 节点)
|
||
- ✅ 1 个 Dify Dataset(肖战专属知识库)
|
||
- ✅ WebSocket 协议(沿用现有)
|
||
- ✅ JWT 鉴权(沿用现有)
|
||
- ✅ Audit 前置 + 后置(沿用现有 AuditService)
|
||
- ✅ Conversation 持久化到 PostgreSQL(**核心决策,MVP 即落地**)
|
||
- ✅ Redis 缓存(沿用现有)
|
||
|
||
### 1.2 不包含(Stage 2+ 再做)
|
||
|
||
- ❌ 多星切换(用户不能选其他明星)
|
||
- ❌ MiniMax fallback(Dify 失败就报错给用户)
|
||
- ❌ Persona 自定义(人设固定"角角")
|
||
- ❌ 长期记忆提取(不分析对话提取记忆)
|
||
- ❌ Provider 抽象接口(直接调 DifyClient)
|
||
- ❌ 人设/风格/语言/记忆等参数化(都写死在 Dify Prompt 里)
|
||
- ❌ Dify 内容审核节点(AuditService 已拦截,MVP 阶段够用)
|
||
- ❌ Datasets 动态切换(固定"肖战知识库")
|
||
|
||
---
|
||
|
||
## 二、整体架构
|
||
|
||
### 2.1 数据流(一次完整对话)
|
||
|
||
```
|
||
[Mobile]
|
||
│ WebSocket send {action: "send_message", session_id, message}
|
||
│
|
||
▼
|
||
[Gateway Hub]
|
||
│ 鉴权 (JWT → user_id)
|
||
│
|
||
▼
|
||
[AIChatService Provider.SendMessage]
|
||
│
|
||
├─ 1. 前置审核 (AuditService.AuditText) ★ 现有代码
|
||
│
|
||
├─ 2. 获取/创建会话 (ConversationRepository)
|
||
│ ├─ PostgreSQL ai_conversations (★ V2 关键)
|
||
│ └─ Redis 缓存 1h (现有代码)
|
||
│
|
||
├─ 3. 调 Dify (★ MVP 唯一 AI 源)
|
||
│ └─ DifyClient.StreamChat()
|
||
│ └─ POST /v1/workflows/run
|
||
│
|
||
├─ 4. 流式返回 + 逐 token 后置审核 (AuditService.AuditResponse)
|
||
│ └─ ★ 现有代码
|
||
│
|
||
├─ 5. 保存消息 (ConversationRepository)
|
||
│ └─ PostgreSQL ai_messages (★ V2 关键)
|
||
│
|
||
└─ 6. (Stage 2+ 才做) 记忆提取
|
||
```
|
||
|
||
### 2.2 关键简化点
|
||
|
||
| 维度 | V2 文档 | MVP 实际 |
|
||
|------|--------|---------|
|
||
| 核心业务逻辑 | 9+ 步骤 | **4 步骤**(审计/会话/Dify/保存) |
|
||
| Provider 数 | 2 个(Dify + MiniMax) | **1 个**(Dify) |
|
||
| Fallback | 复杂的 Provider 切换 | **没有**(Dify 失败就报错) |
|
||
| 星切换 | 动态 + dataset 映射 | **固定肖战** |
|
||
| 人设/风格/记忆 | 4 个 SystemInputs 参数 | **写死在 Dify Prompt** |
|
||
| 长期记忆 | MemoryStore + 5 轮触发 | **不做** |
|
||
| Provider 抽象 | `AIProvider` interface | **直接调 DifyClient** |
|
||
| Workflow 节点 | 5 个(含 Code + Moderation) | **3 个**(开始/检索/LLM/结束) |
|
||
| 锁 | RedisLock | **不需要**(单实例部署,无并发问题) |
|
||
| 后端代码行数估算 | 1500-2000 | **300-500** |
|
||
|
||
---
|
||
|
||
## 三、Dify 端配置
|
||
|
||
### 3.1 准备知识库(Dataset)
|
||
|
||
1. 登录 Dify → "知识库" → "创建知识库"
|
||
2. 命名:`star-xz-kb`(固定一个)
|
||
3. 索引模式:`high_quality`
|
||
4. 导入肖战资料(作品、行程、近期事件等)
|
||
5. 等待向量化完成(每个文档显示 ✓)
|
||
|
||
### 3.2 创建 Workflow(仅 3 节点)
|
||
|
||
1. 进入"工作室" → "创建空白应用" → 类型选 **Workflow**
|
||
2. 命名:`star-chat-workflow`
|
||
3. 配置"开始"节点的 Input 变量:
|
||
```yaml
|
||
inputs:
|
||
- name: query # 用户消息
|
||
type: text
|
||
```
|
||
> **注**:`user_id`(哈希后的用户标识)由后端通过 Dify 协议**顶层 `user` 字段**传入(见 [§4.5 DifyClient](2026-06-29-ai-chat-dify-mvp-design.md#45-difyclient--mvp-唯一-ai-客户端)),不作为 workflow input。
|
||
>
|
||
> **与实际工作流对齐**:[`docs/dify/角角.yml`](../../dify/角角.yml) v0.6.0 只声明了 `query` 一个 input。若后端仍把 `user_id` 塞进 `inputs`,Dify 会静默丢弃,不影响功能(`user` 字段仍生效)。
|
||
4. 添加 **知识检索节点**:
|
||
- Knowledge:选 `star-xz-kb`(固定)
|
||
- Query:`{{ query }}`
|
||
- TopK:3
|
||
5. 添加 **LLM 节点**:
|
||
```markdown
|
||
你叫"角角",是肖战的 AI 形象。
|
||
|
||
【用户问题】
|
||
{{ query }}
|
||
|
||
【知识库检索结果】
|
||
{{#knowledge_retrieval_node.result#}}
|
||
|
||
请用温柔、自然的语言回答,参考知识库内容,不要编造。
|
||
```
|
||
6. 添加"直接回复"或"结束"节点
|
||
|
||
**节点序列**:
|
||
```
|
||
[开始] inputs:{query}
|
||
↓
|
||
[知识检索] star-xz-kb(固定)
|
||
↓
|
||
[LLM] 角角人设(固定)
|
||
↓
|
||
[结束]
|
||
```
|
||
|
||
### 3.3 调试
|
||
|
||
1. 传 `inputs={query: "肖战最近在干嘛?", user_id: "aichat-xxxx"}`
|
||
2. 验证:返回基于知识库的回答
|
||
3. 发布 → 复制 API Key(`app-xxx`)
|
||
|
||
---
|
||
|
||
## 四、后端代码
|
||
|
||
### 4.1 改动总览
|
||
|
||
| 层级 | 改动 | 工作量 |
|
||
|------|------|--------|
|
||
| `model/ai_chat_models.go` | **新增**:`AIConversation` / `AIMessage` GORM 模型 | 小 |
|
||
| `repository/conversation_repository.go` | **新建**:ai_conversations / ai_messages CRUD | 中 |
|
||
| `service/chat_service.go` | **修改**:直接调 DifyClient(不再有 ChatEngine 编排) | 中 |
|
||
| `provider/ai_chat_provider.go` | **修改**:Dubbo 入口 + 调 ChatService | 小 |
|
||
| `main.go` | **修改**:加 ConversationRepository 装配 | 小 |
|
||
| `migrations/ai_conversations.sql` | **新建**:2 张表 DDL | 小 |
|
||
| **前端** | **无改动** ✅ | 0 |
|
||
|
||
**总工作量**:约 **300-500 行核心代码**
|
||
|
||
### 4.2 model/ai_chat_models.go 新增
|
||
|
||
```go
|
||
package model
|
||
|
||
import "github.com/google/uuid"
|
||
|
||
type AIConversation struct {
|
||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||
UserID int64 `gorm:"index;not null"`
|
||
StarID int64 `gorm:"index;not null"` // MVP 固定为 87
|
||
ProviderName string `gorm:"type:varchar(32);not null;default:'dify'"`
|
||
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 (AIConversation) 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"` // 'user' / 'assistant'
|
||
Content string `gorm:"type:text;not null"`
|
||
CreatedAt int64 `gorm:"autoCreateTime:milli"`
|
||
}
|
||
|
||
func (AIMessage) TableName() string { return "ai_messages" }
|
||
```
|
||
|
||
### 4.3 PostgreSQL DDL
|
||
|
||
```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 DEFAULT 'dify',
|
||
external_conversation_id VARCHAR(128) DEFAULT '',
|
||
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
|
||
);
|
||
CREATE INDEX idx_ai_conv_user_star ON ai_conversations(user_id, star_id);
|
||
|
||
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);
|
||
```
|
||
|
||
> MVP 阶段**不**加唯一约束(user_id + star_id),方便 Stage 2 加多星时再处理
|
||
> MVP 阶段**不**加 `is_archived` 等字段
|
||
|
||
### 4.4 ConversationRepository
|
||
|
||
```go
|
||
package repository
|
||
|
||
import (
|
||
"context"
|
||
"github.com/topfans/backend/services/aiChatService/model"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type ConversationRepository struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
func NewConversationRepository(db *gorm.DB) *ConversationRepository {
|
||
return &ConversationRepository{db: db}
|
||
}
|
||
|
||
func (r *ConversationRepository) GetOrCreate(ctx context.Context, userID, starID int64) (*model.AIConversation, error) {
|
||
var conv model.AIConversation
|
||
err := r.db.WithContext(ctx).Where("user_id = ? AND star_id = ?", userID, starID).First(&conv).Error
|
||
if err == gorm.ErrRecordNotFound {
|
||
conv = model.AIConversation{UserID: userID, StarID: starID, ProviderName: "dify"}
|
||
if err := r.db.WithContext(ctx).Create(&conv).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return &conv, nil
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &conv, nil
|
||
}
|
||
|
||
func (r *ConversationRepository) AppendMessage(ctx context.Context, convID int64, role, content string) error {
|
||
msg := model.AIMessage{ConversationID: convID, Role: role, Content: content}
|
||
return r.db.WithContext(ctx).Create(&msg).Error
|
||
}
|
||
|
||
func (r *ConversationRepository) UpdateExternalConvID(ctx context.Context, convID int64, externalID string) error {
|
||
return r.db.WithContext(ctx).Model(&model.AIConversation{}).
|
||
Where("id = ?", convID).
|
||
Updates(map[string]interface{}{
|
||
"external_conversation_id": externalID,
|
||
"message_count": gorm.Expr("message_count + 1"),
|
||
"last_active_at": gorm.Expr("(EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT"),
|
||
}).Error
|
||
}
|
||
```
|
||
|
||
### 4.5 DifyClient(★ MVP 唯一 AI 客户端)
|
||
|
||
```go
|
||
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/topfans/backend/pkg/logger"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
type DifyClient struct {
|
||
apiBase string
|
||
workflowURL string
|
||
apiKey string
|
||
httpClient *http.Client
|
||
}
|
||
|
||
type DifyConfig struct {
|
||
APIBase string
|
||
WorkflowURL string
|
||
APIKey string
|
||
TimeoutSec int
|
||
}
|
||
|
||
func NewDifyClient(cfg DifyConfig) *DifyClient {
|
||
return &DifyClient{
|
||
apiBase: cfg.APIBase,
|
||
workflowURL: cfg.WorkflowURL,
|
||
apiKey: cfg.APIKey,
|
||
httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second},
|
||
}
|
||
}
|
||
|
||
// StreamChat 流式调用 Dify Workflow
|
||
// 返回 (StreamReader, error):StreamReader 可逐 token 读取
|
||
func (c *DifyClient) StreamChat(ctx context.Context, query, userHashedID, convID string) (*DifyStreamReader, error) {
|
||
inputs := map[string]interface{}{
|
||
"query": query,
|
||
"user_id": userHashedID,
|
||
}
|
||
body := map[string]interface{}{
|
||
"inputs": inputs,
|
||
"response_mode": "streaming",
|
||
"conversation_id": convID, // 首次为空
|
||
"user": userHashedID,
|
||
}
|
||
jsonData, _ := json.Marshal(body)
|
||
httpReq, _ := http.NewRequestWithContext(ctx, "POST",
|
||
c.apiBase+c.workflowURL, bytes.NewReader(jsonData))
|
||
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
|
||
resp, err := c.httpClient.Do(httpReq)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("dify request: %w", err)
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
return nil, fmt.Errorf("dify returned HTTP %d: %s", resp.StatusCode, string(body))
|
||
}
|
||
return &DifyStreamReader{reader: resp.Body, decoder: NewSSEDecoder(resp.Body), conversationID: ""}, nil
|
||
}
|
||
|
||
// DifyStreamReader 解析 Dify SSE 流
|
||
type DifyStreamReader struct {
|
||
reader io.ReadCloser
|
||
decoder *SSEDecoder
|
||
conversationID string
|
||
}
|
||
|
||
func (r *DifyStreamReader) Next() (content string, done bool, err error) {
|
||
for {
|
||
line, err := r.decoder.Next()
|
||
if err != nil {
|
||
if err == io.EOF { return "", true, nil }
|
||
return "", true, err
|
||
}
|
||
if !strings.HasPrefix(line, "data: ") { continue }
|
||
data := strings.TrimPrefix(line, "data: ")
|
||
|
||
var event struct {
|
||
Event string `json:"event"`
|
||
Answer string `json:"answer"`
|
||
ConversationID string `json:"conversation_id"`
|
||
}
|
||
if err := json.Unmarshal([]byte(data), &event); err != nil { continue }
|
||
|
||
if event.ConversationID != "" && r.conversationID == "" {
|
||
r.conversationID = event.ConversationID
|
||
}
|
||
switch event.Event {
|
||
case "message":
|
||
return event.Answer, false, nil
|
||
case "message_end":
|
||
return "", true, nil
|
||
case "error":
|
||
return "", true, fmt.Errorf("dify error event")
|
||
}
|
||
}
|
||
}
|
||
|
||
func (r *DifyStreamReader) GetConversationID() string { return r.conversationID }
|
||
func (r *DifyStreamReader) Close() error { return r.reader.Close() }
|
||
```
|
||
|
||
> ★ MVP 阶段**没有**滑动窗口审计、retry 循环、敏感词检测(现有 AuditService 已足够)
|
||
|
||
### 4.6 ChatService(核心业务逻辑)
|
||
|
||
```go
|
||
package service
|
||
|
||
type ChatService struct {
|
||
audit *AuditService
|
||
convRepo *repository.ConversationRepository
|
||
difyClient *DifyClient
|
||
userIDSalt string
|
||
}
|
||
|
||
func NewChatService(audit *AuditService, convRepo *repository.ConversationRepository, dify *DifyClient) *ChatService {
|
||
return &ChatService{audit: audit, convRepo: convRepo, difyClient: dify}
|
||
}
|
||
|
||
const (
|
||
DefaultStarID = int64(87) // 肖战
|
||
DefaultUserSalt = "topfans-default-salt"
|
||
)
|
||
|
||
func (s *ChatService) hashUserID(userID int64) string {
|
||
h := sha256.Sum256([]byte(fmt.Sprintf("%d:%s", userID, s.userIDSalt)))
|
||
return "aichat-" + hex.EncodeToString(h[:8])
|
||
}
|
||
|
||
// SendMessage 核心流程(4 步)
|
||
func (s *ChatService) SendMessage(ctx context.Context, userID int64, message string) (<-chan *StreamChunk, error) {
|
||
out := make(chan *StreamChunk, 16)
|
||
go func() {
|
||
defer close(out)
|
||
|
||
// 1. 前置审核
|
||
if !s.audit.AuditText(message) {
|
||
out <- &StreamChunk{Type: "message", Content: s.audit.DefaultSafeResponse(), IsEnd: false}
|
||
out <- &StreamChunk{Type: "message", IsEnd: true}
|
||
return
|
||
}
|
||
|
||
// 2. 获取/创建会话(★ V2 关键决策:PostgreSQL 持久化)
|
||
conv, err := s.convRepo.GetOrCreate(ctx, userID, DefaultStarID)
|
||
if err != nil {
|
||
out <- &StreamChunk{Type: "error", Error: "会话创建失败"}
|
||
return
|
||
}
|
||
|
||
// 3. 调 Dify(★ MVP 唯一 AI 源)
|
||
streamReader, err := s.difyClient.StreamChat(ctx, message, s.hashUserID(userID), conv.ExternalConvID)
|
||
if err != nil {
|
||
logger.Logger.Error("Dify call failed", zap.Error(err))
|
||
out <- &StreamChunk{Type: "error", Error: "服务暂不可用"}
|
||
return
|
||
}
|
||
defer streamReader.Close()
|
||
|
||
// 4. 流式返回 + 后置审核 + 保存
|
||
var fullResponse string
|
||
for {
|
||
content, done, err := streamReader.Next()
|
||
if err != nil {
|
||
out <- &StreamChunk{Type: "error", Error: "服务异常"}
|
||
return
|
||
}
|
||
if content != "" && !s.audit.AuditResponse(content) {
|
||
// 命中敏感词
|
||
out <- &StreamChunk{Type: "message", Content: s.audit.DefaultSafeResponse(), IsEnd: false}
|
||
out <- &StreamChunk{Type: "message", IsEnd: true}
|
||
s.convRepo.AppendMessage(ctx, conv.ID, "assistant", s.audit.DefaultSafeResponse())
|
||
return
|
||
}
|
||
fullResponse += content
|
||
out <- &StreamChunk{Type: "message", Content: content, IsEnd: done}
|
||
if done { break }
|
||
}
|
||
|
||
// 5. 更新 Dify conv_id(首次)
|
||
if newConvID := streamReader.GetConversationID(); newConvID != "" && newConvID != conv.ExternalConvID {
|
||
s.convRepo.UpdateExternalConvID(ctx, conv.ID, newConvID)
|
||
}
|
||
|
||
// 6. 保存消息
|
||
s.convRepo.AppendMessage(ctx, conv.ID, "user", message)
|
||
s.convRepo.AppendMessage(ctx, conv.ID, "assistant", fullResponse)
|
||
}()
|
||
return out, nil
|
||
}
|
||
|
||
// GetWelcomeMessage MVP 阶段固定返回"角角"欢迎语
|
||
func (s *ChatService) GetWelcomeMessage() string {
|
||
return "你好,我是角角,专注肖战的 AI 搭子。有什么想了解的?"
|
||
}
|
||
```
|
||
|
||
### 4.7 Provider 大幅简化
|
||
|
||
```go
|
||
package provider
|
||
|
||
type AIChatProvider struct {
|
||
chatService *service.ChatService
|
||
}
|
||
|
||
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.chatService.SendMessage(ctx, userID, 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
|
||
}
|
||
```
|
||
|
||
> **极简**:~30 行。**完全没有 V2 里的 ChatEngine 编排、锁、Provider 抽象、Factory 等**
|
||
|
||
### 4.8 main.go 装配
|
||
|
||
```go
|
||
// MVP 装配:极简
|
||
convRepo := repository.NewConversationRepository(database.GetDB())
|
||
difyClient := service.NewDifyClient(service.DifyConfig{
|
||
APIBase: getEnv("DIFY_API_BASE", "https://api.dify.ai/v1"),
|
||
WorkflowURL: "/workflows/run",
|
||
APIKey: getEnv("DIFY_API_KEY", ""),
|
||
TimeoutSec: 60,
|
||
})
|
||
chatService := service.NewChatService(auditService, convRepo, difyClient)
|
||
aiChatProvider := provider.NewAIChatProvider(chatService)
|
||
```
|
||
|
||
---
|
||
|
||
## 五、消息协议(无改动)
|
||
|
||
WebSocket 协议与现有实现一致:
|
||
- Client → Server:`{action: "send_message", session_id, message}`
|
||
- Server → Client:`{type: "message", content, is_end}` 或 `{type: "error", error}`
|
||
|
||
**前端零改动**。
|
||
|
||
---
|
||
|
||
## 六、关键设计决策
|
||
|
||
| 决策 | 选择 | 理由 |
|
||
|------|------|------|
|
||
| Provider 抽象 | **不做** | MVP 只有 1 个 AI 源,抽象无价值 |
|
||
| MiniMax fallback | **不做** | Dify 失败就报错,避免增加复杂度 |
|
||
| 长期记忆提取 | **不做** | 业务假设未验证前不做 |
|
||
| Redis 缓存 | **保留** | 1h 缓存会话元数据,避免每次查 DB |
|
||
| PostgreSQL 持久化 | **保留(★ 关键)** | 跨设备/跨天续接(追星场景长生命周期) |
|
||
| 滑动窗口审计 | **不做** | 现有 AuditService 逐 token 检查已足够 |
|
||
| 锁 | **不做** | 单实例部署,并发问题不严重 |
|
||
| Dify 内容审核节点 | **不做** | 现有 AuditService 已拦截,MVP 够用 |
|
||
| UserStyle/UserNickname 参数 | **不做** | 写死在 Dify Prompt 里 |
|
||
| 星切换 | **不做** | 固定 star_id=87(肖战) |
|
||
|
||
---
|
||
|
||
## 七、配置清单
|
||
|
||
### 7.1 环境变量
|
||
|
||
| 变量 | 用途 | 必填 |
|
||
|------|------|------|
|
||
| `DIFY_API_KEY` | Dify Workflow API Key | 是 |
|
||
| `DIFY_API_BASE` | Dify API 地址 | 否(默认 https://api.dify.ai/v1) |
|
||
|
||
### 7.2 ai_chat_configs(★ MVP 全部不要)
|
||
|
||
**MVP 阶段直接用环境变量,不写 ai_chat_configs 数据库**。
|
||
|
||
**V2 文档里 9 个 `dify.*` 配置项 MVP 全部不需要**(enabler、api_base、workflow_url、star_dataset_mapping、api_key、timeout_sec、user_id_salt、retry_count、fallback_to_minimax)——**全部 hardcode 或用环境变量**。
|
||
|
||
> Stage 2+ 才把这些移到数据库配置。
|
||
|
||
---
|
||
|
||
## 八、部署清单
|
||
|
||
> **不要分配实施时间**。按业务节奏推进。
|
||
|
||
### 8.1 数据库
|
||
|
||
- [ ] DBA 执行 `migrations/ai_conversations.sql`
|
||
- [ ] 验证表结构和索引
|
||
|
||
### 8.2 Dify 端
|
||
|
||
- [ ] Dify 管理员创建 `star-xz-kb` 知识库
|
||
- [ ] 导入肖战资料并等待向量化完成
|
||
- [ ] 创建 `star-chat-workflow`(3 节点:开始/检索/LLM)
|
||
- [ ] 配置 Prompt("你是角角,温柔回复,参考知识库...")
|
||
- [ ] 调试并发布
|
||
- [ ] 把 API Key 安全转给后端
|
||
|
||
### 8.3 后端代码
|
||
|
||
- [ ] 新建 `model/ai_chat_models.go` 的 AIConversation/AIMessage
|
||
- [ ] 新建 `repository/conversation_repository.go`
|
||
- [ ] 新建 `service/dify_client.go`
|
||
- [ ] 修改 `service/chat_service.go`(**直接调 DifyClient,不引入 ChatEngine**)
|
||
- [ ] 简化 `provider/ai_chat_provider.go`
|
||
- [ ] 修改 `main.go` 装配
|
||
- [ ] 单元测试:ConversationRepository CRUD
|
||
- [ ] 集成测试:mock Dify server 跑完整 SendMessage
|
||
|
||
### 8.4 联调测试
|
||
|
||
- [ ] 内部账号测试:进 ai-dazi 页面发消息
|
||
- [ ] 验证:流式返回正常
|
||
- [ ] 验证:ai_messages 表有 user + assistant 两条记录
|
||
- [ ] 验证:关掉重开会话能续接
|
||
- [ ] 验证:敏感词("裸聊"等)被拦截
|
||
- [ ] 验证:Dify 故障时返回明确错误
|
||
|
||
---
|
||
|
||
## 九、验证清单
|
||
|
||
### 9.1 功能验证
|
||
|
||
- [ ] 发送"肖战最近在干嘛?"能返回基于知识库的回答
|
||
- [ ] 发送"你好"能返回通用问候
|
||
- [ ] 同用户第二次发消息能续接上下文(Dify conversation_id)
|
||
- [ ] ai_messages 表有 user + assistant 两条记录
|
||
- [ ] ai_conversations 表的 message_count 正确递增
|
||
|
||
### 9.2 安全验证
|
||
|
||
- [ ] 前置审核:用户发"裸聊"等敏感词被拦截
|
||
- [ ] 后置审核:Dify 回复中含敏感词被拦截
|
||
- [ ] Dify API Key 不出现在日志
|
||
|
||
### 9.3 不验证(Stage 2+ 再做)
|
||
|
||
- ~~多星切换~~(MVP 不做)
|
||
- ~~Fallback~~(MVP 不做)
|
||
- ~~长期记忆提取~~(MVP 不做)
|
||
|
||
---
|
||
|
||
## 十、Stage 2+ 演进路径
|
||
|
||
MVP 跑通后,根据用户量和业务反馈,按以下顺序演进:
|
||
|
||
| Stage | 触发条件 | 关键改动 |
|
||
|-------|---------|---------|
|
||
| **Stage 2** | 用户量 > 1000 OR 加第 2 个星 | 1. 多星 Dataset 切换(star_dataset_mapping)<br>2. WebSocket 端 InitSession 欢迎语动态化<br>3. ai_conversations 加 UNIQUE(user_id, star_id) |
|
||
| **Stage 3** | Dify 偶发故障 OR SLA 要求 | 1. MiniMax fallback(仅 message_count=0 时)<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 配置化(A/B 测试、灰度)<br>3. 长期记忆提取 |
|
||
|
||
**关键原则**:每个 Stage 都是**业务驱动**,不是架构驱动。
|
||
|
||
### ★ Stage 2+ 演进时必踩的 3 个坑(P0 修复笔记)
|
||
|
||
> ★ **这些是 V2 架构评审发现的真实 bug**,MVP 阶段不修(流量小、问题不暴露),但 **Stage 2+ 流量上来后必现**。
|
||
> ★ **必读**:实施 Stage 2 之前,**必须**先修这 3 个 P0 问题。
|
||
|
||
#### 坑 1:并发请求分裂会话(★ P0-1)
|
||
|
||
**症状**:用户手机 + 平板同时发消息,Dify 端产生两个会话,AI 上下文错乱。
|
||
|
||
**根因**:两个并发请求都查到 `ExternalConvID=""`,都调 Dify,Dify 给两个不同的 `conversation_id`,后写入的覆盖先写入的。
|
||
|
||
**修复**:
|
||
- 加 Redis 分布式锁 `conv_lock:{userId}:{starId}`,TTL 30s
|
||
- 锁范围:`GetOrCreateConversation` → `UpdateExternalID`
|
||
- 锁未获取时 sleep 200ms 重试一次
|
||
|
||
#### 坑 2:审计拦截后不保存对话(★ P0-2)
|
||
|
||
**症状**:用户每次触发敏感词拦截后,AI 都不记得之前说过什么,行为诡异。
|
||
|
||
**根因**:审计分支直接 return,没保存"user 原句 + 安全回复"。
|
||
|
||
**修复**:审计分支也调 `AppendConversationMessages` 保存对话。
|
||
|
||
#### 坑 3:组合敏感词漏检(★ P0-3)
|
||
|
||
**症状**:Dify 返回"色"+"情"分两个 token,单独都不违规,组合违规。
|
||
|
||
**根因**:V1 AuditService 逐 token 检查(`strings.Contains(token, word)`),单 token 视角。
|
||
|
||
**修复**:Dify 流式接收时维护 sliding window buffer(20 字符),每个 token 检查 buffer。
|
||
|
||
### ★ Stage 4 抽象时必踩的 3 个坑(架构评审笔记)
|
||
|
||
> ★ 这些是 V2 架构评审发现的 **设计层面**问题,Stage 4 做 AIProvider 抽象时必踩。
|
||
|
||
#### 坑 4:Provider God Class(评审 #1)
|
||
|
||
**症状**:DifyProvider 写了 2000+ 行,什么都管(HTTP / Stream / Cache / Conversation / Retry / Hash / Audit)。
|
||
|
||
**修复**:拆为 4 个组件:
|
||
- `WorkflowClient`:HTTP + SSE 解析 + Retry
|
||
- `HistoryClient`:拉历史消息
|
||
- `DatasetResolver`:star_id → dataset_id 映射
|
||
- `ConversationStore`:缓存 + 持久化
|
||
|
||
**原则**:Provider 只做协调,不做任何具体工作。
|
||
|
||
#### 坑 5:Provider 直接依赖 Redis/Repository(评审 #5)
|
||
|
||
**症状**:Provider 改存储(Redis → Memcached)时,所有 Provider 都要改。
|
||
|
||
**修复**:引入 `ConversationStore` 抽象,Provider 只依赖接口。底层是 `CachedConversationStore`(PostgreSQL + Redis 缓存)。
|
||
|
||
#### 坑 6:Workflow 与 Backend 重复维护 dataset 映射(评审 #3)
|
||
|
||
**症状**:Backend 改 `star_dataset_mapping` 忘改 Workflow → 用户问"肖战"答"王一博"的资料。
|
||
|
||
**修复**:
|
||
- **单一数据源**:映射只在 Backend `ai_chat_configs.dify.star_dataset_mapping` 维护
|
||
- Workflow 输入直接接 `dataset_id`(Backend 传过来的)
|
||
- Workflow 内部**不**维护任何 `star_id → dataset_id` 映射(**没有 Code 节点**)
|
||
|
||
### ★ 演进时不要做
|
||
|
||
- ❌ **不要预先做 P0 修复**:MVP 阶段流量小,race / 审计保存 / 组合敏感词都暴露不出来
|
||
- ❌ **不要预先做 Provider 抽象**:MVP 只有 1 个 AI 源(写死就行)
|
||
- ❌ **不要预先做 Pipeline**:SendMessage 函数 < 200 行不需要 Pipeline
|
||
- ❌ **不要预先做 AIProfile**:1 个星 1 种 AI 源根本不需要 A/B
|
||
|
||
---
|
||
|
||
## 十一、与 V2 文档的关系
|
||
|
||
**V2 文档已删除**(2026-06-29 决定)。
|
||
|
||
原因:V2 是"100 明星 + 多 AI 平台"的完整架构设计,**MVP 不需要 80% 的内容**。V2 关键内容已迁移到本 MVP 文档的 [§10 演进路径](#十stage-2-演进路径),包含 3 个 P0 修复笔记 + 3 个 Stage 4 抽象笔记。
|
||
|
||
实施时按 MVP 推进,跑通后再按 §10 Stage 2+ 演进。
|
||
|
||
---
|
||
|
||
## 十二、关键文件清单
|
||
|
||
### 12.1 新建文件
|
||
|
||
```
|
||
backend/services/aiChatService/
|
||
├── model/
|
||
│ └── ai_chat_models.go (新增 AIConversation / AIMessage struct)
|
||
├── repository/
|
||
│ └── conversation_repository.go (新建,3 个方法)
|
||
├── service/
|
||
│ ├── dify_client.go (新建,唯一 AI 客户端)
|
||
│ └── chat_service.go (修改,4 步流程)
|
||
|
||
migrations/
|
||
└── ai_conversations.sql (新建,2 张表 DDL)
|
||
```
|
||
|
||
### 12.2 修改文件
|
||
|
||
```
|
||
backend/services/aiChatService/
|
||
├── provider/
|
||
│ └── ai_chat_provider.go (大幅简化)
|
||
└── main.go (加 convRepo + difyClient 装配)
|
||
```
|
||
|
||
### 12.3 不动文件
|
||
|
||
- 前端(所有 .vue / .js)
|
||
- Gateway(WebSocket Hub)
|
||
- AuditService(现有代码,MVP 沿用)
|
||
- JWT 鉴权(现有代码)
|
||
|
||
---
|
||
|
||
## 总结
|
||
|
||
**MVP 阶段的核心是验证业务假设,不是搭建完美架构**。
|
||
|
||
### MVP 范围
|
||
|
||
- ✅ 用户能跟"角角"聊天
|
||
- ✅ AI 能基于肖战知识库回答
|
||
- ✅ 对话跨天续接(PostgreSQL 持久化)
|
||
- ✅ 敏感词拦截(AuditService 沿用)
|
||
|
||
### 不做(业务驱动)
|
||
|
||
- ❌ Provider 抽象(只有 1 个 AI 源)
|
||
- ❌ Fallback(Dify 失败就报错)
|
||
- ❌ 多星(只有 1 个星)
|
||
- ❌ 长期记忆提取(每轮直接入 ai_messages)
|
||
- ❌ Persona 自定义(写死在 Dify Prompt)
|
||
- ❌ Dify 内容审核节点(AuditService 够用)
|
||
- ❌ 9 个 dify.* 数据库配置(环境变量够用)
|
||
|
||
### 业务驱动原则
|
||
|
||
每个 Stage 都是**业务驱动**,不是架构驱动:
|
||
|
||
1. 业务没到的复杂度 → **不预先做**
|
||
2. 架构是演进的,**不是一次性完美设计**
|
||
3. V2 完整文档作为**长期演进路线图**,**不删除**
|
||
4. 跑通 MVP 后,按 §十 Stage 2-5 渐进改进
|
||
|
||
**这些就够了**。其他都是"未来 100 明星 + 多 AI 平台"的事。
|