feat: 引入循环交易管理、邮件通知和用户设置功能

This commit is contained in:
2026-02-02 11:04:03 +08:00
parent 8b2cf37b18
commit 2e869e0c85
8 changed files with 145 additions and 12 deletions

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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)