Backend Go: - Remplacement complet des anciennes migrations par la base V1 alignée sur ORIGIN. - Durcissement global du parsing JSON (BindAndValidateJSON + RespondWithAppError). - Sécurisation de config.go, CORS, statuts de santé et monitoring. - Implémentation des transactions P0 (RBAC, duplication de playlists, social toggles). - Ajout d’un job worker structuré (emails, analytics, thumbnails) + tests associés. - Nouvelle doc backend : AUDIT_CONFIG, BACKEND_CONFIG, AUTH_PASSWORD_RESET, JOB_WORKER_*. Chat server (Rust): - Refonte du pipeline JWT + sécurité, audit et rate limiting avancé. - Implémentation complète du cycle de message (read receipts, delivered, edit/delete, typing). - Nettoyage des panics, gestion d’erreurs robuste, logs structurés. - Migrations chat alignées sur le schéma UUID et nouvelles features. Stream server (Rust): - Refonte du moteur de streaming (encoding pipeline + HLS) et des modules core. - Transactions P0 pour les jobs et segments, garanties d’atomicité. - Documentation détaillée de la pipeline (AUDIT_STREAM_*, DESIGN_STREAM_PIPELINE, TRANSACTIONS_P0_IMPLEMENTATION). Documentation & audits: - TRIAGE.md et AUDIT_STABILITY.md à jour avec l’état réel des 3 services. - Cartographie complète des migrations et des transactions (DB_MIGRATIONS_*, DB_TRANSACTION_PLAN, AUDIT_DB_TRANSACTIONS, TRANSACTION_TESTS_PHASE3). - Scripts de reset et de cleanup pour la lab DB et la V1. Ce commit fige l’ensemble du travail de stabilisation P0 (UUID, backend, chat et stream) avant les phases suivantes (Coherence Guardian, WS hardening, etc.).
248 lines
7.2 KiB
Go
248 lines
7.2 KiB
Go
package social
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// SocialService gère les interactions sociales
|
|
type SocialService interface {
|
|
CreatePost(ctx context.Context, userID uuid.UUID, content string, attachments map[string]uuid.UUID) (*Post, error)
|
|
GetGlobalFeed(ctx context.Context, limit, offset int) ([]FeedItem, error)
|
|
GetUserFeed(ctx context.Context, userID uuid.UUID, limit, offset int) ([]FeedItem, error)
|
|
|
|
// Interactions
|
|
ToggleLike(ctx context.Context, userID uuid.UUID, targetID uuid.UUID, targetType string) (bool, error)
|
|
AddComment(ctx context.Context, userID uuid.UUID, targetID uuid.UUID, targetType string, content string) (*Comment, error)
|
|
|
|
// Internal
|
|
CreateActivityPost(ctx context.Context, userID uuid.UUID, content string, meta map[string]interface{}) error
|
|
}
|
|
|
|
// Service implémente SocialService
|
|
type Service struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewService crée une nouvelle instance du service social
|
|
func NewService(db *gorm.DB, logger *zap.Logger) *Service {
|
|
return &Service{
|
|
db: db,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// CreatePost crée une nouvelle publication
|
|
func (s *Service) CreatePost(ctx context.Context, userID uuid.UUID, content string, attachments map[string]uuid.UUID) (*Post, error) {
|
|
post := &Post{
|
|
UserID: userID,
|
|
Content: content,
|
|
Type: PostTypeStatus,
|
|
}
|
|
|
|
// Handle attachments
|
|
if trackID, ok := attachments["track_id"]; ok {
|
|
post.TrackID = &trackID
|
|
post.Type = PostTypeShare
|
|
}
|
|
if playlistID, ok := attachments["playlist_id"]; ok {
|
|
post.PlaylistID = &playlistID
|
|
post.Type = PostTypeShare
|
|
}
|
|
|
|
if err := s.db.Create(post).Error; err != nil {
|
|
s.logger.Error("Failed to create post", zap.Error(err), zap.String("user_id", userID.String()))
|
|
return nil, err
|
|
}
|
|
|
|
return post, nil
|
|
}
|
|
|
|
// GetGlobalFeed récupère un flux d'activité global
|
|
func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int) ([]FeedItem, error) {
|
|
var posts []Post
|
|
if err := s.db.Order("created_at desc").Limit(limit).Offset(offset).Find(&posts).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var feed []FeedItem
|
|
for _, p := range posts {
|
|
targetType := "none"
|
|
targetID := uuid.Nil
|
|
|
|
if p.TrackID != nil {
|
|
targetType = "track"
|
|
targetID = *p.TrackID
|
|
} else if p.PlaylistID != nil {
|
|
targetType = "playlist"
|
|
targetID = *p.PlaylistID
|
|
}
|
|
|
|
item := FeedItem{
|
|
ID: fmt.Sprintf("post:%s", p.ID.String()),
|
|
Type: ActivityPost,
|
|
ActorID: p.UserID,
|
|
TargetID: targetID,
|
|
TargetType: targetType,
|
|
Content: p.Content,
|
|
CreatedAt: p.CreatedAt,
|
|
}
|
|
|
|
// Spécial pour les activités automatiques
|
|
if p.Type == PostTypeActivity {
|
|
item.Type = ActivityPurchase // Ou autre logique plus fine
|
|
}
|
|
|
|
feed = append(feed, item)
|
|
}
|
|
|
|
return feed, nil
|
|
}
|
|
|
|
// GetUserFeed récupère le flux d'un utilisateur
|
|
func (s *Service) GetUserFeed(ctx context.Context, userID uuid.UUID, limit, offset int) ([]FeedItem, error) {
|
|
var posts []Post
|
|
if err := s.db.Where("user_id = ?", userID).Order("created_at desc").Limit(limit).Offset(offset).Find(&posts).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var feed []FeedItem
|
|
for _, p := range posts {
|
|
item := FeedItem{
|
|
ID: fmt.Sprintf("post:%s", p.ID.String()),
|
|
Type: ActivityPost,
|
|
ActorID: p.UserID,
|
|
Content: p.Content,
|
|
CreatedAt: p.CreatedAt,
|
|
TargetType: "user_wall",
|
|
}
|
|
feed = append(feed, item)
|
|
}
|
|
|
|
return feed, nil
|
|
}
|
|
|
|
// ToggleLike ajoute ou supprime un like
|
|
// Transactionnelle : SELECT like + DELETE/CREATE + UPDATE compteur dans une seule transaction
|
|
func (s *Service) ToggleLike(ctx context.Context, userID uuid.UUID, targetID uuid.UUID, targetType string) (bool, error) {
|
|
var liked bool
|
|
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
// 1. VÉRIFICATION : Like existe déjà ? (SELECT dans la transaction)
|
|
var like Like
|
|
err := tx.Where("user_id = ? AND target_id = ? AND target_type = ?", userID, targetID, targetType).First(&like).Error
|
|
|
|
if err == nil {
|
|
// 2a. Mode UNLIKE : Like existe, on le supprime
|
|
if err := tx.Delete(&like).Error; err != nil {
|
|
return fmt.Errorf("ToggleLike: failed to delete like: %w", err)
|
|
}
|
|
|
|
// 3a. Décrémenter le compteur si c'est un post (dans la transaction)
|
|
if targetType == "post" {
|
|
if err := tx.Model(&Post{}).Where("id = ?", targetID).Update("like_count", gorm.Expr("like_count - 1")).Error; err != nil {
|
|
return fmt.Errorf("ToggleLike: failed to decrement like_count: %w", err)
|
|
}
|
|
}
|
|
|
|
liked = false
|
|
return nil
|
|
} else if err == gorm.ErrRecordNotFound {
|
|
// 2b. Mode LIKE : Like n'existe pas, on le crée
|
|
like = Like{
|
|
UserID: userID,
|
|
TargetID: targetID,
|
|
TargetType: targetType,
|
|
}
|
|
if err := tx.Create(&like).Error; err != nil {
|
|
return fmt.Errorf("ToggleLike: failed to create like: %w", err)
|
|
}
|
|
|
|
// 3b. Incrémenter le compteur si c'est un post (dans la transaction)
|
|
if targetType == "post" {
|
|
if err := tx.Model(&Post{}).Where("id = ?", targetID).Update("like_count", gorm.Expr("like_count + 1")).Error; err != nil {
|
|
return fmt.Errorf("ToggleLike: failed to increment like_count: %w", err)
|
|
}
|
|
}
|
|
|
|
liked = true
|
|
return nil
|
|
} else {
|
|
return fmt.Errorf("ToggleLike: failed to check like existence: %w", err)
|
|
}
|
|
})
|
|
|
|
if err != nil {
|
|
return false, err // Rollback automatique si erreur
|
|
}
|
|
|
|
return liked, nil
|
|
}
|
|
|
|
// AddComment ajoute un commentaire
|
|
// Transactionnelle : CREATE comment + UPDATE compteur dans une seule transaction
|
|
func (s *Service) AddComment(ctx context.Context, userID uuid.UUID, targetID uuid.UUID, targetType string, content string) (*Comment, error) {
|
|
var comment *Comment
|
|
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
// 1. VALIDATION : Post existe ? (SELECT dans la transaction si targetType == "post")
|
|
if targetType == "post" {
|
|
var post Post
|
|
if err := tx.First(&post, "id = ?", targetID).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return fmt.Errorf("post not found")
|
|
}
|
|
return fmt.Errorf("AddComment: failed to validate post: %w", err)
|
|
}
|
|
}
|
|
|
|
// 2. CRÉATION : Commentaire (INSERT dans la transaction)
|
|
comment = &Comment{
|
|
UserID: userID,
|
|
TargetID: targetID,
|
|
TargetType: targetType,
|
|
Content: content,
|
|
}
|
|
if err := tx.Create(comment).Error; err != nil {
|
|
return fmt.Errorf("AddComment: failed to create comment: %w", err)
|
|
}
|
|
|
|
// 3. MISE À JOUR : Compteur (UPDATE dans la transaction)
|
|
if targetType == "post" {
|
|
if err := tx.Model(&Post{}).Where("id = ?", targetID).Update("comment_count", gorm.Expr("comment_count + 1")).Error; err != nil {
|
|
return fmt.Errorf("AddComment: failed to increment comment_count: %w", err)
|
|
}
|
|
}
|
|
|
|
// 4. RETOUR nil = commit automatique
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err // Rollback automatique si erreur
|
|
}
|
|
|
|
return comment, nil
|
|
}
|
|
|
|
// CreateActivityPost crée un post automatique pour une activité (ex: Achat)
|
|
func (s *Service) CreateActivityPost(ctx context.Context, userID uuid.UUID, content string, meta map[string]interface{}) error {
|
|
post := &Post{
|
|
UserID: userID,
|
|
Content: content,
|
|
Type: PostTypeActivity,
|
|
}
|
|
|
|
if trackIDStr, ok := meta["track_id"].(string); ok {
|
|
if trackID, err := uuid.Parse(trackIDStr); err == nil {
|
|
post.TrackID = &trackID
|
|
}
|
|
}
|
|
|
|
return s.db.Create(post).Error
|
|
}
|