From e811256d99277776904bc62b62bca08612a9b1be Mon Sep 17 00:00:00 2001 From: admin <1297598740@qq.com> Date: Wed, 28 Jan 2026 10:08:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E3=80=81=E4=BA=A4=E6=98=93=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=92=8C=E7=94=A8=E6=88=B7=E8=BF=9E=E5=87=BB=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=88=9D=E5=A7=8B=E5=8C=96=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E4=B8=8E=E5=A4=84=E7=90=86=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/sql/schema.sql | 16 ++ internal/handler/streak_handler.go | 73 +++++++ internal/handler/transaction_handler.go | 17 ++ internal/models/user_streak.go | 31 +++ internal/repository/streak_repository.go | 109 ++++++++++ internal/router/router.go | 21 +- internal/service/streak_service.go | 243 +++++++++++++++++++++++ 7 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 internal/handler/streak_handler.go create mode 100644 internal/models/user_streak.go create mode 100644 internal/repository/streak_repository.go create mode 100644 internal/service/streak_service.go diff --git a/database/sql/schema.sql b/database/sql/schema.sql index 4b7ad91..369a560 100644 --- a/database/sql/schema.sql +++ b/database/sql/schema.sql @@ -242,7 +242,23 @@ CREATE TABLE IF NOT EXISTS `default_categories` ( CONSTRAINT `fk_default_categories_parent` FOREIGN KEY (`parent_id`) REFERENCES `default_categories` (`id`) ON DELETE SET NULL ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- User Streaks table (for tracking consecutive recording days) +CREATE TABLE IF NOT EXISTS `user_streaks` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `user_id` bigint(20) unsigned NOT NULL, + `current_streak` int DEFAULT '0', + `longest_streak` int DEFAULT '0', + `last_record_date` date DEFAULT NULL, + `total_record_days` int DEFAULT '0', + `created_at` datetime(3) DEFAULT NULL, + `updated_at` datetime(3) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_user_streaks_user_id` (`user_id`), + CONSTRAINT `fk_user_streaks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Budgets table + CREATE TABLE IF NOT EXISTS `budgets` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `created_at` datetime(3) DEFAULT NULL, diff --git a/internal/handler/streak_handler.go b/internal/handler/streak_handler.go new file mode 100644 index 0000000..fd1d588 --- /dev/null +++ b/internal/handler/streak_handler.go @@ -0,0 +1,73 @@ +package handler + +import ( + "accounting-app/internal/service" + "accounting-app/pkg/api" + + "github.com/gin-gonic/gin" +) + +// StreakHandler handles HTTP requests for user streaks +type StreakHandler struct { + streakService *service.StreakService +} + +// NewStreakHandler creates a new StreakHandler instance +func NewStreakHandler(streakService *service.StreakService) *StreakHandler { + return &StreakHandler{ + streakService: streakService, + } +} + +// GetStreak handles GET /api/v1/user/streak +// Returns the user's current streak information +func (h *StreakHandler) GetStreak(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + // Check and reset streak if needed (in case user hasn't opened app in a while) + if err := h.streakService.CheckAndResetStreak(userID.(uint)); err != nil { + // Log error but don't fail the request + // Just continue to get streak info + } + + streakInfo, err := h.streakService.GetStreakInfo(userID.(uint)) + if err != nil { + api.InternalError(c, "Failed to get streak info: "+err.Error()) + return + } + + api.Success(c, streakInfo) +} + +// RecalculateStreak handles POST /api/v1/user/streak/recalculate +// Recalculates the streak from transaction history (admin/debug use) +func (h *StreakHandler) RecalculateStreak(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + api.Unauthorized(c, "User not authenticated") + return + } + + if err := h.streakService.RecalculateStreak(userID.(uint)); err != nil { + api.InternalError(c, "Failed to recalculate streak: "+err.Error()) + return + } + + streakInfo, err := h.streakService.GetStreakInfo(userID.(uint)) + if err != nil { + api.InternalError(c, "Failed to get streak info: "+err.Error()) + return + } + + api.Success(c, streakInfo) +} + +// RegisterRoutes registers streak-related routes +func (h *StreakHandler) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/user/streak", h.GetStreak) + rg.POST("/user/streak/recalculate", h.RecalculateStreak) +} diff --git a/internal/handler/transaction_handler.go b/internal/handler/transaction_handler.go index b86cd2f..10d80ab 100644 --- a/internal/handler/transaction_handler.go +++ b/internal/handler/transaction_handler.go @@ -15,6 +15,7 @@ import ( // TransactionHandler handles HTTP requests for transaction operations type TransactionHandler struct { transactionService *service.TransactionService + streakService *service.StreakService // Optional: for updating streak on transaction creation } // NewTransactionHandler creates a new TransactionHandler instance @@ -24,6 +25,14 @@ func NewTransactionHandler(transactionService *service.TransactionService) *Tran } } +// NewTransactionHandlerWithStreak creates a new TransactionHandler with streak tracking support +func NewTransactionHandlerWithStreak(transactionService *service.TransactionService, streakService *service.StreakService) *TransactionHandler { + return &TransactionHandler{ + transactionService: transactionService, + streakService: streakService, + } +} + // CreateTransactionRequest represents the request body for creating a transaction type CreateTransactionRequest struct { Amount float64 `json:"amount" binding:"required"` @@ -96,6 +105,14 @@ func (h *TransactionHandler) CreateTransaction(c *gin.Context) { return } + // Update streak if streak service is configured + if h.streakService != nil { + // Fire and forget - don't fail the transaction if streak update fails + go func() { + _ = h.streakService.UpdateStreak(userID.(uint), transactionDate) + }() + } + api.Created(c, transaction) } diff --git a/internal/models/user_streak.go b/internal/models/user_streak.go new file mode 100644 index 0000000..e2d1341 --- /dev/null +++ b/internal/models/user_streak.go @@ -0,0 +1,31 @@ +package models + +import ( + "time" +) + +// UserStreak represents a user's consecutive recording streak +type UserStreak struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"uniqueIndex;not null" json:"user_id"` + CurrentStreak int `gorm:"default:0" json:"current_streak"` // 当前连续天数 + LongestStreak int `gorm:"default:0" json:"longest_streak"` // 最长连续记录 + LastRecordDate *time.Time `gorm:"type:date" json:"last_record_date"` // 最后记账日期 + TotalRecordDays int `gorm:"default:0" json:"total_record_days"` // 累计记账天数 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TableName specifies the table name for UserStreak +func (UserStreak) TableName() string { + return "user_streaks" +} + +// StreakInfo represents the streak information returned to frontend +type StreakInfo struct { + CurrentStreak int `json:"current_streak"` // 当前连续天数 + LongestStreak int `json:"longest_streak"` // 最长连续记录 + TotalRecordDays int `json:"total_record_days"` // 累计记账天数 + HasRecordToday bool `json:"has_record_today"` // 今天是否已记账 + Message string `json:"message"` // 提示信息 +} diff --git a/internal/repository/streak_repository.go b/internal/repository/streak_repository.go new file mode 100644 index 0000000..c6b22e7 --- /dev/null +++ b/internal/repository/streak_repository.go @@ -0,0 +1,109 @@ +package repository + +import ( + "errors" + "time" + + "accounting-app/internal/models" + + "gorm.io/gorm" +) + +// StreakRepository handles database operations for user streaks +type StreakRepository struct { + db *gorm.DB +} + +// NewStreakRepository creates a new StreakRepository instance +func NewStreakRepository(db *gorm.DB) *StreakRepository { + return &StreakRepository{db: db} +} + +// GetByUserID retrieves a user's streak record +func (r *StreakRepository) GetByUserID(userID uint) (*models.UserStreak, error) { + var streak models.UserStreak + if err := r.db.Where("user_id = ?", userID).First(&streak).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil // Return nil without error if not found + } + return nil, err + } + return &streak, nil +} + +// Create creates a new streak record +func (r *StreakRepository) Create(streak *models.UserStreak) error { + return r.db.Create(streak).Error +} + +// Update updates an existing streak record +func (r *StreakRepository) Update(streak *models.UserStreak) error { + return r.db.Save(streak).Error +} + +// GetOrCreate retrieves existing streak record or creates a new one +func (r *StreakRepository) GetOrCreate(userID uint) (*models.UserStreak, error) { + streak, err := r.GetByUserID(userID) + if err != nil { + return nil, err + } + + if streak == nil { + // Create new streak record + streak = &models.UserStreak{ + UserID: userID, + CurrentStreak: 0, + LongestStreak: 0, + TotalRecordDays: 0, + } + if err := r.Create(streak); err != nil { + return nil, err + } + } + + return streak, nil +} + +// HasTransactionOnDate checks if user has any transaction on the given date +func (r *StreakRepository) HasTransactionOnDate(userID uint, date time.Time) (bool, error) { + var count int64 + startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + endOfDay := startOfDay.Add(24 * time.Hour) + + err := r.db.Model(&models.Transaction{}). + Where("user_id = ? AND transaction_date >= ? AND transaction_date < ?", userID, startOfDay, endOfDay). + Count(&count).Error + + if err != nil { + return false, err + } + + return count > 0, nil +} + +// GetTransactionDatesInRange returns all dates with transactions in a date range +func (r *StreakRepository) GetTransactionDatesInRange(userID uint, startDate, endDate time.Time) ([]time.Time, error) { + var dates []time.Time + + rows, err := r.db.Model(&models.Transaction{}). + Select("DATE(transaction_date) as date"). + Where("user_id = ? AND transaction_date >= ? AND transaction_date <= ?", userID, startDate, endDate). + Group("DATE(transaction_date)"). + Order("date ASC"). + Rows() + + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var date time.Time + if err := rows.Scan(&date); err != nil { + return nil, err + } + dates = append(dates, date) + } + + return dates, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index fb5161c..5e12541 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -50,6 +50,7 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) userSettingsRepo := repository.NewUserSettingsRepository(db) userRepo := repository.NewUserRepository(db) notificationRepo := repository.NewNotificationRepository(db) + streakRepo := repository.NewStreakRepository(db) // Initialize auth services authService := service.NewAuthService(userRepo, cfg) @@ -85,6 +86,7 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) refundService := service.NewRefundService(db, transactionRepo, accountRepo) userSettingsService := service.NewUserSettingsService(userSettingsRepo) notificationService := service.NewNotificationService(notificationRepo) + streakService := service.NewStreakService(streakRepo) // Feature: financial-core-upgrade - Initialize new services subAccountService := service.NewSubAccountService(accountRepo, db) @@ -100,7 +102,8 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) categoryHandler := handler.NewCategoryHandler(categoryService) tagHandler := handler.NewTagHandler(tagService) classificationHandler := handler.NewClassificationHandler(classificationService) - transactionHandler := handler.NewTransactionHandler(transactionService) + transactionHandler := handler.NewTransactionHandlerWithStreak(transactionService, streakService) + imageHandler := handler.NewImageHandler(imageService) recurringHandler := handler.NewRecurringTransactionHandler(recurringService) exchangeRateHandler := handler.NewExchangeRateHandlerWithClient(exchangeRateService, yunAPIClient) @@ -128,6 +131,9 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) // Feature: health-score - Initialize health score handler healthScoreHandler := handler.NewHealthScoreHandler(healthScoreService) + // Feature: streak - Initialize streak handler + streakHandler := handler.NewStreakHandler(streakService) + // AI Bookkeeping Service and Handler aiBookkeepingService := service.NewAIBookkeepingService( cfg, @@ -253,6 +259,9 @@ func Setup(db *gorm.DB, yunAPIClient *service.YunAPIClient, cfg *config.Config) // Register health score routes healthScoreHandler.RegisterRoutes(protected) + + // Feature: streak - Register streak routes + streakHandler.RegisterRoutes(protected) } } @@ -314,6 +323,7 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient userSettingsRepo := repository.NewUserSettingsRepository(db) userRepo := repository.NewUserRepository(db) notificationRepo := repository.NewNotificationRepository(db) + streakRepo := repository.NewStreakRepository(db) // Initialize auth services authService := service.NewAuthService(userRepo, cfg) @@ -348,6 +358,7 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient refundService := service.NewRefundService(db, transactionRepo, accountRepo) userSettingsService := service.NewUserSettingsService(userSettingsRepo) notificationService := service.NewNotificationService(notificationRepo) + streakService := service.NewStreakService(streakRepo) // Feature: financial-core-upgrade - Initialize new services subAccountService := service.NewSubAccountService(accountRepo, db) @@ -367,7 +378,7 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient categoryHandler := handler.NewCategoryHandler(categoryService) tagHandler := handler.NewTagHandler(tagService) classificationHandler := handler.NewClassificationHandler(classificationService) - transactionHandler := handler.NewTransactionHandler(transactionService) + transactionHandler := handler.NewTransactionHandlerWithStreak(transactionService, streakService) imageHandler := handler.NewImageHandler(imageService) recurringHandler := handler.NewRecurringTransactionHandler(recurringService) reportHandler := handler.NewReportHandler(reportService, pdfExportService, excelExportService) @@ -391,6 +402,9 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient defaultAccountHandler := handler.NewDefaultAccountHandler(userSettingsServiceWithAccounts) interestHandler := handler.NewInterestHandler(interestService, nil) + // Feature: streak - Initialize streak handler + streakHandler := handler.NewStreakHandler(streakService) + // AI Bookkeeping Service and Handler for Redis setup aiBookkeepingServiceRedis := service.NewAIBookkeepingService( cfg, @@ -523,6 +537,9 @@ func SetupWithRedis(db *gorm.DB, yunAPIClient *service.YunAPIClient, redisClient // Register notification routes notificationHandler.RegisterUserRoutes(protected) notificationHandler.RegisterAdminRoutes(v1) + + // Feature: streak - Register streak routes + streakHandler.RegisterRoutes(v1) } return r, syncScheduler diff --git a/internal/service/streak_service.go b/internal/service/streak_service.go new file mode 100644 index 0000000..d02dcb1 --- /dev/null +++ b/internal/service/streak_service.go @@ -0,0 +1,243 @@ +package service + +import ( + "time" + + "accounting-app/internal/models" + "accounting-app/internal/repository" +) + +// StreakService handles business logic for user streaks +type StreakService struct { + repo *repository.StreakRepository +} + +// NewStreakService creates a new StreakService instance +func NewStreakService(repo *repository.StreakRepository) *StreakService { + return &StreakService{repo: repo} +} + +// GetStreakInfo returns the streak information for a user +func (s *StreakService) GetStreakInfo(userID uint) (*models.StreakInfo, error) { + streak, err := s.repo.GetOrCreate(userID) + if err != nil { + return nil, err + } + + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Check if user has recorded today + hasRecordToday, err := s.repo.HasTransactionOnDate(userID, today) + if err != nil { + return nil, err + } + + // Determine message based on streak status + var message string + if hasRecordToday { + if streak.CurrentStreak >= 7 { + message = "太厉害了!连续记账一周以上 🔥" + } else if streak.CurrentStreak >= 3 { + message = "继续保持,养成好习惯!💪" + } else { + message = "今日已记账,明天继续哦 ✅" + } + } else { + if streak.CurrentStreak > 0 { + message = "今天还没有记账哦,别断了连续记录!" + } else { + message = "今天还没有记账哦,开始记录吧!" + } + } + + return &models.StreakInfo{ + CurrentStreak: streak.CurrentStreak, + LongestStreak: streak.LongestStreak, + TotalRecordDays: streak.TotalRecordDays, + HasRecordToday: hasRecordToday, + Message: message, + }, nil +} + +// UpdateStreak updates the streak when a transaction is created +// This should be called after a transaction is created +func (s *StreakService) UpdateStreak(userID uint, transactionDate time.Time) error { + streak, err := s.repo.GetOrCreate(userID) + if err != nil { + return err + } + + // Normalize transaction date to start of day + txDate := time.Date(transactionDate.Year(), transactionDate.Month(), transactionDate.Day(), 0, 0, 0, 0, transactionDate.Location()) + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + // Only update streak for today's date or past dates (not future) + if txDate.After(today) { + return nil // Don't update streak for future transactions + } + + // If this is the first record ever + if streak.LastRecordDate == nil { + streak.CurrentStreak = 1 + streak.LongestStreak = 1 + streak.TotalRecordDays = 1 + streak.LastRecordDate = &txDate + return s.repo.Update(streak) + } + + lastDate := *streak.LastRecordDate + lastDateNormalized := time.Date(lastDate.Year(), lastDate.Month(), lastDate.Day(), 0, 0, 0, 0, lastDate.Location()) + + // If already recorded on this date, no update needed + if txDate.Equal(lastDateNormalized) { + return nil + } + + // If recording for a new date + if txDate.After(lastDateNormalized) { + daysDiff := int(txDate.Sub(lastDateNormalized).Hours() / 24) + + if daysDiff == 1 { + // Consecutive day - increment streak + streak.CurrentStreak++ + } else { + // Streak broken - reset to 1 + streak.CurrentStreak = 1 + } + + // Update longest streak if current is higher + if streak.CurrentStreak > streak.LongestStreak { + streak.LongestStreak = streak.CurrentStreak + } + + // Increment total record days + streak.TotalRecordDays++ + streak.LastRecordDate = &txDate + } else { + // Recording for a past date - just increment total if it's a new day + hasRecord, err := s.repo.HasTransactionOnDate(userID, txDate) + if err != nil { + return err + } + // This is a bit tricky - we'd need to check if this specific date already has records + // For simplicity, if it's a past date and we're adding a new transaction, + // we'll just increment total (this might not be 100% accurate for edits) + if !hasRecord { + streak.TotalRecordDays++ + } + } + + return s.repo.Update(streak) +} + +// RecalculateStreak recalculates the entire streak from transaction history +// This is useful for fixing streak data or after bulk imports +func (s *StreakService) RecalculateStreak(userID uint) error { + streak, err := s.repo.GetOrCreate(userID) + if err != nil { + return err + } + + // Get all transaction dates for the user (last 365 days should be enough) + now := time.Now() + startDate := now.AddDate(-1, 0, 0) // 1 year ago + + dates, err := s.repo.GetTransactionDatesInRange(userID, startDate, now) + if err != nil { + return err + } + + if len(dates) == 0 { + // No transactions, reset streak + streak.CurrentStreak = 0 + streak.LongestStreak = 0 + streak.TotalRecordDays = 0 + streak.LastRecordDate = nil + return s.repo.Update(streak) + } + + // Calculate streaks + streak.TotalRecordDays = len(dates) + + // Find longest streak and current streak + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + currentStreak := 1 + longestStreak := 1 + tempStreak := 1 + + for i := 1; i < len(dates); i++ { + prevDate := dates[i-1] + currDate := dates[i] + daysDiff := int(currDate.Sub(prevDate).Hours() / 24) + + if daysDiff == 1 { + tempStreak++ + if tempStreak > longestStreak { + longestStreak = tempStreak + } + } else { + tempStreak = 1 + } + } + + // Determine current streak (must include today or yesterday) + lastRecordDate := dates[len(dates)-1] + lastRecordNormalized := time.Date(lastRecordDate.Year(), lastRecordDate.Month(), lastRecordDate.Day(), 0, 0, 0, 0, lastRecordDate.Location()) + + daysSinceLastRecord := int(today.Sub(lastRecordNormalized).Hours() / 24) + + if daysSinceLastRecord <= 1 { + // Calculate current streak going backwards from last record + currentStreak = 1 + for i := len(dates) - 2; i >= 0; i-- { + currDate := dates[i+1] + prevDate := dates[i] + daysDiff := int(currDate.Sub(prevDate).Hours() / 24) + + if daysDiff == 1 { + currentStreak++ + } else { + break + } + } + } else { + // Streak is broken + currentStreak = 0 + } + + streak.CurrentStreak = currentStreak + streak.LongestStreak = longestStreak + streak.LastRecordDate = &lastRecordNormalized + + return s.repo.Update(streak) +} + +// CheckAndResetStreak checks if streak should be reset (called daily or on app open) +func (s *StreakService) CheckAndResetStreak(userID uint) error { + streak, err := s.repo.GetOrCreate(userID) + if err != nil { + return err + } + + if streak.LastRecordDate == nil { + return nil + } + + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + lastDate := *streak.LastRecordDate + lastDateNormalized := time.Date(lastDate.Year(), lastDate.Month(), lastDate.Day(), 0, 0, 0, 0, lastDate.Location()) + + daysDiff := int(today.Sub(lastDateNormalized).Hours() / 24) + + // If more than 1 day has passed without recording, reset streak + if daysDiff > 1 { + streak.CurrentStreak = 0 + return s.repo.Update(streak) + } + + return nil +}