conversation_service.go 18 KB

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