conversation_list_batch.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. package service
  2. import (
  3. "errors"
  4. "log"
  5. "os"
  6. "strconv"
  7. "strings"
  8. "time"
  9. "github.com/2930134478/AI-CS/backend/models"
  10. )
  11. const (
  12. defaultConversationPageSize = 50
  13. maxConversationPageSize = 100
  14. )
  15. func parseConversationListPagination(page, pageSize int) (int, int) {
  16. if page < 1 {
  17. page = 1
  18. }
  19. if pageSize <= 0 {
  20. pageSize = defaultConversationPageSize
  21. }
  22. if pageSize > maxConversationPageSize {
  23. pageSize = maxConversationPageSize
  24. }
  25. return page, pageSize
  26. }
  27. func (s *ConversationService) buildSummariesBatch(conversations []models.Conversation, userID uint) ([]ConversationSummary, error) {
  28. if len(conversations) == 0 {
  29. return []ConversationSummary{}, nil
  30. }
  31. ids := make([]uint, len(conversations))
  32. for i, conv := range conversations {
  33. ids[i] = conv.ID
  34. }
  35. latestMap, err := s.messages.BatchLatestByConversationIDs(ids)
  36. if err != nil {
  37. return nil, err
  38. }
  39. unreadMap, err := s.messages.BatchCountUnreadBySender(ids, false)
  40. if err != nil {
  41. return nil, err
  42. }
  43. participatedMap := map[uint]bool{}
  44. if userID > 0 {
  45. participatedMap, err = s.messages.BatchHasAgentParticipated(ids, userID)
  46. if err != nil {
  47. return nil, err
  48. }
  49. }
  50. result := make([]ConversationSummary, 0, len(conversations))
  51. for _, conv := range conversations {
  52. var lastSeen *time.Time
  53. if conv.LastSeenAt != nil {
  54. lastSeen = conv.LastSeenAt
  55. }
  56. summary := ConversationSummary{
  57. ID: conv.ID,
  58. ConversationType: conv.ConversationType,
  59. VisitorID: conv.VisitorID,
  60. AgentID: conv.AgentID,
  61. Status: conv.Status,
  62. ChatMode: conv.ChatMode,
  63. CreatedAt: conv.CreatedAt,
  64. UpdatedAt: conv.UpdatedAt,
  65. LastSeenAt: lastSeen,
  66. UnreadCount: unreadMap[conv.ID],
  67. HasParticipated: participatedMap[conv.ID],
  68. }
  69. if message := latestMap[conv.ID]; message != nil {
  70. var readAt *time.Time
  71. if message.ReadAt != nil {
  72. readAt = message.ReadAt
  73. }
  74. summary.LastMessage = &LastMessageSummary{
  75. ID: message.ID,
  76. Content: message.Content,
  77. SenderIsAgent: message.SenderIsAgent,
  78. MessageType: message.MessageType,
  79. IsRead: message.IsRead,
  80. ReadAt: readAt,
  81. CreatedAt: message.CreatedAt,
  82. }
  83. }
  84. result = append(result, summary)
  85. }
  86. return result, nil
  87. }
  88. // ListConversationsPaginated 分页返回访客会话列表(批量 SQL,无 N+1)。
  89. func (s *ConversationService) ListConversationsPaginated(userID uint, status string, page, pageSize int) (*ConversationListResult, error) {
  90. if status == "" {
  91. status = "open"
  92. }
  93. page, pageSize = parseConversationListPagination(page, pageSize)
  94. offset := (page - 1) * pageSize
  95. conversations, total, err := s.conversations.ListVisitorForAgentList(status, offset, pageSize)
  96. if err != nil {
  97. return nil, err
  98. }
  99. items, err := s.buildSummariesBatch(conversations, userID)
  100. if err != nil {
  101. return nil, err
  102. }
  103. totalUnread, err := s.messages.CountTotalUnreadVisitorForAgentList(status)
  104. if err != nil {
  105. totalUnread = 0
  106. }
  107. return &ConversationListResult{
  108. Items: items,
  109. Total: total,
  110. Page: page,
  111. PageSize: pageSize,
  112. HasMore: int64(offset+len(items)) < total,
  113. TotalUnread: totalUnread,
  114. }, nil
  115. }
  116. // ListInternalConversationsPaginated 分页返回内部对话列表。
  117. func (s *ConversationService) ListInternalConversationsPaginated(agentID uint, status string, page, pageSize int) (*ConversationListResult, error) {
  118. if agentID == 0 {
  119. return &ConversationListResult{Items: []ConversationSummary{}, Page: 1, PageSize: defaultConversationPageSize}, nil
  120. }
  121. if status == "" {
  122. status = "open"
  123. }
  124. page, pageSize = parseConversationListPagination(page, pageSize)
  125. conversations, err := s.conversations.ListInternalByAgentIDAndStatus(agentID, status)
  126. if err != nil {
  127. return nil, err
  128. }
  129. total := int64(len(conversations))
  130. offset := (page - 1) * pageSize
  131. if offset > len(conversations) {
  132. offset = len(conversations)
  133. }
  134. end := offset + pageSize
  135. if end > len(conversations) {
  136. end = len(conversations)
  137. }
  138. pageItems := conversations[offset:end]
  139. items, err := s.buildSummariesBatch(pageItems, agentID)
  140. if err != nil {
  141. return nil, err
  142. }
  143. return &ConversationListResult{
  144. Items: items,
  145. Total: total,
  146. Page: page,
  147. PageSize: pageSize,
  148. HasMore: int64(offset+len(items)) < total,
  149. }, nil
  150. }
  151. // AutoCloseConversationDaysPolicy 自动关闭 stale 会话的策略(数据库覆盖优先于 .env)。
  152. type AutoCloseConversationDaysPolicy struct {
  153. EffectiveDays int `json:"effective_days"`
  154. EnvDays int `json:"env_days"`
  155. PersistedInDatabase bool `json:"persisted_in_database"`
  156. }
  157. func autoCloseConversationDaysFromEnv() int {
  158. days := 7
  159. if v := os.Getenv("AUTO_CLOSE_CONVERSATION_DAYS"); v != "" {
  160. if parsed, err := strconv.Atoi(v); err == nil {
  161. days = parsed
  162. }
  163. }
  164. return days
  165. }
  166. // EffectiveAutoCloseConversationDays 返回当前生效的自动关闭天数(0=禁用)。
  167. func (s *ConversationService) EffectiveAutoCloseConversationDays() int {
  168. if s.appSettings != nil {
  169. if row, err := s.appSettings.Get(models.AppSettingKeyAutoCloseConversationDays); err == nil && row != nil {
  170. if v := strings.TrimSpace(row.Value); v != "" {
  171. if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
  172. return parsed
  173. }
  174. }
  175. }
  176. }
  177. return autoCloseConversationDaysFromEnv()
  178. }
  179. // GetAutoCloseConversationDaysPolicy 读取自动关闭 stale 会话策略。
  180. func (s *ConversationService) GetAutoCloseConversationDaysPolicy() AutoCloseConversationDaysPolicy {
  181. envDays := autoCloseConversationDaysFromEnv()
  182. policy := AutoCloseConversationDaysPolicy{
  183. EffectiveDays: envDays,
  184. EnvDays: envDays,
  185. }
  186. if s.appSettings != nil {
  187. if row, err := s.appSettings.Get(models.AppSettingKeyAutoCloseConversationDays); err == nil && row != nil {
  188. if v := strings.TrimSpace(row.Value); v != "" {
  189. policy.PersistedInDatabase = true
  190. if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
  191. policy.EffectiveDays = parsed
  192. }
  193. }
  194. }
  195. }
  196. return policy
  197. }
  198. // SetAutoCloseConversationDaysPolicy 写入自动关闭天数(0=禁用)。
  199. func (s *ConversationService) SetAutoCloseConversationDaysPolicy(inactiveDays int) error {
  200. if inactiveDays < 0 {
  201. return errors.New("inactive_days 不能为负数")
  202. }
  203. if s.appSettings == nil {
  204. return errors.New("配置存储不可用")
  205. }
  206. return s.appSettings.SetValue(models.AppSettingKeyAutoCloseConversationDays, strconv.Itoa(inactiveDays))
  207. }
  208. // ClearAutoCloseConversationDaysPolicy 删除数据库覆盖,恢复为 .env 默认值。
  209. func (s *ConversationService) ClearAutoCloseConversationDaysPolicy() error {
  210. if s.appSettings == nil {
  211. return errors.New("配置存储不可用")
  212. }
  213. return s.appSettings.Delete(models.AppSettingKeyAutoCloseConversationDays)
  214. }
  215. // CloseStaleOpenVisitorConversations 关闭超过指定天数未更新的 open 访客会话(仅改 status,不删记录)。
  216. func (s *ConversationService) CloseStaleOpenVisitorConversations(inactiveDays int) (int64, error) {
  217. if inactiveDays <= 0 {
  218. return 0, nil
  219. }
  220. cutoff := time.Now().AddDate(0, 0, -inactiveDays)
  221. return s.conversations.CloseStaleOpenVisitorConversations(cutoff)
  222. }
  223. // StartStaleConversationCleanup 启动后台任务:定期关闭长期未活跃的 open 访客会话。
  224. func (s *ConversationService) StartStaleConversationCleanup() {
  225. run := func() {
  226. inactiveDays := s.EffectiveAutoCloseConversationDays()
  227. if inactiveDays <= 0 {
  228. return
  229. }
  230. n, err := s.CloseStaleOpenVisitorConversations(inactiveDays)
  231. if err != nil {
  232. log.Printf("[会话维护] 自动关闭 stale 会话失败: %v", err)
  233. return
  234. }
  235. if n > 0 {
  236. log.Printf("[会话维护] 已自动关闭 %d 条超过 %d 天未更新的 open 访客会话", n, inactiveDays)
  237. }
  238. }
  239. if days := s.EffectiveAutoCloseConversationDays(); days <= 0 {
  240. log.Println("[会话维护] 自动关闭 stale 会话已禁用(effective_days=0)")
  241. } else {
  242. run()
  243. }
  244. go func() {
  245. ticker := time.NewTicker(24 * time.Hour)
  246. defer ticker.Stop()
  247. for range ticker.C {
  248. run()
  249. }
  250. }()
  251. }