| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270 |
- 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 {
- conversations *repository.ConversationRepository
- messages *repository.MessageRepository
- hub BroadcastHub
- aiService *AIService // AI 服务(用于 AI 自动回复)
- }
- // NewMessageService 创建 MessageService 实例。
- func NewMessageService(
- conversations *repository.ConversationRepository,
- messages *repository.MessageRepository,
- hub BroadcastHub,
- aiService *AIService,
- ) *MessageService {
- return &MessageService{
- conversations: conversations,
- messages: messages,
- hub: hub,
- aiService: aiService,
- }
- }
- // CreateMessage 创建消息并通过 WebSocket 广播。
- func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Message, error) {
- conv, err := s.conversations.GetByID(input.ConversationID)
- if err != nil {
- return nil, err
- }
- if conv.Status == "closed" {
- return nil, ErrConversationClosed
- }
- if input.SenderIsAgent && input.SenderID == 0 {
- return nil, 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 := s.messages.Create(message); err != nil {
- return nil, err
- }
- // 如果客服发送消息,且会话的 agent_id 为 0,则更新为当前客服的 ID
- updateFields := map[string]interface{}{
- "updated_at": message.CreatedAt,
- }
- if input.SenderIsAgent && input.SenderID > 0 && conv.AgentID == 0 {
- updateFields["agent_id"] = input.SenderID
- }
- if err := s.conversations.UpdateFields(conv.ID, updateFields); err != nil {
- return nil, err
- }
- if s.hub != nil {
- // 1. 先广播到该对话的所有客户端(访客和已连接该对话的客服)
- s.hub.BroadcastMessage(message.ConversationID, "new_message", message)
- // 2. 如果是访客发送的消息,且对话模式是人工客服,才广播到所有客服
- // 这样即使客服没有连接到这个对话,也能收到新消息的通知
- // 注意:AI 模式下的访客消息不广播给客服(避免干扰)
- if !input.SenderIsAgent && conv.ChatMode == "human" {
- 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
- }
|