package services import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" "veza-backend-api/internal/models" ) // PlaybackAnalyticsService gère les analytics de lecture de tracks // T0357: Create Playback Analytics Service // T0381: Create Playback Analytics Performance Optimization type PlaybackAnalyticsService struct { db *gorm.DB logger *zap.Logger cache *CacheService // Optionnel, pour le cache des agrégations cacheTTL time.Duration // TTL pour le cache des statistiques batchSize int // Taille du batch pour l'enregistrement en lot } // NewPlaybackAnalyticsService crée un nouveau service d'analytics de lecture func NewPlaybackAnalyticsService(db *gorm.DB, logger *zap.Logger) *PlaybackAnalyticsService { if logger == nil { logger = zap.NewNop() } return &PlaybackAnalyticsService{ db: db, logger: logger, cache: nil, // Cache optionnel cacheTTL: 5 * time.Minute, // TTL par défaut de 5 minutes batchSize: 100, // Taille de batch par défaut } } // NewPlaybackAnalyticsServiceWithCache crée un nouveau service avec cache // T0381: Create Playback Analytics Performance Optimization func NewPlaybackAnalyticsServiceWithCache(db *gorm.DB, cache *CacheService, logger *zap.Logger) *PlaybackAnalyticsService { service := NewPlaybackAnalyticsService(db, logger) service.cache = cache return service } // SetBatchSize définit la taille du batch pour l'enregistrement en lot // T0381: Create Playback Analytics Performance Optimization func (s *PlaybackAnalyticsService) SetBatchSize(size int) { if size > 0 { s.batchSize = size } } // RecordPlayback enregistre un événement d'analytics de lecture // T0357: Create Playback Analytics Service func (s *PlaybackAnalyticsService) RecordPlayback(ctx context.Context, analytics *models.PlaybackAnalytics) error { // Valider les paramètres if analytics.TrackID == uuid.Nil { return fmt.Errorf("invalid track ID: 0") } if analytics.UserID == uuid.Nil { return fmt.Errorf("invalid user ID: nil UUID") } if analytics.PlayTime < 0 { return fmt.Errorf("invalid play time: %d", analytics.PlayTime) } if analytics.PauseCount < 0 { return fmt.Errorf("invalid pause count: %d", analytics.PauseCount) } if analytics.SeekCount < 0 { return fmt.Errorf("invalid seek count: %d", analytics.SeekCount) } if analytics.CompletionRate < 0 || analytics.CompletionRate > 100 { return fmt.Errorf("invalid completion rate: %f (must be between 0 and 100)", analytics.CompletionRate) } if analytics.StartedAt.IsZero() { return fmt.Errorf("started_at is required") } // Vérifier que le track existe var track models.Track if err := s.db.WithContext(ctx).First(&track, analytics.TrackID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrTrackNotFound } return fmt.Errorf("failed to get track: %w", err) } // Calculer le taux de complétion si non fourni if analytics.CompletionRate == 0 && track.Duration > 0 { analytics.CompletionRate = s.CalculateCompletionRate(analytics.PlayTime, track.Duration) } // Enregistrer l'analytics avec retry logic // T0385: Create Playback Analytics Error Handling maxRetries := 3 var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { err := s.db.WithContext(ctx).Create(analytics).Error if err == nil { // Succès if attempt > 0 { s.logger.Info("Playback analytics recorded after retry", zap.Int("attempt", attempt+1), zap.String("track_id", analytics.TrackID.String()), zap.String("user_id", analytics.UserID.String())) } break } lastErr = err // Logger l'erreur s.logger.Warn("Failed to record playback analytics, retrying", zap.Error(err), zap.Int("attempt", attempt+1), zap.Int("max_retries", maxRetries), zap.String("track_id", analytics.TrackID.String()), zap.String("user_id", analytics.UserID.String())) // Ne pas retry pour certaines erreurs (contraintes, etc.) if attempt < maxRetries-1 { // Attendre avant de retry (exponential backoff) backoffDuration := time.Duration(attempt+1) * 100 * time.Millisecond time.Sleep(backoffDuration) } } if lastErr != nil { s.logger.Error("Failed to record playback analytics after all retries", zap.Error(lastErr), zap.Int("max_retries", maxRetries), zap.String("track_id", analytics.TrackID.String()), zap.String("user_id", analytics.UserID.String())) return fmt.Errorf("failed to record playback analytics after %d retries: %w", maxRetries, lastErr) } // Invalider le cache si disponible if s.cache != nil { cacheKey := fmt.Sprintf("playback_stats:track:%s", analytics.TrackID) if err := s.cache.Delete(ctx, cacheKey); err != nil { s.logger.Warn("Failed to invalidate cache", zap.Error(err), zap.String("track_id", analytics.TrackID.String())) } } s.logger.Info("Playback analytics recorded", zap.String("id", analytics.ID.String()), zap.String("track_id", analytics.TrackID.String()), zap.String("user_id", analytics.UserID.String()), zap.Int("play_time", analytics.PlayTime), zap.Float64("completion_rate", analytics.CompletionRate)) return nil } // RecordPlaybackBatch enregistre plusieurs analytics en lot pour optimiser les performances // T0381: Create Playback Analytics Performance Optimization func (s *PlaybackAnalyticsService) RecordPlaybackBatch(ctx context.Context, analyticsList []*models.PlaybackAnalytics) error { if len(analyticsList) == 0 { return fmt.Errorf("analytics list cannot be empty") } // Valider tous les analytics avant l'insertion for i, analytics := range analyticsList { if analytics.TrackID == uuid.Nil { return fmt.Errorf("invalid track ID at index %d: 0", i) } if analytics.UserID == uuid.Nil { return fmt.Errorf("invalid user ID at index %d: nil UUID", i) } if analytics.PlayTime < 0 { return fmt.Errorf("invalid play time at index %d: %d", i, analytics.PlayTime) } if analytics.StartedAt.IsZero() { return fmt.Errorf("started_at is required at index %d", i) } } // Enregistrer par batch pour optimiser les performances trackIDs := make(map[uuid.UUID]bool) for i := 0; i < len(analyticsList); i += s.batchSize { end := i + s.batchSize if end > len(analyticsList) { end = len(analyticsList) } batch := analyticsList[i:end] if err := s.db.WithContext(ctx).Create(batch).Error; err != nil { s.logger.Error("Failed to record playback analytics batch", zap.Error(err), zap.Int("batch_start", i), zap.Int("batch_end", end)) return fmt.Errorf("failed to record playback analytics batch: %w", err) } // Collecter les track IDs pour invalider le cache for _, analytics := range batch { trackIDs[analytics.TrackID] = true } } // Invalider le cache pour tous les tracks affectés if s.cache != nil { for trackID := range trackIDs { cacheKey := fmt.Sprintf("playback_stats:track:%s", trackID) if err := s.cache.Delete(ctx, cacheKey); err != nil { s.logger.Warn("Failed to invalidate cache", zap.Error(err), zap.String("track_id", trackID.String())) } } } s.logger.Info("Playback analytics batch recorded", zap.Int("count", len(analyticsList)), zap.Int("batches", (len(analyticsList)+s.batchSize-1)/s.batchSize)) return nil } // CalculateCompletionRate calcule le taux de complétion en pourcentage // playTime: temps de lecture en secondes // trackDuration: durée totale du track en secondes // Retourne le taux de complétion (0-100) func (s *PlaybackAnalyticsService) CalculateCompletionRate(playTime int, trackDuration int) float64 { if trackDuration <= 0 { return 0.0 } if playTime < 0 { return 0.0 } rate := float64(playTime) / float64(trackDuration) * 100.0 // Limiter à 100% if rate > 100.0 { rate = 100.0 } return rate } // PlaybackStats représente les statistiques agrégées de lecture type PlaybackStats struct { TotalSessions int64 `json:"total_sessions"` TotalPlayTime int64 `json:"total_play_time"` // seconds AveragePlayTime float64 `json:"average_play_time"` // seconds TotalPauses int64 `json:"total_pauses"` AveragePauses float64 `json:"average_pauses"` TotalSeeks int64 `json:"total_seeks"` AverageSeeks float64 `json:"average_seeks"` AverageCompletion float64 `json:"average_completion"` // percentage CompletionRate float64 `json:"completion_rate"` // percentage of sessions with >90% completion } // GetTrackStats récupère les statistiques agrégées pour un track // T0381: Optimisé avec cache func (s *PlaybackAnalyticsService) GetTrackStats(ctx context.Context, trackID uuid.UUID) (*PlaybackStats, error) { if trackID == uuid.Nil { return nil, fmt.Errorf("invalid track ID: 0") } // Vérifier le cache si disponible if s.cache != nil { cacheKey := fmt.Sprintf("playback_stats:track:%s", trackID) var cachedStats PlaybackStats if err := s.cache.Get(ctx, cacheKey, &cachedStats); err == nil { s.logger.Debug("Cache hit for track stats", zap.String("track_id", trackID.String())) return &cachedStats, nil } } // Vérifier que le track existe var track models.Track if err := s.db.WithContext(ctx).First(&track, trackID).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("track not found: %s", trackID) } return nil, fmt.Errorf("failed to get track: %w", err) } var stats PlaybackStats // Total sessions if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ?", trackID). Count(&stats.TotalSessions).Error; err != nil { return nil, fmt.Errorf("failed to count sessions: %w", err) } if stats.TotalSessions == 0 { return &stats, nil } // Total play time var totalPlayTime int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ?", trackID). Select("COALESCE(SUM(play_time), 0)"). Scan(&totalPlayTime).Error; err != nil { return nil, fmt.Errorf("failed to calculate total play time: %w", err) } stats.TotalPlayTime = totalPlayTime // Average play time stats.AveragePlayTime = float64(totalPlayTime) / float64(stats.TotalSessions) // Total pauses var totalPauses int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ?", trackID). Select("COALESCE(SUM(pause_count), 0)"). Scan(&totalPauses).Error; err != nil { return nil, fmt.Errorf("failed to calculate total pauses: %w", err) } stats.TotalPauses = totalPauses stats.AveragePauses = float64(totalPauses) / float64(stats.TotalSessions) // Total seeks var totalSeeks int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ?", trackID). Select("COALESCE(SUM(seek_count), 0)"). Scan(&totalSeeks).Error; err != nil { return nil, fmt.Errorf("failed to calculate total seeks: %w", err) } stats.TotalSeeks = totalSeeks stats.AverageSeeks = float64(totalSeeks) / float64(stats.TotalSessions) // Average completion rate var avgCompletion float64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ?", trackID). Select("COALESCE(AVG(completion_rate), 0)"). Scan(&avgCompletion).Error; err != nil { return nil, fmt.Errorf("failed to calculate average completion: %w", err) } stats.AverageCompletion = avgCompletion // Completion rate (sessions with >90% completion) var completedSessions int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND completion_rate >= 90", trackID). Count(&completedSessions).Error; err != nil { return nil, fmt.Errorf("failed to count completed sessions: %w", err) } if stats.TotalSessions > 0 { stats.CompletionRate = float64(completedSessions) / float64(stats.TotalSessions) * 100.0 } // SECURITY(MEDIUM-012): K-anonymity — suppress behavioral breakdown when fewer // than 5 unique listeners to prevent individual re-identification. const kAnonymityThreshold int64 = 5 var uniqueListeners int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ?", trackID). Select("COUNT(DISTINCT user_id)"). Scan(&uniqueListeners).Error; err != nil { s.logger.Warn("Failed to count unique listeners for k-anonymity", zap.Error(err)) // Fail-safe: suppress behavioral data if we can't verify threshold uniqueListeners = 0 } if uniqueListeners < kAnonymityThreshold { stats.AveragePlayTime = 0 stats.AveragePauses = 0 stats.AverageSeeks = 0 stats.AverageCompletion = 0 stats.CompletionRate = 0 } // Mettre en cache si disponible if s.cache != nil { cacheKey := fmt.Sprintf("playback_stats:track:%s", trackID) if err := s.cache.Set(ctx, cacheKey, stats, s.cacheTTL); err != nil { s.logger.Warn("Failed to cache track stats", zap.Error(err), zap.String("track_id", trackID.String())) } } return &stats, nil } // GetUserStats récupère les statistiques agrégées pour un utilisateur func (s *PlaybackAnalyticsService) GetUserStats(ctx context.Context, userID uuid.UUID) (*PlaybackStats, error) { if userID == uuid.Nil { return nil, fmt.Errorf("invalid user ID: nil UUID") } // Vérifier que l'utilisateur existe var user models.User if err := s.db.WithContext(ctx).First(&user, userID).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("user not found: %s", userID) } return nil, fmt.Errorf("failed to get user: %w", err) } var stats PlaybackStats // Total sessions if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("user_id = ?", userID). Count(&stats.TotalSessions).Error; err != nil { return nil, fmt.Errorf("failed to count sessions: %w", err) } if stats.TotalSessions == 0 { return &stats, nil } // Total play time var totalPlayTime int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("user_id = ?", userID). Select("COALESCE(SUM(play_time), 0)"). Scan(&totalPlayTime).Error; err != nil { return nil, fmt.Errorf("failed to calculate total play time: %w", err) } stats.TotalPlayTime = totalPlayTime stats.AveragePlayTime = float64(totalPlayTime) / float64(stats.TotalSessions) // Total pauses var totalPauses int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("user_id = ?", userID). Select("COALESCE(SUM(pause_count), 0)"). Scan(&totalPauses).Error; err != nil { return nil, fmt.Errorf("failed to calculate total pauses: %w", err) } stats.TotalPauses = totalPauses stats.AveragePauses = float64(totalPauses) / float64(stats.TotalSessions) // Total seeks var totalSeeks int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("user_id = ?", userID). Select("COALESCE(SUM(seek_count), 0)"). Scan(&totalSeeks).Error; err != nil { return nil, fmt.Errorf("failed to calculate total seeks: %w", err) } stats.TotalSeeks = totalSeeks stats.AverageSeeks = float64(totalSeeks) / float64(stats.TotalSessions) // Average completion rate var avgCompletion float64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("user_id = ?", userID). Select("COALESCE(AVG(completion_rate), 0)"). Scan(&avgCompletion).Error; err != nil { return nil, fmt.Errorf("failed to calculate average completion: %w", err) } stats.AverageCompletion = avgCompletion // Completion rate (sessions with >90% completion) var completedSessions int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("user_id = ? AND completion_rate >= 90", userID). Count(&completedSessions).Error; err != nil { return nil, fmt.Errorf("failed to count completed sessions: %w", err) } if stats.TotalSessions > 0 { stats.CompletionRate = float64(completedSessions) / float64(stats.TotalSessions) * 100.0 } return &stats, nil } // GetSessionsByDateRange récupère les sessions dans une plage de dates func (s *PlaybackAnalyticsService) GetSessionsByDateRange(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time) ([]models.PlaybackAnalytics, error) { return s.GetSessionsByDateRangePaginated(ctx, trackID, startDate, endDate, 0, 0) } // PaginationParams représente les paramètres de pagination // T0381: Create Playback Analytics Performance Optimization type PaginationParams struct { Page int // Numéro de page (commence à 1) PageSize int // Taille de la page } // PaginatedResult représente un résultat paginé // T0381: Create Playback Analytics Performance Optimization type PaginatedResult[T any] struct { Data []T `json:"data"` Total int64 `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` TotalPages int `json:"total_pages"` } // GetSessionsByDateRangePaginated récupère les sessions dans une plage de dates avec pagination // T0381: Create Playback Analytics Performance Optimization func (s *PlaybackAnalyticsService) GetSessionsByDateRangePaginated(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, page, pageSize int) ([]models.PlaybackAnalytics, error) { if trackID == uuid.Nil { return nil, fmt.Errorf("invalid track ID: 0") } query := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND created_at >= ? AND created_at <= ?", trackID, startDate, endDate). Order("created_at DESC") // Appliquer la pagination si spécifiée if pageSize > 0 { offset := (page - 1) * pageSize if offset < 0 { offset = 0 } query = query.Offset(offset).Limit(pageSize) } var sessions []models.PlaybackAnalytics err := query.Find(&sessions).Error if err != nil { return nil, fmt.Errorf("failed to get sessions: %w", err) } return sessions, nil } // GetSessionsByDateRangePaginatedResult récupère les sessions avec pagination complète // T0381: Create Playback Analytics Performance Optimization func (s *PlaybackAnalyticsService) GetSessionsByDateRangePaginatedResult(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, page, pageSize int) (*PaginatedResult[models.PlaybackAnalytics], error) { if trackID == uuid.Nil { return nil, fmt.Errorf("invalid track ID: 0") } if page < 1 { page = 1 } if pageSize < 1 { pageSize = 50 // Taille par défaut } if pageSize > 1000 { pageSize = 1000 // Limite maximale } // Compter le total var total int64 err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND created_at >= ? AND created_at <= ?", trackID, startDate, endDate). Count(&total).Error if err != nil { return nil, fmt.Errorf("failed to count sessions: %w", err) } // Récupérer les données paginées sessions, err := s.GetSessionsByDateRangePaginated(ctx, trackID, startDate, endDate, page, pageSize) if err != nil { return nil, err } totalPages := int((total + int64(pageSize) - 1) / int64(pageSize)) return &PaginatedResult[models.PlaybackAnalytics]{ Data: sessions, Total: total, Page: page, PageSize: pageSize, TotalPages: totalPages, }, nil } // TrackCompletion détecte et enregistre la completion d'un track (≥95%) // T0366: Create Playback Completion Tracking func (s *PlaybackAnalyticsService) TrackCompletion(ctx context.Context, analytics *models.PlaybackAnalytics, trackDuration int) error { if analytics == nil { return fmt.Errorf("analytics cannot be nil") } if analytics.ID == uuid.Nil { return fmt.Errorf("analytics must be saved before tracking completion") } if trackDuration <= 0 { return fmt.Errorf("invalid track duration: %d", trackDuration) } // Calculer le taux de complétion completionRate := s.CalculateCompletionRate(analytics.PlayTime, trackDuration) analytics.CompletionRate = completionRate // Détecter si le track est complété (≥95%) if completionRate >= 95.0 { // Marquer comme complété en définissant EndedAt now := time.Now() analytics.EndedAt = &now s.logger.Info("Track completion detected", zap.String("analytics_id", analytics.ID.String()), zap.String("track_id", analytics.TrackID.String()), zap.String("user_id", analytics.UserID.String()), zap.Float64("completion_rate", completionRate), zap.Int("play_time", analytics.PlayTime), zap.Int("track_duration", trackDuration)) } // Mettre à jour les analytics dans la base de données if err := s.db.WithContext(ctx).Save(analytics).Error; err != nil { s.logger.Error("Failed to update analytics completion", zap.Error(err), zap.String("analytics_id", analytics.ID.String()), zap.String("track_id", analytics.TrackID.String())) return fmt.Errorf("failed to update analytics completion: %w", err) } return nil } // UpdatePlaybackProgress met à jour le progrès de lecture et détecte la completion // T0366: Create Playback Completion Tracking func (s *PlaybackAnalyticsService) UpdatePlaybackProgress(ctx context.Context, analyticsID uuid.UUID, playTime int, trackDuration int) error { if analyticsID == uuid.Nil { return fmt.Errorf("invalid analytics ID: 0") } if playTime < 0 { return fmt.Errorf("invalid play time: %d", playTime) } if trackDuration <= 0 { return fmt.Errorf("invalid track duration: %d", trackDuration) } // Récupérer l'analytics existant var analytics models.PlaybackAnalytics if err := s.db.WithContext(ctx).First(&analytics, analyticsID).Error; err != nil { if err == gorm.ErrRecordNotFound { return fmt.Errorf("analytics not found: %s", analyticsID) } return fmt.Errorf("failed to get analytics: %w", err) } // Mettre à jour le temps de lecture analytics.PlayTime = playTime // Utiliser TrackCompletion pour calculer et détecter la completion return s.TrackCompletion(ctx, &analytics, trackDuration) }