topfans/backend/docs/AI-Chat-Service设计方案.md

2081 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AI Chat Service 设计方案
> **目标:** 在 TopFans Backend 微服务体系中,新增 AI 伴侣对话服务,实现「用户输入→人设注入→大模型调用→记忆召回→合规审核→流式输出」完整链路。
>
> **通信方式:** WebSocket移动端使用 UniAppWebSocket 支持更好)
---
## 一、架构设计
### 1.1 整体架构
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Mobile App (UniApp) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ WebSocket Client │ │
│ │ 连接 /ws/ai-chat # WebSocket 连接 │ │
│ │ 发送消息 → 接收流式回复 # 双向通信 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────┬───────────────────────────────────────────┘
│ WebSocket
┌─────────────────────────────────────────────────────────────────────────────┐
│ Gateway (:8080) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ AIChatWebSocketHandler (WebSocket 处理) │ │
│ │ /ws/ai-chat # WebSocket 连接升级 │ │
│ │ GET /api/v1/ai-chat/personas # 获取人设列表 │ │
│ │ GET /api/v1/ai-chat/history/{sessionId} # 获取对话历史 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ Dubbo Triple (gRPC) │
└────────────────────────────────────┼─────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ AIChatService (:20008) │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Provider 层 │ │
│ │ AIChatProvider (Dubbo RPC 入口) │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Service 层 │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │
│ │ │LLMService │ │PersonaService│ │MemoryService │ │AuditService│ │ │
│ │ │大模型调用 │ │人设管理 │ │记忆管理 │ │合规审核 │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Repository 层 │ │
│ │ ┌───────────────────────────┐ ┌───────────────────────────────────┐ │ │
│ │ │ PostgreSQL │ │ Redis │ │ │
│ │ │ ai_user_memories (长期记忆) │ │ context:{sessionId} - 短期上下文 │ │ │
│ │ │ ai_personas (人设) │ │ persona_cache:{userId}:{id} │ │ │
│ │ └───────────────────────────┘ └───────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 1.2 服务间调用关系
```
Gateway ──────────────────────────────────────────────────────────────────────
│ │
│ Dubbo Triple 协议 │
▼ │
AIChatService │
│ │
├─── 调用 MiniMax M2-her API ──► AI 大模型 (外部服务) │
│ │
├─── 读写 ──► Redis (短期上下文、Session) │
│ │
└─── 读写 ──► PostgreSQL (长期记忆 + 用户自定义人设) │
```
### 1.3 数据流
```
1. WebSocket 连接建立
Mobile → Gateway (WebSocket Upgrade)
Gateway 验证 JWT Token获取 user_id 和 star_id
建立 WebSocket 连接,关联 user_id
2. 用户发送消息
Mobile → Gateway (WebSocket 发送 JSON)
{
"action": "send_message",
"session_id": "10001_123",
"message": "今天工作好累",
"persona_id": "uuid-xxx" // 可选
}
3. Gateway → AIChatService
通过 Dubbo Triple 调用 SendMessage
4. 前置审核
AIChatService.AuditService.audit_text() → 通过/拒绝
5. 记忆召回
AIChatService.MemoryService.recall_memories() → 召回相关记忆
6. Prompt 组装
AIChatService.PersonaService.get_persona() → 获取人设
组装: SystemPrompt + 召回记忆 + 对话历史 + 用户输入
7. 大模型调用 (流式)
AIChatService.LLMService.stream_chat() → MiniMax API
8. 后置审核 (逐Token)
AIChatService.AuditService.audit_response() → 拦截敏感输出
9. 流式返回 (WebSocket)
AIChatService → Gateway (Dubbo Stream)
Gateway → Mobile (WebSocket)
{
"type": "message",
"content": "宝,",
"session_id": "10001_123",
"is_end": false
}
... (持续推送)
{
"type": "message",
"content": "",
"session_id": "10001_123",
"is_end": true
}
10. 保存上下文
AIChatService.MemoryService.save_context() → Redis
11. 触发记忆提取 (每5轮)
AIChatService.MemoryService.extract_memory() → PostgreSQL
```
### 1.4 WebSocket 消息协议
#### 1.4.1 客户端 → 服务器
**发送消息:**
```json
{
"action": "send_message",
"session_id": "10001_123",
"message": "今天工作好累",
"persona_id": "uuid-xxx" // 可选,不传则使用用户默认人设
}
```
**心跳检测:**
```json
{
"action": "ping"
}
```
**获取历史:**
```json
{
"action": "get_history",
"session_id": "10001_123",
"limit": 20
}
```
**获取人设列表:**
```json
{
"action": "get_personas"
}
```
#### 1.4.2 服务器 → 客户端
**鉴权响应:**
```json
{
"type": "auth_response",
"success": true,
"user_id": 10001,
"star_id": 123
}
```
**消息片段(流式):**
```json
{
"type": "message",
"content": "宝,",
"session_id": "10001_123",
"is_end": false
}
```
**消息结束:**
```json
{
"type": "message",
"content": "",
"session_id": "10001_123",
"is_end": true
}
```
**心跳响应:**
```json
{
"type": "pong"
}
```
**错误响应:**
```json
{
"type": "error",
"code": "AUDIT_BLOCKED",
"message": "抱歉,这个话题我无法继续,我们换个话题聊聊吧。",
"session_id": "10001_123"
}
```
**历史消息响应:**
```json
{
"type": "history_response",
"session_id": "10001_123",
"history": [
{"role": "user", "content": "今天工作好累"},
{"role": "assistant", "content": "宝,辛苦了~"}
]
}
```
**人设列表响应:**
```json
{
"type": "personas_response",
"personas": [
{"id": "uuid-xxx", "name": "小雪", "description": "温柔陪伴型闺蜜", "is_default": true},
{"id": "uuid-yyy", "name": "阿逗", "description": "幽默搭子", "is_default": false}
]
}
```
---
## 二、目录结构
```
services/aiChatService/
├── main.go # 程序入口
├── configs/
│ └── dubbo.yaml # Dubbo 配置
├── go.mod
├── go.sum
├── provider/
│ └── ai_chat_provider.go # Dubbo Provider (RPC 入口)
├── service/
│ ├── llm_service.go # 大模型调用 (MiniMax + 通义备用)
│ ├── persona_service.go # 人设管理
│ ├── memory_service.go # 记忆管理 (Redis + PostgreSQL)
│ ├── audit_service.go # 合规审核
│ └── prompt_builder.go # Prompt 组装
├── repository/
│ ├── memory_repository.go # 长期记忆 PostgreSQL 存储
│ └── persona_repository.go # 人设 PostgreSQL 存储
├── model/
│ ├── ai_chat_models.go # 数据模型定义
│ └── ai_chat_errors.go # 错误定义
└── pkg/
└── ai_chat_config.go # 配置加载
migrations/
└── ai_chat.sql # 数据库迁移 SQL 文件
```
---
## 三、接口设计
### 3.1 WebSocket 接口 (Gateway → Mobile)
#### 连接地址
```
ws://gateway:8080/ws/ai-chat?token=Bearer_xxx
```
> **鉴权方式:** 连接时通过 URL 参数传递 JWT TokenGateway 验证通过后建立连接。
> 相比连接后发送 auth 消息,此方式可避免恶意连接浪费资源。
#### 连接流程
1. Mobile 携带 Token 建立 WebSocket 连接:`ws://gateway:8080/ws/ai-chat?token=Bearer_xxx`
2. Gateway 解析并验证 Token获取 user_id 和 star_id
3. 验证成功,返回 `{"type": "auth_response", "success": true}`
4. 连接建立成功,开始收发消息
#### 连接失败响应
```json
{
"type": "auth_response",
"success": false,
"error": "invalid_token"
}
```
#### 心跳保活
- Mobile 每 30 秒发送一次 ping 消息
- Gateway 回复 pong 消息
- 如果 60 秒内未收到任何消息Gateway 主动关闭连接
### 3.2 HTTP 接口 (Gateway → Mobile) - 兼容性接口
#### GET /api/v1/ai-chat/history/{sessionId}
**获取对话历史**
> 注意user_id 从 JWT Token 中解析获取无需请求参数。sessionId 格式为 `{userId}_{starId}`
Response:
```json
{
"history": [
{"role": "user", "content": "今天工作好累"},
{"role": "assistant", "content": "宝,辛苦了~"}
]
}
```
#### GET /api/v1/ai-chat/personas
**获取用户的所有人设列表**
> 注意user_id 从 JWT Token 中解析获取,无需请求参数
Response:
```json
{
"personas": [
{"id": "uuid-xxx", "name": "小雪", "description": "温柔陪伴型闺蜜", "is_default": true},
{"id": "uuid-yyy", "name": "阿逗", "description": "幽默搭子", "is_default": false}
]
}
```
### 3.3 Dubbo Triple 接口 (Gateway → AIChatService)
```protobuf
service AIChatService {
// 发送消息 (流式返回)
rpc SendMessage(ChatMessageRequest) returns (stream ChatMessageResponse);
// 获取对话历史
rpc GetHistory(ChatHistoryRequest) returns (ChatHistoryResponse);
// ============= 人设管理 =============
// 获取用户的所有人设
rpc GetPersonas(GetPersonasRequest) returns (PersonaListResponse);
}
```
---
## 四、核心模块设计
### 4.1 LLM Service (大模型调用)
**功能:** 封装 MiniMax M2-her 文本对话 API支持流式输出和模型降级。
**实现要点:**
```go
type LLMService struct {
minimaxClient *http.Client
qwenClient *http.Client
}
func (s *LLMService) StreamChat(ctx context.Context, messages []Message) (*StreamReader, error)
```
**StreamReader 接口定义:**
```go
// StreamReader 流式读取器接口
type StreamReader interface {
// Next 返回下一条消息内容done=true 表示流结束
Next() (content string, done bool, err error)
// Close 关闭流式读取器
Close() error
}
```
**API 调用:**
- 主模型MiniMax `M2-her` ( `/v1/text/chatcompletion_v2` )
- 备用模型:通义 `qwen-plus` ( `/compatible-mode/v1/chat/completions` )
**流式处理:**
- MiniMax 原生 SSE 格式,逐行解析 `choices[0].delta.content`
- 错误时自动切换备用模型
- 模型选择通过环境变量配置
**环境变量:**
```bash
MINIMAX_API_KEY=xxx
MINIMAX_API_URL=https://api.minimaxi.com/v1
MINIMAX_MODEL=M2-her
QWEN_API_KEY=xxx
QWEN_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
QWEN_MODEL=qwen-plus
```
### 4.2 Persona Service (人设管理)
**功能:** 管理用户自定义 AI 角色人设。
**默认人设:** 系统在用户首次使用 AI Chat 时自动创建一个默认人设,默认 SystemPrompt 如下:
```
你是一个温柔体贴的AI伴侣名字叫小雪。你善于倾听能理解用户的情绪
用温暖的话语陪伴用户。说话风格亲切自然,像朋友聊天一样。
不要过于正式或说教,当用户情绪低落时,先给予共情和安慰。
```
**人设数据结构:**
```go
type Persona struct {
ID string `json:"id"` // UUID
UserID int64 `json:"user_id"` // 所属用户ID
Name string `json:"name"` // 人设名称
Description string `json:"description"` // 人设描述
AvatarURL string `json:"avatar_url"` // 头像URL可选
SystemPrompt string `json:"system_prompt"` // 核心设定Prompt
TalkStyle string `json:"talk_style"` // 说话风格(可选)
IsDefault bool `json:"is_default"` // 是否默认人设
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
```
**获取人设 GetPersonas**
- 根据 user_id 从数据库查询该用户的所有人设
- 系统自动创建默认人设,用户不可能无人设
**错误定义:**
```go
ErrPersonaNotFound = errors.New("persona_not_found", "人设不存在")
```
### 4.3 Memory Service (记忆管理)
**功能:** 短期上下文 + 长期记忆的分层记忆系统。
**短期记忆 (Redis)**
```go
// Key: context:{sessionId}
// Value: JSON array of messages
// TTL: 24小时 (86400秒)
// sessionId 格式: {userId}_{starId},例如 "10001_123"
// 注意session 与 persona 分离,同一个 session 可以切换不同 persona 对话
```
**长期记忆 (PostgreSQL)**
详见 7.2 节 `ai_user_memories` 表定义
**记忆召回流程:**
1. 从用户输入提取关键词
2. PostgreSQL 数组匹配 `keywords && $1`
3. 按 weight 降序、created_at 降序返回 Top 5
4. 组装成 "# 用户核心记忆\n- ...\n- ..." 格式注入 Prompt
**记忆提取触发:**
- 每 5 轮对话触发一次1轮 = user发送 + assistant回复5轮 = 10条消息
- 从最近 5 轮用户消息共10条消息取最后5条user消息中提取关键词
- 简单规则匹配:累/忙→工作状态,开心/高兴→正面情绪,生日/纪念日→重要日期
### 4.4 Audit Service (合规审核)
**功能:** 输入/输出内容安全审核。
#### 4.4.1 审核维度
| 类别 | 关键词示例 |
|------|-----------|
| 政治类 | 台独、港独、藏独、疆独 |
| 色情类 | 色情、裸聊、约炮 |
| 暴力类 | 杀人、虐待、暴力 |
| 违规诱导 | 转账、汇款、银行卡 |
| AI身份冒充 | "我是真人"、"我是人类" |
#### 4.4.2 审核策略
| 层级 | 位置 | 策略 | 说明 |
|------|------|------|------|
| **输入审核** | 后端前置 | 前置拦截 | 用户发送消息后、大模型处理前,检测敏感词,违规直接返回错误 |
| **输出审核** | 后端流式 | 后置拦截 | AI 回复时逐 Token 审核,检测到敏感词立即终止流并返回标准回复 |
**后置审核工作原理(逐 Token 检查):**
```go
// 后置审核在流式输出循环中实时检查每个 token
for {
token, done, err := streamReader.Next()
if err != nil {
break
}
// 逐 Token 审核 - 检测到违规立即终止
if auditService.audit_response(token) == false {
// 终止流式输出
sendFinalMessage("抱歉,这个话题我无法继续,我们换个话题聊聊吧。")
streamReader.Close()
return
}
// 审核通过,发送 token 给客户端
sendToClient(token)
if done {
break
}
}
```
**回复替换(检测到违规时的标准回复):**
```go
defaultSafeResponse = "抱歉,这个话题我无法继续,我们换个话题聊聊吧。"
```
#### 4.4.3 前端敏感词过滤UniApp 客户端 - 可选额外保护)
前端敏感词过滤是**可选的额外保护层**,不替代后端审核。用于减少不必要的网络请求和在展示层面做基础过滤。
**前端过滤时机:**
| 时机 | 过滤目标 | 说明 |
|------|----------|------|
| 发送前 | 用户输入 | 检测到敏感词时本地拦截,不发送请求 |
| 接收后 | AI 回复 | 展示前做基础过滤(可选) |
**前端敏感词列表:**
```javascript
// 基础敏感词列表(建议与后端同步,定期更新)
const FRONTEND_SENSITIVE_WORDS = [
// 政治类
'台独', '港独', '藏独', '疆独', '分裂', '颠覆',
// 色情类
'色情', '裸聊', '约炮', '成人',
// 暴力类
'杀人', '虐待', '暴力',
// 违规诱导
'转账', '汇款', '银行卡', '密码',
// AI 身份冒充相关
'你是真人', '你是人类', '真人在吗'
];
// 简化版(仅最常见词汇,减少误判)
const BASIC_SENSITIVE_WORDS = [
'台独', '港独', '藏独', '疆独',
'色情', '裸聊', '约炮',
'杀人', '虐待',
'转账', '汇款'
];
```
**前端过滤实现:**
```javascript
/**
* 检查文本是否包含敏感词
* @param {string} text - 待检查文本
* @param {string[]} wordList - 敏感词列表
* @returns {boolean} - true 表示包含敏感词
*/
function containsSensitiveWords(text, wordList) {
for (const word of wordList) {
if (text.includes(word)) {
return true;
}
}
return false;
}
/**
* 发送前检查(本地拦截)
* @param {string} message - 用户输入消息
* @returns {object} - { blocked: boolean, message: string }
*/
function checkBeforeSend(message) {
if (containsSensitiveWords(message, BASIC_SENSITIVE_WORDS)) {
return {
blocked: true,
message: '抱歉,发送内容包含敏感词,请修改后重试'
};
}
return { blocked: false };
}
// 发送消息示例
function sendMessage(message, sessionId, personaId) {
// 1. 发送前本地检查
const checkResult = checkBeforeSend(message);
if (checkResult.blocked) {
uni.showToast({ title: checkResult.message, icon: 'none' });
return false;
}
// 2. 发送消息到服务器(后端仍会进行完整审核)
socket.send({
data: JSON.stringify({
action: 'send_message',
session_id: sessionId,
message: message,
persona_id: personaId || ''
})
});
return true;
}
```
**注意事项:**
- 前端过滤**不能替代后端审核**,仅作为减少无效请求的优化
- 敏感词列表应**定期同步更新**(可从后端 API 获取)
- 前端过滤**不进行 AI 身份冒充检测**(需要上下文判断,后端负责)
- 避免过度拦截导致用户体验下降,建议使用最小化的敏感词列表
### 4.5 Prompt Builder (Prompt组装)
**组装顺序:**
1. System Prompt (人设设定)
2. 用户核心记忆 (如有)
3. 对话历史 (最近 N 条Token 限制内)
4. 用户当前输入
#### 4.5.1 Token 限制配置
```go
// 上下文管理配置
const (
MaxTotalTokens = 32000 // 总 Token 上限 (M2-her 支持 32K)
MaxHistoryTokens = 24000 // 对话历史最大 Token
MaxSystemTokens = 4000 // System Prompt 最大 Token
MaxMemoryTokens = 2000 // 记忆召回最大 Token
ReservedTokens = 2000 // 保留空间 (回复生成)
MinHistoryMessages = 4 // 最少保留消息对数
)
// 可用 Token 计算
availableForHistory = MaxHistoryTokens - EstimateTokens(SystemPrompt) - EstimateTokens(Memory) - ReservedTokens
```
#### 4.5.2 Token 计算
```go
// Tokenizer Token 计算器 (轻量实现,无需引入完整 tiktoken)
type Tokenizer struct{}
// EstimateTokens 估算 Token 数量
// 规则中文×2 + 英文/数字×1 + ASCII符号×1 + 其他×2
func (t *Tokenizer) EstimateTokens(text string) int {
var count int
for _, r := range text {
switch {
case r >= 0x4e00 && r <= 0x9fff: // 中文
count += 2
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z': // 英文
count += 1
case r >= '0' && r <= '9':
count += 1
case r < 128: // ASCII 符号
count += 1
default:
count += 2 // 其他字符
}
}
return count
}
// EstimateMessagesTokens 估算消息列表的总 Token
func (t *Tokenizer) EstimateMessagesTokens(messages []Message) int {
var total int
for _, m := range messages {
// role + content + overhead
total += t.EstimateTokens(m.Role) + t.EstimateTokens(m.Content) + 10
}
return total
}
```
#### 4.5.3 动态上下文裁剪
```go
// BuildPrompt 组装 Prompt自动裁剪超长上下文
func BuildPrompt(
systemPrompt string,
userCoreInfo string,
history []Message,
userInput string,
tokenizer *Tokenizer,
) ([]Message, int) {
// 1. 计算各部分 Token
systemTokens := tokenizer.EstimateTokens(systemPrompt)
memoryTokens := tokenizer.EstimateTokens(userCoreInfo)
// 2. 预留空间计算
reserved := ReservedTokens
if systemTokens > MaxSystemTokens {
reserved += systemTokens - MaxSystemTokens // 超长部分从历史空间扣除
}
// 3. 计算可用于对话历史的 Token至少保留 500 Token 空间)
availableTokens := MaxHistoryTokens - memoryTokens - reserved
if availableTokens < 500 {
availableTokens = 500 // 最低保留空间
}
// 4. 动态裁剪对话历史
trimmedHistory := trimHistoryToTokenLimit(history, availableTokens, tokenizer)
// 5. 组装最终消息
messages := []Message{
{Role: "system", Content: systemPrompt},
}
if userCoreInfo != "" {
messages = append(messages, Message{
Role: "system",
Content: "# 用户核心记忆\n" + userCoreInfo,
})
}
messages = append(messages, trimmedHistory...)
messages = append(messages, Message{Role: "user", Content: userInput})
// 6. 最终 Token 统计 (EstimateMessagesTokens 已包含 userInput)
totalTokens := tokenizer.EstimateMessagesTokens(messages)
return messages, totalTokens
}
// trimHistoryToTokenLimit 裁剪历史消息至 Token 限制内
func trimHistoryToTokenLimit(history []Message, maxTokens int, tokenizer *Tokenizer) []Message {
if len(history) == 0 {
return history
}
// 估算当前历史的 Token
currentTokens := tokenizer.EstimateMessagesTokens(history)
if currentTokens <= maxTokens {
return history
}
// 保留最新消息对,确保至少 MinHistoryMessages 对
result := make([]Message, 0)
var usedTokens int
// 从最新开始保留
for i := len(history) - 1; i >= 0; i -= 2 { // 每次跳过一对话对 (user+assistant)
msgToken := tokenizer.EstimateTokens(history[i].Content) + 10
prevToken := 0
if i > 0 {
prevToken = tokenizer.EstimateTokens(history[i-1].Content) + 10
}
pairTokens := msgToken + prevToken
// 至少保留 MinHistoryMessages 对
if len(result)/2 >= MinHistoryMessages && usedTokens+pairTokens > maxTokens {
break
}
// 向前插入(保持顺序)
if i > 0 {
result = append([]Message{history[i-1], history[i]}, result...)
} else {
result = append([]Message{history[i]}, result...)
}
usedTokens += pairTokens
}
return result
}
```
#### 4.5.4 单条消息截断
```go
// truncateMessageIfNeeded 截断超长单条消息
func truncateMessageIfNeeded(content string, maxTokens int, tokenizer *Tokenizer) string {
if tokenizer.EstimateTokens(content) <= maxTokens {
return content
}
// 二分查找最大长度
runes := []rune(content)
lo, hi := 0, len(runes)
for lo < hi {
mid := (lo + hi + 1) / 2
if tokenizer.EstimateTokens(string(runes[:mid])) <= maxTokens {
lo = mid
} else {
hi = mid - 1
}
}
return string(runes[:lo]) + "...(已截断)"
}
// 截断阈值
const MaxSingleMessageTokens = 4000 // 单条消息最大 4000 Token
```
#### 4.5.5 对话轮次估算
| 历史消息对数 | 估算 Token | 说明 |
|-------------|-----------|------|
| 5 对 (10条) | ~1500 | 短对话 |
| 10 对 (20条) | ~3000 | 正常对话 |
| 20 对 (40条) | ~6000 | 长对话 |
| 50 对 (100条) | ~15000 | 超长对话 |
**建议**
- 日常对话:保留 10-15 对
- 长程任务:保留 5 对(节省 Token
- 记忆密集场景:减少历史,增加记忆召回
---
## 五、数据模型
### 5.1 请求/响应结构
```go
// ========== 对话 ==========
// ChatMessageRequest 发送消息请求
type ChatMessageRequest struct {
SessionID string `json:"session_id"`
Message string `json:"message"`
PersonaID string `json:"persona_id"` // 可选,空则用默认人设
UserID int64 `json:"user_id"` // 从 JWT 获取
}
// ChatMessageResponse 流式消息响应
type ChatMessageResponse struct {
Content string `json:"content"`
SessionID string `json:"session_id"`
IsEnd bool `json:"is_end"`
Error string `json:"error,omitempty"`
}
// ChatHistoryRequest 获取历史请求
type ChatHistoryRequest struct {
SessionID string `json:"session_id"`
Limit int32 `json:"limit"` // 默认 20
}
// ChatHistoryResponse 获取历史响应
type ChatHistoryResponse struct {
History []Message `json:"history"`
}
// ========== 人设管理 ==========
// GetPersonasRequest 获取人设列表请求
type GetPersonasRequest struct {
UserID int64 `json:"user_id"`
}
// PersonaListResponse 人设列表响应
type PersonaListResponse struct {
Personas []PersonaInfo `json:"personas"`
}
// PersonaInfo 人设信息
type PersonaInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
AvatarURL string `json:"avatar_url"`
TalkStyle string `json:"talk_style"`
IsDefault bool `json:"is_default"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
// ========== 通用 ==========
// Message 对话消息
type Message struct {
Role string `json:"role"` // "user" / "assistant"
Content string `json:"content"`
}
```
---
## 六、配置设计
### 6.1 dubbo.yaml
```yaml
dubbo:
application:
name: ai-chat-service
version: 1.0.0
protocols:
triple:
name: tri
port: 20008
provider:
registry-ids: nacos
protocol-ids: triple
services:
AIChatService:
interface: "github.com/topfans/backend/pkg/proto/ai_chat.AIChatService"
consumer:
registry-ids: nacos
timeout: 30s
```
### 6.2 Gateway 配置更新
`gateway/config/config.go` 的 DubboConfig 中添加:
```go
DubboConfig struct {
// ... 现有配置
AIChatServiceURL string // tri://127.0.0.1:20008
}
```
### 6.3 Gateway WebSocket 配置
`gateway/config/config.go` 中添加:
```go
// WebSocketConfig WebSocket 配置
type WebSocketConfig struct {
AIChatPath string // WebSocket 路径,默认 /ws/ai-chat
}
```
---
## 七、数据库表
### 7.1 ai_personas (人设表)
```sql
CREATE TABLE IF NOT EXISTS ai_personas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id BIGINT NOT NULL,
name VARCHAR(64) NOT NULL,
description TEXT,
avatar_url VARCHAR(512),
talk_style VARCHAR(256),
system_prompt TEXT NOT NULL,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 索引
CREATE INDEX idx_ai_personas_user_id ON ai_personas(user_id);
CREATE UNIQUE INDEX idx_ai_personas_user_default ON ai_personas(user_id) WHERE is_default = TRUE; -- 每个用户只能有一个默认人设
```
### 7.2 ai_user_memories (长期记忆表)
```sql
CREATE TABLE IF NOT EXISTS ai_user_memories (
id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL, -- 与 JWT user_id 类型一致
content TEXT NOT NULL,
keywords TEXT[],
weight INTEGER DEFAULT 50,
is_core BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_ai_user_memories_user_id ON ai_user_memories(user_id);
CREATE INDEX IF NOT EXISTS idx_ai_user_memories_keywords ON ai_user_memories USING GIN(keywords);
CREATE INDEX IF NOT EXISTS idx_ai_user_memories_weight ON ai_user_memories(weight DESC);
```
### 7.3 ai_chat_configs (配置表)
用于存储 AI Chat Service 的可配置参数,支持运行时修改。
```sql
CREATE TABLE IF NOT EXISTS ai_chat_configs (
id SERIAL PRIMARY KEY,
config_key VARCHAR(128) NOT NULL UNIQUE, -- 配置键,唯一标识
config_value TEXT NOT NULL, -- 配置值
config_type VARCHAR(32) NOT NULL DEFAULT 'string', -- 值类型: string, number, boolean, json
category VARCHAR(64) NOT NULL, -- 配置分类: db, redis, llm, dialog
description VARCHAR(256), -- 配置描述
is_encrypted BOOLEAN DEFAULT FALSE, -- 是否加密(敏感信息如密码)
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_ai_chat_configs_category ON ai_chat_configs(category);
CREATE INDEX IF NOT EXISTS idx_ai_chat_configs_key ON ai_chat_configs(config_key);
-- 初始配置数据
INSERT INTO ai_chat_configs (config_key, config_value, config_type, category, description, is_encrypted) VALUES
-- Redis 配置
('redis.host', '127.0.0.1', 'string', 'redis', 'Redis 主机地址', FALSE),
('redis.port', '6379', 'number', 'redis', 'Redis 端口', FALSE),
('redis.password', '123456', 'string', 'redis', 'Redis 密码', TRUE),
('redis.db', '0', 'number', 'redis', 'Redis 数据库编号', FALSE),
-- MiniMax 大模型配置
('minimax.api_key', 'xxx', 'string', 'llm', 'MiniMax API Key', TRUE),
('minimax.api_url', 'https://api.minimaxi.com/v1', 'string', 'llm', 'MiniMax API 地址', FALSE),
('minimax.model', 'M2-her', 'string', 'llm', 'MiniMax 模型名称', FALSE),
-- 通义备用模型配置
('qwen.api_key', 'xxx', 'string', 'llm', '通义 API Key', TRUE),
('qwen.api_url', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'string', 'llm', '通义 API 地址', FALSE),
('qwen.model', 'qwen-plus', 'string', 'llm', '通义模型名称', FALSE),
-- 对话配置
('dialog.max_context_turns', '10', 'number', 'dialog', '最大上下文轮数', FALSE),
('dialog.context_expire_seconds', '86400', 'number', 'dialog', '上下文过期时间(秒)', FALSE),
('dialog.memory_recall_topn', '5', 'number', 'dialog', '记忆召回返回条数', FALSE),
('dialog.fallback_threshold', '3', 'number', 'dialog', '模型降级连续失败次数阈值', FALSE),
('dialog.slow_response_ms', '5000', 'number', 'dialog', '慢响应判定阈值(毫秒)', FALSE),
-- AI Token 限制配置
('token.max_total', '32000', 'number', 'token', '总 Token 上限', FALSE),
('token.max_history', '24000', 'number', 'token', '对话历史最大 Token', FALSE),
('token.max_system', '4000', 'number', 'token', 'System Prompt 最大 Token', FALSE),
('token.max_memory', '2000', 'number', 'token', '记忆召回最大 Token', FALSE),
('token.reserved', '2000', 'number', 'token', '保留空间 Token', FALSE),
-- 熔断配置
('circuit.max_fail_count', '5', 'number', 'circuit', '熔断连续失败次数', FALSE),
('circuit.breaker_timeout', '60', 'number', 'circuit', '熔断恢复超时(秒)', FALSE),
-- 摘要配置
('summary.trigger_turns', '10', 'number', 'summary', '自动摘要触发轮数', FALSE),
('summary.max_length', '100', 'number', 'summary', '摘要最大字数', FALSE);
```
**配置读取示例:**
```go
// ConfigRepository 配置仓库
type ConfigRepository struct {
db *gorm.DB
}
// GetConfig 获取单个配置值
func (r *ConfigRepository) GetConfig(key string) (string, error) {
var config Config
if err := r.db.Where("config_key = ?", key).First(&config).Error; err != nil {
return "", err
}
return config.ConfigValue, nil
}
// GetConfigByCategory 按分类获取所有配置
func (r *ConfigRepository) GetConfigByCategory(category string) (map[string]string, error) {
var configs []Config
if err := r.db.Where("category = ?", category).Find(&configs).Error; err != nil {
return nil, err
}
result := make(map[string]string)
for _, c := range configs {
result[c.ConfigKey] = c.ConfigValue
}
return result, nil
}
// UpdateConfig 更新配置值
func (r *ConfigRepository) UpdateConfig(key, value string) error {
return r.db.Model(&Config{}).Where("config_key = ?", key).Update("config_value", value).Error
}
// Config 结构体
type Config struct {
ID int64 `gorm:"primaryKey"`
ConfigKey string `gorm:"uniqueIndex;not null"`
ConfigValue string `gorm:"not null"`
ConfigType string `gorm:"default:string"`
Category string `gorm:"index"`
Description string
IsEncrypted bool
}
```
**配置热更新:**
```go
// 配置监听器,检测配置变更并刷新内存缓存
type ConfigWatcher struct {
repo *ConfigRepository
cache sync.Map // 内存缓存
interval time.Duration
}
func (w *ConfigWatcher) Start() {
ticker := time.NewTicker(w.interval)
go func() {
for range ticker.C {
w.refreshCache()
}
}()
}
func (w *ConfigWatcher) Get(key string) string {
if val, ok := w.cache.Load(key); ok {
return val.(string)
}
// 缓存未命中,从数据库读取
val, _ := w.repo.GetConfig(key)
w.cache.Store(key, val)
return val
}
```
**分类说明:**
| 分类 | 说明 | 配置示例 |
|------|------|----------|
| redis | Redis 连接配置 | host, port, password, db |
| llm | 大模型 API 配置 | api_key, api_url, model |
| dialog | 对话行为配置 | max_context_turns, context_expire_seconds |
| token | Token 限制配置 | max_total, max_history, max_system |
| circuit | 熔断策略配置 | max_fail_count, breaker_timeout |
| summary | 摘要功能配置 | trigger_turns, max_length |
---
## 八、Proto 定义
### 8.1 ai_chat.proto
```protobuf
syntax = "proto3";
package proto;
option go_package = "github.com/topfans/backend/pkg/proto/ai_chat";
service AIChatService {
// 发送消息,流式返回
rpc SendMessage(ChatMessageRequest) returns (stream ChatMessageResponse);
// 获取对话历史
rpc GetHistory(ChatHistoryRequest) returns (ChatHistoryResponse);
// ============= 人设管理 =============
// 获取用户的所有人设
rpc GetPersonas(GetPersonasRequest) returns (PersonaListResponse);
}
// ========== 对话 ==========
message ChatMessageRequest {
string session_id = 1;
string message = 2;
string persona_id = 3; // 可选,空则用默认人设
int64 user_id = 4;
}
message ChatMessageResponse {
string content = 1;
string session_id = 2;
bool is_end = 3;
string error = 4;
}
message ChatHistoryRequest {
string session_id = 1;
int32 limit = 2; // 默认 20
}
message ChatHistoryResponse {
repeated Message history = 1;
}
// ========== 人设管理 ==========
message GetPersonasRequest {
int64 user_id = 1;
}
message PersonaListResponse {
repeated PersonaInfo personas = 1;
}
message PersonaInfo {
string id = 1;
string name = 2;
string description = 3;
string avatar_url = 4;
string talk_style = 5;
bool is_default = 6;
int64 created_at = 7;
int64 updated_at = 8;
}
// ========== 通用 ==========
message Message {
string role = 1; // "user" / "assistant"
string content = 2;
}
```
---
## 九、Gateway 接入
`gateway/socket/` 目录下创建 WebSocket 处理:
```
gateway/socket/
├── ai_chat_socket.go # AI Chat WebSocket 处理
└── hub.go # WebSocket Hub 管理连接
```
**Hub 架构:**
```go
// Hub 管理所有 WebSocket 连接
type Hub struct {
// 用户连接映射: userId -> *Connection
clients map[int64]*Connection
// WebSocket 升级器
upgrader websocket.Upgrader
// Dubbo 客户端
aiChatClient *client.Client
}
```
**连接流程:**
1. Mobile 携带 Token 连接 `/ws/ai-chat?token=Bearer_xxx`
2. Hub 接收连接,从 URL 参数获取 Token 并验证
3. 验证通过后,创建用户连接映射
4. 返回鉴权成功消息,开始处理消息
5. Mobile 每 30s 发送 pingHub 回复 pong
### 9.2 Gateway Dubbo Client 初始化
`gateway/main.go` 中添加:
```go
// AIChatService Client
aiChatClient, err := client.NewClient(
client.WithClientURL(cfg.Dubbo.AIChatServiceURL),
)
if err != nil {
logger.Logger.Fatal("Failed to create AI Chat Service Dubbo client", zap.Error(err))
}
logger.Logger.Info("AI Chat Service Dubbo client connected successfully")
```
### 9.3 Router 配置
`gateway/router/router.go` 中添加 AIChat HTTP 路由(兼容性):
```go
// API v1 路由组
v1 := r.Group("/api/v1")
{
// AI Chat HTTP 兼容接口
aiChat := v1.Group("/ai-chat")
aiChat.Use(middleware.AuthMiddleware())
{
aiChat.GET("/personas", aiChatCtrl.GetPersonas) // 获取人设列表
aiChat.GET("/history/:sessionId", aiChatCtrl.GetHistory) // 获取对话历史
}
}
// WebSocket 路由
r.GET("/ws/ai-chat", socketCtrl.HandleAIChatWebSocket)
```
---
## 十、部署脚本
### 10.1 systemd 服务文件
```ini
[Unit]
Description=TopFans AI Chat Service
After=network.target
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/topfans/backend
Environment="ENV=production"
ExecStart=/opt/topfans/backend/services/aiChatService/aiChatService
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
---
## 十一、AI Token 节省策略
### 11.1 Token 优化矩阵
| 策略 | 节省比例 | 实现成本 | 适用场景 |
|------|----------|----------|----------|
| 动态上下文裁剪 | 20-40% | 低 | 所有场景 |
| 单条消息截断 | 10-20% | 低 | 超长消息输入 |
| 记忆召回限制 | 15-25% | 低 | 高频对话 |
| System Prompt 精简 | 10-30% | 中 | 人设优化 |
| 历史摘要压缩 | 30-50% | 高 | 长程对话 |
### 11.2 核心优化策略
#### 11.2.1 智能历史裁剪
```go
// 优化:基于对话质量裁剪,非简单先进先出
type HistoryManager struct {
maxTurns int
// 对话质量评分:用于决定保留哪些对话
qualityScore map[string]float64
}
// 对话质量评分因素
// - 包含关键信息(用户偏好、重要事实):+0.3
// - 情感共情对话:+0.1
// - 日常寒暄:-0.1
// - 重复内容:-0.2
```
#### 11.2.2 Prompt 压缩
```go
// System Prompt 压缩示例
// 压缩前 (原始人设)
"你是一个温柔体贴的AI伴侣名字叫小雪。你善于倾听能理解用户的情绪
用温暖的话语陪伴用户。说话风格亲切自然,像朋友聊天一样。
不要过于正式或说教,当用户情绪低落时,先给予共情和安慰。"
// 压缩后 (精简版 - 保持核心特征)
"你是小雪温柔体贴的AI伴侣。善于倾听、共情陪伴。
风格:亲切自然,像朋友聊天,不说教。"
```
#### 11.2.3 上下文摘要
```go
// 长对话自动摘要(每 N 轮触发)
const SUMMARY_TRIGGER_TURNS = 10 // 10轮对话后触发摘要
// 摘要 Prompt
summaryPrompt := `将以下对话核心内容压缩为 100 字以内的摘要,
保留关键信息(用户偏好、重要事实、情绪状态):
{对话内容}
摘要格式:["关键信息1", "关键信息2", "用户情绪状态"]`
// 摘要结果存入长期记忆
memoryService.SaveSummary(userID, summary, keywords)
```
#### 11.2.4 快速拒绝Early Rejection
```go
// 检测是否需要调用大模型
// 场景:用户只是刷新页面、获取历史等不需要 AI 回复的操作
// 无需调用大模型的情况:
// - get_history 请求
// - 纯符号/数字输入(如 ""、"123"
// - 重复发送相同消息
if isNoNeedLLMCall(input) {
return fallbackResponse // 直接返回,不消耗 Token
}
```
#### 11.2.5 备用模型降级策略
```go
// 成本对比 (参考价)
// MiniMax M2-her: ¥0.01/1K tokens
// 通义 qwen-plus: ¥0.004/1K tokens
// 降级策略
type ModelConfig struct {
primary string // M2-her
backup string // qwen-plus
useBackup bool // 是否使用备用
}
// 当连续失败 N 次或响应慢时,自动降级到通义
const FALLBACK_THRESHOLD = 3
const SLOW_RESPONSE_MS = 5000 // 超过 5s 判定为慢
```
### 11.3 Token 预算分配
```
总 Token 预算: 32,000 (M2-her 最大)
├── System Prompt: 4,000 (12.5%)
├── 用户核心记忆: 2,000 (6.3%)
├── 对话历史: 22,000 (68.8%) ← 主要裁剪目标
└── 保留回复空间: 4,000 (12.5%)
```
### 11.4 监控指标
```go
// Token 消耗监控
type TokenMetrics struct {
totalTokens int64 // 总消耗
promptTokens int64 // Prompt 消耗
completionTokens int64 // 回复消耗
avgPerRequest float64 // 平均每次请求 Token
costEstimate float64 // 预估成本
}
// 上报至监控系统
metricsClient.Report(TokenMetrics{
totalTokens: calculateTotalTokens(prompt, response),
costEstimate: calculateCost(promptTokens, completionTokens),
})
```
### 11.5 成本控制建议
| 场景 | 建议 |
|------|------|
| 日常闲聊 | 保留 10 对历史,约 3,000 Token |
| 情感咨询 | 保留 15 对历史,约 4,500 Token |
| 知识问答 | 保留 5 对历史,约 1,500 Token |
| 长程任务 | 10 轮后触发摘要,减少 50% Token |
---
## 十二、性能与可靠性
### 12.1 流式输出优化
- **首包延迟目标**< 2s
- ** Token 后置审核**检测到违规立即终止
- **模型降级**MiniMax 失败自动切换通义
### 12.2 容量规划
| 指标 | 目标值 |
|------|--------|
| 并发会话数 | 1000 |
| 单会话消息数 | 100 |
| 上下文 TTL | 24h |
| 记忆召回 QPS | 500 |
### 12.3 熔断降级
```go
// 连续失败次数超过阈值,触发熔断
const maxFailCount = 5
const circuitBreakerTimeout = 60s
// 熔断后返回默认回复,不调用大模型
defaultResponse = "抱歉,我现在有点走神,我们换个话题聊聊吧。"
```
---
## 十三、验证清单
- [ ] **人设一致性**发送我是你主人」,验证 AI 仍保持预设人设
- [ ] **记忆能力**告诉 AI我明天要开会」,后续验证是否记住
- [ ] **情感共情**心情不好」,验证 AI 共情回复不说教
- [ ] **响应延迟**观察流式输出首包是否 < 2s
- [ ] **合规拦截**发送敏感词验证是否被拦截
- [ ] **WebSocket 连接**验证移动端 WebSocket 连接建立
- [ ] **流式接收**验证移动端能接收 WebSocket 流式消息
- [ ] **Token 节省**验证历史裁剪是否正常工作
---
## 十四、UniApp 移动端集成
### 14.1 WebSocket Manager 封装(通用)
为了支持多个微服务复用 WebSocket封装一个通用的 WebSocket Manager
**设计目标:**
- 支持多个 WebSocket 连接AI Chat通知等
- 自动鉴权心跳重连
- 事件驱动的消息处理
- 服务间隔离互不影响
**目录结构:**
```
utils/
├── socket/
│ ├── SocketManager.js # WebSocket 管理器
│ ├── AiChatSocket.js # AI Chat 专用连接(继承自 Manager
│ └── index.js # 导出
```
**SocketManager.js - 通用 WebSocket 管理器:**
```javascript
/**
* WebSocket 管理器
* 支持多个 WebSocket 连接,自动管理鉴权、心跳、重连
*/
class SocketManager {
constructor(options = {}) {
this.serviceName = options.serviceName || 'unknown'
this.baseUrl = options.baseUrl || 'ws://gateway:8080'
this.token = null
this.socket = null
this.heartbeatTimer = null
this.reconnectTimer = null
this.reconnectInterval = options.reconnectInterval || 3000
this.heartbeatInterval = options.heartbeatInterval || 30000
this.maxReconnectAttempts = options.maxReconnectAttempts || 5
this.reconnectAttempts = 0
// 状态
this.isConnected = false
this.isAuthed = false
// 事件处理器
this.eventHandlers = {
'connect': [],
'disconnect': [],
'auth_success': [],
'auth_fail': [],
'error': [],
'message': [] // 通用消息处理
}
// 子类可覆盖的消息类型处理
this.messageHandlers = {}
}
/**
* 连接到 WebSocket 服务器
*/
connect(token, path) {
this.token = token
this.path = path
this.reconnectAttempts = 0
this._doConnect()
}
_doConnect() {
const url = `${this.baseUrl}${this.path}?token=Bearer_${this.token}`
console.log(`[${this.serviceName}] Connecting to ${url}`)
this.socket = uni.connectSocket({ url })
this._setupListeners()
}
_setupListeners() {
// 连接打开
this.socket.onOpen(() => {
console.log(`[${this.serviceName}] WebSocket connected`)
this.isConnected = true
this._emit('connect')
})
// 接收消息
this.socket.onMessage((event) => {
const data = JSON.parse(event.data)
this._handleMessage(data)
})
// 连接关闭
this.socket.onClose(() => {
console.log(`[${this.serviceName}] WebSocket closed`)
this._cleanup()
this._emit('disconnect')
this._tryReconnect()
})
// 连接错误
this.socket.onError((err) => {
console.error(`[${this.serviceName}] WebSocket error:`, err)
this._emit('error', err)
})
}
_handleMessage(data) {
// 触发通用消息事件
this._emit('message', data)
// 根据消息类型处理
const { type, action } = data
// 1. 鉴权响应(通用)
if (type === 'auth_response') {
if (data.success) {
this.isAuthed = true
this._emit('auth_success', data)
this._startHeartbeat()
} else {
this.isAuthed = false
this._emit('auth_fail', data)
this.close()
}
return
}
// 2. 心跳响应(通用)
if (type === 'pong') {
console.log(`[${this.serviceName}] Heartbeat received`)
return
}
// 3. 错误响应(通用)
if (type === 'error') {
this._emit('error', data)
return
}
// 4. 服务特定消息类型处理
const handler = this.messageHandlers[type] || this.messageHandlers[action]
if (handler) {
handler(data)
}
}
/**
* 发送消息
*/
send(data) {
if (!this.socket || !this.isConnected) {
console.warn(`[${this.serviceName}] Socket not connected`)
return false
}
this.socket.send({
data: JSON.stringify(data)
})
return true
}
/**
* 发送心跳
*/
_startHeartbeat() {
this._stopHeartbeat()
this.heartbeatTimer = setInterval(() => {
if (this.isConnected) {
this.send({ action: 'ping' })
}
}, this.heartbeatInterval)
}
_stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
/**
* 重连机制
*/
_tryReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.warn(`[${this.serviceName}] Max reconnect attempts reached`)
this._emit('error', { code: 'RECONNECT_FAILED', message: '重连次数已达上限' })
return
}
this.reconnectAttempts++
console.log(`[${this.serviceName}] Reconnecting... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
this.reconnectTimer = setTimeout(() => {
this._doConnect()
}, this.reconnectInterval)
}
_cleanup() {
this._stopHeartbeat()
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
this.isConnected = false
this.isAuthed = false
}
/**
* 关闭连接
*/
close() {
this._cleanup()
if (this.socket) {
this.socket.close()
this.socket = null
}
}
// ===== 事件系统 =====
on(event, handler) {
if (this.eventHandlers[event]) {
this.eventHandlers[event].push(handler)
}
return () => this.off(event, handler) // 返回取消订阅函数
}
off(event, handler) {
if (this.eventHandlers[event]) {
this.eventHandlers[event] = this.eventHandlers[event].filter(h => h !== handler)
}
}
_emit(event, data) {
if (this.eventHandlers[event]) {
this.eventHandlers[event].forEach(handler => handler(data))
}
}
// ===== 订阅特定消息类型 =====
/**
* 注册特定消息类型的处理器
* @param {string} type 消息类型或 action
* @param {function} handler 处理函数
*/
registerHandler(type, handler) {
this.messageHandlers[type] = handler
}
}
/** 导出 */
export default SocketManager
```
**AiChatSocket.js - AI Chat 专用连接:**
```javascript
import SocketManager from './SocketManager'
/**
* AI Chat WebSocket 连接
* 继承自 SocketManager添加 AI Chat 特定的业务逻辑
*/
class AiChatSocket extends SocketManager {
constructor() {
super({
serviceName: 'AiChat',
baseUrl: 'ws://gateway:8080',
path: '/ws/ai-chat',
reconnectInterval: 3000,
heartbeatInterval: 30000,
maxReconnectAttempts: 5
})
// AI Chat 特定回调
this.onMessageCallback = null
this.onHistoryCallback = null
this.onPersonasCallback = null
this.onErrorCallback = null
// 注册 AI Chat 特定的消息处理器
this._registerAiChatHandlers()
}
_registerAiChatHandlers() {
// 流式消息
this.registerHandler('message', (data) => {
if (this.onMessageCallback) {
this.onMessageCallback(data)
}
})
// 历史消息响应
this.registerHandler('history_response', (data) => {
if (this.onHistoryCallback) {
this.onHistoryCallback(data)
}
})
// 人设列表响应
this.registerHandler('personas_response', (data) => {
if (this.onPersonasCallback) {
this.onPersonasCallback(data)
}
})
}
/**
* 连接到 AI Chat 服务
*/
connect(token) {
super.connect(token, '/ws/ai-chat')
}
/**
* 发送消息
*/
sendMessage(message, sessionId, personaId = '') {
return this.send({
action: 'send_message',
session_id: sessionId,
message: message,
persona_id: personaId
})
}
/**
* 获取历史记录
*/
getHistory(sessionId, limit = 20) {
return this.send({
action: 'get_history',
session_id: sessionId,
limit: limit
})
}
/**
* 获取人设列表
*/
getPersonas() {
return this.send({
action: 'get_personas'
})
}
/**
* 设置消息回调
*/
setOnMessageCallback(callback) {
this.onMessageCallback = callback
}
/**
* 设置历史记录回调
*/
setOnHistoryCallback(callback) {
this.onHistoryCallback = callback
}
/**
* 设置人设列表回调
*/
setOnPersonasCallback(callback) {
this.onPersonasCallback = callback
}
/**
* 设置错误回调
*/
setOnErrorCallback(callback) {
this.onErrorCallback = callback
this.on('error', (data) => {
if (callback) callback(data)
})
}
}
// 单例模式
let aiChatInstance = null
export function getAiChatSocket() {
if (!aiChatInstance) {
aiChatInstance = new AiChatSocket()
}
return aiChatInstance
}
export function closeAiChatSocket() {
if (aiChatInstance) {
aiChatInstance.close()
aiChatInstance = null
}
}
export default AiChatSocket
```
### 14.3 替换轮询为 WebSocket
多个业务场景可复用 WebSocket Manager将轮询替换为 WebSocket 推送
**适用场景:**
| 场景 | 轮询接口 | WebSocket 替换 |
|------|----------|----------------|
| 好友请求通知 | `GET /api/v1/social/friend-requests` | 复用同一连接接收 `friend_request` 消息 |
| 排行榜更新 | `GET /api/v1/rankings/hot` | 复用同一连接接收 `ranking_update` 消息 |
| 活动进度 | `GET /api/v1/activities/:id/progress` | 复用同一连接接收 `activity_progress` 消息 |
| 系统通知 | 轮询获取 | 新建 NotificationSocket接收 `notification` 消息 |
**架构设计:**
```
┌─────────────────────────────────────────────────────────┐
│ UniApp 前端 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ GlobalSocketManager │ │
│ │ - 管理多个 WebSocket 连接 │ │
│ │ - 共享同一个 Token │ │
│ │ - 统一心跳管理 │ │
│ └─────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────┴──────────┐ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ AiChatSocket│ │NotifySocket│ ← 新增 │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Gateway (:8080) │
│ /ws/ai-chat → AIChatService │
│ /ws/notify → NotificationService (Future) │
└─────────────────────────────────────────────────────────┘
```
**GlobalSocketManager.js - 全局 Socket 管理器:**
```javascript
/**
* 全局 WebSocket 管理器
* 统一管理多个服务的 WebSocket 连接
*/
class GlobalSocketManager {
constructor() {
this.sockets = {} // serviceName -> SocketManager
this.token = null
this.isAllConnected = false
}
/**
* 初始化所有连接
*/
init(token) {
this.token = token
this._initAiChat()
this._initNotification() // Future
}
_initAiChat() {
const aiChat = getAiChatSocket()
aiChat.on('connect', () => console.log('AI Chat connected'))
aiChat.on('error', (err) => console.error('AI Chat error:', err))
aiChat.connect(this.token)
this.sockets['ai_chat'] = aiChat
}
_initNotification() {
const notify = getNotificationSocket()
notify.on('connect', () => console.log('Notification connected'))
notify.on('notification', (data) => this._handleNotification(data))
notify.connect(this.token)
this.sockets['notification'] = notify
}
_handleNotification(data) {
switch (data.sub_type) {
case 'friend_request':
uni.$emit('friend_request_received', data)
break
case 'ranking_update':
uni.$emit('ranking_updated', data)
break
case 'activity_progress':
uni.$emit('activity_progress_updated', data)
break
default:
console.log('Unknown notification:', data)
}
}
getSocket(serviceName) {
return this.sockets[serviceName]
}
closeAll() {
Object.values(this.sockets).forEach(socket => socket.close())
this.sockets = {}
}
}
let globalInstance = null
export function getGlobalSocket() {
if (!globalInstance) {
globalInstance = new GlobalSocketManager()
}
return globalInstance
}
export default GlobalSocketManager
```
**在 App.vue 中初始化:**
```javascript
// App.vue
import { getGlobalSocket } from '@/utils/socket/GlobalSocketManager'
export default {
onLaunch() {
const token = uni.getStorageSync('token')
if (token) {
getGlobalSocket().init(token)
}
},
onHide() {
getGlobalSocket().closeAll()
}
}
```
**在页面中使用:**
```javascript
// pages/friend/friend.vue
export default {
data() {
return { friendRequests: [] }
},
onLoad() {
uni.$on('friend_request_received', (data) => {
this.friendRequests.unshift(data)
uni.showToast({ title: '收到新好友请求', icon: 'none' })
})
},
onUnload() {
uni.$off('friend_request_received')
}
}
```
**复用连接的好处:**
- **资源节省**多个业务共享一个 WebSocket 连接
- **实时性**从轮询 5-10s 延迟降为即时推送
- **维护简单**统一的心跳重连鉴权逻辑
### 14.4 注意事项
1. **鉴权方式**连接时通过 URL 参数传递 Token`?token=Bearer_xxx`Gateway 验证通过后立即建立连接
2. **重连机制**建议实现断线重连间隔 3-5
3. **心跳保活** 30 秒发送一次 ping 消息60 秒无响应则主动关闭连接
4. **Session 管理**建议在本地存储 session_id便于恢复对话
5. **错误处理**收到 `auth_response.success=false` 时立即关闭连接并提示用户重新登录
---
## 十五、与 SSE 方案的对比
| 对比项 | SSE | WebSocket |
|--------|-----|-----------|
| UniApp 支持 | 有限需条件编译 | 完整支持 |
| 实现复杂度 | 中等 | 较低 |
| 双向通信 | 需轮询模拟 | 原生支持 |
| 服务器资源 | 较低 | 稍高每个连接维护状态 |
| 扩展性 | 仅单向 | 可扩展主动推送 |
| 代理支持 | 需特殊配置Nginx 流式 | 需配置 WebSocket 支持 |
**选择 WebSocket 的原因**
1. UniApp WebSocket 支持更好
2. 便于后续扩展主动推送功能设计文档 V2
3. 实现更简单双向通信更自然