veza/veza-backend-api/internal/services/playback_analytics_rate_limiter.go
2025-12-03 20:29:37 +01:00

370 lines
11 KiB
Go

package services
import (
"context"
"fmt"
"sync"
"time"
"github.com/google/uuid" // Added import
"go.uber.org/zap"
"gorm.io/gorm"
)
// PlaybackAnalyticsRateLimiter gère le rate limiting pour les analytics de playback
// T0389: Create Playback Analytics Rate Limiting
type PlaybackAnalyticsRateLimiter struct {
db *gorm.DB
logger *zap.Logger
// Rate limiting par utilisateur (requêtes par minute)
requestsPerMinute int
requestsWindow time.Duration
// Throttling (délai minimum entre requêtes)
minRequestInterval time.Duration
// Quotas (limites quotidiennes et hebdomadaires)
dailyQuota int
weeklyQuota int
// Cache en mémoire pour le rate limiting
mu sync.RWMutex
userRequests map[uuid.UUID][]time.Time // userID -> []time.Time
userLastRequest map[uuid.UUID]time.Time // userID -> last request time
userDailyCount map[uuid.UUID]int // userID -> daily count
userWeeklyCount map[uuid.UUID]int // userID -> weekly count
lastCleanup time.Time
}
// RateLimitConfig configuration pour le rate limiter
// T0389: Create Playback Analytics Rate Limiting
type RateLimitConfig struct {
RequestsPerMinute int // Nombre de requêtes par minute
RequestsWindow time.Duration // Fenêtre de temps pour les requêtes
MinRequestInterval time.Duration // Délai minimum entre requêtes (throttling)
DailyQuota int // Quota quotidien
WeeklyQuota int // Quota hebdomadaire
}
// DefaultRateLimitConfig retourne une configuration par défaut
func DefaultRateLimitConfig() RateLimitConfig {
return RateLimitConfig{
RequestsPerMinute: 60, // 60 requêtes par minute
RequestsWindow: 1 * time.Minute, // Fenêtre de 1 minute
MinRequestInterval: 1 * time.Second, // Minimum 1 seconde entre requêtes
DailyQuota: 10000, // 10000 analytics par jour
WeeklyQuota: 50000, // 50000 analytics par semaine
}
}
// NewPlaybackAnalyticsRateLimiter crée un nouveau rate limiter pour les analytics
// T0389: Create Playback Analytics Rate Limiting
func NewPlaybackAnalyticsRateLimiter(db *gorm.DB, logger *zap.Logger, config RateLimitConfig) *PlaybackAnalyticsRateLimiter {
if logger == nil {
logger = zap.NewNop()
}
limiter := &PlaybackAnalyticsRateLimiter{
db: db,
logger: logger,
requestsPerMinute: config.RequestsPerMinute,
requestsWindow: config.RequestsWindow,
minRequestInterval: config.MinRequestInterval,
dailyQuota: config.DailyQuota,
weeklyQuota: config.WeeklyQuota,
userRequests: make(map[uuid.UUID][]time.Time),
userLastRequest: make(map[uuid.UUID]time.Time),
userDailyCount: make(map[uuid.UUID]int),
userWeeklyCount: make(map[uuid.UUID]int),
lastCleanup: time.Now(),
}
// Démarrer le nettoyage périodique
go limiter.cleanup()
return limiter
}
// RateLimitResult représente le résultat d'une vérification de rate limit
// T0389: Create Playback Analytics Rate Limiting
type RateLimitResult struct {
Allowed bool
Reason string
RetryAfter time.Duration
Remaining int
QuotaUsed int
QuotaLimit int
}
// CheckRateLimit vérifie si une requête est autorisée selon les limites
// T0389: Create Playback Analytics Rate Limiting
func (rl *PlaybackAnalyticsRateLimiter) CheckRateLimit(ctx context.Context, userID uuid.UUID) (*RateLimitResult, error) {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
// Nettoyer périodiquement le cache
if now.Sub(rl.lastCleanup) > 5*time.Minute {
rl.cleanupLocked(now)
rl.lastCleanup = now
}
// 1. Vérifier le throttling (délai minimum entre requêtes)
if lastRequest, exists := rl.userLastRequest[userID]; exists {
timeSinceLastRequest := now.Sub(lastRequest)
if timeSinceLastRequest < rl.minRequestInterval {
retryAfter := rl.minRequestInterval - timeSinceLastRequest
return &RateLimitResult{
Allowed: false,
Reason: "throttling: request too soon",
RetryAfter: retryAfter,
}, nil
}
}
// 2. Vérifier le rate limiting (requêtes par minute)
cutoff := now.Add(-rl.requestsWindow)
validRequests := []time.Time{}
if requests, exists := rl.userRequests[userID]; exists {
for _, reqTime := range requests {
if reqTime.After(cutoff) {
validRequests = append(validRequests, reqTime)
}
}
}
if len(validRequests) >= rl.requestsPerMinute {
// Calculer le temps d'attente jusqu'à ce que la plus ancienne requête expire
oldestRequest := validRequests[0]
retryAfter := oldestRequest.Add(rl.requestsWindow).Sub(now)
if retryAfter < 0 {
retryAfter = 0
}
return &RateLimitResult{
Allowed: false,
Reason: fmt.Sprintf("rate limit exceeded: %d requests per %v", rl.requestsPerMinute, rl.requestsWindow),
RetryAfter: retryAfter,
Remaining: 0,
}, nil
}
// 3. Vérifier les quotas (quotas quotidiens et hebdomadaires)
dailyCount, weeklyCount, err := rl.getQuotaCounts(ctx, userID, now)
if err != nil {
rl.logger.Warn("Failed to get quota counts, using cache",
zap.Error(err),
zap.String("user_id", userID.String())) // Utiliser les valeurs en cache en cas d'erreur
dailyCount = rl.userDailyCount[userID]
weeklyCount = rl.userWeeklyCount[userID]
}
if dailyCount >= rl.dailyQuota {
return &RateLimitResult{
Allowed: false,
Reason: fmt.Sprintf("daily quota exceeded: %d/%d", dailyCount, rl.dailyQuota),
RetryAfter: timeUntilMidnight(now),
QuotaUsed: dailyCount,
QuotaLimit: rl.dailyQuota,
}, nil
}
if weeklyCount >= rl.weeklyQuota {
return &RateLimitResult{
Allowed: false,
Reason: fmt.Sprintf("weekly quota exceeded: %d/%d", weeklyCount, rl.weeklyQuota),
RetryAfter: timeUntilNextWeek(now),
QuotaUsed: weeklyCount,
QuotaLimit: rl.weeklyQuota,
}, nil
}
// Toutes les vérifications passées, autoriser la requête
validRequests = append(validRequests, now)
rl.userRequests[userID] = validRequests
rl.userLastRequest[userID] = now
remaining := rl.requestsPerMinute - len(validRequests)
return &RateLimitResult{
Allowed: true,
Remaining: remaining,
QuotaUsed: dailyCount,
QuotaLimit: rl.dailyQuota,
}, nil
}
// RecordRequest enregistre une requête (appelé après qu'une requête a été traitée avec succès)
// T0389: Create Playback Analytics Rate Limiting
func (rl *PlaybackAnalyticsRateLimiter) RecordRequest(ctx context.Context, userID uuid.UUID) error {
rl.mu.Lock()
defer rl.mu.Unlock()
// Mettre à jour les compteurs de quota
rl.userDailyCount[userID]++
rl.userWeeklyCount[userID]++
// Enregistrer dans la base de données pour persistance
// Note: On pourrait créer une table de quotas si nécessaire
// Pour l'instant, on utilise uniquement le cache en mémoire
return nil
}
// GetQuotaInfo retourne les informations de quota pour un utilisateur
// T0389: Create Playback Analytics Rate Limiting
func (rl *PlaybackAnalyticsRateLimiter) GetQuotaInfo(ctx context.Context, userID uuid.UUID) (map[string]interface{}, error) {
rl.mu.RLock()
defer rl.mu.RUnlock()
now := time.Now()
dailyCount, weeklyCount, err := rl.getQuotaCounts(ctx, userID, now)
if err != nil {
// Utiliser les valeurs en cache
dailyCount = rl.userDailyCount[userID]
weeklyCount = rl.userWeeklyCount[userID]
}
// Calculer les requêtes restantes dans la fenêtre actuelle
cutoff := now.Add(-rl.requestsWindow)
validRequests := []time.Time{}
if requests, exists := rl.userRequests[userID]; exists {
for _, reqTime := range requests {
if reqTime.After(cutoff) {
validRequests = append(validRequests, reqTime)
}
}
}
remainingRequests := rl.requestsPerMinute - len(validRequests)
if remainingRequests < 0 {
remainingRequests = 0
}
return map[string]interface{}{
"rate_limit": map[string]interface{}{
"requests_per_minute": rl.requestsPerMinute,
"remaining": remainingRequests,
"window": rl.requestsWindow.String(),
},
"throttling": map[string]interface{}{
"min_interval": rl.minRequestInterval.String(),
},
"quotas": map[string]interface{}{
"daily": map[string]interface{}{
"used": dailyCount,
"limit": rl.dailyQuota,
"remaining": rl.dailyQuota - dailyCount,
},
"weekly": map[string]interface{}{
"used": weeklyCount,
"limit": rl.weeklyQuota,
"remaining": rl.weeklyQuota - weeklyCount,
},
},
}, nil
}
// getQuotaCounts récupère les compteurs de quota depuis la base de données
// T0389: Create Playback Analytics Rate Limiting
func (rl *PlaybackAnalyticsRateLimiter) getQuotaCounts(ctx context.Context, userID uuid.UUID, now time.Time) (int, int, error) {
// Calculer les dates de début
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
startOfWeek := startOfDay
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7 // Dimanche = 7
}
startOfWeek = startOfWeek.AddDate(0, 0, -weekday+1) // Lundi
// Compter les analytics enregistrées aujourd'hui
var dailyCount int64
err := rl.db.WithContext(ctx).
Model(&struct {
Count int64
}{}).
Select("COUNT(*)").
Table("playback_analytics").
Where("user_id = ? AND created_at >= ?", userID.String(), startOfDay).
Scan(&dailyCount).Error
if err != nil {
return 0, 0, err
}
// Compter les analytics enregistrées cette semaine
var weeklyCount int64
err = rl.db.WithContext(ctx).
Model(&struct {
Count int64
}{}).
Select("COUNT(*)").
Table("playback_analytics").
Where("user_id = ? AND created_at >= ?", userID.String(), startOfWeek).
Scan(&weeklyCount).Error
if err != nil {
return 0, 0, err
}
return int(dailyCount), int(weeklyCount), nil
}
// cleanup nettoie périodiquement le cache
// T0389: Create Playback Analytics Rate Limiting
func (rl *PlaybackAnalyticsRateLimiter) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
rl.cleanupLocked(time.Now())
rl.lastCleanup = time.Now()
rl.mu.Unlock()
}
}
// cleanupLocked nettoie le cache (doit être appelé avec le mutex verrouillé)
// T0389: Create Playback Analytics Rate Limiting
func (rl *PlaybackAnalyticsRateLimiter) cleanupLocked(now time.Time) {
cutoff := now.Add(-rl.requestsWindow)
// Nettoyer les requêtes expirées
for userID, requests := range rl.userRequests {
validRequests := []time.Time{}
for _, reqTime := range requests {
if reqTime.After(cutoff) {
validRequests = append(validRequests, reqTime)
}
}
if len(validRequests) == 0 {
delete(rl.userRequests, userID)
} else {
rl.userRequests[userID] = validRequests
}
}
// Nettoyer les dernières requêtes si trop anciennes
cutoffLastRequest := now.Add(-1 * time.Hour)
for userID, lastRequest := range rl.userLastRequest {
if lastRequest.Before(cutoffLastRequest) {
delete(rl.userLastRequest, userID)
}
}
}
// timeUntilMidnight calcule le temps jusqu'à minuit
func timeUntilMidnight(now time.Time) time.Duration {
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
return midnight.Sub(now)
}
// timeUntilNextWeek calcule le temps jusqu'au prochain lundi
func timeUntilNextWeek(now time.Time) time.Duration {
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7 // Dimanche = 7
}
daysUntilMonday := 8 - weekday // Jours jusqu'au prochain lundi
nextMonday := time.Date(now.Year(), now.Month(), now.Day()+daysUntilMonday, 0, 0, 0, 0, now.Location())
return nextMonday.Sub(now)
}