veza/veza-backend-api/internal/services/playback_analytics_service.go
senke 7846bbab28 fix(backend): remediation plan — tests, playback_analytics, job queue, gamification
Phase 1 - Backend tests:
- Add PlaybackAnalytics to AutoMigrate in setupTestTrackHandler
- Create migration 081_create_playback_analytics.sql for production
- PlaybackAnalyticsService: return ErrTrackNotFound for missing track
- RecordPlay handler: return 404 when track not found
- CreateShare: use RespondSuccess, fix services.ErrTrackNotFound/ErrForbidden
- GetTrackLikes, UnlikeTrack: use RespondSuccess for consistent response
- GetUserLikedTracks test: fix route /users/:id/likes and params
- GetSharedTrack_InvalidToken: set share service in test

Phase 4 - Job queue transcoding:
- Add EnqueueTranscodingJob to JobEnqueuer interface
- Add TypeTranscoding and processTranscodingJob (stub) in JobWorker
- MockJobEnqueuer: implement EnqueueTranscodingJob

Phase 5 - Gamification cleanup:
- Move api_manager.go to internal/api/archive/
- Add archive/README.md documenting archived modules
- Update TODOS_AUDIT.md and FEATURE_STATUS.md
2026-02-17 16:01:45 +01:00

618 lines
20 KiB
Go

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
}
// 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)
}