-feat 修复部分bug

This commit is contained in:
2026-01-27 18:42:35 +08:00
parent a339a9adce
commit 6422f2c45f
10 changed files with 1001 additions and 1 deletions

View File

@@ -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 "警示", "您的财务状况存在风险,请尽快采取行动改善。"
}
}

View File

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

View File

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