2025-12-03 19:29:37 +00:00
package services
import (
"context"
"fmt"
"math"
"time"
2025-12-06 16:21:59 +00:00
"github.com/google/uuid"
2025-12-03 19:29:37 +00:00
"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
2025-12-06 16:21:59 +00:00
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 )
2025-12-03 19:29:37 +00:00
}
// 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 {
2025-12-06 16:21:59 +00:00
s . logger . Warn ( "Failed to detect anomalies" , zap . Error ( err ) , zap . String ( "track_id" , trackID . String ( ) ) )
2025-12-03 19:29:37 +00:00
} else {
alerts = append ( alerts , anomalyAlerts ... )
}
// Détecter les completion rates bas
completionAlerts , err := s . detectLowCompletionRate ( ctx , trackID , config )
if err != nil {
2025-12-06 16:21:59 +00:00
s . logger . Warn ( "Failed to detect low completion rates" , zap . Error ( err ) , zap . String ( "track_id" , trackID . String ( ) ) )
2025-12-03 19:29:37 +00:00
} else {
alerts = append ( alerts , completionAlerts ... )
}
// Détecter les drop-off points
dropOffAlerts , err := s . detectDropOffPoints ( ctx , trackID , config )
if err != nil {
2025-12-06 16:21:59 +00:00
s . logger . Warn ( "Failed to detect drop-off points" , zap . Error ( err ) , zap . String ( "track_id" , trackID . String ( ) ) )
2025-12-03 19:29:37 +00:00
} else {
alerts = append ( alerts , dropOffAlerts ... )
}
s . logger . Info ( "Checked playback alerts" ,
2025-12-06 16:21:59 +00:00
zap . String ( "track_id" , trackID . String ( ) ) ,
2025-12-03 19:29:37 +00:00
zap . Int ( "alerts_count" , len ( alerts ) ) )
return alerts , nil
}
// detectAnomalies détecte les anomalies dans les statistiques de lecture
2025-12-06 16:21:59 +00:00
func ( s * PlaybackAlertsService ) detectAnomalies ( ctx context . Context , trackID uuid . UUID , config * AlertConfig ) ( [ ] Alert , error ) {
2025-12-03 19:29:37 +00:00
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
2025-12-06 16:21:59 +00:00
func ( s * PlaybackAlertsService ) detectLowCompletionRate ( ctx context . Context , trackID uuid . UUID , config * AlertConfig ) ( [ ] Alert , error ) {
2025-12-03 19:29:37 +00:00
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)
2025-12-06 16:21:59 +00:00
func ( s * PlaybackAlertsService ) detectDropOffPoints ( ctx context . Context , trackID uuid . UUID , config * AlertConfig ) ( [ ] Alert , error ) {
2025-12-03 19:29:37 +00:00
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
}