package services import ( "bytes" "context" "encoding/json" "fmt" "goalfymax-admin/internal/config" "goalfymax-admin/internal/models" "goalfymax-admin/internal/storage" "html" "html/template" "io" "net/http" "time" "unicode/utf8" ) // MessagePushService 消息推送服务接口 type MessagePushService interface { SendMessage(ctx context.Context, req *models.MessagePushRequest, senderID int, senderName string) (*models.MessagePushResponse, error) GetPushLogs(ctx context.Context, req *models.MessagePushListRequest) (*models.MessagePushListResponse, error) GetPushLogByID(ctx context.Context, id int64) (*models.MessagePushLog, error) SearchUsers(ctx context.Context, req *models.UserSearchRequest) (*models.UserSearchResponse, error) } type messagePushService struct { storage storage.MessagePushStorage goalfymaxClient *http.Client goalfymaxURL string retryCount int retryInterval time.Duration emailService *EmailService goalfyMaxUserStorage storage.GoalfyMaxUserStorage } // NewMessagePushService 创建消息推送服务实例 func NewMessagePushService() MessagePushService { cfg := config.GetConfig() return &messagePushService{ storage: storage.NewMessagePushStorage(), goalfymaxClient: &http.Client{ Timeout: time.Duration(cfg.MessagePush.Timeout) * time.Second, }, goalfymaxURL: cfg.MessagePush.GoalfyMaxBaseURL, retryCount: cfg.MessagePush.RetryCount, retryInterval: time.Duration(cfg.MessagePush.RetryInterval) * time.Millisecond, emailService: NewEmailService(), goalfyMaxUserStorage: storage.NewGoalfyMaxUserStorage(), } } // SendMessage 发送消息 func (s *messagePushService) SendMessage(ctx context.Context, req *models.MessagePushRequest, senderID int, senderName string) (*models.MessagePushResponse, error) { // 参数校验 if len(req.UserIDs) == 0 { return nil, fmt.Errorf("目标用户不能为空") } if len(req.Title) == 0 { return nil, fmt.Errorf("消息标题不能为空") } titleRuneCount := utf8.RuneCountInString(req.Title) if titleRuneCount > 100 { return nil, fmt.Errorf("消息标题不能超过100字符") } if len(req.Content) == 0 { return nil, fmt.Errorf("消息内容不能为空") } contentRuneCount := utf8.RuneCountInString(req.Content) if contentRuneCount > 2000 { return nil, fmt.Errorf("消息内容不能超过2000字符") } // 序列化目标用户 targetUsersJSON, err := storage.SerializeTargetUsers(req.UserIDs) if err != nil { return nil, fmt.Errorf("序列化目标用户失败: %w", err) } // 创建推送记录 log := &models.MessagePushLog{ Title: req.Title, Content: req.Content, TargetUsers: targetUsersJSON, SenderID: senderID, SenderName: senderName, Status: 0, // 待发送 } if err := s.storage.Create(ctx, log); err != nil { return nil, fmt.Errorf("创建推送记录失败: %w", err) } // 异步发送消息 go s.sendToGoalfyMaxAsync(context.Background(), log.ID, req.Title, req.Content, req.UserIDs) return &models.MessagePushResponse{ LogID: log.ID, SuccessCount: 0, FailCount: 0, }, nil } // sendToGoalfyMaxAsync 异步发送到GoalfyMax func (s *messagePushService) sendToGoalfyMaxAsync(ctx context.Context, logID int64, title string, content string, userIDs []int) { // 更新状态为发送中 s.storage.UpdateStatus(ctx, logID, 1, 0, 0, "") successCount := 0 failCount := 0 var errorMessage string // 调用GoalfyMax接口 err := s.callGoalfyMaxAPI(ctx, title, content, userIDs) if err != nil { failCount = len(userIDs) errorMessage = err.Error() s.storage.UpdateStatus(ctx, logID, 3, successCount, failCount, errorMessage) return } // 发送成功,同时发送邮件 s.sendEmails(ctx, title, content, userIDs) // 发送成功 successCount = len(userIDs) s.storage.UpdateStatus(ctx, logID, 2, successCount, failCount, "") } // sendEmails 发送邮件给用户 func (s *messagePushService) sendEmails(ctx context.Context, title string, content string, userIDs []int) { // 生成邮件HTML内容 htmlContent := s.generateMessageEmailHTML(title, content) // 循环发送邮件给每个用户 for _, userID := range userIDs { user, err := s.goalfyMaxUserStorage.GetByUserID(userID) if err != nil { // 如果获取用户失败,跳过该用户 continue } if user.Email != "" { // 发送邮件 if err := s.emailService.sendEmail(user.Email, title, htmlContent); err != nil { // 记录错误但不中断其他邮件的发送 continue } // 避免发送过快,每次发送后稍作延迟 time.Sleep(100 * time.Millisecond) } } } // generateMessageEmailHTML 生成消息通知邮件的HTML内容 func (s *messagePushService) generateMessageEmailHTML(title, content string) string { // 转义HTML特殊字符,然后将换行符转换为
escapedContent := html.EscapeString(content) // 将换行符转换为HTML换行 contentHTML := fmt.Sprintf(`
%s
`, escapedContent) tmplStr := `
{{.Title}}
{{.Content}}
` tmpl, _ := template.New("message").Parse(tmplStr) var buf bytes.Buffer tmpl.Execute(&buf, map[string]interface{}{ "Title": html.EscapeString(title), "Content": template.HTML(contentHTML), }) return buf.String() } // callGoalfyMaxAPI 调用GoalfyMax API func (s *messagePushService) callGoalfyMaxAPI(ctx context.Context, title string, content string, userIDs []int) error { // 构建请求体 reqBody := map[string]interface{}{ "title": title, "content": content, "user_ids": userIDs, } jsonData, err := json.Marshal(reqBody) if err != nil { return fmt.Errorf("序列化请求失败: %w", err) } var lastErr error for i := 0; i < s.retryCount; i++ { // 创建HTTP请求 req, err := http.NewRequestWithContext(ctx, "POST", s.goalfymaxURL+"/api/notifications/send", bytes.NewBuffer(jsonData)) if err != nil { lastErr = fmt.Errorf("创建请求失败: %w", err) continue } // 设置固定Token req.Header.Set("Authorization", "Bearer admin-message-push") req.Header.Set("Content-Type", "application/json") // 发送请求 resp, err := s.goalfymaxClient.Do(req) if err != nil { lastErr = fmt.Errorf("发送请求失败 (尝试 %d/%d): %w", i+1, s.retryCount, err) if i < s.retryCount-1 { time.Sleep(s.retryInterval) } continue } defer resp.Body.Close() // 读取响应 body, err := io.ReadAll(resp.Body) if err != nil { lastErr = fmt.Errorf("读取响应失败: %w", err) if i < s.retryCount-1 { time.Sleep(s.retryInterval) } continue } if resp.StatusCode != http.StatusOK { lastErr = fmt.Errorf("GoalfyMax接口返回错误: %d, 响应: %s", resp.StatusCode, string(body)) if i < s.retryCount-1 { time.Sleep(s.retryInterval) } continue } // 成功,返回nil return nil } // 所有重试都失败 return lastErr } // GetPushLogs 获取推送记录列表 func (s *messagePushService) GetPushLogs(ctx context.Context, req *models.MessagePushListRequest) (*models.MessagePushListResponse, error) { // 参数校验 if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 || req.PageSize > 100 { req.PageSize = 10 } // 调用存储层 logs, total, err := s.storage.List(ctx, req) if err != nil { return nil, fmt.Errorf("获取推送记录失败: %w", err) } return &models.MessagePushListResponse{ List: logs, Total: total, Page: req.Page, PageSize: req.PageSize, }, nil } // GetPushLogByID 根据ID获取推送记录 func (s *messagePushService) GetPushLogByID(ctx context.Context, id int64) (*models.MessagePushLog, error) { if id <= 0 { return nil, fmt.Errorf("推送记录ID无效") } log, err := s.storage.GetByID(ctx, id) if err != nil { return nil, err } return log, nil } // SearchUsers 搜索用户 func (s *messagePushService) SearchUsers(ctx context.Context, req *models.UserSearchRequest) (*models.UserSearchResponse, error) { // 参数校验 if req.Limit <= 0 || req.Limit > 1000 { req.Limit = 20 } users, err := s.storage.SearchUsers(ctx, req.Keyword, req.Limit) if err != nil { return nil, fmt.Errorf("搜索用户失败: %w", err) } return &models.UserSearchResponse{ Users: users, Total: len(users), }, nil }