Files
Novault-backend/internal/service/interest_scheduler.go
2026-01-25 21:59:00 +08:00

338 lines
11 KiB
Go

package service
import (
"context"
"fmt"
"log"
"sync"
"time"
)
// InterestScheduler handles scheduled calculation of daily interest for all enabled accounts
// Feature: financial-core-upgrade
// Validates: Requirements 17.1-17.6
type InterestScheduler struct {
interestService *InterestService
executionTime time.Duration // Time of day to execute (e.g., 5 minutes after midnight)
stopChan chan struct{}
mu sync.Mutex
running bool
lastExecution time.Time
}
// InterestSchedulerConfig holds configuration for the interest scheduler
type InterestSchedulerConfig struct {
// ExecutionHour is the hour of day to run (0-23), default 0
ExecutionHour int
// ExecutionMinute is the minute of hour to run (0-59), default 5
ExecutionMinute int
}
// DefaultInterestSchedulerConfig returns the default configuration
// Default execution time: 00:05 (5 minutes after midnight)
// Validates: Requirements 17.1
func DefaultInterestSchedulerConfig() InterestSchedulerConfig {
return InterestSchedulerConfig{
ExecutionHour: 0,
ExecutionMinute: 5,
}
}
// NewInterestScheduler creates a new InterestScheduler instance
// Validates: Requirements 17.1
func NewInterestScheduler(interestService *InterestService, config InterestSchedulerConfig) *InterestScheduler {
// Calculate execution time as duration from midnight
executionTime := time.Duration(config.ExecutionHour)*time.Hour + time.Duration(config.ExecutionMinute)*time.Minute
return &InterestScheduler{
interestService: interestService,
executionTime: executionTime,
stopChan: make(chan struct{}),
running: false,
}
}
// Start begins the scheduled interest calculation
// It checks for missed calculations on startup, then runs daily at the configured time
// This method blocks until Stop() is called or context is cancelled
// Validates: Requirements 17.1, 17.6
func (s *InterestScheduler) Start(ctx context.Context) {
s.mu.Lock()
if s.running {
s.mu.Unlock()
log.Println("[InterestScheduler] Scheduler is already running")
return
}
s.running = true
s.stopChan = make(chan struct{}) // Reset stop channel
s.mu.Unlock()
log.Printf("[InterestScheduler] Starting interest scheduler, execution time: %02d:%02d",
int(s.executionTime.Hours()), int(s.executionTime.Minutes())%60)
// Check for missed calculations on startup (Requirement 17.6)
s.checkMissedCalculations(ctx)
// Calculate time until next execution
nextExecution := s.calculateNextExecution()
log.Printf("[InterestScheduler] Next scheduled execution: %s", nextExecution.Format("2006-01-02 15:04:05"))
timer := time.NewTimer(time.Until(nextExecution))
defer timer.Stop()
for {
select {
case <-timer.C:
s.executeInterestCalculation(ctx)
// Reset timer for next day
nextExecution = s.calculateNextExecution()
log.Printf("[InterestScheduler] Next scheduled execution: %s", nextExecution.Format("2006-01-02 15:04:05"))
timer.Reset(time.Until(nextExecution))
case <-s.stopChan:
log.Println("[InterestScheduler] Scheduler stopped by Stop() call")
s.mu.Lock()
s.running = false
s.mu.Unlock()
return
case <-ctx.Done():
log.Println("[InterestScheduler] Scheduler stopped due to context cancellation")
s.mu.Lock()
s.running = false
s.mu.Unlock()
return
}
}
}
// Stop gracefully stops the scheduler
func (s *InterestScheduler) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
log.Println("[InterestScheduler] Scheduler is not running")
return
}
log.Println("[InterestScheduler] Stopping scheduler...")
close(s.stopChan)
}
// IsRunning returns whether the scheduler is currently running
func (s *InterestScheduler) IsRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
// GetLastExecution returns the time of the last execution
func (s *InterestScheduler) GetLastExecution() time.Time {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastExecution
}
// calculateNextExecution calculates the next execution time
func (s *InterestScheduler) calculateNextExecution() time.Time {
now := time.Now()
// Calculate today's execution time
todayExecution := time.Date(
now.Year(), now.Month(), now.Day(),
int(s.executionTime.Hours()),
int(s.executionTime.Minutes())%60,
0, 0, now.Location(),
)
// If today's execution time has passed, schedule for tomorrow
if now.After(todayExecution) {
return todayExecution.Add(24 * time.Hour)
}
return todayExecution
}
// checkMissedCalculations checks for and processes any missed interest calculations
// This is called on startup to ensure no interest calculations are missed
// Validates: Requirements 17.6
func (s *InterestScheduler) checkMissedCalculations(ctx context.Context) {
log.Println("[InterestScheduler] Checking for missed interest calculations...")
// Get all interest-enabled accounts
accounts, err := s.interestService.GetInterestEnabledAccounts()
if err != nil {
log.Printf("[InterestScheduler] Error getting interest-enabled accounts: %v", err)
return
}
if len(accounts) == 0 {
log.Println("[InterestScheduler] No interest-enabled accounts found")
return
}
// Check yesterday's date (the most recent date that should have been calculated)
yesterday := time.Now().AddDate(0, 0, -1)
yesterday = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location())
missedCount := 0
processedCount := 0
for _, account := range accounts {
select {
case <-ctx.Done():
log.Println("[InterestScheduler] Missed calculation check cancelled")
return
default:
}
// Check if interest was calculated for yesterday
calculated, err := s.interestService.IsInterestCalculated(account.ID, yesterday)
if err != nil {
log.Printf("[InterestScheduler] Error checking interest calculation for account %d: %v", account.ID, err)
continue
}
if !calculated {
missedCount++
log.Printf("[InterestScheduler] Processing missed interest calculation for account %d (%s) for date %s",
account.ID, account.Name, yesterday.Format("2006-01-02"))
result, err := s.interestService.CalculateDailyInterest(account.UserID, account.ID, yesterday)
if err != nil {
log.Printf("[InterestScheduler] Error calculating missed interest for account %d: %v", account.ID, err)
continue
}
if result != nil && result.DailyInterest > 0 {
processedCount++
log.Printf("[InterestScheduler] Processed missed interest for account %d: %.2f", account.ID, result.DailyInterest)
}
}
}
if missedCount > 0 {
log.Printf("[InterestScheduler] Missed calculation check complete: %d missed, %d processed", missedCount, processedCount)
} else {
log.Println("[InterestScheduler] No missed calculations found")
}
}
// executeInterestCalculation executes the daily interest calculation for all enabled accounts
// Validates: Requirements 17.2, 17.3, 17.4
func (s *InterestScheduler) executeInterestCalculation(ctx context.Context) {
startTime := time.Now()
log.Printf("[InterestScheduler] Starting daily interest calculation at %s", startTime.Format("2006-01-02 15:04:05"))
// Use today's date for calculation
calculationDate := time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, startTime.Location())
// Get all interest-enabled accounts
accounts, err := s.interestService.GetInterestEnabledAccounts()
if err != nil {
log.Printf("[InterestScheduler] Error getting interest-enabled accounts: %v", err)
return
}
totalAccounts := len(accounts)
successCount := 0
skipCount := 0
errorCount := 0
totalInterest := 0.0
log.Printf("[InterestScheduler] Processing %d interest-enabled accounts", totalAccounts)
// Process each account independently (Requirement 17.4)
for _, account := range accounts {
select {
case <-ctx.Done():
log.Println("[InterestScheduler] Interest calculation cancelled")
return
default:
}
result, err := s.interestService.CalculateDailyInterest(account.UserID, account.ID, calculationDate)
if err != nil {
// Log error but continue with other accounts (Requirement 17.4)
errorCount++
log.Printf("[InterestScheduler] Error calculating interest for account %d (%s): %v",
account.ID, account.Name, err)
continue
}
if result == nil {
skipCount++
continue
}
if result.DailyInterest > 0 {
successCount++
totalInterest += result.DailyInterest
log.Printf("[InterestScheduler] Account %d (%s): balance=%.2f, rate=%.4f, interest=%.2f",
result.AccountID, result.AccountName, result.Balance, result.AnnualRate, result.DailyInterest)
} else {
skipCount++
}
}
// Update last execution time
s.mu.Lock()
s.lastExecution = startTime
s.mu.Unlock()
// Log execution summary (Requirement 17.3)
endTime := time.Now()
duration := endTime.Sub(startTime)
log.Printf("[InterestScheduler] Daily interest calculation completed:")
log.Printf("[InterestScheduler] Start time: %s", startTime.Format("2006-01-02 15:04:05"))
log.Printf("[InterestScheduler] End time: %s", endTime.Format("2006-01-02 15:04:05"))
log.Printf("[InterestScheduler] Duration: %v", duration)
log.Printf("[InterestScheduler] Total accounts: %d", totalAccounts)
log.Printf("[InterestScheduler] Successful: %d", successCount)
log.Printf("[InterestScheduler] Skipped: %d", skipCount)
log.Printf("[InterestScheduler] Errors: %d", errorCount)
log.Printf("[InterestScheduler] Total interest: %.2f", totalInterest)
}
// ForceCalculation triggers an immediate interest calculation for a specific user outside of the regular schedule
// This can be used for manual trigger or testing
func (s *InterestScheduler) ForceCalculation(ctx context.Context, userID uint) (*InterestCalculationSummary, error) {
log.Printf("[InterestScheduler] Force calculation triggered for user %d", userID)
startTime := time.Now()
calculationDate := time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, startTime.Location())
results, err := s.interestService.CalculateAllInterest(userID, calculationDate)
if err != nil {
return nil, fmt.Errorf("failed to calculate interest: %w", err)
}
endTime := time.Now()
summary := &InterestCalculationSummary{
StartTime: startTime,
EndTime: endTime,
Duration: endTime.Sub(startTime),
AccountsCount: len(results),
Results: results,
}
// Calculate total interest
for _, r := range results {
summary.TotalInterest += r.DailyInterest
}
return summary, nil
}
// InterestCalculationSummary represents the summary of an interest calculation run
type InterestCalculationSummary struct {
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Duration time.Duration `json:"duration"`
AccountsCount int `json:"accounts_count"`
TotalInterest float64 `json:"total_interest"`
Results []InterestResult `json:"results"`
}