Files
goalfylearning-admin/internal/services/message_push_service.go

394 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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特殊字符然后将换行符转换为<br>
escapedContent := html.EscapeString(content)
// 将换行符转换为HTML换行
contentHTML := fmt.Sprintf(`<div style="white-space: pre-wrap;">%s</div>`, escapedContent)
tmplStr := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
line-height: 1.6;
color: #1a1a1a;
background-color: #f5f5f5;
padding: 40px 20px;
}
.email-wrapper {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.header {
background-color: #000000;
padding: 32px 40px;
text-align: center;
}
.header-logo {
font-size: 28px;
font-weight: 700;
color: #ffffff;
letter-spacing: -0.5px;
}
.content {
padding: 40px;
}
.title {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 24px;
line-height: 1.4;
}
.message-content {
font-size: 15px;
color: #4a4a4a;
line-height: 1.7;
background-color: #fafafa;
padding: 20px 24px;
border-radius: 6px;
margin-bottom: 24px;
}
.footer {
background-color: #fafafa;
padding: 32px 40px;
text-align: center;
border-top: 1px solid #e5e5e5;
}
.footer-brand {
font-size: 14px;
color: #1a1a1a;
font-weight: 600;
margin-bottom: 8px;
}
.footer-contact {
font-size: 13px;
color: #666666;
margin-top: 8px;
}
@media only screen and (max-width: 600px) {
.content {
padding: 24px 20px;
}
.header {
padding: 24px 20px;
}
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="header">
<div class="header-logo">GoalfyAI</div>
</div>
<div class="content">
<div class="title">{{.Title}}</div>
<div class="message-content">{{.Content}}</div>
</div>
<div class="footer">
<div class="footer-brand">GoalfyAI 团队</div>
<div class="footer-contact">
© 2025 GoalfyAI. All rights reserved.
</div>
</div>
</div>
</body>
</html>
`
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
}