conversation_service.go 17 KB

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