offline_email_service.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. package service
  2. import (
  3. "context"
  4. "fmt"
  5. "log"
  6. "strconv"
  7. "strings"
  8. "time"
  9. "github.com/2930134478/AI-CS/backend/infra"
  10. "github.com/2930134478/AI-CS/backend/models"
  11. "github.com/2930134478/AI-CS/backend/repository"
  12. )
  13. // VisitorPresenceHub 访客在线状态探测
  14. type VisitorPresenceHub interface {
  15. BroadcastHub
  16. VisitorConnectionCount(conversationID uint) int
  17. }
  18. // OfflineEmailService 离线邮件延迟推送
  19. type OfflineEmailService struct {
  20. configSvc *EmailNotificationConfigService
  21. jobRepo *repository.OfflineEmailJobRepository
  22. convRepo *repository.ConversationRepository
  23. messageRepo *repository.MessageRepository
  24. hub VisitorPresenceHub
  25. }
  26. // NewOfflineEmailService 创建离线邮件服务
  27. func NewOfflineEmailService(
  28. configSvc *EmailNotificationConfigService,
  29. jobRepo *repository.OfflineEmailJobRepository,
  30. convRepo *repository.ConversationRepository,
  31. messageRepo *repository.MessageRepository,
  32. hub VisitorPresenceHub,
  33. ) *OfflineEmailService {
  34. return &OfflineEmailService{
  35. configSvc: configSvc,
  36. jobRepo: jobRepo,
  37. convRepo: convRepo,
  38. messageRepo: messageRepo,
  39. hub: hub,
  40. }
  41. }
  42. // OnAgentMessage 客服在人工访客会话发消息后,若访客离线则调度邮件
  43. func (s *OfflineEmailService) OnAgentMessage(conversationID, messageID uint) {
  44. if s == nil {
  45. return
  46. }
  47. go func() {
  48. if err := s.scheduleAgentMessage(conversationID, messageID); err != nil {
  49. log.Printf("[离线邮件] 调度失败 conv=%d msg=%d: %v", conversationID, messageID, err)
  50. }
  51. }()
  52. }
  53. // CancelPending 访客上线时取消待发送任务
  54. func (s *OfflineEmailService) CancelPending(conversationID uint) {
  55. if s == nil {
  56. return
  57. }
  58. job, err := s.jobRepo.GetPendingByConversationID(conversationID)
  59. if err != nil || job == nil {
  60. return
  61. }
  62. job.Status = "cancelled"
  63. _ = s.jobRepo.Save(job)
  64. }
  65. func (s *OfflineEmailService) scheduleAgentMessage(conversationID, messageID uint) error {
  66. cfg, err := s.configSvc.ResolveEffective()
  67. if err != nil || !cfg.Enabled {
  68. return err
  69. }
  70. if cfg.SMTPHost == "" || cfg.FromEmail == "" {
  71. return fmt.Errorf("SMTP 未配置")
  72. }
  73. conv, err := s.convRepo.GetByID(conversationID)
  74. if err != nil {
  75. return err
  76. }
  77. if conv.ConversationType != "visitor" || conv.ChatMode != "human" {
  78. return nil
  79. }
  80. if strings.TrimSpace(conv.Email) == "" {
  81. return nil
  82. }
  83. if s.hub != nil && s.hub.VisitorConnectionCount(conversationID) > 0 {
  84. return nil
  85. }
  86. delay := cfg.OfflineDelaySeconds
  87. if delay < 0 {
  88. delay = 0
  89. }
  90. scheduledAt := time.Now().Add(time.Duration(delay) * time.Second)
  91. job, err := s.jobRepo.GetPendingByConversationID(conversationID)
  92. if err != nil {
  93. return err
  94. }
  95. if job != nil {
  96. ids := parseMessageIDs(job.MessageIDs)
  97. if !containsUint(ids, messageID) {
  98. ids = append(ids, messageID)
  99. }
  100. job.MessageIDs = joinMessageIDs(ids)
  101. job.ScheduledAt = scheduledAt
  102. return s.jobRepo.Save(job)
  103. }
  104. return s.jobRepo.Create(&models.OfflineEmailJob{
  105. ConversationID: conversationID,
  106. MessageIDs: strconv.FormatUint(uint64(messageID), 10),
  107. ScheduledAt: scheduledAt,
  108. Status: "pending",
  109. })
  110. }
  111. // StartWorker 后台轮询到期任务
  112. func (s *OfflineEmailService) StartWorker(ctx context.Context) {
  113. if s == nil {
  114. return
  115. }
  116. ticker := time.NewTicker(15 * time.Second)
  117. defer ticker.Stop()
  118. for {
  119. select {
  120. case <-ctx.Done():
  121. return
  122. case <-ticker.C:
  123. s.processDueJobs()
  124. }
  125. }
  126. }
  127. func (s *OfflineEmailService) processDueJobs() {
  128. jobs, err := s.jobRepo.ListDuePending(time.Now(), 20)
  129. if err != nil {
  130. log.Printf("[离线邮件] 查询待发送任务失败: %v", err)
  131. return
  132. }
  133. for i := range jobs {
  134. if err := s.processJob(&jobs[i]); err != nil {
  135. log.Printf("[离线邮件] 处理任务 #%d 失败: %v", jobs[i].ID, err)
  136. }
  137. }
  138. }
  139. func (s *OfflineEmailService) processJob(job *models.OfflineEmailJob) error {
  140. cfg, err := s.configSvc.ResolveEffective()
  141. if err != nil {
  142. return err
  143. }
  144. if !cfg.Enabled {
  145. job.Status = "cancelled"
  146. return s.jobRepo.Save(job)
  147. }
  148. if s.hub != nil && s.hub.VisitorConnectionCount(job.ConversationID) > 0 {
  149. job.Status = "cancelled"
  150. return s.jobRepo.Save(job)
  151. }
  152. conv, err := s.convRepo.GetByID(job.ConversationID)
  153. if err != nil {
  154. job.Status = "failed"
  155. job.LastError = "会话不存在"
  156. return s.jobRepo.Save(job)
  157. }
  158. to := strings.TrimSpace(conv.Email)
  159. if to == "" {
  160. job.Status = "cancelled"
  161. job.LastError = "访客未留邮箱"
  162. return s.jobRepo.Save(job)
  163. }
  164. ids := parseMessageIDs(job.MessageIDs)
  165. var parts []string
  166. for _, id := range ids {
  167. msg, err := s.messageRepo.GetByID(id)
  168. if err != nil || msg == nil {
  169. continue
  170. }
  171. if !msg.SenderIsAgent || msg.MessageType == "system_message" {
  172. continue
  173. }
  174. content := strings.TrimSpace(msg.Content)
  175. if content == "" && msg.FileURL != nil {
  176. content = "[附件消息]"
  177. }
  178. if content != "" {
  179. parts = append(parts, content)
  180. }
  181. }
  182. if len(parts) == 0 {
  183. job.Status = "cancelled"
  184. return s.jobRepo.Save(job)
  185. }
  186. subject := "您有一条新的客服回复"
  187. body := "您好,\n\n客服在您离线时给您留了消息:\n\n"
  188. body += strings.Join(parts, "\n\n---\n\n")
  189. body += "\n\n"
  190. if conv.Website != "" {
  191. body += "您可返回原页面继续对话:\n" + conv.Website + "\n"
  192. }
  193. body += "\n—— AI-CS 智能客服"
  194. mailCfg := infra.SMTPMailConfig{
  195. Host: cfg.SMTPHost,
  196. Port: cfg.SMTPPort,
  197. User: cfg.SMTPUser,
  198. Password: cfg.SMTPPassword,
  199. FromEmail: cfg.FromEmail,
  200. FromName: cfg.FromName,
  201. }
  202. if err := infra.SendSMTPMail(mailCfg, to, subject, body); err != nil {
  203. job.Status = "failed"
  204. job.LastError = err.Error()
  205. return s.jobRepo.Save(job)
  206. }
  207. job.Status = "sent"
  208. job.LastError = ""
  209. log.Printf("[离线邮件] 已发送 conv=%d to=%s messages=%d", job.ConversationID, to, len(parts))
  210. return s.jobRepo.Save(job)
  211. }
  212. // SendTestEmail 发送测试邮件(设置页验证 SMTP)
  213. func (s *OfflineEmailService) SendTestEmail(to string) error {
  214. cfg, err := s.configSvc.ResolveEffective()
  215. if err != nil {
  216. return err
  217. }
  218. if cfg.SMTPHost == "" || cfg.FromEmail == "" {
  219. return fmt.Errorf("请先配置 SMTP 服务器与发件邮箱")
  220. }
  221. mailCfg := infra.SMTPMailConfig{
  222. Host: cfg.SMTPHost,
  223. Port: cfg.SMTPPort,
  224. User: cfg.SMTPUser,
  225. Password: cfg.SMTPPassword,
  226. FromEmail: cfg.FromEmail,
  227. FromName: cfg.FromName,
  228. }
  229. body := "这是一封来自 AI-CS 的测试邮件。若您收到此邮件,说明 SMTP 配置正确。"
  230. return infra.SendSMTPMail(mailCfg, to, "AI-CS SMTP 测试", body)
  231. }
  232. func parseMessageIDs(raw string) []uint {
  233. parts := strings.Split(raw, ",")
  234. var ids []uint
  235. for _, p := range parts {
  236. p = strings.TrimSpace(p)
  237. if p == "" {
  238. continue
  239. }
  240. if n, err := strconv.ParseUint(p, 10, 32); err == nil {
  241. ids = append(ids, uint(n))
  242. }
  243. }
  244. return ids
  245. }
  246. func joinMessageIDs(ids []uint) string {
  247. strs := make([]string, len(ids))
  248. for i, id := range ids {
  249. strs[i] = strconv.FormatUint(uint64(id), 10)
  250. }
  251. return strings.Join(strs, ",")
  252. }
  253. func containsUint(ids []uint, target uint) bool {
  254. for _, id := range ids {
  255. if id == target {
  256. return true
  257. }
  258. }
  259. return false
  260. }