394 lines
11 KiB
Go
394 lines
11 KiB
Go
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
|
||
}
|