feat: 引入循环交易管理、邮件通知和用户设置功能
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
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"
|
||||||
|
)
|
||||||
@@ -24,6 +24,8 @@ type UserSettings struct {
|
|||||||
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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ var (
|
|||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user