conversation_service.go 17 KB

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