feat: add notification
This commit is contained in:
parent
4d770723b3
commit
2aa8bd061d
|
@ -1,5 +1,7 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
type SMTPConfig struct {
|
type SMTPConfig struct {
|
||||||
Enable bool
|
Enable bool
|
||||||
Sort int
|
Sort int
|
||||||
|
@ -32,3 +34,13 @@ type MitakeSMSSender struct {
|
||||||
User string
|
User string
|
||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeliveryConfig 傳送重試配置
|
||||||
|
type DeliveryConfig struct {
|
||||||
|
MaxRetries int `json:"max_retries"` // 最大重試次數
|
||||||
|
InitialDelay time.Duration `json:"initial_delay"` // 初始重試延遲 (100ms)
|
||||||
|
BackoffFactor float64 `json:"backoff_factor"` // 指數退避因子 (2.0)
|
||||||
|
MaxDelay time.Duration `json:"max_delay"` // 最大延遲時間
|
||||||
|
Timeout time.Duration `json:"timeout"` // 單次發送超時時間
|
||||||
|
EnableHistory bool `json:"enable_history"` // 是否啟用歷史記錄
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
package entity
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// DeliveryStatus 傳送狀態
|
||||||
|
type DeliveryStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeliveryStatusPending DeliveryStatus = "pending" // 待發送
|
||||||
|
DeliveryStatusSending DeliveryStatus = "sending" // 發送中
|
||||||
|
DeliveryStatusSuccess DeliveryStatus = "success" // 發送成功
|
||||||
|
DeliveryStatusFailed DeliveryStatus = "failed" // 發送失敗
|
||||||
|
DeliveryStatusRetrying DeliveryStatus = "retrying" // 重試中
|
||||||
|
DeliveryStatusCancelled DeliveryStatus = "cancelled" // 已取消
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeliveryHistory 傳送歷史記錄
|
||||||
|
type DeliveryHistory struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
Type string `bson:"type" json:"type"` // email/sms
|
||||||
|
Recipient string `bson:"recipient" json:"recipient"` // 收件人
|
||||||
|
Subject string `bson:"subject" json:"subject"` // 主題
|
||||||
|
Content string `bson:"content" json:"content"` // 內容
|
||||||
|
Provider string `bson:"provider" json:"provider"` // aws_ses, mitake, smtp
|
||||||
|
Status DeliveryStatus `bson:"status" json:"status"` // 狀態
|
||||||
|
AttemptCount int `bson:"attempt_count" json:"attempt_count"` // 嘗試次數
|
||||||
|
ErrorMessage string `bson:"error_message" json:"error_message"` // 錯誤訊息
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||||
|
CompletedAt *time.Time `bson:"completed_at" json:"completed_at"` // 完成時間
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeliveryAttempt 單次發送嘗試記錄
|
||||||
|
type DeliveryAttempt struct {
|
||||||
|
Provider string `bson:"provider" json:"provider"`
|
||||||
|
AttemptAt time.Time `bson:"attempt_at" json:"attempt_at"`
|
||||||
|
Success bool `bson:"success" json:"success"`
|
||||||
|
ErrorMessage string `bson:"error_message" json:"error_message"`
|
||||||
|
Duration int64 `bson:"duration_ms" json:"duration_ms"` // 執行時間(毫秒)
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
package entity
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Template 通知模板實體
|
||||||
|
type Template struct {
|
||||||
|
ID string `bson:"_id" json:"id"`
|
||||||
|
Type string `bson:"type" json:"type"` // email/sms
|
||||||
|
Language string `bson:"language" json:"language"` // zh-tw, en
|
||||||
|
Category string `bson:"category" json:"category"` // forget_password, binding_email
|
||||||
|
Subject string `bson:"subject" json:"subject"` // 郵件主題 (SMS 可為空)
|
||||||
|
Body string `bson:"body" json:"body"` // 內容
|
||||||
|
IsActive bool `bson:"is_active" json:"is_active"`
|
||||||
|
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateParams 模板參數
|
||||||
|
type TemplateParams struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
VerifyCode string `json:"verify_code"`
|
||||||
|
Extra map[string]string `json:"extra"` // 額外參數
|
||||||
|
}
|
|
@ -1,29 +1,13 @@
|
||||||
package domain
|
package domain
|
||||||
|
|
||||||
import (
|
import "backend/pkg/library/errs"
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
ers "backend/pkg/library/errs"
|
|
||||||
"backend/pkg/library/errs/code"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ThirdPartyError(scope uint32, ec ers.ErrorCode, s ...string) *ers.LibError {
|
|
||||||
return ers.NewError(scope, code.ThirdParty, ec.ToUint32(), fmt.Sprintf("thirty error: %s", strings.Join(s, " ")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func ThirdPartyErrorL(scope uint32, ec ers.ErrorCode,
|
|
||||||
l logx.Logger, filed []logx.LogField, s ...string) *ers.LibError {
|
|
||||||
e := ThirdPartyError(scope, ec, s...)
|
|
||||||
l.WithCallerSkip(1).WithFields(filed...).Error(e.Error())
|
|
||||||
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Notification Error Codes
|
||||||
const (
|
const (
|
||||||
NotificationErrorCode = 1 + iota
|
NotificationErrorCode errs.ErrorCode = 1 + iota
|
||||||
FailedToSendEmailErrorCode
|
FailedToSendEmailErrorCode
|
||||||
FailedToSendSMSErrorCode
|
FailedToSendSMSErrorCode
|
||||||
|
FailedToGetTemplateErrorCode
|
||||||
|
FailedToSaveHistoryErrorCode
|
||||||
|
FailedToRetryDeliveryErrorCode
|
||||||
)
|
)
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/pkg/notification/domain/entity"
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HistoryRepository 傳送歷史記錄接口
|
||||||
|
type HistoryRepository interface {
|
||||||
|
// CreateHistory 創建歷史記錄
|
||||||
|
CreateHistory(ctx context.Context, history *entity.DeliveryHistory) error
|
||||||
|
|
||||||
|
// UpdateHistory 更新歷史記錄
|
||||||
|
UpdateHistory(ctx context.Context, history *entity.DeliveryHistory) error
|
||||||
|
|
||||||
|
// GetHistory 根據ID獲取歷史記錄
|
||||||
|
GetHistory(ctx context.Context, id string) (*entity.DeliveryHistory, error)
|
||||||
|
|
||||||
|
// ListHistory 列出歷史記錄
|
||||||
|
ListHistory(ctx context.Context, filter HistoryFilter) ([]*entity.DeliveryHistory, error)
|
||||||
|
|
||||||
|
// AddAttempt 添加發送嘗試記錄
|
||||||
|
AddAttempt(ctx context.Context, historyID string, attempt entity.DeliveryAttempt) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryFilter 歷史記錄查詢過濾器
|
||||||
|
type HistoryFilter struct {
|
||||||
|
Type string `json:"type"` // email/sms
|
||||||
|
Recipient string `json:"recipient"` // 收件人
|
||||||
|
Status entity.DeliveryStatus `json:"status"` // 狀態
|
||||||
|
Provider string `json:"provider"` // 提供商
|
||||||
|
StartTime *int64 `json:"start_time"` // 開始時間
|
||||||
|
EndTime *int64 `json:"end_time"` // 結束時間
|
||||||
|
Limit int `json:"limit"` // 限制數量
|
||||||
|
Offset int `json:"offset"` // 偏移量
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/pkg/notification/domain/entity"
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TemplateRepository 模板資料庫接口
|
||||||
|
type TemplateRepository interface {
|
||||||
|
// GetTemplate 根據類型、語言、分類獲取模板
|
||||||
|
GetTemplate(ctx context.Context, templateType, language, category string) (*entity.Template, error)
|
||||||
|
|
||||||
|
// ListTemplates 列出所有活躍的模板
|
||||||
|
ListTemplates(ctx context.Context, templateType, language string) ([]*entity.Template, error)
|
||||||
|
|
||||||
|
// CreateTemplate 創建模板
|
||||||
|
CreateTemplate(ctx context.Context, template *entity.Template) error
|
||||||
|
|
||||||
|
// UpdateTemplate 更新模板
|
||||||
|
UpdateTemplate(ctx context.Context, template *entity.Template) error
|
||||||
|
|
||||||
|
// DeleteTemplate 刪除模板
|
||||||
|
DeleteTemplate(ctx context.Context, id string) error
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"backend/pkg/library/errs"
|
||||||
"backend/pkg/library/errs/code"
|
"backend/pkg/library/errs/code"
|
||||||
pool "backend/pkg/library/worker_pool"
|
pool "backend/pkg/library/worker_pool"
|
||||||
|
|
||||||
|
@ -79,7 +80,7 @@ func (use *AwsEmailDeliveryRepository) SendMail(ctx context.Context, req reposit
|
||||||
|
|
||||||
//nolint:contextcheck
|
//nolint:contextcheck
|
||||||
if _, err := use.Client.SendEmail(newCtx, input); err != nil {
|
if _, err := use.Client.SendEmail(newCtx, input); err != nil {
|
||||||
_ = domain.ThirdPartyErrorL(
|
_ = errs.ThirdPartyErrorL(
|
||||||
code.CloudEPNotification,
|
code.CloudEPNotification,
|
||||||
domain.FailedToSendEmailErrorCode,
|
domain.FailedToSendEmailErrorCode,
|
||||||
logx.WithContext(ctx),
|
logx.WithContext(ctx),
|
||||||
|
@ -92,7 +93,7 @@ func (use *AwsEmailDeliveryRepository) SendMail(ctx context.Context, req reposit
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e := domain.ThirdPartyErrorL(
|
e := errs.ThirdPartyErrorL(
|
||||||
code.CloudEPNotification,
|
code.CloudEPNotification,
|
||||||
domain.FailedToSendEmailErrorCode,
|
domain.FailedToSendEmailErrorCode,
|
||||||
logx.WithContext(ctx),
|
logx.WithContext(ctx),
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"backend/pkg/notification/domain/repository"
|
"backend/pkg/notification/domain/repository"
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"backend/pkg/library/errs"
|
||||||
"backend/pkg/library/errs/code"
|
"backend/pkg/library/errs/code"
|
||||||
pool "backend/pkg/library/worker_pool"
|
pool "backend/pkg/library/worker_pool"
|
||||||
|
|
||||||
|
@ -39,7 +40,7 @@ func (use *MitakeSMSDeliveryRepository) SendSMS(ctx context.Context, req reposit
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 錯誤代碼 20-201-04
|
// 錯誤代碼 20-201-04
|
||||||
e := domain.ThirdPartyErrorL(
|
e := errs.ThirdPartyErrorL(
|
||||||
code.CloudEPNotification,
|
code.CloudEPNotification,
|
||||||
domain.FailedToSendSMSErrorCode,
|
domain.FailedToSendSMSErrorCode,
|
||||||
logx.WithContext(ctx),
|
logx.WithContext(ctx),
|
||||||
|
|
|
@ -1,75 +1,342 @@
|
||||||
package usecase
|
package usecase
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"backend/pkg/notification/config"
|
||||||
|
"backend/pkg/notification/domain"
|
||||||
|
"backend/pkg/notification/domain/entity"
|
||||||
"backend/pkg/notification/domain/repository"
|
"backend/pkg/notification/domain/repository"
|
||||||
"backend/pkg/notification/domain/usecase"
|
"backend/pkg/notification/domain/usecase"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"backend/pkg/library/errs"
|
||||||
|
"backend/pkg/library/errs/code"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeliveryUseCaseParam 傳送參數配置
|
// DeliveryUseCaseParam 傳送參數配置
|
||||||
type DeliveryUseCaseParam struct {
|
type DeliveryUseCaseParam struct {
|
||||||
SMSProviders []usecase.SMSProvider
|
SMSProviders []usecase.SMSProvider
|
||||||
EmailProviders []usecase.EmailProvider
|
EmailProviders []usecase.EmailProvider
|
||||||
|
DeliveryConfig config.DeliveryConfig
|
||||||
|
HistoryRepo repository.HistoryRepository // 可選的歷史記錄 repository
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeliveryUseCase 通知
|
// DeliveryUseCase 通知發送服務
|
||||||
type DeliveryUseCase struct {
|
type DeliveryUseCase struct {
|
||||||
param DeliveryUseCaseParam
|
param DeliveryUseCaseParam
|
||||||
}
|
}
|
||||||
|
|
||||||
func MustDeliveryUseCase(param DeliveryUseCaseParam) usecase.DeliveryUseCase {
|
func MustDeliveryUseCase(param DeliveryUseCaseParam) usecase.DeliveryUseCase {
|
||||||
|
// 設置默認配置
|
||||||
|
if param.DeliveryConfig.MaxRetries == 0 {
|
||||||
|
param.DeliveryConfig.MaxRetries = 3
|
||||||
|
}
|
||||||
|
if param.DeliveryConfig.InitialDelay == 0 {
|
||||||
|
param.DeliveryConfig.InitialDelay = 100 * time.Millisecond
|
||||||
|
}
|
||||||
|
if param.DeliveryConfig.BackoffFactor == 0 {
|
||||||
|
param.DeliveryConfig.BackoffFactor = 2.0
|
||||||
|
}
|
||||||
|
if param.DeliveryConfig.MaxDelay == 0 {
|
||||||
|
param.DeliveryConfig.MaxDelay = 30 * time.Second
|
||||||
|
}
|
||||||
|
if param.DeliveryConfig.Timeout == 0 {
|
||||||
|
param.DeliveryConfig.Timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
return &DeliveryUseCase{
|
return &DeliveryUseCase{
|
||||||
param: param,
|
param: param,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (use *DeliveryUseCase) SendMessage(ctx context.Context, req usecase.SMSMessageRequest) error {
|
func (use *DeliveryUseCase) SendMessage(ctx context.Context, req usecase.SMSMessageRequest) error {
|
||||||
var err error
|
// 創建歷史記錄
|
||||||
// 根據 Sort 欄位對 SMSProviders 進行排序
|
history := &entity.DeliveryHistory{
|
||||||
sort.Slice(use.param.SMSProviders, func(i, j int) bool {
|
ID: generateID(),
|
||||||
return use.param.SMSProviders[i].Sort < use.param.SMSProviders[j].Sort
|
Type: "sms",
|
||||||
})
|
Recipient: req.PhoneNumber,
|
||||||
|
Subject: "",
|
||||||
|
Content: req.MessageContent,
|
||||||
|
Status: entity.DeliveryStatusPending,
|
||||||
|
AttemptCount: 0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
newCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
if use.param.DeliveryConfig.EnableHistory && use.param.HistoryRepo != nil {
|
||||||
defer cancel()
|
if err := use.param.HistoryRepo.CreateHistory(ctx, history); err != nil {
|
||||||
|
logx.WithContext(ctx).Errorf("Failed to create SMS history: %v", err)
|
||||||
// 依序嘗試發送
|
|
||||||
for _, provider := range use.param.SMSProviders {
|
|
||||||
if err = provider.Repo.SendSMS(newCtx, repository.SMSMessageRequest{
|
|
||||||
PhoneNumber: req.PhoneNumber,
|
|
||||||
RecipientName: req.RecipientName,
|
|
||||||
MessageContent: req.MessageContent,
|
|
||||||
}); err == nil {
|
|
||||||
return nil // 發送成功
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
// 執行發送邏輯
|
||||||
|
return use.sendSMSWithRetry(ctx, req, history)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (use *DeliveryUseCase) SendEmail(ctx context.Context, req usecase.MailReq) error {
|
func (use *DeliveryUseCase) SendEmail(ctx context.Context, req usecase.MailReq) error {
|
||||||
var err error
|
// 創建歷史記錄
|
||||||
|
history := &entity.DeliveryHistory{
|
||||||
|
ID: generateID(),
|
||||||
|
Type: "email",
|
||||||
|
Recipient: fmt.Sprintf("%v", req.To),
|
||||||
|
Subject: req.Subject,
|
||||||
|
Content: req.Body,
|
||||||
|
Status: entity.DeliveryStatusPending,
|
||||||
|
AttemptCount: 0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if use.param.DeliveryConfig.EnableHistory && use.param.HistoryRepo != nil {
|
||||||
|
if err := use.param.HistoryRepo.CreateHistory(ctx, history); err != nil {
|
||||||
|
logx.WithContext(ctx).Errorf("Failed to create email history: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 執行發送邏輯
|
||||||
|
return use.sendEmailWithRetry(ctx, req, history)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendSMSWithRetry 發送 SMS 並實現重試機制
|
||||||
|
func (use *DeliveryUseCase) sendSMSWithRetry(ctx context.Context, req usecase.SMSMessageRequest, history *entity.DeliveryHistory) error {
|
||||||
// 根據 Sort 欄位對 SMSProviders 進行排序
|
// 根據 Sort 欄位對 SMSProviders 進行排序
|
||||||
sort.Slice(use.param.EmailProviders, func(i, j int) bool {
|
providers := make([]usecase.SMSProvider, len(use.param.SMSProviders))
|
||||||
return use.param.EmailProviders[i].Sort < use.param.EmailProviders[j].Sort
|
copy(providers, use.param.SMSProviders)
|
||||||
|
sort.Slice(providers, func(i, j int) bool {
|
||||||
|
return providers[i].Sort < providers[j].Sort
|
||||||
})
|
})
|
||||||
|
|
||||||
newCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
var lastErr error
|
||||||
defer cancel()
|
totalAttempts := 0
|
||||||
|
|
||||||
// 依序嘗試發送 dreq
|
// 嘗試所有 providers
|
||||||
for _, provider := range use.param.EmailProviders {
|
for providerIndex, provider := range providers {
|
||||||
if err = provider.Repo.SendMail(newCtx, repository.MailReq{
|
// 為每個 provider 嘗試發送
|
||||||
|
for attempt := 0; attempt < use.param.DeliveryConfig.MaxRetries; attempt++ {
|
||||||
|
totalAttempts++
|
||||||
|
|
||||||
|
// 更新歷史記錄狀態
|
||||||
|
history.Status = entity.DeliveryStatusSending
|
||||||
|
history.Provider = fmt.Sprintf("sms_provider_%d", providerIndex)
|
||||||
|
history.AttemptCount = totalAttempts
|
||||||
|
history.UpdatedAt = time.Now()
|
||||||
|
use.updateHistory(ctx, history)
|
||||||
|
|
||||||
|
// 記錄發送嘗試
|
||||||
|
attemptStart := time.Now()
|
||||||
|
|
||||||
|
// 創建帶超時的 context
|
||||||
|
sendCtx, cancel := context.WithTimeout(ctx, use.param.DeliveryConfig.Timeout)
|
||||||
|
|
||||||
|
err := provider.Repo.SendSMS(sendCtx, repository.SMSMessageRequest{
|
||||||
|
PhoneNumber: req.PhoneNumber,
|
||||||
|
RecipientName: req.RecipientName,
|
||||||
|
MessageContent: req.MessageContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// 記錄嘗試結果
|
||||||
|
attemptDuration := time.Since(attemptStart)
|
||||||
|
attemptRecord := entity.DeliveryAttempt{
|
||||||
|
Provider: history.Provider,
|
||||||
|
AttemptAt: attemptStart,
|
||||||
|
Success: err == nil,
|
||||||
|
ErrorMessage: "",
|
||||||
|
Duration: attemptDuration.Milliseconds(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
attemptRecord.ErrorMessage = err.Error()
|
||||||
|
lastErr = err
|
||||||
|
|
||||||
|
logx.WithContext(ctx).Errorf("SMS send attempt %d failed for provider %d: %v",
|
||||||
|
attempt+1, providerIndex, err)
|
||||||
|
|
||||||
|
// 如果不是最後一次嘗試,等待後重試
|
||||||
|
if attempt < use.param.DeliveryConfig.MaxRetries-1 {
|
||||||
|
delay := use.calculateDelay(attempt)
|
||||||
|
history.Status = entity.DeliveryStatusRetrying
|
||||||
|
use.updateHistory(ctx, history)
|
||||||
|
use.addAttemptRecord(ctx, history.ID, attemptRecord)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(delay):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 發送成功
|
||||||
|
history.Status = entity.DeliveryStatusSuccess
|
||||||
|
history.UpdatedAt = time.Now()
|
||||||
|
now := time.Now()
|
||||||
|
history.CompletedAt = &now
|
||||||
|
use.updateHistory(ctx, history)
|
||||||
|
use.addAttemptRecord(ctx, history.ID, attemptRecord)
|
||||||
|
|
||||||
|
logx.WithContext(ctx).Infof("SMS sent successfully after %d attempts", totalAttempts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
use.addAttemptRecord(ctx, history.ID, attemptRecord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有 providers 都失敗了
|
||||||
|
history.Status = entity.DeliveryStatusFailed
|
||||||
|
history.ErrorMessage = fmt.Sprintf("All providers failed. Last error: %v", lastErr)
|
||||||
|
history.UpdatedAt = time.Now()
|
||||||
|
now := time.Now()
|
||||||
|
history.CompletedAt = &now
|
||||||
|
use.updateHistory(ctx, history)
|
||||||
|
|
||||||
|
return errs.ThirdPartyError(
|
||||||
|
code.CloudEPNotification,
|
||||||
|
domain.FailedToSendSMSErrorCode,
|
||||||
|
fmt.Sprintf("Failed to send SMS after %d attempts across %d providers",
|
||||||
|
totalAttempts, len(providers)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendEmailWithRetry 發送 Email 並實現重試機制
|
||||||
|
func (use *DeliveryUseCase) sendEmailWithRetry(ctx context.Context, req usecase.MailReq, history *entity.DeliveryHistory) error {
|
||||||
|
// 根據 Sort 欄位對 EmailProviders 進行排序
|
||||||
|
providers := make([]usecase.EmailProvider, len(use.param.EmailProviders))
|
||||||
|
copy(providers, use.param.EmailProviders)
|
||||||
|
sort.Slice(providers, func(i, j int) bool {
|
||||||
|
return providers[i].Sort < providers[j].Sort
|
||||||
|
})
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
totalAttempts := 0
|
||||||
|
|
||||||
|
// 嘗試所有 providers
|
||||||
|
for providerIndex, provider := range providers {
|
||||||
|
// 為每個 provider 嘗試發送
|
||||||
|
for attempt := 0; attempt < use.param.DeliveryConfig.MaxRetries; attempt++ {
|
||||||
|
totalAttempts++
|
||||||
|
|
||||||
|
// 更新歷史記錄狀態
|
||||||
|
history.Status = entity.DeliveryStatusSending
|
||||||
|
history.Provider = fmt.Sprintf("email_provider_%d", providerIndex)
|
||||||
|
history.AttemptCount = totalAttempts
|
||||||
|
history.UpdatedAt = time.Now()
|
||||||
|
use.updateHistory(ctx, history)
|
||||||
|
|
||||||
|
// 記錄發送嘗試
|
||||||
|
attemptStart := time.Now()
|
||||||
|
|
||||||
|
// 創建帶超時的 context
|
||||||
|
sendCtx, cancel := context.WithTimeout(ctx, use.param.DeliveryConfig.Timeout)
|
||||||
|
|
||||||
|
err := provider.Repo.SendMail(sendCtx, repository.MailReq{
|
||||||
From: req.From,
|
From: req.From,
|
||||||
To: req.To,
|
To: req.To,
|
||||||
Subject: req.Subject,
|
Subject: req.Subject,
|
||||||
Body: req.Body,
|
Body: req.Body,
|
||||||
}); err == nil {
|
})
|
||||||
return nil // 發送成功
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
// 記錄嘗試結果
|
||||||
|
attemptDuration := time.Since(attemptStart)
|
||||||
|
attemptRecord := entity.DeliveryAttempt{
|
||||||
|
Provider: history.Provider,
|
||||||
|
AttemptAt: attemptStart,
|
||||||
|
Success: err == nil,
|
||||||
|
ErrorMessage: "",
|
||||||
|
Duration: attemptDuration.Milliseconds(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
attemptRecord.ErrorMessage = err.Error()
|
||||||
|
lastErr = err
|
||||||
|
|
||||||
|
logx.WithContext(ctx).Errorf("Email send attempt %d failed for provider %d: %v",
|
||||||
|
attempt+1, providerIndex, err)
|
||||||
|
|
||||||
|
// 如果不是最後一次嘗試,等待後重試
|
||||||
|
if attempt < use.param.DeliveryConfig.MaxRetries-1 {
|
||||||
|
delay := use.calculateDelay(attempt)
|
||||||
|
history.Status = entity.DeliveryStatusRetrying
|
||||||
|
use.updateHistory(ctx, history)
|
||||||
|
use.addAttemptRecord(ctx, history.ID, attemptRecord)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(delay):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 發送成功
|
||||||
|
history.Status = entity.DeliveryStatusSuccess
|
||||||
|
history.UpdatedAt = time.Now()
|
||||||
|
now := time.Now()
|
||||||
|
history.CompletedAt = &now
|
||||||
|
use.updateHistory(ctx, history)
|
||||||
|
use.addAttemptRecord(ctx, history.ID, attemptRecord)
|
||||||
|
|
||||||
|
logx.WithContext(ctx).Infof("Email sent successfully after %d attempts", totalAttempts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
use.addAttemptRecord(ctx, history.ID, attemptRecord)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
// 所有 providers 都失敗了
|
||||||
|
history.Status = entity.DeliveryStatusFailed
|
||||||
|
history.ErrorMessage = fmt.Sprintf("All providers failed. Last error: %v", lastErr)
|
||||||
|
history.UpdatedAt = time.Now()
|
||||||
|
now := time.Now()
|
||||||
|
history.CompletedAt = &now
|
||||||
|
use.updateHistory(ctx, history)
|
||||||
|
|
||||||
|
return errs.ThirdPartyError(
|
||||||
|
code.CloudEPNotification,
|
||||||
|
domain.FailedToSendEmailErrorCode,
|
||||||
|
fmt.Sprintf("Failed to send email after %d attempts across %d providers",
|
||||||
|
totalAttempts, len(providers)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateDelay 計算指數退避延遲
|
||||||
|
func (use *DeliveryUseCase) calculateDelay(attempt int) time.Duration {
|
||||||
|
delay := float64(use.param.DeliveryConfig.InitialDelay) * math.Pow(use.param.DeliveryConfig.BackoffFactor, float64(attempt))
|
||||||
|
|
||||||
|
if delay > float64(use.param.DeliveryConfig.MaxDelay) {
|
||||||
|
delay = float64(use.param.DeliveryConfig.MaxDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Duration(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateHistory 更新歷史記錄
|
||||||
|
func (use *DeliveryUseCase) updateHistory(ctx context.Context, history *entity.DeliveryHistory) {
|
||||||
|
if use.param.DeliveryConfig.EnableHistory && use.param.HistoryRepo != nil {
|
||||||
|
if err := use.param.HistoryRepo.UpdateHistory(ctx, history); err != nil {
|
||||||
|
logx.WithContext(ctx).Errorf("Failed to update delivery history: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addAttemptRecord 添加發送嘗試記錄
|
||||||
|
func (use *DeliveryUseCase) addAttemptRecord(ctx context.Context, historyID string, attempt entity.DeliveryAttempt) {
|
||||||
|
if use.param.DeliveryConfig.EnableHistory && use.param.HistoryRepo != nil {
|
||||||
|
if err := use.param.HistoryRepo.AddAttempt(ctx, historyID, attempt); err != nil {
|
||||||
|
logx.WithContext(ctx).Errorf("Failed to add attempt record: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateID 生成唯一 ID (簡單實現,實際應該使用更好的 ID 生成器)
|
||||||
|
func generateID() string {
|
||||||
|
return fmt.Sprintf("delivery_%d", time.Now().UnixNano())
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,27 @@
|
||||||
package usecase
|
package usecase
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"backend/pkg/notification/domain"
|
||||||
|
"backend/pkg/notification/domain/entity"
|
||||||
|
"backend/pkg/notification/domain/repository"
|
||||||
"backend/pkg/notification/domain/template"
|
"backend/pkg/notification/domain/template"
|
||||||
"backend/pkg/notification/domain/usecase"
|
"backend/pkg/notification/domain/usecase"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"backend/pkg/library/errs"
|
||||||
|
"backend/pkg/library/errs/code"
|
||||||
|
|
||||||
|
"github.com/zeromicro/go-zero/core/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
type TemplateUseCaseParam struct{}
|
type TemplateUseCaseParam struct {
|
||||||
|
TemplateRepo repository.TemplateRepository // 可選的資料庫模板 repository
|
||||||
|
}
|
||||||
|
|
||||||
type TemplateUseCase struct {
|
type TemplateUseCase struct {
|
||||||
TemplateUseCaseParam
|
param TemplateUseCaseParam
|
||||||
}
|
}
|
||||||
|
|
||||||
func MustTemplateUseCase(param TemplateUseCaseParam) usecase.TemplateUseCase {
|
func MustTemplateUseCase(param TemplateUseCaseParam) usecase.TemplateUseCase {
|
||||||
|
@ -19,25 +30,136 @@ func MustTemplateUseCase(param TemplateUseCaseParam) usecase.TemplateUseCase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetEmailTemplateByStatic 從靜態模板獲取郵件模板
|
||||||
func (use *TemplateUseCase) GetEmailTemplateByStatic(_ context.Context, language template.Language, templateID template.Type) (template.EmailTemplate, error) {
|
func (use *TemplateUseCase) GetEmailTemplateByStatic(_ context.Context, language template.Language, templateID template.Type) (template.EmailTemplate, error) {
|
||||||
// 查找指定語言的模板映射
|
// 查找指定語言的模板映射
|
||||||
templateByLang, exists := template.EmailTemplateMap[language]
|
templateByLang, exists := template.EmailTemplateMap[language]
|
||||||
if !exists {
|
if !exists {
|
||||||
return template.EmailTemplate{}, fmt.Errorf("email template not found for language: %s", language)
|
return template.EmailTemplate{}, errs.ResourceNotFoundWithScope(
|
||||||
|
code.CloudEPNotification,
|
||||||
|
domain.FailedToGetTemplateErrorCode,
|
||||||
|
fmt.Sprintf("email template not found for language: %s", language))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找指定類型的模板生成函數
|
// 查找指定類型的模板生成函數
|
||||||
templateFunc, exists := templateByLang[templateID]
|
templateFunc, exists := templateByLang[templateID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return template.EmailTemplate{}, fmt.Errorf("email template not found for type ID: %s", templateID)
|
return template.EmailTemplate{}, errs.ResourceNotFoundWithScope(
|
||||||
|
code.CloudEPNotification,
|
||||||
|
domain.FailedToGetTemplateErrorCode,
|
||||||
|
fmt.Sprintf("email template not found for type ID: %s", templateID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 執行模板生成函數
|
// 執行模板生成函數
|
||||||
tmp, err := templateFunc()
|
tmp, err := templateFunc()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return template.EmailTemplate{}, fmt.Errorf("error generating email template: %w", err)
|
return template.EmailTemplate{}, errs.DatabaseErrorWithScope(
|
||||||
|
code.CloudEPNotification,
|
||||||
|
domain.FailedToGetTemplateErrorCode,
|
||||||
|
fmt.Sprintf("error generating email template: %v", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回構建好的響應
|
|
||||||
return tmp, nil
|
return tmp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetEmailTemplate 獲取郵件模板(優先從資料庫,回退到靜態模板)
|
||||||
|
func (use *TemplateUseCase) GetEmailTemplate(ctx context.Context, language template.Language, templateID template.Type) (template.EmailTemplate, error) {
|
||||||
|
// 1. 嘗試從資料庫獲取模板
|
||||||
|
if use.param.TemplateRepo != nil {
|
||||||
|
dbTemplate, err := use.param.TemplateRepo.GetTemplate(ctx, "email", string(language), string(templateID))
|
||||||
|
if err == nil && dbTemplate != nil && dbTemplate.IsActive {
|
||||||
|
logx.WithContext(ctx).Infof("Using database template for %s/%s", language, templateID)
|
||||||
|
return template.EmailTemplate{
|
||||||
|
Title: dbTemplate.Subject,
|
||||||
|
Body: dbTemplate.Body,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 記錄資料庫查詢失敗,但不返回錯誤,繼續使用靜態模板
|
||||||
|
if err != nil {
|
||||||
|
logx.WithContext(ctx).WithFields(logx.LogField{Key: "error", Value: err.Error()}).Error("Failed to get template from database, falling back to static")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 回退到靜態模板
|
||||||
|
logx.WithContext(ctx).Infof("Using static template for %s/%s", language, templateID)
|
||||||
|
return use.GetEmailTemplateByStatic(ctx, language, templateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSMSTemplate 獲取 SMS 模板(優先從資料庫,回退到靜態模板)
|
||||||
|
func (use *TemplateUseCase) GetSMSTemplate(ctx context.Context, language template.Language, templateID template.Type) (usecase.SMSTemplateResp, error) {
|
||||||
|
// 1. 嘗試從資料庫獲取模板
|
||||||
|
if use.param.TemplateRepo != nil {
|
||||||
|
dbTemplate, err := use.param.TemplateRepo.GetTemplate(ctx, "sms", string(language), string(templateID))
|
||||||
|
if err == nil && dbTemplate != nil && dbTemplate.IsActive {
|
||||||
|
logx.WithContext(ctx).Infof("Using database SMS template for %s/%s", language, templateID)
|
||||||
|
return usecase.SMSTemplateResp{
|
||||||
|
Body: dbTemplate.Body,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 記錄資料庫查詢失敗,但不返回錯誤,繼續使用靜態模板
|
||||||
|
if err != nil {
|
||||||
|
logx.WithContext(ctx).WithFields(logx.LogField{Key: "error", Value: err.Error()}).Error("Failed to get SMS template from database, falling back to static")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 回退到靜態模板(SMS 暫時沒有靜態模板,返回默認)
|
||||||
|
logx.WithContext(ctx).Infof("Using default SMS template for %s/%s", language, templateID)
|
||||||
|
return use.getDefaultSMSTemplate(templateID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderEmailTemplate 渲染郵件模板(替換變數)
|
||||||
|
func (use *TemplateUseCase) RenderEmailTemplate(ctx context.Context, tmpl template.EmailTemplate, params entity.TemplateParams) (usecase.EmailTemplateResp, error) {
|
||||||
|
renderedSubject := use.renderTemplate(tmpl.Title, params)
|
||||||
|
renderedBody := use.renderTemplate(tmpl.Body, params)
|
||||||
|
|
||||||
|
return usecase.EmailTemplateResp{
|
||||||
|
Subject: renderedSubject,
|
||||||
|
Body: renderedBody,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderSMSTemplate 渲染 SMS 模板(替換變數)
|
||||||
|
func (use *TemplateUseCase) RenderSMSTemplate(ctx context.Context, tmpl usecase.SMSTemplateResp, params entity.TemplateParams) (usecase.SMSTemplateResp, error) {
|
||||||
|
renderedBody := use.renderTemplate(tmpl.Body, params)
|
||||||
|
|
||||||
|
return usecase.SMSTemplateResp{
|
||||||
|
Body: renderedBody,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTemplate 渲染模板內容(簡單的字符串替換)
|
||||||
|
func (use *TemplateUseCase) renderTemplate(content string, params entity.TemplateParams) string {
|
||||||
|
result := content
|
||||||
|
|
||||||
|
// 替換基本參數
|
||||||
|
result = strings.ReplaceAll(result, "{{.Username}}", params.Username)
|
||||||
|
result = strings.ReplaceAll(result, "{{.VerifyCode}}", params.VerifyCode)
|
||||||
|
|
||||||
|
// 替換額外參數
|
||||||
|
for key, value := range params.Extra {
|
||||||
|
placeholder := fmt.Sprintf("{{.%s}}", key)
|
||||||
|
result = strings.ReplaceAll(result, placeholder, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDefaultSMSTemplate 獲取默認 SMS 模板
|
||||||
|
func (use *TemplateUseCase) getDefaultSMSTemplate(templateID template.Type) usecase.SMSTemplateResp {
|
||||||
|
switch templateID {
|
||||||
|
case template.ForgetPasswordVerify:
|
||||||
|
return usecase.SMSTemplateResp{
|
||||||
|
Body: "您的密碼重設驗證碼是:{{.VerifyCode}},請在5分鐘內使用。如非本人操作請忽略此訊息。",
|
||||||
|
}
|
||||||
|
case template.BindingEmail:
|
||||||
|
return usecase.SMSTemplateResp{
|
||||||
|
Body: "您的綁定驗證碼是:{{.VerifyCode}},請在5分鐘內使用。如非本人操作請忽略此訊息。",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return usecase.SMSTemplateResp{
|
||||||
|
Body: "您的驗證碼是:{{.VerifyCode}},請在5分鐘內使用。",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue