| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285 |
- package service
- import (
- "context"
- "fmt"
- "log"
- "strconv"
- "strings"
- "time"
- "github.com/2930134478/AI-CS/backend/infra"
- "github.com/2930134478/AI-CS/backend/models"
- "github.com/2930134478/AI-CS/backend/repository"
- )
- // VisitorPresenceHub 访客在线状态探测
- type VisitorPresenceHub interface {
- BroadcastHub
- VisitorConnectionCount(conversationID uint) int
- }
- // OfflineEmailService 离线邮件延迟推送
- type OfflineEmailService struct {
- configSvc *EmailNotificationConfigService
- jobRepo *repository.OfflineEmailJobRepository
- convRepo *repository.ConversationRepository
- messageRepo *repository.MessageRepository
- hub VisitorPresenceHub
- }
- // NewOfflineEmailService 创建离线邮件服务
- func NewOfflineEmailService(
- configSvc *EmailNotificationConfigService,
- jobRepo *repository.OfflineEmailJobRepository,
- convRepo *repository.ConversationRepository,
- messageRepo *repository.MessageRepository,
- hub VisitorPresenceHub,
- ) *OfflineEmailService {
- return &OfflineEmailService{
- configSvc: configSvc,
- jobRepo: jobRepo,
- convRepo: convRepo,
- messageRepo: messageRepo,
- hub: hub,
- }
- }
- // OnAgentMessage 客服在人工访客会话发消息后,若访客离线则调度邮件
- func (s *OfflineEmailService) OnAgentMessage(conversationID, messageID uint) {
- if s == nil {
- return
- }
- go func() {
- if err := s.scheduleAgentMessage(conversationID, messageID); err != nil {
- log.Printf("[离线邮件] 调度失败 conv=%d msg=%d: %v", conversationID, messageID, err)
- }
- }()
- }
- // CancelPending 访客上线时取消待发送任务
- func (s *OfflineEmailService) CancelPending(conversationID uint) {
- if s == nil {
- return
- }
- job, err := s.jobRepo.GetPendingByConversationID(conversationID)
- if err != nil || job == nil {
- return
- }
- job.Status = "cancelled"
- _ = s.jobRepo.Save(job)
- }
- func (s *OfflineEmailService) scheduleAgentMessage(conversationID, messageID uint) error {
- cfg, err := s.configSvc.ResolveEffective()
- if err != nil || !cfg.Enabled {
- return err
- }
- if cfg.SMTPHost == "" || cfg.FromEmail == "" {
- return fmt.Errorf("SMTP 未配置")
- }
- conv, err := s.convRepo.GetByID(conversationID)
- if err != nil {
- return err
- }
- if conv.ConversationType != "visitor" || conv.ChatMode != "human" {
- return nil
- }
- if strings.TrimSpace(conv.Email) == "" {
- return nil
- }
- if s.hub != nil && s.hub.VisitorConnectionCount(conversationID) > 0 {
- return nil
- }
- delay := cfg.OfflineDelaySeconds
- if delay < 0 {
- delay = 0
- }
- scheduledAt := time.Now().Add(time.Duration(delay) * time.Second)
- job, err := s.jobRepo.GetPendingByConversationID(conversationID)
- if err != nil {
- return err
- }
- if job != nil {
- ids := parseMessageIDs(job.MessageIDs)
- if !containsUint(ids, messageID) {
- ids = append(ids, messageID)
- }
- job.MessageIDs = joinMessageIDs(ids)
- job.ScheduledAt = scheduledAt
- return s.jobRepo.Save(job)
- }
- return s.jobRepo.Create(&models.OfflineEmailJob{
- ConversationID: conversationID,
- MessageIDs: strconv.FormatUint(uint64(messageID), 10),
- ScheduledAt: scheduledAt,
- Status: "pending",
- })
- }
- // StartWorker 后台轮询到期任务
- func (s *OfflineEmailService) StartWorker(ctx context.Context) {
- if s == nil {
- return
- }
- ticker := time.NewTicker(15 * time.Second)
- defer ticker.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- s.processDueJobs()
- }
- }
- }
- func (s *OfflineEmailService) processDueJobs() {
- jobs, err := s.jobRepo.ListDuePending(time.Now(), 20)
- if err != nil {
- log.Printf("[离线邮件] 查询待发送任务失败: %v", err)
- return
- }
- for i := range jobs {
- if err := s.processJob(&jobs[i]); err != nil {
- log.Printf("[离线邮件] 处理任务 #%d 失败: %v", jobs[i].ID, err)
- }
- }
- }
- func (s *OfflineEmailService) processJob(job *models.OfflineEmailJob) error {
- cfg, err := s.configSvc.ResolveEffective()
- if err != nil {
- return err
- }
- if !cfg.Enabled {
- job.Status = "cancelled"
- return s.jobRepo.Save(job)
- }
- if s.hub != nil && s.hub.VisitorConnectionCount(job.ConversationID) > 0 {
- job.Status = "cancelled"
- return s.jobRepo.Save(job)
- }
- conv, err := s.convRepo.GetByID(job.ConversationID)
- if err != nil {
- job.Status = "failed"
- job.LastError = "会话不存在"
- return s.jobRepo.Save(job)
- }
- to := strings.TrimSpace(conv.Email)
- if to == "" {
- job.Status = "cancelled"
- job.LastError = "访客未留邮箱"
- return s.jobRepo.Save(job)
- }
- ids := parseMessageIDs(job.MessageIDs)
- var parts []string
- for _, id := range ids {
- msg, err := s.messageRepo.GetByID(id)
- if err != nil || msg == nil {
- continue
- }
- if !msg.SenderIsAgent || msg.MessageType == "system_message" {
- continue
- }
- content := strings.TrimSpace(msg.Content)
- if content == "" && msg.FileURL != nil {
- content = "[附件消息]"
- }
- if content != "" {
- parts = append(parts, content)
- }
- }
- if len(parts) == 0 {
- job.Status = "cancelled"
- return s.jobRepo.Save(job)
- }
- subject := "您有一条新的客服回复"
- body := "您好,\n\n客服在您离线时给您留了消息:\n\n"
- body += strings.Join(parts, "\n\n---\n\n")
- body += "\n\n"
- if conv.Website != "" {
- body += "您可返回原页面继续对话:\n" + conv.Website + "\n"
- }
- body += "\n—— AI-CS 智能客服"
- mailCfg := infra.SMTPMailConfig{
- Host: cfg.SMTPHost,
- Port: cfg.SMTPPort,
- User: cfg.SMTPUser,
- Password: cfg.SMTPPassword,
- FromEmail: cfg.FromEmail,
- FromName: cfg.FromName,
- }
- if err := infra.SendSMTPMail(mailCfg, to, subject, body); err != nil {
- job.Status = "failed"
- job.LastError = err.Error()
- return s.jobRepo.Save(job)
- }
- job.Status = "sent"
- job.LastError = ""
- log.Printf("[离线邮件] 已发送 conv=%d to=%s messages=%d", job.ConversationID, to, len(parts))
- return s.jobRepo.Save(job)
- }
- // SendTestEmail 发送测试邮件(设置页验证 SMTP)
- func (s *OfflineEmailService) SendTestEmail(to string) error {
- cfg, err := s.configSvc.ResolveEffective()
- if err != nil {
- return err
- }
- if cfg.SMTPHost == "" || cfg.FromEmail == "" {
- return fmt.Errorf("请先配置 SMTP 服务器与发件邮箱")
- }
- mailCfg := infra.SMTPMailConfig{
- Host: cfg.SMTPHost,
- Port: cfg.SMTPPort,
- User: cfg.SMTPUser,
- Password: cfg.SMTPPassword,
- FromEmail: cfg.FromEmail,
- FromName: cfg.FromName,
- }
- body := "这是一封来自 AI-CS 的测试邮件。若您收到此邮件,说明 SMTP 配置正确。"
- return infra.SendSMTPMail(mailCfg, to, "AI-CS SMTP 测试", body)
- }
- func parseMessageIDs(raw string) []uint {
- parts := strings.Split(raw, ",")
- var ids []uint
- for _, p := range parts {
- p = strings.TrimSpace(p)
- if p == "" {
- continue
- }
- if n, err := strconv.ParseUint(p, 10, 32); err == nil {
- ids = append(ids, uint(n))
- }
- }
- return ids
- }
- func joinMessageIDs(ids []uint) string {
- strs := make([]string, len(ids))
- for i, id := range ids {
- strs[i] = strconv.FormatUint(uint64(id), 10)
- }
- return strings.Join(strs, ",")
- }
- func containsUint(ids []uint, target uint) bool {
- for _, id := range ids {
- if id == target {
- return true
- }
- }
- return false
- }
|