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

@@ -73,6 +73,13 @@ type Config struct {
AliyunAccessKeySecret string AliyunAccessKeySecret string
AliyunSignName string AliyunSignName string
AliyunTemplateCode string AliyunTemplateCode string
// SMTP Configuration (Email)
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPassword string
SMTPFrom string
} }
// Load loads configuration from environment variables // Load loads configuration from environment variables
@@ -143,6 +150,13 @@ func Load() *Config {
AliyunAccessKeySecret: getEnv("ALIYUN_ACCESS_KEY_SECRET", ""), AliyunAccessKeySecret: getEnv("ALIYUN_ACCESS_KEY_SECRET", ""),
AliyunSignName: getEnv("ALIYUN_SIGN_NAME", ""), AliyunSignName: getEnv("ALIYUN_SIGN_NAME", ""),
AliyunTemplateCode: getEnv("ALIYUN_TEMPLATE_CODE", ""), 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 // Ensure data directory exists

View File

@@ -3,8 +3,8 @@ package handler
import ( import (
"errors" "errors"
"accounting-app/pkg/api"
"accounting-app/internal/service" "accounting-app/internal/service"
"accounting-app/pkg/api"
"github.com/gin-gonic/gin" "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") api.BadRequest(c, "Invalid image compression, must be one of: low, medium, high")
return return
} }
if errors.Is(err, service.ErrInvalidNotificationChannel) {
api.BadRequest(c, "Invalid notification channel")
return
}
api.InternalError(c, "Failed to update settings: "+err.Error()) api.InternalError(c, "Failed to update settings: "+err.Error())
return return
} }

View File

@@ -0,0 +1,9 @@
package models
// Notification Channel Constants
const (
NotificationChannelSMS = "sms"
NotificationChannelEmail = "email"
NotificationChannelBoth = "both"
NotificationChannelNone = "none"
)

View File

