conversation_service.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. package service
  2. import (
  3. "errors"
  4. "strings"
  5. "time"
  6. "github.com/2930134478/AI-CS/backend/models"
  7. "github.com/2930134478/AI-CS/backend/repository"
  8. "gorm.io/gorm"
  9. )
  10. // ConversationService 负责会话领域的业务编排。
  11. type ConversationService struct {
  12. conversations *repository.ConversationRepository
  13. messages *repository.MessageRepository
  14. aiConfigRepo *repository.AIConfigRepository // 用于验证 AI 配置
  15. userRepo *repository.UserRepository // 用于查询用户设置
  16. systemLogSvc *SystemLogService // 可选,结构化日志
  17. }
  18. // NewConversationService 创建 ConversationService 实例。
  19. func NewConversationService(
  20. conversations *repository.ConversationRepository,
  21. messages *repository.MessageRepository,
  22. aiConfigRepo *repository.AIConfigRepository,
  23. userRepo *repository.UserRepository,
  24. systemLogSvc *SystemLogService,
  25. ) *ConversationService {
  26. return &ConversationService{
  27. conversations: conversations,
  28. messages: messages,
  29. aiConfigRepo: aiConfigRepo,
  30. userRepo: userRepo,
  31. systemLogSvc: systemLogSvc,
  32. }
  33. }
  34. // InitConversation 为访客创建或恢复会话。
  35. func (s *ConversationService) InitConversation(input InitConversationInput) (*InitConversationResult, error) {
  36. var (
  37. conv *models.Conversation
  38. err error
  39. )
  40. conv, err = s.conversations.FindOpenByVisitorID(input.VisitorID)
  41. isNewConversation := false
  42. if err != nil {
  43. if errors.Is(err, gorm.ErrRecordNotFound) {
  44. now := time.Now()
  45. chatMode := input.ChatMode
  46. if chatMode == "" {
  47. chatMode = "human" // 默认人工客服
  48. }
  49. // 如果是 AI 模式,验证 AI 配置
  50. var aiConfigID *uint
  51. if chatMode == "ai" {
  52. if input.AIConfigID == nil || *input.AIConfigID == 0 {
  53. return nil, errors.New("AI 模式需要选择模型配置")
  54. }
  55. // 验证配置是否存在且开放
  56. config, err := s.aiConfigRepo.GetByID(*input.AIConfigID)
  57. if err != nil {
  58. return nil, errors.New("模型配置不存在")
  59. }
  60. if !config.IsPublic {
  61. return nil, errors.New("该模型未开放给访客使用")
  62. }
  63. if !config.IsActive {
  64. return nil, errors.New("该模型配置已禁用")
  65. }
  66. aiConfigID = input.AIConfigID
  67. }
  68. conv = &models.Conversation{
  69. ConversationType: "visitor",
  70. VisitorID: input.VisitorID,
  71. Status: "open",
  72. Website: input.Website,
  73. Referrer: input.Referrer,
  74. Browser: input.Browser,
  75. OS: input.OS,
  76. Language: input.Language,
  77. IPAddress: input.IPAddress,
  78. LastSeenAt: &now,
  79. ChatMode: chatMode,
  80. AIConfigID: aiConfigID,
  81. }
  82. if err := s.conversations.Create(conv); err != nil {
  83. return nil, err
  84. }
  85. if s.systemLogSvc != nil {
  86. _ = s.systemLogSvc.Create(CreateSystemLogInput{
  87. Level: "info",
  88. Category: "business",
  89. Event: "conversation_created",
  90. Source: "backend",
  91. Message: "访客会话已创建",
  92. ConversationID: &conv.ID,
  93. VisitorID: &input.VisitorID,
  94. Meta: map[string]interface{}{
  95. "chat_mode": conv.ChatMode,
  96. "ai_config": conv.AIConfigID,
  97. },
  98. })
  99. }
  100. isNewConversation = true
  101. } else {
  102. return nil, err
  103. }
  104. } else {
  105. // 恢复已存在的对话
  106. now := time.Now()
  107. updates := map[string]interface{}{
  108. "last_seen_at": &now,
  109. }
  110. // 更新访客信息(如果之前没有)
  111. if input.Website != "" && conv.Website == "" {
  112. updates["website"] = input.Website
  113. }
  114. if input.Referrer != "" && conv.Referrer == "" {
  115. updates["referrer"] = input.Referrer
  116. }
  117. if input.Browser != "" && conv.Browser == "" {
  118. updates["browser"] = input.Browser
  119. }
  120. if input.OS != "" && conv.OS == "" {
  121. updates["os"] = input.OS
  122. }
  123. if input.Language != "" && conv.Language == "" {
  124. updates["language"] = input.Language
  125. }
  126. if input.IPAddress != "" && conv.IPAddress == "" {
  127. updates["ip_address"] = input.IPAddress
  128. }
  129. // 重要:如果用户选择了新的 ChatMode,更新对话模式
  130. // 这样访客可以在人工客服和 AI 客服之间切换
  131. if input.ChatMode != "" && input.ChatMode != conv.ChatMode {
  132. chatMode := input.ChatMode
  133. oldMode := conv.ChatMode
  134. updates["chat_mode"] = chatMode
  135. // 如果是 AI 模式,验证并更新 AI 配置
  136. if chatMode == "ai" {
  137. if input.AIConfigID == nil || *input.AIConfigID == 0 {
  138. return nil, errors.New("AI 模式需要选择模型配置")
  139. }
  140. // 验证配置是否存在且开放
  141. config, err := s.aiConfigRepo.GetByID(*input.AIConfigID)
  142. if err != nil {
  143. return nil, errors.New("模型配置不存在")
  144. }
  145. if !config.IsPublic {
  146. return nil, errors.New("该模型未开放给访客使用")
  147. }
  148. if !config.IsActive {
  149. return nil, errors.New("该模型配置已禁用")
  150. }
  151. updates["ai_config_id"] = input.AIConfigID
  152. } else {
  153. // 切换到人工客服模式,清除 AI 配置
  154. updates["ai_config_id"] = nil
  155. }
  156. if s.systemLogSvc != nil {
  157. convID := conv.ID
  158. visitorID := conv.VisitorID
  159. _ = s.systemLogSvc.Create(CreateSystemLogInput{
  160. Level: "info",
  161. Category: "business",
  162. Event: "conversation_mode_switch",
  163. Source: "backend",
  164. ConversationID: &convID,
  165. VisitorID: &visitorID,
  166. Message: "会话模式切换",
  167. Meta: map[string]interface{}{
  168. "from": oldMode,
  169. "to": chatMode,
  170. },
  171. })
  172. }
  173. }
  174. // 已在 AI 模式时,若用户在下拉中切换了模型(对话↔绘画),也要更新 ai_config_id
  175. if input.ChatMode == "ai" && conv.ChatMode == "ai" && input.AIConfigID != nil && *input.AIConfigID != 0 {
  176. if conv.AIConfigID == nil || *conv.AIConfigID != *input.AIConfigID {
  177. config, err := s.aiConfigRepo.GetByID(*input.AIConfigID)
  178. if err != nil {
  179. return nil, errors.New("模型配置不存在")
  180. }
  181. if !config.IsPublic {
  182. return nil, errors.New("该模型未开放给访客使用")
  183. }
  184. if !config.IsActive {
  185. return nil, errors.New("该模型配置已禁用")
  186. }
  187. updates["ai_config_id"] = input.AIConfigID
  188. }
  189. }
  190. if err := s.conversations.UpdateFields(conv.ID, updates); err != nil {
  191. return nil, err
  192. }
  193. // 重新获取更新后的对话信息
  194. conv, err = s.conversations.GetByID(conv.ID)
  195. if err != nil {
  196. return nil, err
  197. }
  198. }
  199. if isNewConversation {
  200. now := time.Now()
  201. chatMode := input.ChatMode
  202. if chatMode == "" {
  203. chatMode = "human" // 默认人工模式
  204. }
  205. message := &models.Message{
  206. ConversationID: conv.ID,
  207. SenderID: 0,
  208. SenderIsAgent: false,
  209. Content: "Visitor opened the page",
  210. MessageType: "system_message",
  211. ChatMode: chatMode, // 记录系统消息发送时的对话模式
  212. IsRead: true,
  213. ReadAt: &now,
  214. }
  215. if input.Website != "" {
  216. message.Content += " [" + input.Website + "]"
  217. }
  218. if err := s.messages.Create(message); err != nil {
  219. return nil, err
  220. }
  221. if input.Referrer != "" {
  222. readTime := time.Now()
  223. chatMode := input.ChatMode
  224. if chatMode == "" {
  225. chatMode = "human" // 默认人工模式
  226. }
  227. referrerMsg := &models.Message{
  228. ConversationID: conv.ID,
  229. SenderID: 0,
  230. SenderIsAgent: false,
  231. Content: "Visitor came from [" + input.Referrer + "]",
  232. MessageType: "system_message",
  233. ChatMode: chatMode, // 记录系统消息发送时的对话模式
  234. IsRead: true,
  235. ReadAt: &readTime,
  236. }
  237. if err := s.messages.Create(referrerMsg); err != nil {
  238. return nil, err
  239. }
  240. }
  241. }
  242. return &InitConversationResult{
  243. ConversationID: conv.ID,
  244. Status: conv.Status,
  245. }, nil
  246. }
  247. // UpdateConversationContact 更新访客的联系信息(邮箱、电话、备注)。
  248. func (s *ConversationService) UpdateConversationContact(input UpdateConversationContactInput) (*ConversationDetail, error) {
  249. if _, err := s.conversations.GetByID(input.ConversationID); err != nil {
  250. if errors.Is(err, gorm.ErrRecordNotFound) {
  251. return nil, ErrConversationNotFound
  252. }
  253. return nil, err
  254. }
  255. updates := map[string]interface{}{}
  256. if input.Email != nil {
  257. updates["email"] = strings.TrimSpace(*input.Email)
  258. }
  259. if input.Phone != nil {
  260. updates["phone"] = strings.TrimSpace(*input.Phone)
  261. }
  262. if input.Notes != nil {
  263. updates["notes"] = strings.TrimSpace(*input.Notes)
  264. }
  265. if err := s.conversations.UpdateFields(input.ConversationID, updates); err != nil {
  266. return nil, err
  267. }
  268. // UpdateConversationContact 不传递 userID,因为更新联系信息时不需要检查参与状态
  269. return s.GetConversationDetail(input.ConversationID, 0)
  270. }
  271. func (s *ConversationService) buildSummary(conv models.Conversation, userID uint) (ConversationSummary, error) {
  272. var lastSeen *time.Time
  273. if conv.LastSeenAt != nil {
  274. lastSeen = conv.LastSeenAt
  275. }
  276. // 检查当前用户是否参与过该会话(是否发送过消息)
  277. hasParticipated := false
  278. if userID > 0 {
  279. if participated, err := s.messages.HasAgentParticipated(conv.ID, userID); err == nil {
  280. hasParticipated = participated
  281. }
  282. // 错误时静默处理,不影响流程
  283. }
  284. summary := ConversationSummary{
  285. ID: conv.ID,
  286. ConversationType: conv.ConversationType,
  287. VisitorID: conv.VisitorID,
  288. AgentID: conv.AgentID,
  289. Status: conv.Status,
  290. ChatMode: conv.ChatMode,
  291. CreatedAt: conv.CreatedAt,
  292. UpdatedAt: conv.UpdatedAt,
  293. LastSeenAt: lastSeen,
  294. HasParticipated: hasParticipated,
  295. }
  296. if message, err := s.messages.LatestByConversationID(conv.ID); err == nil && message != nil {
  297. var readAt *time.Time
  298. if message.ReadAt != nil {
  299. readAt = message.ReadAt
  300. }
  301. summary.LastMessage = &LastMessageSummary{
  302. ID: message.ID,
  303. Content: message.Content,
  304. SenderIsAgent: message.SenderIsAgent,
  305. MessageType: message.MessageType,
  306. IsRead: message.IsRead,
  307. ReadAt: readAt,
  308. CreatedAt: message.CreatedAt,
  309. }
  310. }
  311. if count, err := s.messages.CountUnreadBySender(conv.ID, false); err == nil {
  312. summary.UnreadCount = count
  313. }
  314. return summary, nil
  315. }
  316. // ListConversations 返回当前活跃会话的摘要信息。
  317. // userID: 当前登录的客服ID(可选,如果为0则使用默认过滤规则)
  318. // 过滤规则:
  319. // 1. 默认不显示 ChatMode == "ai" 的对话
  320. // 2. 如果 userID > 0 且该用户的 ReceiveAIConversations == false,则不显示 AI 对话
  321. // 3. 只显示 ChatMode == "human" 且存在访客消息的对话(访客切换到人工并发送消息后)
  322. func (s *ConversationService) ListConversations(userID uint) ([]ConversationSummary, error) {
  323. conversations, err := s.conversations.ListActive()
  324. if err != nil {
  325. return nil, err
  326. }
  327. result := make([]ConversationSummary, 0, len(conversations))
  328. for _, conv := range conversations {
  329. // 过滤规则 1: 默认不显示 AI 对话
  330. // 只有在会话页面手动开启"显示 AI 对话"时才显示
  331. if conv.ChatMode == "ai" {
  332. continue
  333. }
  334. // 过滤规则 2: 如果是人工对话,检查是否有访客发送的消息
  335. // 只有当访客切换到人工并发送消息后,才显示在列表中
  336. if conv.ChatMode == "human" {
  337. hasVisitorMessage, err := s.messages.HasVisitorMessageInHumanMode(conv.ID)
  338. if err != nil {
  339. // 如果查询失败,为了安全起见,不显示该对话
  340. continue
  341. }
  342. if !hasVisitorMessage {
  343. // 没有访客消息,不显示(访客只是切换了模式,但还没发送消息)
  344. continue
  345. }
  346. }
  347. // 通过过滤,添加到结果列表
  348. summary, err := s.buildSummary(conv, userID)
  349. if err != nil {
  350. continue // 如果构建摘要失败,跳过该对话
  351. }
  352. result = append(result, summary)
  353. }
  354. return result, nil
  355. }
  356. // GetConversationDetail 获取指定会话的详细信息。内部对话仅创建者(agent_id)可查看。
  357. func (s *ConversationService) GetConversationDetail(id uint, userID uint) (*ConversationDetail, error) {
  358. conv, err := s.conversations.GetByID(id)
  359. if err != nil {
  360. return nil, err
  361. }
  362. if conv.ConversationType == "internal" && userID > 0 && conv.AgentID != userID {
  363. return nil, gorm.ErrRecordNotFound
  364. }
  365. summary, err := s.buildSummary(*conv, userID)
  366. if err != nil {
  367. return nil, err
  368. }
  369. var lastSeen *time.Time
  370. if conv.LastSeenAt != nil {
  371. lastSeen = conv.LastSeenAt
  372. }
  373. return &ConversationDetail{
  374. ConversationSummary: summary,
  375. Website: conv.Website,
  376. Referrer: conv.Referrer,
  377. Browser: conv.Browser,
  378. OS: conv.OS,
  379. Language: conv.Language,
  380. IPAddress: conv.IPAddress,
  381. Location: conv.Location,
  382. Email: conv.Email,
  383. Phone: conv.Phone,
  384. Notes: conv.Notes,
  385. LastSeen: lastSeen,
  386. }, nil
  387. }
  388. // SearchConversations 根据关键字检索会话摘要。
  389. // userID: 当前登录的客服ID(可选,用于检查参与状态)
  390. func (s *ConversationService) SearchConversations(query string, userID uint) ([]ConversationSummary, error) {
  391. pattern := "%" + query + "%"
  392. idSet := map[uint]struct{}{}
  393. if ids, err := s.messages.FindConversationIDsByContent(pattern); err == nil {
  394. for _, id := range ids {
  395. idSet[id] = struct{}{}
  396. }
  397. } else {
  398. return nil, err
  399. }
  400. if convs, err := s.conversations.SearchByIDOrVisitorLike(pattern); err == nil {
  401. for _, conv := range convs {
  402. idSet[conv.ID] = struct{}{}
  403. }
  404. } else {
  405. return nil, err
  406. }
  407. if len(idSet) == 0 {
  408. return []ConversationSummary{}, nil
  409. }
  410. ids := make([]uint, 0, len(idSet))
  411. for id := range idSet {
  412. ids = append(ids, id)
  413. }
  414. conversations, err := s.conversations.ListByIDs(ids)
  415. if err != nil {
  416. return nil, err
  417. }
  418. result := make([]ConversationSummary, 0, len(conversations))
  419. for _, conv := range conversations {
  420. summary, err := s.buildSummary(conv, userID)
  421. if err != nil {
  422. return nil, err
  423. }
  424. result = append(result, summary)
  425. }
  426. return result, nil
  427. }
  428. // UpdateVisitorOnlineStatus 更新访客在线状态和最后活跃时间。
  429. // 当 isOnline 为 true 时,更新 last_seen_at 为当前时间,并确保状态为 "open"。
  430. // 当 isOnline 为 false 时,仅更新 last_seen_at 为当前时间,不改变状态。
  431. func (s *ConversationService) UpdateVisitorOnlineStatus(conversationID uint, isOnline bool) error {
  432. now := time.Now()
  433. updates := map[string]interface{}{
  434. "last_seen_at": &now,
  435. }
  436. // 如果标记为在线,确保状态为 "open"(但不要将已关闭的会话重新打开)
  437. if isOnline {
  438. conv, err := s.conversations.GetByID(conversationID)
  439. if err != nil {
  440. return err
  441. }
  442. // 只有当前状态不是 "closed" 时,才更新为 "open"
  443. if conv.Status != "closed" {
  444. updates["status"] = "open"
  445. }
  446. }
  447. return s.conversations.UpdateFields(conversationID, updates)
  448. }
  449. // UpdateLastSeenAt 更新访客的最后活跃时间。
  450. func (s *ConversationService) UpdateLastSeenAt(conversationID uint) error {
  451. now := time.Now()
  452. return s.conversations.UpdateFields(conversationID, map[string]interface{}{
  453. "last_seen_at": &now,
  454. })
  455. }
  456. // InitInternalConversation 为客服创建一条新的内部对话(知识库测试用)。每次调用创建新会话。
  457. func (s *ConversationService) InitInternalConversation(agentID uint) (*InitConversationResult, error) {
  458. if agentID == 0 {
  459. return nil, errors.New("agent_id is required for internal conversation")
  460. }
  461. conv := &models.Conversation{
  462. ConversationType: "internal",
  463. VisitorID: 0,
  464. AgentID: agentID,
  465. Status: "open",
  466. ChatMode: "ai",
  467. }
  468. if err := s.conversations.Create(conv); err != nil {
  469. return nil, err
  470. }
  471. return &InitConversationResult{
  472. ConversationID: conv.ID,
  473. Status: conv.Status,
  474. }, nil
  475. }
  476. // ListInternalConversations 返回当前客服的全部内部对话(知识库测试用)。
  477. func (s *ConversationService) ListInternalConversations(agentID uint) ([]ConversationSummary, error) {
  478. if agentID == 0 {
  479. return []ConversationSummary{}, nil
  480. }
  481. conversations, err := s.conversations.ListActiveInternalByAgentID(agentID)
  482. if err != nil {
  483. return nil, err
  484. }
  485. result := make([]ConversationSummary, 0, len(conversations))
  486. for _, conv := range conversations {
  487. summary, err := s.buildSummary(conv, agentID)
  488. if err != nil {
  489. continue
  490. }
  491. result = append(result, summary)
  492. }
  493. return result, nil
  494. }