diff --git a/pkg/notification/config/config.go b/pkg/notification/config/config.go index 054b66a..efcdec4 100644 --- a/pkg/notification/config/config.go +++ b/pkg/notification/config/config.go @@ -1,5 +1,7 @@ package config +import "time" + type SMTPConfig struct { Enable bool Sort int @@ -32,3 +34,13 @@ type MitakeSMSSender struct { User 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"` // 是否啟用歷史記錄 +} diff --git a/pkg/notification/domain/entity/delivery_history.go b/pkg/notification/domain/entity/delivery_history.go new file mode 100644 index 0000000..34453a1 --- /dev/null +++ b/pkg/notification/domain/entity/delivery_history.go @@ -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"` // 執行時間(毫秒) +} diff --git a/pkg/notification/domain/entity/template.go b/pkg/notification/domain/entity/template.go new file mode 100644 index 0000000..aee5c5e --- /dev/null +++ b/pkg/notification/domain/entity/template.go @@ -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"` // 額外參數 +} diff --git a/pkg/notification/domain/error.go b/pkg/notification/domain/error.go index 37b5200..d0c942d 100644 --- a/pkg/notification/domain/error.go +++ b/pkg/notification/domain/error.go @@ -1,29 +1,13 @@ package domain -import ( - "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 -} +import "backend/pkg/library/errs" +// Notification Error Codes const ( - NotificationErrorCode = 1 + iota + NotificationErrorCode errs.ErrorCode = 1 + iota FailedToSendEmailErrorCode FailedToSendSMSErrorCode + FailedToGetTemplateErrorCode + FailedToSaveHistoryErrorCode + FailedToRetryDeliveryErrorCode ) diff --git a/pkg/notification/domain/repository/history.go b/pkg/notification/domain/repository/history.go new file mode 100644 index 0000000..8abd306 --- /dev/null +++ b/pkg/notification/domain/repository/history.go @@ -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"` // 偏移量 +} diff --git a/pkg/notification/domain/repository/template.go b/pkg/notification/domain/repository/template.go new file mode 100644 index 0000000..907c4ed --- /dev/null +++ b/pkg/notification/domain/repository/template.go @@ -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 +} diff --git a/pkg/notification/domain/usecase/delivary.go b/pkg/notification/domain/usecase/delivery.go similarity index 100% rename from pkg/notification/domain/usecase/delivary.go rename to pkg/notification/domain/usecase/delivery.go diff --git a/pkg/notification/repository/aws_ses_mailer.go b/pkg/notification/repository/aws_ses_mailer.go index d6782bc..be072ba 100644 --- a/pkg/notification/repository/aws_ses_mailer.go +++ b/pkg/notification/repository/aws_ses_mailer.go @@ -7,6 +7,7 @@ import ( "context" "time" + "backend/pkg/library/errs" "backend/pkg/library/errs/code" pool "backend/pkg/library/worker_pool" @@ -79,7 +80,7 @@ func (use *AwsEmailDeliveryRepository) SendMail(ctx context.Context, req reposit //nolint:contextcheck if _, err := use.Client.SendEmail(newCtx, input); err != nil { - _ = domain.ThirdPartyErrorL( + _ = errs.ThirdPartyErrorL( code.CloudEPNotification, domain.FailedToSendEmailErrorCode, logx.WithContext(ctx), @@ -92,7 +93,7 @@ func (use *AwsEmailDeliveryRepository) SendMail(ctx context.Context, req reposit } }) if err != nil { - e := domain.ThirdPartyErrorL( + e := errs.ThirdPartyErrorL( code.CloudEPNotification, domain.FailedToSendEmailErrorCode, logx.WithContext(ctx), diff --git a/pkg/notification/repository/mitake_sms_sender.go b/pkg/notification/repository/mitake_sms_sender.go index 1a36127..a8d1fdc 100644 --- a/pkg/notification/repository/mitake_sms_sender.go +++ b/pkg/notification/repository/mitake_sms_sender.go @@ -6,6 +6,7 @@ import ( "backend/pkg/notification/domain/repository" "context" + "backend/pkg/library/errs" "backend/pkg/library/errs/code" pool "backend/pkg/library/worker_pool" @@ -39,7 +40,7 @@ func (use *MitakeSMSDeliveryRepository) SendSMS(ctx context.Context, req reposit if err != nil { // 錯誤代碼 20-201-04 - e := domain.ThirdPartyErrorL( + e := errs.ThirdPartyErrorL( code.CloudEPNotification, domain.FailedToSendSMSErrorCode, logx.WithContext(ctx), diff --git a/pkg/notification/usecase/delivery.go b/pkg/notification/usecase/delivery.go index f48115a..165bf4f 100644 --- a/pkg/notification/usecase/delivery.go +++ b/pkg/notification/usecase/delivery.go @@ -1,75 +1,342 @@ package usecase import ( + "backend/pkg/notification/config" + "backend/pkg/notification/domain" + "backend/pkg/notification/domain/entity" "backend/pkg/notification/domain/repository" "backend/pkg/notification/domain/usecase" "context" + "fmt" + "math" "sort" "time" + + "backend/pkg/library/errs" + "backend/pkg/library/errs/code" + + "github.com/zeromicro/go-zero/core/logx" ) // DeliveryUseCaseParam 傳送參數配置 type DeliveryUseCaseParam struct { SMSProviders []usecase.SMSProvider EmailProviders []usecase.EmailProvider + DeliveryConfig config.DeliveryConfig + HistoryRepo repository.HistoryRepository // 可選的歷史記錄 repository } -// DeliveryUseCase 通知 +// DeliveryUseCase 通知發送服務 type DeliveryUseCase struct { param DeliveryUseCaseParam } 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{ param: param, } } func (use *DeliveryUseCase) SendMessage(ctx context.Context, req usecase.SMSMessageRequest) error { - var err error - // 根據 Sort 欄位對 SMSProviders 進行排序 - sort.Slice(use.param.SMSProviders, func(i, j int) bool { - return use.param.SMSProviders[i].Sort < use.param.SMSProviders[j].Sort - }) + // 創建歷史記錄 + history := &entity.DeliveryHistory{ + ID: generateID(), + 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) - defer cancel() - - // 依序嘗試發送 - 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 // 發送成功 + 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 SMS history: %v", err) } } - return err + // 執行發送邏輯 + return use.sendSMSWithRetry(ctx, req, history) } func (use *DeliveryUseCase) SendEmail(ctx context.Context, req usecase.MailReq) error { - var err error - // 根據 Sort 欄位對 SMSProviders 進行排序 - sort.Slice(use.param.EmailProviders, func(i, j int) bool { - return use.param.EmailProviders[i].Sort < use.param.EmailProviders[j].Sort - }) + // 創建歷史記錄 + 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(), + } - newCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - // 依序嘗試發送 dreq - for _, provider := range use.param.EmailProviders { - if err = provider.Repo.SendMail(newCtx, repository.MailReq{ - From: req.From, - To: req.To, - Subject: req.Subject, - Body: req.Body, - }); err == nil { - return nil // 發送成功 + 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 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 進行排序 + providers := make([]usecase.SMSProvider, len(use.param.SMSProviders)) + copy(providers, use.param.SMSProviders) + 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("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, + To: req.To, + Subject: req.Subject, + Body: req.Body, + }) + + 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) + } + } + + // 所有 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()) } diff --git a/pkg/notification/usecase/template.go b/pkg/notification/usecase/template.go index e980748..6352164 100644 --- a/pkg/notification/usecase/template.go +++ b/pkg/notification/usecase/template.go @@ -1,16 +1,27 @@ package usecase import ( + "backend/pkg/notification/domain" + "backend/pkg/notification/domain/entity" + "backend/pkg/notification/domain/repository" "backend/pkg/notification/domain/template" "backend/pkg/notification/domain/usecase" "context" "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 { - TemplateUseCaseParam + param TemplateUseCaseParam } 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) { // 查找指定語言的模板映射 templateByLang, exists := template.EmailTemplateMap[language] 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] 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() 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 } + +// 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分鐘內使用。", + } + } +}