package services import ( "context" "fmt" "time" "github.com/google/uuid" "veza-backend-api/internal/models" "go.uber.org/zap" "gorm.io/gorm" ) // UserSegment représente un segment d'utilisateur // T0378: Create Playback Analytics User Segmentation type UserSegment string const ( // Segments par engagement SegmentHighEngagement UserSegment = "high_engagement" SegmentMediumEngagement UserSegment = "medium_engagement" SegmentLowEngagement UserSegment = "low_engagement" // Segments par completion rate SegmentHighCompletion UserSegment = "high_completion" SegmentMediumCompletion UserSegment = "medium_completion" SegmentLowCompletion UserSegment = "low_completion" // Segments par comportement SegmentActiveListener UserSegment = "active_listener" // Beaucoup de sessions SegmentCasualListener UserSegment = "casual_listener" // Peu de sessions SegmentFrequentSkipper UserSegment = "frequent_skipper" // Beaucoup de skips SegmentFocusedListener UserSegment = "focused_listener" // Peu de skips, beaucoup d'écoute ) // PlaybackSegmentationService gère la segmentation des utilisateurs pour les analytics de lecture // T0378: Create Playback Analytics User Segmentation type PlaybackSegmentationService struct { db *gorm.DB logger *zap.Logger } // NewPlaybackSegmentationService crée un nouveau service de segmentation d'utilisateurs func NewPlaybackSegmentationService(db *gorm.DB, logger *zap.Logger) *PlaybackSegmentationService { if logger == nil { logger = zap.NewNop() } return &PlaybackSegmentationService{ db: db, logger: logger, } } // UserMetrics représente les métriques agrégées pour un utilisateur // MIGRATION UUID: UserID migré vers uuid.UUID type UserMetrics struct { UserID uuid.UUID `json:"user_id"` SessionCount int64 `json:"session_count"` AverageCompletion float64 `json:"average_completion"` // Taux de complétion moyen (%) AveragePlayTime float64 `json:"average_play_time"` // Temps de lecture moyen (secondes) TotalPlayTime int64 `json:"total_play_time"` // Temps de lecture total (secondes) AveragePauses float64 `json:"average_pauses"` // Nombre moyen de pauses AverageSeeks float64 `json:"average_seeks"` // Nombre moyen de seeks EngagementScore float64 `json:"engagement_score"` // Score d'engagement (0-100) CompletionRate float64 `json:"completion_rate"` // Pourcentage de sessions complétées (>90%) SkipRate float64 `json:"skip_rate"` // Taux de skips (seeks par session) } // SegmentationResult représente le résultat de la segmentation type SegmentationResult struct { TrackID uuid.UUID `json:"track_id"` TotalUsers int64 `json:"total_users"` Segments map[UserSegment][]uuid.UUID `json:"segments"` // Map de segment -> liste d'user UUIDs UserMetrics map[uuid.UUID]*UserMetrics `json:"user_metrics,omitempty"` // Métriques par utilisateur SegmentCounts map[UserSegment]int64 `json:"segment_counts"` // Nombre d'utilisateurs par segment AnalyzedAt time.Time `json:"analyzed_at"` } // SegmentUsers segmente les utilisateurs pour un track donné // T0378: Create Playback Analytics User Segmentation func (s *PlaybackSegmentationService) SegmentUsers(ctx context.Context, trackID uuid.UUID) (*SegmentationResult, error) { if trackID == uuid.Nil { return nil, fmt.Errorf("invalid track ID: %s", trackID) } // Vérifier que le track existe var track models.Track if err := s.db.WithContext(ctx).First(&track, "id = ?", 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) } // Récupérer toutes les analytics pour ce track var analytics []models.PlaybackAnalytics if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ?", trackID). Find(&analytics).Error; err != nil { return nil, fmt.Errorf("failed to get analytics: %w", err) } if len(analytics) == 0 { // Retourner un résultat vide return &SegmentationResult{ TrackID: trackID, TotalUsers: 0, Segments: make(map[UserSegment][]uuid.UUID), UserMetrics: make(map[uuid.UUID]*UserMetrics), SegmentCounts: make(map[UserSegment]int64), AnalyzedAt: time.Now(), }, nil } // Calculer les métriques par utilisateur userMetrics := s.calculateUserMetrics(analytics) // Segmenter par engagement engagementSegments := s.segmentByEngagement(userMetrics) // Segmenter par completion rate completionSegments := s.segmentByCompletionRate(userMetrics) // Segmenter par comportement behaviorSegments := s.segmentByBehavior(userMetrics) // Combiner tous les segments allSegments := make(map[UserSegment][]uuid.UUID) for segment, userIDs := range engagementSegments { allSegments[segment] = userIDs } for segment, userIDs := range completionSegments { allSegments[segment] = userIDs } for segment, userIDs := range behaviorSegments { allSegments[segment] = userIDs } // Calculer les compteurs par segment segmentCounts := make(map[UserSegment]int64) for segment, userIDs := range allSegments { segmentCounts[segment] = int64(len(userIDs)) } result := &SegmentationResult{ TrackID: trackID, TotalUsers: int64(len(userMetrics)), Segments: allSegments, UserMetrics: userMetrics, SegmentCounts: segmentCounts, AnalyzedAt: time.Now(), } s.logger.Info("Segmented users for track", zap.String("track_id", trackID.String()), zap.Int64("total_users", result.TotalUsers), zap.Int("total_segments", len(allSegments))) return result, nil } // calculateUserMetrics calcule les métriques agrégées pour chaque utilisateur // MIGRATION UUID: retourne map[uuid.UUID]*UserMetrics func (s *PlaybackSegmentationService) calculateUserMetrics(analytics []models.PlaybackAnalytics) map[uuid.UUID]*UserMetrics { userMetricsMap := make(map[uuid.UUID]*UserMetrics) // Grouper les analytics par utilisateur userAnalytics := make(map[uuid.UUID][]models.PlaybackAnalytics) for _, a := range analytics { userAnalytics[a.UserID] = append(userAnalytics[a.UserID], a) } // Calculer les métriques pour chaque utilisateur for userID, userSessions := range userAnalytics { if len(userSessions) == 0 { continue } var totalCompletion float64 var totalPlayTime int64 var totalPauses int64 var totalSeeks int64 var completedSessions int64 for _, session := range userSessions { totalCompletion += session.CompletionRate totalPlayTime += int64(session.PlayTime) totalPauses += int64(session.PauseCount) totalSeeks += int64(session.SeekCount) if session.CompletionRate >= 90.0 { completedSessions++ } } sessionCount := int64(len(userSessions)) averageCompletion := totalCompletion / float64(sessionCount) averagePlayTime := float64(totalPlayTime) / float64(sessionCount) averagePauses := float64(totalPauses) / float64(sessionCount) averageSeeks := float64(totalSeeks) / float64(sessionCount) completionRate := float64(completedSessions) / float64(sessionCount) * 100.0 skipRate := averageSeeks // Taux de skips = nombre moyen de seeks // Calculer le score d'engagement (0-100) // Basé sur: completion rate (50%), pauses (25%), seeks (25%) engagementScore := averageCompletion * 0.5 // Normaliser les pauses (0-10 pauses = 0-25 points) pauseScore := 25.0 if averagePauses > 0 { pauseScore = 25.0 - (averagePauses / 10.0 * 25.0) if pauseScore < 0 { pauseScore = 0 } } engagementScore += pauseScore // Normaliser les seeks (0-5 seeks = 0-25 points) seekScore := 25.0 if averageSeeks > 0 { seekScore = 25.0 - (averageSeeks / 5.0 * 25.0) if seekScore < 0 { seekScore = 0 } } engagementScore += seekScore // S'assurer que le score est entre 0 et 100 if engagementScore > 100.0 { engagementScore = 100.0 } if engagementScore < 0.0 { engagementScore = 0.0 } userMetricsMap[userID] = &UserMetrics{ UserID: userID, // UUID SessionCount: sessionCount, AverageCompletion: averageCompletion, AveragePlayTime: averagePlayTime, TotalPlayTime: totalPlayTime, AveragePauses: averagePauses, AverageSeeks: averageSeeks, EngagementScore: engagementScore, CompletionRate: completionRate, SkipRate: skipRate, } } return userMetricsMap } // segmentByEngagement segmente les utilisateurs par niveau d'engagement // MIGRATION UUID: paramètre et retour utilisent uuid.UUID func (s *PlaybackSegmentationService) segmentByEngagement(userMetrics map[uuid.UUID]*UserMetrics) map[UserSegment][]uuid.UUID { segments := make(map[UserSegment][]uuid.UUID) segments[SegmentHighEngagement] = []uuid.UUID{} segments[SegmentMediumEngagement] = []uuid.UUID{} segments[SegmentLowEngagement] = []uuid.UUID{} for userID, metrics := range userMetrics { if metrics.EngagementScore >= 75.0 { segments[SegmentHighEngagement] = append(segments[SegmentHighEngagement], userID) } else if metrics.EngagementScore >= 50.0 { segments[SegmentMediumEngagement] = append(segments[SegmentMediumEngagement], userID) } else { segments[SegmentLowEngagement] = append(segments[SegmentLowEngagement], userID) } } return segments } // segmentByCompletionRate segmente les utilisateurs par taux de complétion // MIGRATION UUID: paramètre et retour utilisent uuid.UUID func (s *PlaybackSegmentationService) segmentByCompletionRate(userMetrics map[uuid.UUID]*UserMetrics) map[UserSegment][]uuid.UUID { segments := make(map[UserSegment][]uuid.UUID) segments[SegmentHighCompletion] = []uuid.UUID{} segments[SegmentMediumCompletion] = []uuid.UUID{} segments[SegmentLowCompletion] = []uuid.UUID{} for userID, metrics := range userMetrics { if metrics.AverageCompletion >= 75.0 { segments[SegmentHighCompletion] = append(segments[SegmentHighCompletion], userID) } else if metrics.AverageCompletion >= 50.0 { segments[SegmentMediumCompletion] = append(segments[SegmentMediumCompletion], userID) } else { segments[SegmentLowCompletion] = append(segments[SegmentLowCompletion], userID) } } return segments } // segmentByBehavior segmente les utilisateurs par comportement d'écoute // MIGRATION UUID: paramètre et retour utilisent uuid.UUID func (s *PlaybackSegmentationService) segmentByBehavior(userMetrics map[uuid.UUID]*UserMetrics) map[UserSegment][]uuid.UUID { segments := make(map[UserSegment][]uuid.UUID) segments[SegmentActiveListener] = []uuid.UUID{} segments[SegmentCasualListener] = []uuid.UUID{} segments[SegmentFrequentSkipper] = []uuid.UUID{} segments[SegmentFocusedListener] = []uuid.UUID{} // Calculer les seuils basés sur les données var totalSessions int64 var totalSeeks float64 var maxSessions int64 for _, metrics := range userMetrics { totalSessions += metrics.SessionCount totalSeeks += metrics.AverageSeeks if metrics.SessionCount > maxSessions { maxSessions = metrics.SessionCount } } avgSessions := float64(totalSessions) / float64(len(userMetrics)) avgSeeks := totalSeeks / float64(len(userMetrics)) // Seuils pour la segmentation activeThreshold := avgSessions * 1.5 // 50% au-dessus de la moyenne casualThreshold := avgSessions * 0.5 // 50% en dessous de la moyenne skipThreshold := avgSeeks * 1.5 // 50% au-dessus de la moyenne des seeks focusedThreshold := avgSeeks * 0.5 // 50% en dessous de la moyenne des seeks for userID, metrics := range userMetrics { // Segmentation par nombre de sessions if float64(metrics.SessionCount) >= activeThreshold { segments[SegmentActiveListener] = append(segments[SegmentActiveListener], userID) } else if float64(metrics.SessionCount) <= casualThreshold { segments[SegmentCasualListener] = append(segments[SegmentCasualListener], userID) } // Segmentation par comportement de skip if metrics.AverageSeeks >= skipThreshold { segments[SegmentFrequentSkipper] = append(segments[SegmentFrequentSkipper], userID) } else if metrics.AverageSeeks <= focusedThreshold && metrics.AverageCompletion >= 70.0 { // Focused listener: peu de skips ET bonne complétion segments[SegmentFocusedListener] = append(segments[SegmentFocusedListener], userID) } } return segments } // GetUserSegment retourne le segment principal d'un utilisateur pour un track // MIGRATION UUID: userID migré vers uuid.UUID, trackID reste int64 func (s *PlaybackSegmentationService) GetUserSegment(ctx context.Context, trackID uuid.UUID, userID uuid.UUID) (UserSegment, error) { if trackID == uuid.Nil || userID == uuid.Nil { return "", fmt.Errorf("invalid track ID or user ID: trackID=%s, userID=%s", trackID, userID) } result, err := s.SegmentUsers(ctx, trackID) if err != nil { return "", err } // Trouver le segment principal de l'utilisateur (priorité: engagement > completion > behavior) userMetrics, exists := result.UserMetrics[userID] if !exists { return "", fmt.Errorf("user %s not found in analytics for track %s", userID, trackID) } // Déterminer le segment principal basé sur l'engagement if userMetrics.EngagementScore >= 75.0 { return SegmentHighEngagement, nil } else if userMetrics.EngagementScore >= 50.0 { return SegmentMediumEngagement, nil } else { return SegmentLowEngagement, nil } }