feat: 新增路由配置、交易处理和用户连击功能,并初始化相关服务与处理器

This commit is contained in:
2026-01-28 10:08:48 +08:00
parent 4d024eba8e
commit e811256d99
7 changed files with 508 additions and 2 deletions

View File

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

View File

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

View File

@@ -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"` // 提示信息
}

View File

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

View File

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

View File

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