373 lines
13 KiB
Go
373 lines
13 KiB
Go
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
|
|
}
|
|
}
|