@@ -23,7 +23,9 @@ type UserSettings struct {
DefaultExpenseAccountID *uint `gorm:"index" json:"default_expense_account_id,omitempty"` DefaultExpenseAccountID *uint `gorm:"index" json:"default_expense_account_id,omitempty"`
DefaultIncomeAccountID *uint `gorm:"index" json:"default_income_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"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`

View File

@@ -76,7 +76,8 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config)
transactionService := service.NewTransactionService(transactionRepo, accountRepo, categoryRepo, tagRepo, ledgerRepo, db) transactionService := service.NewTransactionService(transactionRepo, accountRepo, categoryRepo, tagRepo, ledgerRepo, db)
imageService := service.NewImageService(transactionImageRepo, transactionRepo, db, cfg.ImageUploadDir) imageService := service.NewImageService(transactionImageRepo, transactionRepo, db, cfg.ImageUploadDir)
smsService := service.NewSmsService(cfg) 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) exchangeRateService := service.NewExchangeRateService(exchangeRateRepo)
reportService := service.NewReportService(reportRepo, exchangeRateRepo) reportService := service.NewReportService(reportRepo, exchangeRateRepo)
pdfExportService := service.NewPDFExportService(reportRepo, transactionRepo, 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) transactionService := service.NewTransactionService(transactionRepo, accountRepo, categoryRepo, tagRepo, ledgerRepo, db)
imageService := service.NewImageService(transactionImageRepo, transactionRepo, db, cfg.ImageUploadDir) imageService := service.NewImageService(transactionImageRepo, transactionRepo, db, cfg.ImageUploadDir)
smsService := service.NewSmsService(cfg) 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) reportService := service.NewReportService(reportRepo, exchangeRateRepo)
pdfExportService := service.NewPDFExportService(reportRepo, transactionRepo, exchangeRateRepo) pdfExportService := service.NewPDFExportService(reportRepo, transactionRepo, exchangeRateRepo)
excelExportService := service.NewExcelExportService(reportRepo, transactionRepo, exchangeRateRepo) excelExportService := service.NewExcelExportService(reportRepo, transactionRepo, exchangeRateRepo)

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 piggyBankRepo *repository.PiggyBankRepository
userSettingsRepo *repository.UserSettingsRepository userSettingsRepo *repository.UserSettingsRepository
smsService *SmsService smsService *SmsService
emailService *EmailService
db *gorm.DB db *gorm.DB
} }
@@ -37,6 +38,7 @@ func NewRecurringTransactionService(
piggyBankRepo *repository.PiggyBankRepository, piggyBankRepo *repository.PiggyBankRepository,
userSettingsRepo *repository.UserSettingsRepository, userSettingsRepo *repository.UserSettingsRepository,
smsService *SmsService, smsService *SmsService,
emailService *EmailService,
db *gorm.DB, db *gorm.DB,
) *RecurringTransactionService { ) *RecurringTransactionService {
return &RecurringTransactionService{ return &RecurringTransactionService{
@@ -49,6 +51,7 @@ func NewRecurringTransactionService(
piggyBankRepo: piggyBankRepo, piggyBankRepo: piggyBankRepo,
userSettingsRepo: userSettingsRepo, userSettingsRepo: userSettingsRepo,
smsService: smsService, smsService: smsService,
emailService: emailService,
db: db, db: db,
} }
} }
@@ -384,24 +387,46 @@ func (s *RecurringTransactionService) ProcessDueTransactions(userID uint, now ti
result.Transactions = append(result.Transactions, transaction) result.Transactions = append(result.Transactions, transaction)
} }
// Send SMS notifications for auto-executed allocations // Send notifications for auto-executed allocations
for _, allocation := range result.Allocations { for _, allocation := range result.Allocations {
if allocation.AutoExecuted { if allocation.AutoExecuted {
// Get user phone number // Get user settings
userSettings, err := s.userSettingsRepo.GetOrCreate(userID) userSettings, err := s.userSettingsRepo.GetOrCreate(userID)
if err != nil { if err != nil {
// Log error but don't fail the request // 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 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) { go func(phone string, amount float64, ruleName string) {
if err := s.smsService.SendAllocationNotification(phone, amount, ruleName); err != nil { if err := s.smsService.SendAllocationNotification(phone, amount, ruleName); err != nil {
fmt.Printf("Failed to send allocation SMS: %v\n", err) fmt.Printf("Failed to send allocation SMS: %v\n", err)
} }
}(userSettings.Phone, allocation.TotalAmount, allocation.RuleName) }(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 // Service layer errors for user settings
var ( var (
ErrInvalidIconLayout = errors.New("invalid icon layout, must be one of: four, five, six") 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") ErrInvalidImageCompression = errors.New("invalid image compression, must be one of: low, medium, high")
ErrDefaultAccountNotFound = errors.New("default account not found") ErrDefaultAccountNotFound = errors.New("default account not found")
ErrInvalidDefaultAccount = errors.New("invalid default account") ErrInvalidDefaultAccount = errors.New("invalid default account")
ErrInvalidNotificationChannel = errors.New("invalid notification channel")
) )
// UserSettingsRepositoryInterface defines the interface for user settings repository operations // UserSettingsRepositoryInterface defines the interface for user settings repository operations
@@ -37,6 +38,8 @@ type UserSettingsInput struct {
ShowRefundBtn *bool `json:"show_refund_btn"` ShowRefundBtn *bool `json:"show_refund_btn"`
CurrentLedgerID *uint `json:"current_ledger_id"` CurrentLedgerID *uint `json:"current_ledger_id"`
Phone *string `json:"phone"` Phone *string `json:"phone"`
Email *string `json:"email"`
NotificationChannel *string `json:"notification_channel"`
} }
// DefaultAccountsInput represents the input data for updating default accounts // 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 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 // Save to database
if err := s.repo.Update(settings); err != nil { if err := s.repo.Update(settings); err != nil {
return nil, fmt.Errorf("failed to update settings: %w", err) return nil, fmt.Errorf("failed to update settings: %w", err)