package service import ( "errors" "log" "github.com/2930134478/AI-CS/backend/models" "github.com/2930134478/AI-CS/backend/repository" "gorm.io/gorm" ) // ErrConversationClosed indicates operations are attempted on a closed conversation. var ( // ErrConversationClosed 表示会话已关闭,不能继续发送消息。 ErrConversationClosed = errors.New("conversation is closed") // ErrConversationNotFound 表示未找到指定的会话记录。 ErrConversationNotFound = gorm.ErrRecordNotFound ) // MessageService 负责消息领域的业务处理。 type MessageService struct { db *gorm.DB conversations *repository.ConversationRepository messages *repository.MessageRepository hub BroadcastHub aiService *AIService // AI 服务(用于 AI 自动回复) } // NewMessageService 创建 MessageService 实例。 func NewMessageService( db *gorm.DB, conversations *repository.ConversationRepository, messages *repository.MessageRepository, hub BroadcastHub, aiService *AIService, ) *MessageService { return &MessageService{ db: db, conversations: conversations, messages: messages, hub: hub, aiService: aiService, } } // CreateMessage 创建消息并通过 WebSocket 广播。 func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Message, error) { if s.db == nil { return nil, errors.New("db is not initialized") } var ( conv models.Conversation message *models.Message ) err := s.db.Transaction(func(tx *gorm.DB) error { if err := tx.Where("id = ?", input.ConversationID).First(&conv).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrConversationNotFound } return err } // B 方案:会话关闭后,如访客再次发消息则自动 reopen if conv.Status == "closed" { if input.SenderIsAgent { return ErrConversationClosed } if err := tx.Model(&models.Conversation{}).Where("id = ?", conv.ID).Updates(map[string]interface{}{ "status": "open", }).Error; err != nil { return err } conv.Status = "open" } if input.SenderIsAgent && input.SenderID == 0 { return errors.New("sender_id is required for agent messages") } message = &models.Message{ ConversationID: input.ConversationID, SenderID: input.SenderID, SenderIsAgent: input.SenderIsAgent, Content: input.Content, MessageType: "user_message", ChatMode: conv.ChatMode, IsRead: false, FileURL: input.FileURL, FileType: input.FileType, FileName: input.FileName, FileSize: input.FileSize, MimeType: input.MimeType, } if err := tx.Create(message).Error; err != nil { return err } // 如果客服发送消息,且会话的 agent_id 为 0,则更新为当前客服的 ID updateFields := map[string]interface{}{ "updated_at": message.CreatedAt, } // 访客发送消息可视为在线心跳:同步刷新 last_seen_at,支撑客服端在线状态判定。 if !input.SenderIsAgent { updateFields["last_seen_at"] = message.CreatedAt } if input.SenderIsAgent && input.SenderID > 0 && conv.AgentID == 0 { updateFields["agent_id"] = input.SenderID } if err := tx.Model(&models.Conversation{}).Where("id = ?", conv.ID).Updates(updateFields).Error; err != nil { return err } if agentID, ok := updateFields["agent_id"].(uint); ok { conv.AgentID = agentID } return nil }) if err != nil { return nil, err } if s.hub != nil { // 1. 先广播到该对话房间内的客户端(访客 + 已按该 conversation_id 建连的客服) s.hub.BroadcastMessage(message.ConversationID, "new_message", message) // 2. 人工会话(非内部测试):再向所有在线客服连接广播一次。 // - 原逻辑仅对「访客消息」广播,客服自己发的消息只进房间;若 WS 异常/多实例 Hub 不一致,客服台会迟迟看不到自己发的内容。 // - AI 模式不向全员推访客消息(保持原意);内部知识库会话不向其他客服推(避免无关会话刷屏)。 // handleNewMessage 侧会按 conversation_id 去重,双播不会产生重复气泡。 if conv.ChatMode == "human" && conv.ConversationType != "internal" { s.hub.BroadcastToAllAgents("new_message", message) } } else { log.Printf("⚠️ WebSocket Hub 为空,无法广播消息: 消息ID=%d, 对话ID=%d", message.ID, message.ConversationID) } // 3. 触发 AI 回复(文本/识图或生图,具体由 AI 配置的 model_type 决定) needAIReply := s.aiService != nil && conv.ChatMode == "ai" && ( (!input.SenderIsAgent) || (conv.ConversationType == "internal" && input.SenderIsAgent)) if needAIReply { go func() { // 用于查找 AI 配置的用户 ID:访客对话用 AgentID,内部对话用发送者(客服)ID userID := conv.AgentID if userID == 0 { userID = 1 } if conv.ConversationType == "internal" && input.SenderID > 0 { userID = input.SenderID } opts := &GenerateAIResponseInput{ UseKnowledgeBase: input.UseKnowledgeBase, UseLLM: input.UseLLM, UseWebSearch: input.UseWebSearch, NeedWebSearch: input.NeedWebSearch, } if opts.UseKnowledgeBase == nil { t := true opts.UseKnowledgeBase = &t } if opts.UseLLM == nil { t := true opts.UseLLM = &t } if opts.UseWebSearch == nil { f := false opts.UseWebSearch = &f } // 多模态识图:当前条消息带图片时传给 AI if input.FileURL != nil && input.FileType != nil && *input.FileType == "image" { mime := "" if input.MimeType != nil { mime = *input.MimeType } opts.Attachment = &MessageAttachment{ FileURL: *input.FileURL, FileType: "image", MimeType: mime, } } aiResult, err := s.aiService.GenerateAIResponseWithOptions(message.ConversationID, input.Content, userID, opts) aiResponse := "" sourcesUsed := "" var aiMessageFileURL *string aiGenFailed := false if err != nil { log.Printf("❌ AI 生成回复失败: %v", err) aiResponse = "AI客服好像出了点差错,请联系人工客服解决" aiGenFailed = true } else { aiResponse = aiResult.Content sourcesUsed = aiResult.SourcesUsed aiMessageFileURL = aiResult.GeneratedFileURL aiGenFailed = aiResult.GenerationFailed } // 生图时前端依赖 file_type === "image" 才渲染图片,必须设置 var aiMessageFileType *string if aiMessageFileURL != nil { t := "image" aiMessageFileType = &t } aiMessage := &models.Message{ ConversationID: message.ConversationID, SenderID: 0, SenderIsAgent: true, Content: aiResponse, MessageType: "user_message", ChatMode: conv.ChatMode, IsRead: false, SourcesUsed: sourcesUsed, FileURL: aiMessageFileURL, FileType: aiMessageFileType, IsAIGenerationFailed: aiGenFailed, } if err := s.messages.Create(aiMessage); err != nil { log.Printf("❌ 创建 AI 回复消息失败: %v", err) return } // 更新对话的更新时间 if err := s.conversations.UpdateFields(conv.ID, map[string]interface{}{ "updated_at": aiMessage.CreatedAt, }); err != nil { log.Printf("⚠️ 更新对话时间失败: %v", err) } // 广播 AI 回复消息 if s.hub != nil { // AI 回复只广播给访客,不广播给客服(避免干扰) // 客服可以在会话页面手动开启"显示 AI 消息"来查看 s.hub.BroadcastMessage(aiMessage.ConversationID, "new_message", aiMessage) // 不再广播到所有客服 // s.hub.BroadcastToAllAgents("new_message", aiMessage) } }() } return message, nil } // ListMessages 返回会话内的消息列表。 // includeAIMessages: 是否包含 AI 消息(默认 false,不包含) // 如果 includeAIMessages == false,过滤掉所有 chat_mode == "ai" 的消息 // 这样就能准确区分 AI 模式下的消息和人工模式下的消息,即使对话模式切换了也能正确过滤 func (s *MessageService) ListMessages(conversationID uint, includeAIMessages bool) ([]models.Message, error) { messages, err := s.messages.ListByConversationID(conversationID) if err != nil { return nil, err } // 如果不包含 AI 消息,过滤掉所有 chat_mode == "ai" 的消息 // 这样,无论对话当前是什么模式,都能准确过滤掉 AI 模式下的所有消息 // 包括:访客在 AI 模式下发送的消息、AI 回复消息 if !includeAIMessages { filtered := make([]models.Message, 0, len(messages)) for _, msg := range messages { // 只显示 chat_mode != "ai" 的消息(人工模式下的消息) // 如果 chat_mode 为空(兼容历史数据),则根据 SenderID 和 SenderIsAgent 判断 if msg.ChatMode != "" { // 有 chat_mode 字段,直接根据字段过滤 if msg.ChatMode != "ai" { filtered = append(filtered, msg) } } else { // 兼容历史数据:chat_mode 为空时,使用旧逻辑 // 过滤掉 AI 回复消息(SenderID == 0 && SenderIsAgent == true) if msg.SenderID != 0 || !msg.SenderIsAgent { filtered = append(filtered, msg) } } } return filtered, nil } return messages, nil } // MarkMessagesRead 将消息标记为已读并通知监听方。 func (s *MessageService) MarkMessagesRead(conversationID uint, readerIsAgent bool) (*MarkMessagesReadResult, error) { messageIDs, unreadRemaining, readAt, err := s.messages.MarkMessagesRead(conversationID, !readerIsAgent) if err != nil { return nil, err } result := &MarkMessagesReadResult{ ConversationID: conversationID, MessageIDs: messageIDs, UnreadCount: unreadRemaining, ReadAt: readAt, } if s.hub != nil && len(messageIDs) > 0 { s.hub.BroadcastMessage(conversationID, "messages_read", map[string]interface{}{ "message_ids": messageIDs, "reader_is_agent": readerIsAgent, "read_at": readAt, "unread_count": unreadRemaining, "conversation_id": conversationID, // 确保 payload 中也包含 conversation_id }) } return result, nil }