# 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)
2. WebSocket 端 InitSession 欢迎语动态化
3. ai_conversations 加 UNIQUE(user_id, star_id) |
| **Stage 3** | Dify 偶发故障 OR SLA 要求 | 1. MiniMax fallback(仅 message_count=0 时)
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 配置化(A/B 测试、灰度)
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 平台"的事。