From 6422f2c45f9e357ea7d2e04da5e2bb7d0df7373c Mon Sep 17 00:00:00 2001 From: 12975 <1297598740@qq.com> Date: Tue, 27 Jan 2026 18:42:35 +0800 Subject: [PATCH] =?UTF-8?q?-feat=20=E4=BF=AE=E5=A4=8D=E9=83=A8=E5=88=86bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/health_score_handler.go | 43 ++ internal/handler/notification_handler.go | 198 +++++++++ internal/handler/transaction_handler.go | 13 +- internal/models/models.go | 30 ++ .../repository/notification_repository.go | 151 +++++++ internal/repository/transaction_repository.go | 8 + internal/router/router.go | 16 + internal/service/health_score_service.go | 381 ++++++++++++++++++ internal/service/notification_service.go | 160 ++++++++ internal/service/transaction_service.go | 2 + 10 files changed, 1001 insertions(+), 1 deletion(-) create mode 100644 internal/handler/health_score_handler.go create mode 100644 internal/handler/notification_handler.go create mode 100644 internal/repository/notification_repository.go create mode 100644 internal/service/health_score_service.go create mode 100644 internal/service/notification_service.go diff --git a/internal/handler/health_score_handler.go b/internal/handler/health_score_handler.go new file mode 100644 index 0000000..243ee41 --- /dev/null +++ b/internal/handler/health_score_handler.go @@ -0,0 +1,43 @@ +package handler + +import ( + "accounting-app/internal/service" + "accounting-app/pkg/api" + + "github.com/gin-gonic/gin" +) + +// HealthScoreHandler handles HTTP requests for health score +type HealthScoreHandler struct { + healthScoreService *service.HealthScoreService +} + +// NewHealthScoreHandler creates a new HealthScoreHandler instance +func NewHealthScoreHandler(healthScoreService *service.HealthScoreService) *HealthScoreHandler { + return &HealthScoreHandler{ + healthScoreService: healthScoreService, + } +} + +// GetHealthScore handles GET /api/v1/reports/health-score +// Returns the user's financial health score +func (h *HealthScoreHandler) GetHealthScore(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + result, err := h.healthScoreService.CalculateHealthScore(userID.(uint)) + if err != nil { + api.InternalError(c, "Failed to calculate health score: "+err.Error()) + return + } + + api.Success(c, result) +} + +// RegisterRoutes registers health score route +func (h *HealthScoreHandler) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/reports/health-score", h.GetHealthScore) +} diff --git a/internal/handler/notification_handler.go b/internal/handler/notification_handler.go new file mode 100644 index 0000000..a81d296 --- /dev/null +++ b/internal/handler/notification_handler.go @@ -0,0 +1,198 @@ +package handler + +import ( + "errors" + "strconv" + + "accounting-app/internal/models" + "accounting-app/internal/service" + "accounting-app/pkg/api" + + "github.com/gin-gonic/gin" +) + +// NotificationHandler handles HTTP requests for notification operations +type NotificationHandler struct { + notificationService *service.NotificationService +} + +// NewNotificationHandler creates a new NotificationHandler instance +func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler { + return &NotificationHandler{ + notificationService: notificationService, + } +} + +// GetNotifications handles GET /api/v1/notifications +// Returns a list of notifications with pagination and filtering +func (h *NotificationHandler) GetNotifications(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + // Parse query parameters + input := service.NotificationListInput{} + + // Parse type filter + if typeStr := c.Query("type"); typeStr != "" { + notifType := models.NotificationType(typeStr) + input.Type = ¬ifType + } + + // Parse is_read filter + if isReadStr := c.Query("is_read"); isReadStr != "" { + isRead := isReadStr == "true" + input.IsRead = &isRead + } + + // Parse pagination + if offsetStr := c.Query("offset"); offsetStr != "" { + offset, err := strconv.Atoi(offsetStr) + if err != nil || offset < 0 { + api.BadRequest(c, "Invalid offset") + return + } + input.Offset = offset + } + + if limitStr := c.Query("limit"); limitStr != "" { + limit, err := strconv.Atoi(limitStr) + if err != nil || limit < 0 { + api.BadRequest(c, "Invalid limit") + return + } + input.Limit = limit + } + + result, err := h.notificationService.ListNotifications(userID.(uint), input) + if err != nil { + api.InternalError(c, "Failed to get notifications: "+err.Error()) + return + } + + // Calculate total pages + totalPages := 0 + if result.Limit > 0 { + totalPages = int((result.Total + int64(result.Limit) - 1) / int64(result.Limit)) + } + + // Calculate current page (1-indexed) + currentPage := 1 + if result.Limit > 0 { + currentPage = (result.Offset / result.Limit) + 1 + } + + api.SuccessWithMeta(c, result.Notifications, &api.Meta{ + Page: currentPage, + PageSize: result.Limit, + TotalCount: result.Total, + TotalPages: totalPages, + }) +} + +// GetUnreadCount handles GET /api/v1/notifications/unread-count +// Returns the count of unread notifications +func (h *NotificationHandler) GetUnreadCount(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + count, err := h.notificationService.GetUnreadCount(userID.(uint)) + if err != nil { + api.InternalError(c, "Failed to get unread count: "+err.Error()) + return + } + + api.Success(c, gin.H{"count": count}) +} + +// MarkAsRead handles PUT /api/v1/notifications/:id/read +// Marks a notification as read +func (h *NotificationHandler) MarkAsRead(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + api.BadRequest(c, "Invalid notification ID") + return + } + + err = h.notificationService.MarkAsRead(userID.(uint), uint(id)) + if err != nil { + if errors.Is(err, service.ErrNotificationNotFound) { + api.NotFound(c, "Notification not found") + return + } + api.InternalError(c, "Failed to mark notification as read: "+err.Error()) + return + } + + api.Success(c, gin.H{"message": "Notification marked as read"}) +} + +// MarkAllAsRead handles POST /api/v1/notifications/read-all +// Marks all notifications as read +func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + err := h.notificationService.MarkAllAsRead(userID.(uint)) + if err != nil { + api.InternalError(c, "Failed to mark all notifications as read: "+err.Error()) + return + } + + api.Success(c, gin.H{"message": "All notifications marked as read"}) +} + +// DeleteNotification handles DELETE /api/v1/notifications/:id +// Deletes a notification +func (h *NotificationHandler) DeleteNotification(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + api.BadRequest(c, "Invalid notification ID") + return + } + + err = h.notificationService.DeleteNotification(userID.(uint), uint(id)) + if err != nil { + if errors.Is(err, service.ErrNotificationNotFound) { + api.NotFound(c, "Notification not found") + return + } + api.InternalError(c, "Failed to delete notification: "+err.Error()) + return + } + + api.NoContent(c) +} + +// RegisterRoutes registers all notification routes to the given router group +func (h *NotificationHandler) RegisterRoutes(rg *gin.RouterGroup) { + notifications := rg.Group("/notifications") + { + notifications.GET("", h.GetNotifications) + notifications.GET("/unread-count", h.GetUnreadCount) + notifications.PUT("/:id/read", h.MarkAsRead) + notifications.POST("/read-all", h.MarkAllAsRead) + notifications.DELETE("/:id", h.DeleteNotification) + } +} diff --git a/internal/handler/transaction_handler.go b/internal/handler/transaction_handler.go index 141bb2c..b86cd2f 100644 --- a/internal/handler/transaction_handler.go +++ b/internal/handler/transaction_handler.go @@ -5,9 +5,9 @@ import ( "strconv" "time" - "accounting-app/pkg/api" "accounting-app/internal/models" "accounting-app/internal/service" + "accounting-app/pkg/api" "github.com/gin-gonic/gin" ) @@ -155,6 +155,17 @@ func (h *TransactionHandler) GetTransactions(c *gin.Context) { input.AccountID = &accID } + // Parse ledger filter + if ledgerIDStr := c.Query("ledger_id"); ledgerIDStr != "" { + ledgerID, err := strconv.ParseUint(ledgerIDStr, 10, 32) + if err != nil { + api.BadRequest(c, "Invalid ledger_id") + return + } + lid := uint(ledgerID) + input.LedgerID = &lid + } + if typeStr := c.Query("type"); typeStr != "" { txnType := models.TransactionType(typeStr) input.Type = &txnType diff --git a/internal/models/models.go b/internal/models/models.go index e0d7eaa..d02fd3d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -835,6 +835,35 @@ func (AllocationRecordDetail) TableName() string { return "allocation_record_details" } +// NotificationType represents the type of notification +type NotificationType string + +const ( + NotificationTypeBudgetAlert NotificationType = "budget_alert" // 预算超支提醒 + NotificationTypeRecurring NotificationType = "recurring" // 周期交易提醒 + NotificationTypeSystem NotificationType = "system" // 系统公告 + NotificationTypePaymentReminder NotificationType = "payment_reminder" // 还款提醒 + NotificationTypeSavingsGoal NotificationType = "savings_goal" // 储蓄目标提醒 +) + +// Notification represents a user notification +// Feature: notification-center +type Notification struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Type NotificationType `gorm:"size:30;not null;index" json:"type"` + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"size:1000;not null" json:"content"` + IsRead bool `gorm:"default:false;index" json:"is_read"` + RelatedID *uint `gorm:"index" json:"related_id,omitempty"` // 关联实体 ID (如预算、周期交易等) + CreatedAt time.Time `json:"created_at"` +} + +// TableName specifies the table name for Notification +func (Notification) TableName() string { + return "notifications" +} + // AllModels returns all models for database migration func AllModels() []interface{} { return []interface{}{ @@ -865,6 +894,7 @@ func AllModels() []interface{} { &SystemCategory{}, // Feature: accounting-feature-upgrade &TransactionImage{}, // Feature: accounting-feature-upgrade &UserSettings{}, // Feature: accounting-feature-upgrade + &Notification{}, // Feature: notification-center } } diff --git a/internal/repository/notification_repository.go b/internal/repository/notification_repository.go new file mode 100644 index 0000000..4f9c675 --- /dev/null +++ b/internal/repository/notification_repository.go @@ -0,0 +1,151 @@ +package repository + +import ( + "errors" + "fmt" + + "accounting-app/internal/models" + + "gorm.io/gorm" +) + +// Notification repository errors +var ( + ErrNotificationNotFound = errors.New("notification not found") +) + +// NotificationRepository handles database operations for notifications +type NotificationRepository struct { + db *gorm.DB +} + +// NewNotificationRepository creates a new NotificationRepository instance +func NewNotificationRepository(db *gorm.DB) *NotificationRepository { + return &NotificationRepository{db: db} +} + +// Create creates a new notification in the database +func (r *NotificationRepository) Create(notification *models.Notification) error { + if err := r.db.Create(notification).Error; err != nil { + return fmt.Errorf("failed to create notification: %w", err) + } + return nil +} + +// GetByID retrieves a notification by its ID +func (r *NotificationRepository) GetByID(userID uint, id uint) (*models.Notification, error) { + var notification models.Notification + if err := r.db.Where("user_id = ?", userID).First(¬ification, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotificationNotFound + } + return nil, fmt.Errorf("failed to get notification: %w", err) + } + return ¬ification, nil +} + +// NotificationListOptions contains options for listing notifications +type NotificationListOptions struct { + Type *models.NotificationType + IsRead *bool + Offset int + Limit int +} + +// NotificationListResult contains the result of a paginated notification list query +type NotificationListResult struct { + Notifications []models.Notification + Total int64 + Offset int + Limit int +} + +// List retrieves notifications with pagination and filtering +func (r *NotificationRepository) List(userID uint, options NotificationListOptions) (*NotificationListResult, error) { + query := r.db.Model(&models.Notification{}).Where("user_id = ?", userID) + + // Apply filters + if options.Type != nil { + query = query.Where("type = ?", *options.Type) + } + if options.IsRead != nil { + query = query.Where("is_read = ?", *options.IsRead) + } + + // Count total before pagination + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, fmt.Errorf("failed to count notifications: %w", err) + } + + // Apply sorting (newest first) + query = query.Order("created_at DESC") + + // Apply pagination + if options.Limit > 0 { + query = query.Limit(options.Limit) + } + if options.Offset > 0 { + query = query.Offset(options.Offset) + } + + // Execute query + var notifications []models.Notification + if err := query.Find(¬ifications).Error; err != nil { + return nil, fmt.Errorf("failed to list notifications: %w", err) + } + + return &NotificationListResult{ + Notifications: notifications, + Total: total, + Offset: options.Offset, + Limit: options.Limit, + }, nil +} + +// MarkAsRead marks a notification as read +func (r *NotificationRepository) MarkAsRead(userID uint, id uint) error { + result := r.db.Model(&models.Notification{}). + Where("user_id = ? AND id = ?", userID, id). + Update("is_read", true) + if result.Error != nil { + return fmt.Errorf("failed to mark notification as read: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrNotificationNotFound + } + return nil +} + +// MarkAllAsRead marks all notifications as read for a user +func (r *NotificationRepository) MarkAllAsRead(userID uint) error { + if err := r.db.Model(&models.Notification{}). + Where("user_id = ? AND is_read = ?", userID, false). + Update("is_read", true).Error; err != nil { + return fmt.Errorf("failed to mark all notifications as read: %w", err) + } + return nil +} + +// Delete deletes a notification by its ID +func (r *NotificationRepository) Delete(userID uint, id uint) error { + result := r.db.Where("user_id = ? AND id = ?", userID, id).Delete(&models.Notification{}) + if result.Error != nil { + return fmt.Errorf("failed to delete notification: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrNotificationNotFound + } + return nil +} + +// GetUnreadCount returns the count of unread notifications for a user +func (r *NotificationRepository) GetUnreadCount(userID uint) (int64, error) { + var count int64 + if err := r.db.Model(&models.Notification{}). + Where("user_id = ? AND is_read = ?", userID, false). + Count(&count).Error; err != nil { + return 0, fmt.Errorf("failed to count unread notifications: %w", err) + } + return count, nil +} diff --git a/internal/repository/transaction_repository.go b/internal/repository/transaction_repository.go index d123744..8578e70 100644 --- a/internal/repository/transaction_repository.go +++ b/internal/repository/transaction_repository.go @@ -24,6 +24,7 @@ type TransactionFilter struct { // Entity filters CategoryID *uint AccountID *uint + LedgerID *uint // 账本过滤 TagIDs []uint Type *models.TransactionType Currency *models.Currency @@ -277,6 +278,13 @@ func (r *TransactionRepository) applyFilters(query *gorm.DB, filter TransactionF if filter.RecurringID != nil { query = query.Where("recurring_id = ?", *filter.RecurringID) } + // LedgerID filter - requires subquery to get accounts belonging to the ledger + if filter.LedgerID != nil { + query = query.Where("account_id IN (?)", + r.db.Model(&models.Account{}). + Select("id"). + Where("ledger_id = ?", *filter.LedgerID)) + } // UserID provided in argument takes precedence, but if filter has it, we can redundant check or ignore. // The caller `List` already applied `Where("user_id = ?", userID)`. diff --git a/internal/router/router.go b/internal/router/router.go index 9b95945..b3ea6ff 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -49,6 +49,7 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) ledgerRepo := repository.NewLedgerRepository(db) userSettingsRepo := repository.NewUserSettingsRepository(db) userRepo := repository.NewUserRepository(db) + notificationRepo := repository.NewNotificationRepository(db) // Initialize auth services authService := service.NewAuthService(userRepo, cfg) @@ -90,6 +91,12 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) interestService := service.NewInterestService(accountRepo, transactionRepo, db) userSettingsServiceWithAccounts := service.NewUserSettingsServiceWithAccountRepo(userSettingsRepo, accountRepo) + // Feature: notification-center - Initialize notification service + notificationService := service.NewNotificationService(notificationRepo) + + // Feature: health-score - Initialize health score service + healthScoreService := service.NewHealthScoreService(reportRepo, accountRepo, budgetRepo) + // Initialize handlers accountHandler := handler.NewAccountHandler(accountService) categoryHandler := handler.NewCategoryHandler(categoryService) @@ -119,6 +126,12 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) defaultAccountHandler := handler.NewDefaultAccountHandler(userSettingsServiceWithAccounts) interestHandler := handler.NewInterestHandler(interestService, nil) + // Feature: notification-center - Initialize notification handler + notificationHandler := handler.NewNotificationHandler(notificationService) + + // Feature: health-score - Initialize health score handler + healthScoreHandler := handler.NewHealthScoreHandler(healthScoreService) + // AI Bookkeeping Service and Handler aiBookkeepingService := service.NewAIBookkeepingService( cfg, @@ -216,6 +229,9 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) // AI bookkeeping routes aiHandler.RegisterRoutes(protected) + // Feature: notification-center - Register notification routes + notificationHandler.RegisterRoutes(protected) + // Register report routes protected.GET("/reports/summary", reportHandler.GetTransactionSummary) protected.GET("/reports/category", reportHandler.GetCategorySummary) diff --git a/internal/service/health_score_service.go b/internal/service/health_score_service.go new file mode 100644 index 0000000..23ee1d0 --- /dev/null +++ b/internal/service/health_score_service.go @@ -0,0 +1,381 @@ +package service + +import ( + "time" + + "accounting-app/internal/models" + "accounting-app/internal/repository" +) + +// HealthScoreService handles financial health score calculation +type HealthScoreService struct { + reportRepo *repository.ReportRepository + accountRepo *repository.AccountRepository + budgetRepo *repository.BudgetRepository +} + +// NewHealthScoreService creates a new HealthScoreService instance +func NewHealthScoreService( + reportRepo *repository.ReportRepository, + accountRepo *repository.AccountRepository, + budgetRepo *repository.BudgetRepository, +) *HealthScoreService { + return &HealthScoreService{ + reportRepo: reportRepo, + accountRepo: accountRepo, + budgetRepo: budgetRepo, + } +} + +// HealthScoreFactor represents a factor contributing to the health score +type HealthScoreFactor struct { + Name string `json:"name"` + Score float64 `json:"score"` // 0-100 + Weight float64 `json:"weight"` // 0-1 + Tip string `json:"tip"` // 改进建议 + Description string `json:"description"` // 因素描述 +} + +// HealthScoreResult represents the overall health score result +type HealthScoreResult struct { + Score int `json:"score"` // 0-100 + Level string `json:"level"` // 优秀/良好/一般/需改善 + Description string `json:"description"` // 总体描述 + Factors []HealthScoreFactor `json:"factors"` +} + +// CalculateHealthScore calculates the financial health score for a user +func (s *HealthScoreService) CalculateHealthScore(userID uint) (*HealthScoreResult, error) { + now := time.Now() + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + threeMonthsAgo := monthStart.AddDate(0, -3, 0) + + factors := []HealthScoreFactor{} + totalWeight := 0.0 + weightedSum := 0.0 + + // Factor 1: 储蓄率 (Savings Rate) - 权重 30% + savingsScore, savingsTip := s.calculateSavingsRate(userID, threeMonthsAgo, now) + savingsFactor := HealthScoreFactor{ + Name: "储蓄率", + Score: savingsScore, + Weight: 0.30, + Tip: savingsTip, + Description: "收入中储蓄的比例", + } + factors = append(factors, savingsFactor) + weightedSum += savingsScore * 0.30 + totalWeight += 0.30 + + // Factor 2: 负债率 (Debt Ratio) - 权重 25% + debtScore, debtTip := s.calculateDebtRatio(userID) + debtFactor := HealthScoreFactor{ + Name: "负债率", + Score: debtScore, + Weight: 0.25, + Tip: debtTip, + Description: "负债占总资产的比例", + } + factors = append(factors, debtFactor) + weightedSum += debtScore * 0.25 + totalWeight += 0.25 + + // Factor 3: 预算执行率 (Budget Compliance) - 权重 20% + budgetScore, budgetTip := s.calculateBudgetCompliance(userID) + budgetFactor := HealthScoreFactor{ + Name: "预算执行", + Score: budgetScore, + Weight: 0.20, + Tip: budgetTip, + Description: "预算控制情况", + } + factors = append(factors, budgetFactor) + weightedSum += budgetScore * 0.20 + totalWeight += 0.20 + + // Factor 4: 消费稳定性 (Spending Stability) - 权重 15% + stabilityScore, stabilityTip := s.calculateSpendingStability(userID, threeMonthsAgo, now) + stabilityFactor := HealthScoreFactor{ + Name: "消费稳定", + Score: stabilityScore, + Weight: 0.15, + Tip: stabilityTip, + Description: "月度支出波动情况", + } + factors = append(factors, stabilityFactor) + weightedSum += stabilityScore * 0.15 + totalWeight += 0.15 + + // Factor 5: 资产多样性 (Asset Diversity) - 权重 10% + diversityScore, diversityTip := s.calculateAssetDiversity(userID) + diversityFactor := HealthScoreFactor{ + Name: "资产配置", + Score: diversityScore, + Weight: 0.10, + Tip: diversityTip, + Description: "资产分散程度", + } + factors = append(factors, diversityFactor) + weightedSum += diversityScore * 0.10 + totalWeight += 0.10 + + // Calculate final score + finalScore := int(weightedSum / totalWeight) + if finalScore > 100 { + finalScore = 100 + } + if finalScore < 0 { + finalScore = 0 + } + + // Determine level and description + level, description := getHealthScoreLevel(finalScore) + + return &HealthScoreResult{ + Score: finalScore, + Level: level, + Description: description, + Factors: factors, + }, nil +} + +// calculateSavingsRate calculates the savings rate factor +func (s *HealthScoreService) calculateSavingsRate(userID uint, startDate, endDate time.Time) (float64, string) { + // Get income and expense totals using existing repository method + summaries, err := s.reportRepo.GetTransactionSummaryByCurrency(userID, startDate, endDate) + if err != nil || len(summaries) == 0 { + return 50.0, "无法获取收支数据" + } + + var totalIncome, totalExpense float64 + for _, cs := range summaries { + totalIncome += cs.TotalIncome + totalExpense += cs.TotalExpense + } + + if totalIncome <= 0 { + return 30.0, "暂无收入记录,建议开始记录收入" + } + + savingsRate := (totalIncome - totalExpense) / totalIncome * 100 + + var score float64 + var tip string + + switch { + case savingsRate >= 30: + score = 100 + tip = "储蓄率非常优秀,继续保持!" + case savingsRate >= 20: + score = 85 + tip = "储蓄率良好,可以考虑增加投资" + case savingsRate >= 10: + score = 70 + tip = "储蓄率一般,建议适当控制支出" + case savingsRate >= 0: + score = 50 + tip = "储蓄率偏低,需要减少非必要支出" + default: + score = 20 + tip = "入不敷出,请尽快调整消费习惯" + } + + return score, tip +} + +// calculateDebtRatio calculates the debt ratio factor +func (s *HealthScoreService) calculateDebtRatio(userID uint) (float64, string) { + // Use GetTotalBalance which returns assets and liabilities + totalAssets, totalLiabilities, err := s.accountRepo.GetTotalBalance(userID) + if err != nil { + return 50.0, "无法获取账户数据" + } + + if totalAssets <= 0 && totalLiabilities <= 0 { + return 70.0, "暂无资产负债记录" + } + + // Debt ratio = Liabilities / (Assets + Liabilities) + total := totalAssets + totalLiabilities + if total <= 0 { + return 30.0, "净资产为负,需要关注财务状况" + } + + debtRatio := totalLiabilities / total * 100 + + var score float64 + var tip string + + switch { + case debtRatio <= 10: + score = 100 + tip = "负债率极低,财务非常健康" + case debtRatio <= 30: + score = 85 + tip = "负债率健康,保持良好习惯" + case debtRatio <= 50: + score = 65 + tip = "负债率中等,建议适度控制" + case debtRatio <= 70: + score = 40 + tip = "负债率偏高,需要制定还款计划" + default: + score = 20 + tip = "负债率过高,请优先处理债务" + } + + return score, tip +} + +// calculateBudgetCompliance calculates budget compliance factor +func (s *HealthScoreService) calculateBudgetCompliance(userID uint) (float64, string) { + now := time.Now() + budgets, err := s.budgetRepo.GetActiveBudgets(userID, now) + if err != nil || len(budgets) == 0 { + return 60.0, "暂无预算设置,建议设置预算" + } + + var totalBudgets int + var overBudgetCount int + + for _, budget := range budgets { + // 获取预算期间的支出 + spent, err := s.budgetRepo.GetSpentAmount(&budget, budget.StartDate, now) + if err != nil { + continue + } + + totalBudgets++ + if spent > budget.Amount { + overBudgetCount++ + } + } + + if totalBudgets == 0 { + return 60.0, "暂无有效预算" + } + + complianceRate := float64(totalBudgets-overBudgetCount) / float64(totalBudgets) * 100 + + var score float64 + var tip string + + switch { + case complianceRate >= 90: + score = 100 + tip = "预算执行非常出色!" + case complianceRate >= 70: + score = 80 + tip = "预算执行良好,继续保持" + case complianceRate >= 50: + score = 60 + tip = "部分预算超支,需要加强控制" + default: + score = 30 + tip = "预算超支严重,请重新规划预算" + } + + return score, tip +} + +// calculateSpendingStability calculates spending stability factor +func (s *HealthScoreService) calculateSpendingStability(userID uint, startDate, endDate time.Time) (float64, string) { + // 获取按月的支出趋势 + trendData, err := s.reportRepo.GetTrendDataByMonth(userID, startDate, endDate, nil) + if err != nil || len(trendData) < 2 { + return 70.0, "数据不足以分析消费稳定性" + } + + // 计算支出的标准差 + var sum, sumSq float64 + n := float64(len(trendData)) + + for _, dp := range trendData { + sum += dp.TotalExpense + sumSq += dp.TotalExpense * dp.TotalExpense + } + + mean := sum / n + variance := (sumSq / n) - (mean * mean) + + // 变异系数 (CV) = 标准差 / 平均值 + if mean <= 0 { + return 70.0, "暂无支出记录" + } + + cv := (variance / (mean * mean)) * 100 // 简化计算 + + var score float64 + var tip string + + switch { + case cv <= 10: + score = 100 + tip = "消费非常稳定,财务规划出色" + case cv <= 25: + score = 80 + tip = "消费较为稳定,理财意识良好" + case cv <= 50: + score = 60 + tip = "消费有一定波动,建议制定月度预算" + default: + score = 40 + tip = "消费波动较大,需要加强支出管理" + } + + return score, tip +} + +// calculateAssetDiversity calculates asset diversity factor +func (s *HealthScoreService) calculateAssetDiversity(userID uint) (float64, string) { + accounts, err := s.accountRepo.GetAll(userID) + if err != nil { + return 50.0, "无法获取账户数据" + } + + // 统计不同类型的账户数量 + accountTypes := make(map[models.AccountType]int) + for _, acc := range accounts { + if acc.Balance > 0 && !acc.IsCredit { + accountTypes[acc.Type]++ + } + } + + diversity := len(accountTypes) + + var score float64 + var tip string + + switch { + case diversity >= 4: + score = 100 + tip = "资产配置多样化,抗风险能力强" + case diversity >= 3: + score = 80 + tip = "资产配置较好,可考虑增加投资类型" + case diversity >= 2: + score = 60 + tip = "资产类型偏少,建议分散配置" + default: + score = 40 + tip = "资产过于单一,建议多元化理财" + } + + return score, tip +} + +// getHealthScoreLevel returns the level and description based on score +func getHealthScoreLevel(score int) (string, string) { + switch { + case score >= 90: + return "优秀", "您的财务状况非常健康,继续保持良好的理财习惯!" + case score >= 75: + return "良好", "您的财务状况良好,还有一些提升空间。" + case score >= 60: + return "一般", "您的财务状况尚可,建议关注评分较低的方面。" + case score >= 40: + return "需改善", "您的财务状况需要改善,请查看具体建议。" + default: + return "警示", "您的财务状况存在风险,请尽快采取行动改善。" + } +} diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go new file mode 100644 index 0000000..e05a6dc --- /dev/null +++ b/internal/service/notification_service.go @@ -0,0 +1,160 @@ +package service + +import ( + "errors" + "fmt" + + "accounting-app/internal/models" + "accounting-app/internal/repository" +) + +// Notification service errors +var ( + ErrNotificationNotFound = errors.New("notification not found") +) + +// NotificationService handles business logic for notifications +type NotificationService struct { + repo *repository.NotificationRepository +} + +// NewNotificationService creates a new NotificationService instance +func NewNotificationService(repo *repository.NotificationRepository) *NotificationService { + return &NotificationService{repo: repo} +} + +// CreateNotificationInput represents input for creating a notification +type CreateNotificationInput struct { + UserID uint + Type models.NotificationType + Title string + Content string + RelatedID *uint +} + +// CreateNotification creates a new notification +func (s *NotificationService) CreateNotification(input CreateNotificationInput) (*models.Notification, error) { + notification := &models.Notification{ + UserID: input.UserID, + Type: input.Type, + Title: input.Title, + Content: input.Content, + RelatedID: input.RelatedID, + IsRead: false, + } + + if err := s.repo.Create(notification); err != nil { + return nil, fmt.Errorf("failed to create notification: %w", err) + } + + return notification, nil +} + +// GetNotification retrieves a notification by ID +func (s *NotificationService) GetNotification(userID, id uint) (*models.Notification, error) { + notification, err := s.repo.GetByID(userID, id) + if err != nil { + if errors.Is(err, repository.ErrNotificationNotFound) { + return nil, ErrNotificationNotFound + } + return nil, err + } + return notification, nil +} + +// NotificationListInput represents input for listing notifications +type NotificationListInput struct { + Type *models.NotificationType + IsRead *bool + Offset int + Limit int +} + +// ListNotifications retrieves notifications with pagination +func (s *NotificationService) ListNotifications(userID uint, input NotificationListInput) (*repository.NotificationListResult, error) { + limit := input.Limit + if limit <= 0 { + limit = 20 + } + + options := repository.NotificationListOptions{ + Type: input.Type, + IsRead: input.IsRead, + Offset: input.Offset, + Limit: limit, + } + + return s.repo.List(userID, options) +} + +// MarkAsRead marks a notification as read +func (s *NotificationService) MarkAsRead(userID, id uint) error { + err := s.repo.MarkAsRead(userID, id) + if err != nil { + if errors.Is(err, repository.ErrNotificationNotFound) { + return ErrNotificationNotFound + } + return err + } + return nil +} + +// MarkAllAsRead marks all notifications as read +func (s *NotificationService) MarkAllAsRead(userID uint) error { + return s.repo.MarkAllAsRead(userID) +} + +// DeleteNotification deletes a notification +func (s *NotificationService) DeleteNotification(userID, id uint) error { + err := s.repo.Delete(userID, id) + if err != nil { + if errors.Is(err, repository.ErrNotificationNotFound) { + return ErrNotificationNotFound + } + return err + } + return nil +} + +// GetUnreadCount returns the count of unread notifications +func (s *NotificationService) GetUnreadCount(userID uint) (int64, error) { + return s.repo.GetUnreadCount(userID) +} + +// SendBudgetAlert creates a budget alert notification +func (s *NotificationService) SendBudgetAlert(userID uint, budgetName string, usedPercentage float64, budgetID uint) (*models.Notification, error) { + title := fmt.Sprintf("预算提醒: %s", budgetName) + content := fmt.Sprintf("您的「%s」预算已使用 %.1f%%,请注意控制支出。", budgetName, usedPercentage) + + return s.CreateNotification(CreateNotificationInput{ + UserID: userID, + Type: models.NotificationTypeBudgetAlert, + Title: title, + Content: content, + RelatedID: &budgetID, + }) +} + +// SendRecurringReminder creates a recurring transaction reminder +func (s *NotificationService) SendRecurringReminder(userID uint, recurringName string, amount float64, recurringID uint) (*models.Notification, error) { + title := fmt.Sprintf("周期交易提醒: %s", recurringName) + content := fmt.Sprintf("您的周期交易「%s」(¥%.2f) 已自动执行。", recurringName, amount) + + return s.CreateNotification(CreateNotificationInput{ + UserID: userID, + Type: models.NotificationTypeRecurring, + Title: title, + Content: content, + RelatedID: &recurringID, + }) +} + +// SendSystemNotification creates a system notification +func (s *NotificationService) SendSystemNotification(userID uint, title, content string) (*models.Notification, error) { + return s.CreateNotification(CreateNotificationInput{ + UserID: userID, + Type: models.NotificationTypeSystem, + Title: title, + Content: content, + }) +} diff --git a/internal/service/transaction_service.go b/internal/service/transaction_service.go index 729e508..aa9d13a 100644 --- a/internal/service/transaction_service.go +++ b/internal/service/transaction_service.go @@ -47,6 +47,7 @@ type TransactionListInput struct { EndDate *time.Time `json:"end_date,omitempty"` CategoryID *uint `json:"category_id,omitempty"` AccountID *uint `json:"account_id,omitempty"` + LedgerID *uint `json:"ledger_id,omitempty"` TagIDs []uint `json:"tag_ids,omitempty"` Type *models.TransactionType `json:"type,omitempty"` Currency *models.Currency `json:"currency,omitempty"` @@ -490,6 +491,7 @@ func (s *TransactionService) ListTransactions(userID uint, input TransactionList EndDate: input.EndDate, CategoryID: input.CategoryID, AccountID: input.AccountID, + LedgerID: input.LedgerID, TagIDs: input.TagIDs, Type: input.Type, Currency: input.Currency,