From 2e869e0c8576fd9356afa0eba4361b902a1a3bd1 Mon Sep 17 00:00:00 2001 From: admin <1297598740@qq.com> Date: Mon, 2 Feb 2026 11:04:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=E5=BE=AA=E7=8E=AF?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E7=AE=A1=E7=90=86=E3=80=81=E9=82=AE=E4=BB=B6?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=92=8C=E7=94=A8=E6=88=B7=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config.go | 14 +++++ internal/handler/settings_handler.go | 6 +- internal/models/constants.go | 9 +++ internal/models/user_settings.go | 4 +- internal/router/router.go | 6 +- internal/service/email_service.go | 59 +++++++++++++++++++ .../service/recurring_transaction_service.go | 33 +++++++++-- internal/service/user_settings_service.go | 26 ++++++-- 8 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 internal/models/constants.go create mode 100644 internal/service/email_service.go diff --git a/internal/config/config.go b/internal/config/config.go index 663fa6d..84f402c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,13 @@ type Config struct { AliyunAccessKeySecret string AliyunSignName string AliyunTemplateCode string + + // SMTP Configuration (Email) + SMTPHost string + SMTPPort int + SMTPUser string + SMTPPassword string + SMTPFrom string } // Load loads configuration from environment variables @@ -143,6 +150,13 @@ func Load() *Config { AliyunAccessKeySecret: getEnv("ALIYUN_ACCESS_KEY_SECRET", ""), AliyunSignName: getEnv("ALIYUN_SIGN_NAME", ""), AliyunTemplateCode: getEnv("ALIYUN_TEMPLATE_CODE", ""), + + // SMTP + SMTPHost: getEnv("SMTP_HOST", ""), + SMTPPort: getEnvInt("SMTP_PORT", 587), + SMTPUser: getEnv("SMTP_USER", ""), + SMTPPassword: getEnv("SMTP_PASSWORD", ""), + SMTPFrom: getEnv("SMTP_FROM", ""), } // Ensure data directory exists diff --git a/internal/handler/settings_handler.go b/internal/handler/settings_handler.go index 799da45..96bd7fb 100644 --- a/internal/handler/settings_handler.go +++ b/internal/handler/settings_handler.go @@ -3,8 +3,8 @@ package handler import ( "errors" - "accounting-app/pkg/api" "accounting-app/internal/service" + "accounting-app/pkg/api" "github.com/gin-gonic/gin" ) @@ -70,6 +70,10 @@ func (h *SettingsHandler) UpdateSettings(c *gin.Context) { api.BadRequest(c, "Invalid image compression, must be one of: low, medium, high") return } + if errors.Is(err, service.ErrInvalidNotificationChannel) { + api.BadRequest(c, "Invalid notification channel") + return + } api.InternalError(c, "Failed to update settings: "+err.Error()) return } diff --git a/internal/models/constants.go b/internal/models/constants.go new file mode 100644 index 0000000..b095562 --- /dev/null +++ b/internal/models/constants.go @@ -0,0 +1,9 @@ +package models + +// Notification Channel Constants +const ( + NotificationChannelSMS = "sms" + NotificationChannelEmail = "email" + NotificationChannelBoth = "both" + NotificationChannelNone = "none" +) diff --git a/internal/models/user_settings.go b/internal/models/user_settings.go index 45eff4e..a2a0848 100644 --- a/internal/models/user_settings.go +++ b/internal/models/user_settings.go @@ -23,7 +23,9 @@ type UserSettings struct { DefaultExpenseAccountID *uint `gorm:"index" json:"default_expense_account_id,omitempty"` DefaultIncomeAccountID *uint `gorm:"index" json:"default_income_account_id,omitempty"` - Phone string `gorm:"size:20" json:"phone,omitempty"` // For SMS notifications + Phone string `gorm:"size:20" json:"phone,omitempty"` // For SMS notifications + Email string `gorm:"size:100" json:"email,omitempty"` + NotificationChannel string `gorm:"size:20;default:'sms'" json:"notification_channel"` // sms, email, both, none CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/router/router.go b/internal/router/router.go index 419b429..4bab3b3 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -76,7 +76,8 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) transactionService := service.NewTransactionService(transactionRepo, accountRepo, categoryRepo, tagRepo, ledgerRepo, db) imageService := service.NewImageService(transactionImageRepo, transactionRepo, db, cfg.ImageUploadDir) smsService := service.NewSmsService(cfg) - recurringService := service.NewRecurringTransactionService(recurringRepo, transactionRepo, accountRepo, categoryRepo, allocationRuleRepo, allocationRecordRepo, piggyBankRepo, userSettingsRepo, smsService, db) + emailService := service.NewEmailService(cfg) + recurringService := service.NewRecurringTransactionService(recurringRepo, transactionRepo, accountRepo, categoryRepo, allocationRuleRepo, allocationRecordRepo, piggyBankRepo, userSettingsRepo, smsService, emailService, db) exchangeRateService := service.NewExchangeRateService(exchangeRateRepo) reportService := service.NewReportService(reportRepo, exchangeRateRepo) pdfExportService := service.NewPDFExportService(reportRepo, transactionRepo, exchangeRateRepo) @@ -357,7 +358,8 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient transactionService := service.NewTransactionService(transactionRepo, accountRepo, categoryRepo, tagRepo, ledgerRepo, db) imageService := service.NewImageService(transactionImageRepo, transactionRepo, db, cfg.ImageUploadDir) smsService := service.NewSmsService(cfg) - recurringService := service.NewRecurringTransactionService(recurringRepo, transactionRepo, accountRepo, categoryRepo, allocationRuleRepo, allocationRecordRepo, piggyBankRepo, userSettingsRepo, smsService, db) + emailService := service.NewEmailService(cfg) + recurringService := service.NewRecurringTransactionService(recurringRepo, transactionRepo, accountRepo, categoryRepo, allocationRuleRepo, allocationRecordRepo, piggyBankRepo, userSettingsRepo, smsService, emailService, db) reportService := service.NewReportService(reportRepo, exchangeRateRepo) pdfExportService := service.NewPDFExportService(reportRepo, transactionRepo, exchangeRateRepo) excelExportService := service.NewExcelExportService(reportRepo, transactionRepo, exchangeRateRepo) diff --git a/internal/service/email_service.go b/internal/service/email_service.go new file mode 100644 index 0000000..156db0a --- /dev/null +++ b/internal/service/email_service.go @@ -0,0 +1,59 @@ +package service + +import ( + "accounting-app/internal/config" + "fmt" + "net/smtp" +) + +// EmailService handles email notifications +type EmailService struct { + host string + port int + username string + password string + from string + enabled bool +} + +// NewEmailService creates a new EmailService instance +func NewEmailService(cfg *config.Config) *EmailService { + if cfg.SMTPHost == "" || cfg.SMTPUser == "" { + return &EmailService{enabled: false} + } + return &EmailService{ + host: cfg.SMTPHost, + port: cfg.SMTPPort, + username: cfg.SMTPUser, + password: cfg.SMTPPassword, + from: cfg.SMTPFrom, + enabled: true, + } +} + +// SendAllocationNotification sends an email notification for auto allocation +func (s *EmailService) SendAllocationNotification(to string, amount float64, ruleName string) error { + if !s.enabled || to == "" { + return nil + } + + subject := "Auto Allocation Executed" + body := fmt.Sprintf("Your automatic allocation rule '%s' has been executed.\nAmount: %.2f", ruleName, amount) + + // Simple text email + msg := []byte(fmt.Sprintf("To: %s\r\n"+ + "From: %s\r\n"+ + "Subject: %s\r\n"+ + "Content-Type: text/plain; charset=UTF-8\r\n"+ + "\r\n"+ + "%s\r\n", to, s.from, subject, body)) + + auth := smtp.PlainAuth("", s.username, s.password, s.host) + addr := fmt.Sprintf("%s:%d", s.host, s.port) + + if err := smtp.SendMail(addr, auth, s.from, []string{to}, msg); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + return nil +} diff --git a/internal/service/recurring_transaction_service.go b/internal/service/recurring_transaction_service.go index 828855e..7512822 100644 --- a/internal/service/recurring_transaction_service.go +++ b/internal/service/recurring_transaction_service.go @@ -23,6 +23,7 @@ type RecurringTransactionService struct { piggyBankRepo *repository.PiggyBankRepository userSettingsRepo *repository.UserSettingsRepository smsService *SmsService + emailService *EmailService db *gorm.DB } @@ -37,6 +38,7 @@ func NewRecurringTransactionService( piggyBankRepo *repository.PiggyBankRepository, userSettingsRepo *repository.UserSettingsRepository, smsService *SmsService, + emailService *EmailService, db *gorm.DB, ) *RecurringTransactionService { return &RecurringTransactionService{ @@ -49,6 +51,7 @@ func NewRecurringTransactionService( piggyBankRepo: piggyBankRepo, userSettingsRepo: userSettingsRepo, smsService: smsService, + emailService: emailService, db: db, } } @@ -384,24 +387,46 @@ func (s *RecurringTransactionService) ProcessDueTransactions(userID uint, now ti result.Transactions = append(result.Transactions, transaction) } - // Send SMS notifications for auto-executed allocations + // Send notifications for auto-executed allocations for _, allocation := range result.Allocations { if allocation.AutoExecuted { - // Get user phone number + // Get user settings userSettings, err := s.userSettingsRepo.GetOrCreate(userID) if err != nil { // Log error but don't fail the request - fmt.Printf("Failed to get user settings for SMS: %v\n", err) + fmt.Printf("Failed to get user settings for notification: %v\n", err) continue } - if userSettings != nil && userSettings.Phone != "" && s.smsService != nil { + if userSettings == nil { + continue + } + + // Determine notification channel + channel := userSettings.NotificationChannel + if channel == "" { + channel = models.NotificationChannelSMS // Default to SMS + } + + // Send SMS + if (channel == models.NotificationChannelSMS || channel == models.NotificationChannelBoth) && + userSettings.Phone != "" && s.smsService != nil { go func(phone string, amount float64, ruleName string) { if err := s.smsService.SendAllocationNotification(phone, amount, ruleName); err != nil { fmt.Printf("Failed to send allocation SMS: %v\n", err) } }(userSettings.Phone, allocation.TotalAmount, allocation.RuleName) } + + // Send Email + if (channel == models.NotificationChannelEmail || channel == models.NotificationChannelBoth) && + userSettings.Email != "" && s.emailService != nil { + go func(email string, amount float64, ruleName string) { + if err := s.emailService.SendAllocationNotification(email, amount, ruleName); err != nil { + fmt.Printf("Failed to send allocation Email: %v\n", err) + } + }(userSettings.Email, allocation.TotalAmount, allocation.RuleName) + } } } diff --git a/internal/service/user_settings_service.go b/internal/service/user_settings_service.go index cdbae8d..c9e2dbd 100644 --- a/internal/service/user_settings_service.go +++ b/internal/service/user_settings_service.go @@ -9,10 +9,11 @@ import ( // Service layer errors for user settings var ( - ErrInvalidIconLayout = errors.New("invalid icon layout, must be one of: four, five, six") - ErrInvalidImageCompression = errors.New("invalid image compression, must be one of: low, medium, high") - ErrDefaultAccountNotFound = errors.New("default account not found") - ErrInvalidDefaultAccount = errors.New("invalid default account") + ErrInvalidIconLayout = errors.New("invalid icon layout, must be one of: four, five, six") + ErrInvalidImageCompression = errors.New("invalid image compression, must be one of: low, medium, high") + ErrDefaultAccountNotFound = errors.New("default account not found") + ErrInvalidDefaultAccount = errors.New("invalid default account") + ErrInvalidNotificationChannel = errors.New("invalid notification channel") ) // UserSettingsRepositoryInterface defines the interface for user settings repository operations @@ -37,6 +38,8 @@ type UserSettingsInput struct { ShowRefundBtn *bool `json:"show_refund_btn"` CurrentLedgerID *uint `json:"current_ledger_id"` Phone *string `json:"phone"` + Email *string `json:"email"` + NotificationChannel *string `json:"notification_channel"` } // DefaultAccountsInput represents the input data for updating default accounts @@ -153,6 +156,21 @@ func (s *UserSettingsService) UpdateSettings(userID uint, input UserSettingsInpu settings.Phone = *input.Phone } + if input.Email != nil { + settings.Email = *input.Email + } + + if input.NotificationChannel != nil { + channel := *input.NotificationChannel + if channel != models.NotificationChannelSMS && + channel != models.NotificationChannelEmail && + channel != models.NotificationChannelBoth && + channel != models.NotificationChannelNone { + return nil, ErrInvalidNotificationChannel + } + settings.NotificationChannel = channel + } + // Save to database if err := s.repo.Update(settings); err != nil { return nil, fmt.Errorf("failed to update settings: %w", err)