package services import ( "context" "fmt" "math" "time" "github.com/google/uuid" "veza-backend-api/internal/models" "go.uber.org/zap" "gorm.io/gorm" ) // PlaybackAlertsService gère la détection d'alertes pour les analytics de lecture // T0374: Create Playback Analytics Alerts Service type PlaybackAlertsService struct { db *gorm.DB logger *zap.Logger } // Alert représente une alerte détectée type Alert struct { Type string `json:"type"` // "anomaly", "low_completion_rate", "drop_off_point" Severity string `json:"severity"` // "low", "medium", "high" Message string `json:"message"` // Message descriptif Value float64 `json:"value"` // Valeur qui a déclenché l'alerte Threshold float64 `json:"threshold"` // Seuil utilisé DetectedAt time.Time `json:"detected_at"` // Date de détection Metadata map[string]interface{} `json:"metadata,omitempty"` // Métadonnées supplémentaires } // AlertConfig représente la configuration des seuils d'alerte type AlertConfig struct { LowCompletionRateThreshold float64 // Seuil pour completion rate bas (défaut: 30%) AnomalyDeviationThreshold float64 // Nombre d'écarts-types pour détecter une anomalie (défaut: 2.0) DropOffPointThreshold float64 // Seuil de drop-off en pourcentage de la durée (défaut: 25%) } // NewPlaybackAlertsService crée un nouveau service d'alertes d'analytics func NewPlaybackAlertsService(db *gorm.DB, logger *zap.Logger) *PlaybackAlertsService { if logger == nil { logger = zap.NewNop() } return &PlaybackAlertsService{ db: db, logger: logger, } } // CheckAlerts vérifie les alertes pour un track donné // T0374: Create Playback Analytics Alerts Service func (s *PlaybackAlertsService) CheckAlerts(ctx context.Context, trackID uuid.UUID, config *AlertConfig) ([]Alert, error) { if trackID == uuid.Nil { return nil, fmt.Errorf("invalid track ID: %s", trackID) } // Utiliser la configuration par défaut si non fournie if config == nil { config = &AlertConfig{ LowCompletionRateThreshold: 30.0, AnomalyDeviationThreshold: 2.0, DropOffPointThreshold: 25.0, } } // 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: %d", trackID) } return nil, fmt.Errorf("failed to get track: %w", err) } alerts := make([]Alert, 0) // Détecter les anomalies anomalyAlerts, err := s.detectAnomalies(ctx, trackID, config) if err != nil { s.logger.Warn("Failed to detect anomalies", zap.Error(err), zap.String("track_id", trackID.String())) } else { alerts = append(alerts, anomalyAlerts...) } // Détecter les completion rates bas completionAlerts, err := s.detectLowCompletionRate(ctx, trackID, config) if err != nil { s.logger.Warn("Failed to detect low completion rates", zap.Error(err), zap.String("track_id", trackID.String())) } else { alerts = append(alerts, completionAlerts...) } // Détecter les drop-off points dropOffAlerts, err := s.detectDropOffPoints(ctx, trackID, config) if err != nil { s.logger.Warn("Failed to detect drop-off points", zap.Error(err), zap.String("track_id", trackID.String())) } else { alerts = append(alerts, dropOffAlerts...) } s.logger.Info("Checked playback alerts", zap.String("track_id", trackID.String()), zap.Int("alerts_count", len(alerts))) return alerts, nil } // detectAnomalies détecte les anomalies dans les statistiques de lecture func (s *PlaybackAlertsService) detectAnomalies(ctx context.Context, trackID uuid.UUID, config *AlertConfig) ([]Alert, error) { var alerts []Alert // Récupérer toutes les analytics récentes (30 derniers jours) thirtyDaysAgo := time.Now().AddDate(0, 0, -30) var analytics []models.PlaybackAnalytics if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND created_at >= ?", trackID, thirtyDaysAgo). Find(&analytics).Error; err != nil { return nil, fmt.Errorf("failed to get analytics: %w", err) } if len(analytics) < 10 { // Pas assez de données pour détecter des anomalies return alerts, nil } // Calculer la moyenne et l'écart-type pour le play_time var playTimes []float64 var completionRates []float64 for _, a := range analytics { playTimes = append(playTimes, float64(a.PlayTime)) completionRates = append(completionRates, a.CompletionRate) } // Détecter les anomalies dans le play_time playTimeMean, playTimeStdDev := s.calculateMeanAndStdDev(playTimes) for _, a := range analytics { playTime := float64(a.PlayTime) deviation := math.Abs(playTime - playTimeMean) if playTimeStdDev > 0 && deviation > config.AnomalyDeviationThreshold*playTimeStdDev { severity := "medium" if deviation > config.AnomalyDeviationThreshold*2*playTimeStdDev { severity = "high" } alerts = append(alerts, Alert{ Type: "anomaly", Severity: severity, Message: fmt.Sprintf("Anomalous play time detected: %.0f seconds (mean: %.0f, std dev: %.0f)", playTime, playTimeMean, playTimeStdDev), Value: playTime, Threshold: playTimeMean + config.AnomalyDeviationThreshold*playTimeStdDev, DetectedAt: time.Now(), Metadata: map[string]interface{}{ "analytics_id": a.ID, "user_id": a.UserID, "mean": playTimeMean, "std_dev": playTimeStdDev, "deviation": deviation, }, }) } } // Détecter les anomalies dans le completion rate completionMean, completionStdDev := s.calculateMeanAndStdDev(completionRates) for _, a := range analytics { deviation := math.Abs(a.CompletionRate - completionMean) if completionStdDev > 0 && deviation > config.AnomalyDeviationThreshold*completionStdDev { severity := "medium" if deviation > config.AnomalyDeviationThreshold*2*completionStdDev { severity = "high" } alerts = append(alerts, Alert{ Type: "anomaly", Severity: severity, Message: fmt.Sprintf("Anomalous completion rate detected: %.2f%% (mean: %.2f%%, std dev: %.2f%%)", a.CompletionRate, completionMean, completionStdDev), Value: a.CompletionRate, Threshold: completionMean + config.AnomalyDeviationThreshold*completionStdDev, DetectedAt: time.Now(), Metadata: map[string]interface{}{ "analytics_id": a.ID, "user_id": a.UserID, "mean": completionMean, "std_dev": completionStdDev, "deviation": deviation, }, }) } } return alerts, nil } // detectLowCompletionRate détecte les completion rates bas func (s *PlaybackAlertsService) detectLowCompletionRate(ctx context.Context, trackID uuid.UUID, config *AlertConfig) ([]Alert, error) { var alerts []Alert // Récupérer les statistiques récentes (7 derniers jours) sevenDaysAgo := time.Now().AddDate(0, 0, -7) var analytics []models.PlaybackAnalytics if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND created_at >= ?", trackID, sevenDaysAgo). Find(&analytics).Error; err != nil { return nil, fmt.Errorf("failed to get analytics: %w", err) } if len(analytics) == 0 { return alerts, nil } // Calculer le taux de completion moyen var totalCompletion float64 var lowCompletionCount int for _, a := range analytics { totalCompletion += a.CompletionRate if a.CompletionRate < config.LowCompletionRateThreshold { lowCompletionCount++ } } averageCompletion := totalCompletion / float64(len(analytics)) // Si le taux moyen est bas, créer une alerte if averageCompletion < config.LowCompletionRateThreshold { severity := "medium" if averageCompletion < config.LowCompletionRateThreshold/2 { severity = "high" } alerts = append(alerts, Alert{ Type: "low_completion_rate", Severity: severity, Message: fmt.Sprintf("Low average completion rate: %.2f%% (threshold: %.2f%%)", averageCompletion, config.LowCompletionRateThreshold), Value: averageCompletion, Threshold: config.LowCompletionRateThreshold, DetectedAt: time.Now(), Metadata: map[string]interface{}{ "total_sessions": len(analytics), "low_completion_count": lowCompletionCount, "percentage_low": float64(lowCompletionCount) / float64(len(analytics)) * 100.0, }, }) } // Si un pourcentage élevé de sessions a un completion rate bas, créer une alerte lowCompletionPercentage := float64(lowCompletionCount) / float64(len(analytics)) * 100.0 if lowCompletionPercentage > 50.0 { severity := "medium" if lowCompletionPercentage > 75.0 { severity = "high" } alerts = append(alerts, Alert{ Type: "low_completion_rate", Severity: severity, Message: fmt.Sprintf("High percentage of sessions with low completion rate: %.2f%%", lowCompletionPercentage), Value: lowCompletionPercentage, Threshold: 50.0, DetectedAt: time.Now(), Metadata: map[string]interface{}{ "total_sessions": len(analytics), "low_completion_count": lowCompletionCount, "average_completion": averageCompletion, }, }) } return alerts, nil } // detectDropOffPoints détecte les points de drop-off (moments où les utilisateurs arrêtent de regarder) func (s *PlaybackAlertsService) detectDropOffPoints(ctx context.Context, trackID uuid.UUID, config *AlertConfig) ([]Alert, error) { var alerts []Alert // Récupérer le track pour connaître sa durée var track models.Track if err := s.db.WithContext(ctx).First(&track, trackID).Error; err != nil { return nil, fmt.Errorf("failed to get track: %w", err) } if track.Duration <= 0 { return alerts, nil } // Récupérer les analytics récentes (7 derniers jours) sevenDaysAgo := time.Now().AddDate(0, 0, -7) var analytics []models.PlaybackAnalytics if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND created_at >= ?", trackID, sevenDaysAgo). Find(&analytics).Error; err != nil { return nil, fmt.Errorf("failed to get analytics: %w", err) } if len(analytics) == 0 { return alerts, nil } // Calculer le pourcentage de la durée où les utilisateurs arrêtent dropOffThresholdSeconds := float64(track.Duration) * (config.DropOffPointThreshold / 100.0) var dropOffCount int var dropOffTimes []float64 for _, a := range analytics { // Si le play_time est inférieur au seuil de drop-off, c'est un drop-off if float64(a.PlayTime) < dropOffThresholdSeconds { dropOffCount++ dropOffTimes = append(dropOffTimes, float64(a.PlayTime)) } } dropOffPercentage := float64(dropOffCount) / float64(len(analytics)) * 100.0 // Si un pourcentage significatif de sessions s'arrête tôt, créer une alerte if dropOffPercentage > 30.0 { // Calculer le temps moyen de drop-off var avgDropOffTime float64 if len(dropOffTimes) > 0 { var sum float64 for _, t := range dropOffTimes { sum += t } avgDropOffTime = sum / float64(len(dropOffTimes)) } severity := "medium" if dropOffPercentage > 50.0 { severity = "high" } dropOffPointPercentage := (avgDropOffTime / float64(track.Duration)) * 100.0 alerts = append(alerts, Alert{ Type: "drop_off_point", Severity: severity, Message: fmt.Sprintf("Drop-off detected: %.2f%% of sessions stop before %.2f%% of track duration (avg drop-off at %.2f%%)", dropOffPercentage, config.DropOffPointThreshold, dropOffPointPercentage), Value: dropOffPercentage, Threshold: 30.0, DetectedAt: time.Now(), Metadata: map[string]interface{}{ "total_sessions": len(analytics), "drop_off_count": dropOffCount, "drop_off_threshold": config.DropOffPointThreshold, "average_drop_off_time": avgDropOffTime, "drop_off_point_percentage": dropOffPointPercentage, "track_duration": track.Duration, }, }) } return alerts, nil } // calculateMeanAndStdDev calcule la moyenne et l'écart-type d'une série de valeurs func (s *PlaybackAlertsService) calculateMeanAndStdDev(values []float64) (mean, stdDev float64) { if len(values) == 0 { return 0, 0 } // Calculer la moyenne var sum float64 for _, v := range values { sum += v } mean = sum / float64(len(values)) // Calculer l'écart-type var variance float64 for _, v := range values { diff := v - mean variance += diff * diff } variance = variance / float64(len(values)) stdDev = math.Sqrt(variance) return mean, stdDev }