feat: 引入循环交易管理、邮件通知和用户设置功能
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
9
internal/models/constants.go
Normal file
9
internal/models/constants.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
// Notification Channel Constants
|
||||
const (
|
||||
NotificationChannelSMS = "sms"
|
||||
NotificationChannelEmail = "email"
|
||||
NotificationChannelBoth = "both"
|
||||
NotificationChannelNone = "none"
|
||||
)
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
59
internal/service/email_service.go
Normal file
59
internal/service/email_service.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user