message_service.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. package service
  2. import (
  3. "errors"
  4. "log"
  5. "github.com/2930134478/AI-CS/backend/models"
  6. "github.com/2930134478/AI-CS/backend/repository"
  7. "gorm.io/gorm"
  8. )
  9. // ErrConversationClosed indicates operations are attempted on a closed conversation.
  10. var (
  11. // ErrConversationClosed 表示会话已关闭,不能继续发送消息。
  12. ErrConversationClosed = errors.New("conversation is closed")
  13. // ErrConversationNotFound 表示未找到指定的会话记录。
  14. ErrConversationNotFound = gorm.ErrRecordNotFound
  15. )
  16. // MessageService 负责消息领域的业务处理。
  17. type MessageService struct {
  18. db *gorm.DB
  19. conversations *repository.ConversationRepository
  20. messages *repository.MessageRepository
  21. hub BroadcastHub
  22. aiService *AIService // AI 服务(用于 AI 自动回复)
  23. }
  24. // NewMessageService 创建 MessageService 实例。
  25. func NewMessageService(
  26. db *gorm.DB,
  27. conversations *repository.ConversationRepository,
  28. messages *repository.MessageRepository,
  29. hub BroadcastHub,
  30. aiService *AIService,
  31. ) *MessageService {
  32. return &MessageService{
  33. db: db,
  34. conversations: conversations,
  35. messages: messages,
  36. hub: hub,
  37. aiService: aiService,
  38. }
  39. }
  40. // CreateMessage 创建消息并通过 WebSocket 广播。
  41. func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Message, error) {
  42. if s.db == nil {
  43. return nil, errors.New("db is not initialized")
  44. }
  45. var (
  46. conv models.Conversation
  47. message *models.Message
  48. )
  49. err := s.db.Transaction(func(tx *gorm.DB) error {
  50. if err := tx.Where("id = ?", input.ConversationID).First(&conv).Error; err != nil {
  51. if errors.Is(err, gorm.ErrRecordNotFound) {
  52. return ErrConversationNotFound
  53. }
  54. return err
  55. }
  56. // B 方案:会话关闭后,如访客再次发消息则自动 reopen
  57. if conv.Status == "closed" {
  58. if input.SenderIsAgent {
  59. return ErrConversationClosed
  60. }
  61. if err := tx.Model(&models.Conversation{}).Where("id = ?", conv.ID).Updates(map[string]interface{}{
  62. "status": "open",
  63. }).Error; err != nil {
  64. return err
  65. }
  66. conv.Status = "open"
  67. }
  68. if input.SenderIsAgent && input.SenderID == 0 {
  69. return errors.New("sender_id is required for agent messages")
  70. }
  71. message = &models.Message{
  72. ConversationID: input.ConversationID,
  73. SenderID: input.SenderID,
  74. SenderIsAgent: input.SenderIsAgent,
  75. Content: input.Content,
  76. MessageType: "user_message",
  77. ChatMode: conv.ChatMode,
  78. IsRead: false,
  79. FileURL: input.FileURL,
  80. FileType: input.FileType,
  81. FileName: input.FileName,
  82. FileSize: input.FileSize,
  83. MimeType: input.MimeType,
  84. }
  85. if err := tx.Create(message).Error; err != nil {
  86. return err
  87. }
  88. // 如果客服发送消息,且会话的 agent_id 为 0,则更新为当前客服的 ID
  89. updateFields := map[string]interface{}{
  90. "updated_at": message.CreatedAt,
  91. }
  92. // 访客发送消息可视为在线心跳:同步刷新 last_seen_at,支撑客服端在线状态判定。
  93. if !input.SenderIsAgent {
  94. updateFields["last_seen_at"] = message.CreatedAt
  95. }
  96. if input.SenderIsAgent && input.SenderID > 0 && conv.AgentID == 0 {
  97. updateFields["agent_id"] = input.SenderID
  98. }
  99. if err := tx.Model(&models.Conversation{}).Where("id = ?", conv.ID).Updates(updateFields).Error; err != nil {
  100. return err
  101. }
  102. if agentID, ok := updateFields["agent_id"].(uint); ok {
  103. conv.AgentID = agentID
  104. }
  105. return nil
  106. })
  107. if err != nil {
  108. return nil, err
  109. }
  110. if s.hub != nil {
  111. // 1. 先广播到该对话房间内的客户端(访客 + 已按该 conversation_id 建连的客服)
  112. s.hub.BroadcastMessage(message.ConversationID, "new_message", message)
  113. // 2. 人工会话(非内部测试):再向所有在线客服连接广播一次。
  114. // - 原逻辑仅对「访客消息」广播,客服自己发的消息只进房间;若 WS 异常/多实例 Hub 不一致,客服台会迟迟看不到自己发的内容。
  115. // - AI 模式不向全员推访客消息(保持原意);内部知识库会话不向其他客服推(避免无关会话刷屏)。
  116. // handleNewMessage 侧会按 conversation_id 去重,双播不会产生重复气泡。
  117. if conv.ChatMode == "human" && conv.ConversationType != "internal" {
  118. s.hub.BroadcastToAllAgents("new_message", message)
  119. }
  120. } else {
  121. log.Printf("⚠️ WebSocket Hub 为空,无法广播消息: 消息ID=%d, 对话ID=%d", message.ID, message.ConversationID)
  122. }
  123. // 3. 触发 AI 回复(文本/识图或生图,具体由 AI 配置的 model_type 决定)
  124. needAIReply := s.aiService != nil && conv.ChatMode == "ai" && (
  125. (!input.SenderIsAgent) || (conv.ConversationType == "internal" && input.SenderIsAgent))
  126. if needAIReply {
  127. go func() {
  128. // 用于查找 AI 配置的用户 ID:访客对话用 AgentID,内部对话用发送者(客服)ID
  129. userID := conv.AgentID
  130. if userID == 0 {
  131. userID = 1
  132. }
  133. if conv.ConversationType == "internal" && input.SenderID > 0 {
  134. userID = input.SenderID
  135. }
  136. opts := &GenerateAIResponseInput{
  137. UseKnowledgeBase: input.UseKnowledgeBase,
  138. UseLLM: input.UseLLM,
  139. UseWebSearch: input.UseWebSearch,
  140. NeedWebSearch: input.NeedWebSearch,
  141. }
  142. if opts.UseKnowledgeBase == nil {
  143. t := true
  144. opts.UseKnowledgeBase = &t
  145. }
  146. if opts.UseLLM == nil {
  147. t := true
  148. opts.UseLLM = &t
  149. }
  150. if opts.UseWebSearch == nil {
  151. f := false
  152. opts.UseWebSearch = &f
  153. }
  154. // 多模态识图:当前条消息带图片时传给 AI
  155. if input.FileURL != nil && input.FileType != nil && *input.FileType == "image" {
  156. mime := ""
  157. if input.MimeType != nil {
  158. mime = *input.MimeType
  159. }
  160. opts.Attachment = &MessageAttachment{
  161. FileURL: *input.FileURL,
  162. FileType: "image",
  163. MimeType: mime,
  164. }
  165. }
  166. aiResult, err := s.aiService.GenerateAIResponseWithOptions(message.ConversationID, input.Content, userID, opts)
  167. aiResponse := ""
  168. sourcesUsed := ""
  169. var aiMessageFileURL *string
  170. aiGenFailed := false
  171. if err != nil {
  172. log.Printf("❌ AI 生成回复失败: %v", err)
  173. aiResponse = "AI客服好像出了点差错,请联系人工客服解决"
  174. aiGenFailed = true
  175. } else {
  176. aiResponse = aiResult.Content
  177. sourcesUsed = aiResult.SourcesUsed
  178. aiMessageFileURL = aiResult.GeneratedFileURL
  179. aiGenFailed = aiResult.GenerationFailed
  180. }
  181. // 生图时前端依赖 file_type === "image" 才渲染图片,必须设置
  182. var aiMessageFileType *string
  183. if aiMessageFileURL != nil {
  184. t := "image"
  185. aiMessageFileType = &t
  186. }
  187. aiMessage := &models.Message{
  188. ConversationID: message.ConversationID,
  189. SenderID: 0,
  190. SenderIsAgent: true,
  191. Content: aiResponse,
  192. MessageType: "user_message",
  193. ChatMode: conv.ChatMode,
  194. IsRead: false,
  195. SourcesUsed: sourcesUsed,
  196. FileURL: aiMessageFileURL,
  197. FileType: aiMessageFileType,
  198. IsAIGenerationFailed: aiGenFailed,
  199. }
  200. if err := s.messages.Create(aiMessage); err != nil {
  201. log.Printf("❌ 创建 AI 回复消息失败: %v", err)
  202. return
  203. }
  204. // 更新对话的更新时间
  205. if err := s.conversations.UpdateFields(conv.ID, map[string]interface{}{
  206. "updated_at": aiMessage.CreatedAt,
  207. }); err != nil {
  208. log.Printf("⚠️ 更新对话时间失败: %v", err)
  209. }
  210. // 广播 AI 回复消息
  211. if s.hub != nil {
  212. // AI 回复只广播给访客,不广播给客服(避免干扰)
  213. // 客服可以在会话页面手动开启"显示 AI 消息"来查看
  214. s.hub.BroadcastMessage(aiMessage.ConversationID, "new_message", aiMessage)
  215. // 不再广播到所有客服
  216. // s.hub.BroadcastToAllAgents("new_message", aiMessage)
  217. }
  218. }()
  219. }
  220. return message, nil
  221. }
  222. // ListMessages 返回会话内的消息列表。
  223. // includeAIMessages: 是否包含 AI 消息(默认 false,不包含)
  224. // 如果 includeAIMessages == false,过滤掉所有 chat_mode == "ai" 的消息
  225. // 这样就能准确区分 AI 模式下的消息和人工模式下的消息,即使对话模式切换了也能正确过滤
  226. func (s *MessageService) ListMessages(conversationID uint, includeAIMessages bool) ([]models.Message, error) {
  227. messages, err := s.messages.ListByConversationID(conversationID)
  228. if err != nil {
  229. return nil, err
  230. }
  231. // 如果不包含 AI 消息,过滤掉所有 chat_mode == "ai" 的消息
  232. // 这样,无论对话当前是什么模式,都能准确过滤掉 AI 模式下的所有消息
  233. // 包括:访客在 AI 模式下发送的消息、AI 回复消息
  234. if !includeAIMessages {
  235. filtered := make([]models.Message, 0, len(messages))
  236. for _, msg := range messages {
  237. // 只显示 chat_mode != "ai" 的消息(人工模式下的消息)
  238. // 如果 chat_mode 为空(兼容历史数据),则根据 SenderID 和 SenderIsAgent 判断
  239. if msg.ChatMode != "" {
  240. // 有 chat_mode 字段,直接根据字段过滤
  241. if msg.ChatMode != "ai" {
  242. filtered = append(filtered, msg)
  243. }
  244. } else {
  245. // 兼容历史数据:chat_mode 为空时,使用旧逻辑
  246. // 过滤掉 AI 回复消息(SenderID == 0 && SenderIsAgent == true)
  247. if msg.SenderID != 0 || !msg.SenderIsAgent {
  248. filtered = append(filtered, msg)
  249. }
  250. }
  251. }
  252. return filtered, nil
  253. }
  254. return messages, nil
  255. }
  256. // MarkMessagesRead 将消息标记为已读并通知监听方。
  257. func (s *MessageService) MarkMessagesRead(conversationID uint, readerIsAgent bool) (*MarkMessagesReadResult, error) {
  258. messageIDs, unreadRemaining, readAt, err := s.messages.MarkMessagesRead(conversationID, !readerIsAgent)
  259. if err != nil {
  260. return nil, err
  261. }
  262. result := &MarkMessagesReadResult{
  263. ConversationID: conversationID,
  264. MessageIDs: messageIDs,
  265. UnreadCount: unreadRemaining,
  266. ReadAt: readAt,
  267. }
  268. if s.hub != nil && len(messageIDs) > 0 {
  269. s.hub.BroadcastMessage(conversationID, "messages_read", map[string]interface{}{
  270. "message_ids": messageIDs,
  271. "reader_is_agent": readerIsAgent,
  272. "read_at": readAt,
  273. "unread_count": unreadRemaining,
  274. "conversation_id": conversationID, // 确保 payload 中也包含 conversation_id
  275. })
  276. }
  277. return result, nil
  278. }