chore(release): v0.931 — Cursor (cursor-based pagination, performance baseline)
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s

This commit is contained in:
senke 2026-03-02 12:35:49 +01:00
parent 2a0a6a1ec9
commit 1318a53a64
12 changed files with 422 additions and 28 deletions

View file

@ -1 +1 @@
0.922 0.931

View file

@ -0,0 +1,41 @@
# Performance Baseline — Veza API
**Version** : v0.931
**Objectif** : Documenter les latences P50/P95/P99 des endpoints critiques pour détecter les régressions.
## Méthodologie
1. Démarrer l'API en mode profiling : `pprof` est exposé si `ENABLE_PPROF=true`
2. Exécuter un load test (k6 ou Go) sur les endpoints critiques
3. Mesurer latences via Prometheus (`http_request_duration_seconds`) ou pprof
## Endpoints critiques à monitorer
| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/api/v1/auth/login` | POST | Login utilisateur |
| `/api/v1/auth/register` | POST | Inscription |
| `/api/v1/tracks` | GET | Liste des tracks (cursor pagination v0.931) |
| `/api/v1/tracks/search` | GET | Recherche |
| `/api/v1/users/me` | GET | Profil utilisateur |
| `/api/v1/marketplace/orders` | POST | Création commande |
| `/api/v1/notifications` | GET | Notifications |
| `/api/v1/conversations` | GET | Conversations |
| `/api/v1/analytics/me` | GET | Analytics |
| `/health` | GET | Health check |
## Cibles v1.0 (voir roadmap v0.951)
- **P99 < 500ms** sur tous les endpoints critiques à 500 req/s
- **GET /tracks** : pagination cursor-based (v0.931) garantit des performances constantes quelle que soit la page
## Commande pprof
```bash
# Profiler 30s pendant un load test
go tool pprof -http=:8081 http://localhost:8080/debug/pprof/profile?seconds=30
```
## Métriques Prometheus
Les middlewares de monitoring exposent `http_request_duration_seconds` avec les labels `method`, `path`, `status`. Utiliser des histogram quantiles pour P50/P95/P99.

View file

@ -68,6 +68,7 @@ github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJn
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@ -76,6 +77,7 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw=
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
@ -121,6 +123,7 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/phpdave11/gofpdi v1.0.13 h1:o61duiW8M9sMlkVXWlvP92sZJtGKENvW3VExs6dZukQ= github.com/phpdave11/gofpdi v1.0.13 h1:o61duiW8M9sMlkVXWlvP92sZJtGKENvW3VExs6dZukQ=
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=

View file

@ -2,8 +2,11 @@ package social
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
@ -17,6 +20,7 @@ import (
type SocialService interface { type SocialService interface {
CreatePost(ctx context.Context, userID uuid.UUID, content string, attachments map[string]uuid.UUID) (*Post, error) CreatePost(ctx context.Context, userID uuid.UUID, content string, attachments map[string]uuid.UUID) (*Post, error)
GetGlobalFeed(ctx context.Context, limit, offset int, feedType string, userID *uuid.UUID) ([]FeedItem, error) GetGlobalFeed(ctx context.Context, limit, offset int, feedType string, userID *uuid.UUID) ([]FeedItem, error)
GetGlobalFeedWithCursor(ctx context.Context, limit int, cursor, feedType string, userID *uuid.UUID) ([]FeedItem, string, error) // v0.931: keyset cursor
GetUserFeed(ctx context.Context, userID uuid.UUID, limit, offset int) ([]FeedItem, error) GetUserFeed(ctx context.Context, userID uuid.UUID, limit, offset int) ([]FeedItem, error)
GetPostsByUser(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Post, error) GetPostsByUser(ctx context.Context, userID uuid.UUID, limit, offset int) ([]Post, error)
@ -97,7 +101,72 @@ func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int, feedType
if err := query.Order("posts.created_at desc").Limit(limit).Offset(offset).Find(&posts).Error; err != nil { if err := query.Order("posts.created_at desc").Limit(limit).Offset(offset).Find(&posts).Error; err != nil {
return nil, err return nil, err
} }
return s.enrichFeedFromPosts(ctx, posts)
}
// GetGlobalFeedWithCursor uses keyset pagination on (created_at, id) for consistent performance (v0.931).
// Cursor format: base64(created_at_unix_nano|uuid). Returns feed items and next cursor.
func (s *Service) GetGlobalFeedWithCursor(ctx context.Context, limit int, cursor, feedType string, userID *uuid.UUID) ([]FeedItem, string, error) {
query := s.db.WithContext(ctx).Model(&Post{})
if feedType == "following" && userID != nil {
query = query.Joins("INNER JOIN follows ON follows.followed_id = posts.user_id AND follows.follower_id = ?", *userID)
} else if feedType == "groups" && userID != nil {
subQuery := s.db.WithContext(ctx).Model(&GroupMember{}).
Select("DISTINCT gm2.user_id").
Joins("INNER JOIN group_members gm2 ON group_members.group_id = gm2.group_id AND gm2.user_id != ?", *userID).
Where("group_members.user_id = ?", *userID)
query = query.Where("posts.user_id IN (?)", subQuery)
}
var cursorCreatedAt int64
var cursorID uuid.UUID
if cursor != "" {
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
if err == nil {
parts := strings.SplitN(string(decoded), "|", 2)
if len(parts) == 2 {
if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
cursorCreatedAt = ts
}
if uid, err := uuid.Parse(parts[1]); err == nil {
cursorID = uid
}
}
}
}
if cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
query = query.Where("(posts.created_at, posts.id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
}
if limit <= 0 {
limit = 20
}
if limit > 50 {
limit = 50
}
query = query.Order("posts.created_at desc, posts.id desc").Limit(limit + 1)
var posts []Post
if err := query.Find(&posts).Error; err != nil {
return nil, "", err
}
var nextCursor string
if len(posts) > limit {
last := posts[limit-1]
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
posts = posts[:limit]
}
feed, err := s.enrichFeedFromPosts(ctx, posts)
if err != nil {
return nil, "", err
}
return feed, nextCursor, nil
}
func (s *Service) enrichFeedFromPosts(ctx context.Context, posts []Post) ([]FeedItem, error) {
var feed []FeedItem var feed []FeedItem
actorIDs := make(map[uuid.UUID]bool) actorIDs := make(map[uuid.UUID]bool)
var trackIDs []uuid.UUID var trackIDs []uuid.UUID
@ -108,7 +177,6 @@ func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int, feedType
} }
} }
// Batch fetch actor names/avatars
actors := make(map[uuid.UUID]struct{ Name, Avatar string }) actors := make(map[uuid.UUID]struct{ Name, Avatar string })
if len(actorIDs) > 0 { if len(actorIDs) > 0 {
ids := make([]uuid.UUID, 0, len(actorIDs)) ids := make([]uuid.UUID, 0, len(actorIDs))
@ -130,7 +198,6 @@ func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int, feedType
} }
} }
// Batch fetch tracks for target_type=track
tracks := make(map[uuid.UUID]*models.Track) tracks := make(map[uuid.UUID]*models.Track)
if len(trackIDs) > 0 { if len(trackIDs) > 0 {
var tr []models.Track var tr []models.Track
@ -187,7 +254,6 @@ func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int, feedType
feed = append(feed, item) feed = append(feed, item)
} }
return feed, nil return feed, nil
} }

View file

@ -2,13 +2,15 @@ package track
import ( import (
"context" "context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"mime/multipart" "mime/multipart"
"os" "os"
"path/filepath" "path/filepath"
"strings" // Removed strconv "strconv"
"strings"
"time" // MOD-P2-008: Ajouté pour timeout asynchrone "time" // MOD-P2-008: Ajouté pour timeout asynchrone
"veza-backend-api/internal/database" "veza-backend-api/internal/database"
@ -554,6 +556,7 @@ func (s *TrackService) GetUserQuota(ctx context.Context, userID uuid.UUID) (*Use
type TrackListParams struct { type TrackListParams struct {
Page int Page int
Limit int Limit int
Cursor string // v0.931: opaque cursor for keyset pagination (base64)
UserID *uuid.UUID UserID *uuid.UUID
Genre *string Genre *string
Format *string Format *string
@ -561,6 +564,13 @@ type TrackListParams struct {
SortOrder string // "asc", "desc" SortOrder string // "asc", "desc"
} }
// TrackListResult holds list result with optional next cursor
type TrackListResult struct {
Tracks []*models.Track
Total int64
NextCursor string
}
// ListTracks récupère une liste de tracks avec pagination, filtres et tri // ListTracks récupère une liste de tracks avec pagination, filtres et tri
func (s *TrackService) ListTracks(ctx context.Context, params TrackListParams) ([]*models.Track, int64, error) { func (s *TrackService) ListTracks(ctx context.Context, params TrackListParams) ([]*models.Track, int64, error) {
// Créer la requête de base avec filtre sur le statut (read replica si configuré) // Créer la requête de base avec filtre sur le statut (read replica si configuré)
@ -633,6 +643,103 @@ func (s *TrackService) ListTracks(ctx context.Context, params TrackListParams) (
return tracks, total, nil return tracks, total, nil
} }
// ListTracksWithCursor uses keyset pagination on (created_at, id) for consistent performance.
// When params.Cursor is set, decodes it and fetches records after that point.
// Returns NextCursor for the next page when more results exist.
// v0.931: Cursor-based pagination for GET /tracks
func (s *TrackService) ListTracksWithCursor(ctx context.Context, params TrackListParams) (*TrackListResult, error) {
// Cursor-based only supported for sort_by=created_at (default)
if params.SortBy != "created_at" && params.SortBy != "" {
// Fallback to offset-based
tracks, total, err := s.ListTracks(ctx, params)
if err != nil {
return nil, err
}
return &TrackListResult{Tracks: tracks, Total: total}, nil
}
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
query := s.forRead().WithContext(ctx).Model(&models.Track{}).Where("status = ?", models.TrackStatusCompleted)
if params.UserID != nil {
query = query.Where("creator_id = ?", *params.UserID)
}
if params.Genre != nil && *params.Genre != "" {
query = query.Where("genre = ?", *params.Genre)
}
if params.Format != nil && *params.Format != "" {
query = query.Where("format = ?", *params.Format)
}
// Decode cursor: base64(created_at_unix_nano|uuid)
var cursorCreatedAt int64
var cursorID uuid.UUID
if params.Cursor != "" {
decoded, err := base64.RawURLEncoding.DecodeString(params.Cursor)
if err == nil {
parts := strings.SplitN(string(decoded), "|", 2)
if len(parts) == 2 {
if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
cursorCreatedAt = ts
}
if uid, err := uuid.Parse(parts[1]); err == nil {
cursorID = uid
}
}
}
}
sortOrder := "DESC"
if params.SortOrder == "asc" {
sortOrder = "ASC"
}
if sortOrder == "DESC" {
if params.Cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
query = query.Where("(created_at, id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
}
query = query.Order("created_at DESC, id DESC")
} else {
if params.Cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
query = query.Where("(created_at, id) > (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
}
query = query.Order("created_at ASC, id ASC")
}
// Fetch limit+1 to know if there's a next page
query = query.Limit(params.Limit + 1)
var tracks []*models.Track
if err := query.Preload("User").Find(&tracks).Error; err != nil {
return nil, fmt.Errorf("failed to list tracks: %w", err)
}
var nextCursor string
var total int64
if len(tracks) > params.Limit {
// Has more - last fetched is the cursor for next page
last := tracks[params.Limit-1]
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
tracks = tracks[:params.Limit]
}
// Total not computed for cursor mode (expensive); use -1 or len as approximation
total = int64(len(tracks))
if nextCursor != "" {
total = int64(params.Limit) + 1
}
return &TrackListResult{
Tracks: tracks,
Total: total,
NextCursor: nextCursor,
}, nil
}
// GetTrackByID récupère un track par son ID // GetTrackByID récupère un track par son ID
// MOD-P1-003: Preload User pour éviter N+1 queries si User est accédé plus tard // MOD-P1-003: Preload User pour éviter N+1 queries si User est accédé plus tard
// BE-SVC-001: Add caching for track metadata // BE-SVC-001: Add caching for track metadata

View file

@ -47,8 +47,11 @@ type BatchUpdateRequest struct {
Updates map[string]interface{} `json:"updates" binding:"required" validate:"required,min=1"` Updates map[string]interface{} `json:"updates" binding:"required" validate:"required,min=1"`
} }
// ListTracks gère la liste des tracks avec pagination, filtres et tri // ListTracks gère la liste des tracks avec pagination, filtres et tri.
// v0.931: Supports cursor-based pagination via ?cursor=xxx&limit=20 for consistent performance.
// Falls back to page/limit (offset) when cursor is not provided.
func (h *TrackHandler) ListTracks(c *gin.Context) { func (h *TrackHandler) ListTracks(c *gin.Context) {
cursor := c.Query("cursor")
page := c.DefaultQuery("page", "1") page := c.DefaultQuery("page", "1")
limit := c.DefaultQuery("limit", "20") limit := c.DefaultQuery("limit", "20")
userIDStr := c.Query("user_id") userIDStr := c.Query("user_id")
@ -57,19 +60,15 @@ func (h *TrackHandler) ListTracks(c *gin.Context) {
sortBy := c.DefaultQuery("sort_by", "created_at") sortBy := c.DefaultQuery("sort_by", "created_at")
sortOrder := c.DefaultQuery("sort_order", "desc") sortOrder := c.DefaultQuery("sort_order", "desc")
var pageInt, limitInt int var limitInt int
if _, err := fmt.Sscanf(page, "%d", &pageInt); err != nil || pageInt < 1 {
response.BadRequest(c, "pagination: page must be >= 1 and limit must be between 1 and 100")
return
}
if _, err := fmt.Sscanf(limit, "%d", &limitInt); err != nil || limitInt < 1 || limitInt > 100 { if _, err := fmt.Sscanf(limit, "%d", &limitInt); err != nil || limitInt < 1 || limitInt > 100 {
response.BadRequest(c, "pagination: page must be >= 1 and limit must be between 1 and 100") response.BadRequest(c, "pagination: limit must be between 1 and 100")
return return
} }
params := TrackListParams{ params := TrackListParams{
Page: pageInt,
Limit: limitInt, Limit: limitInt,
Cursor: cursor,
SortBy: sortBy, SortBy: sortBy,
SortOrder: sortOrder, SortOrder: sortOrder,
} }
@ -85,6 +84,36 @@ func (h *TrackHandler) ListTracks(c *gin.Context) {
params.Format = &format params.Format = &format
} }
_, exists := c.Get("user_id")
if cursor != "" {
// Cursor-based pagination
result, err := h.trackService.ListTracksWithCursor(c.Request.Context(), params)
if err != nil {
response.InternalServerError(c, "failed to list tracks")
return
}
if !exists {
for _, t := range result.Tracks {
t.StreamManifestURL = ""
}
}
pagination := handlers.BuildPaginationDataWithCursor(limitInt, result.Total, result.NextCursor, "")
response.Success(c, gin.H{
"tracks": result.Tracks,
"pagination": pagination,
})
return
}
// Offset-based pagination (fallback)
var pageInt int
if _, err := fmt.Sscanf(page, "%d", &pageInt); err != nil || pageInt < 1 {
response.BadRequest(c, "pagination: page must be >= 1 when not using cursor")
return
}
params.Page = pageInt
tracks, total, err := h.trackService.ListTracks(c.Request.Context(), params) tracks, total, err := h.trackService.ListTracks(c.Request.Context(), params)
if err != nil { if err != nil {
response.InternalServerError(c, "failed to list tracks") response.InternalServerError(c, "failed to list tracks")
@ -92,7 +121,6 @@ func (h *TrackHandler) ListTracks(c *gin.Context) {
} }
pagination := handlers.BuildPaginationData(pageInt, limitInt, total) pagination := handlers.BuildPaginationData(pageInt, limitInt, total)
_, exists := c.Get("user_id")
if !exists { if !exists {
for _, t := range tracks { for _, t := range tracks {
t.StreamManifestURL = "" t.StreamManifestURL = ""

View file

@ -23,6 +23,7 @@ type RoomServiceInterface interface {
AddMember(ctx context.Context, roomID, userID uuid.UUID) error AddMember(ctx context.Context, roomID, userID uuid.UUID) error
RemoveMember(ctx context.Context, roomID, userID uuid.UUID) error // BE-API-011: Remove member method RemoveMember(ctx context.Context, roomID, userID uuid.UUID) error // BE-API-011: Remove member method
GetRoomHistory(ctx context.Context, roomID uuid.UUID, limit, offset int) ([]services.ChatMessageResponse, error) GetRoomHistory(ctx context.Context, roomID uuid.UUID, limit, offset int) ([]services.ChatMessageResponse, error)
GetRoomHistoryWithCursor(ctx context.Context, roomID uuid.UUID, limit int, cursor string) (*services.RoomHistoryWithCursorResult, error) // v0.931: cursor pagination
DeleteRoom(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error // BE-API-010: Delete room method DeleteRoom(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error // BE-API-010: Delete room method
} }
@ -245,6 +246,7 @@ func (h *RoomHandler) AddMember(c *gin.Context) {
// GetRoomHistory récupère l'historique des messages d'une room // GetRoomHistory récupère l'historique des messages d'une room
// GET /api/v1/conversations/:id/history // GET /api/v1/conversations/:id/history
// v0.931: Supports cursor-based pagination via ?cursor=xxx&limit=20. Falls back to offset when cursor not provided.
func (h *RoomHandler) GetRoomHistory(c *gin.Context) { func (h *RoomHandler) GetRoomHistory(c *gin.Context) {
conversationIDStr := c.Param("id") conversationIDStr := c.Param("id")
conversationID, err := uuid.Parse(conversationIDStr) conversationID, err := uuid.Parse(conversationIDStr)
@ -254,8 +256,6 @@ func (h *RoomHandler) GetRoomHistory(c *gin.Context) {
} }
limit := c.DefaultQuery("limit", "50") limit := c.DefaultQuery("limit", "50")
offset := c.DefaultQuery("offset", "0")
limitInt, err := strconv.Atoi(limit) limitInt, err := strconv.Atoi(limit)
if err != nil || limitInt <= 0 { if err != nil || limitInt <= 0 {
limitInt = 50 limitInt = 50
@ -263,6 +263,30 @@ func (h *RoomHandler) GetRoomHistory(c *gin.Context) {
if limitInt > 100 { if limitInt > 100 {
limitInt = 100 limitInt = 100
} }
cursor := c.Query("cursor")
if cursor != "" {
result, err := h.roomService.GetRoomHistoryWithCursor(c.Request.Context(), conversationID, limitInt, cursor)
if err != nil {
if errors.Is(err, services.ErrRoomNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Conversation not found"})
return
}
h.logger.Error("failed to get room history",
zap.Error(err),
zap.String("conversation_id", conversationID.String()))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get conversation history"})
return
}
resp := gin.H{"messages": result.Messages}
if result.NextCursor != "" {
resp["next_cursor"] = result.NextCursor
}
RespondSuccess(c, http.StatusOK, resp)
return
}
offset := c.DefaultQuery("offset", "0")
offsetInt, err := strconv.Atoi(offset) offsetInt, err := strconv.Atoi(offset)
if err != nil || offsetInt < 0 { if err != nil || offsetInt < 0 {
offsetInt = 0 offsetInt = 0

View file

@ -24,6 +24,7 @@ type MockRoomService struct {
AddMemberFunc func(ctx context.Context, roomID, userID uuid.UUID) error AddMemberFunc func(ctx context.Context, roomID, userID uuid.UUID) error
RemoveMemberFunc func(ctx context.Context, roomID, userID uuid.UUID) error RemoveMemberFunc func(ctx context.Context, roomID, userID uuid.UUID) error
GetRoomHistoryFunc func(ctx context.Context, roomID uuid.UUID, limit, offset int) ([]services.ChatMessageResponse, error) GetRoomHistoryFunc func(ctx context.Context, roomID uuid.UUID, limit, offset int) ([]services.ChatMessageResponse, error)
GetRoomHistoryWithCursorFunc func(ctx context.Context, roomID uuid.UUID, limit int, cursor string) (*services.RoomHistoryWithCursorResult, error)
DeleteRoomFunc func(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error DeleteRoomFunc func(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error
} }
@ -62,6 +63,13 @@ func (m *MockRoomService) GetRoomHistory(ctx context.Context, roomID uuid.UUID,
return nil, nil return nil, nil
} }
func (m *MockRoomService) GetRoomHistoryWithCursor(ctx context.Context, roomID uuid.UUID, limit int, cursor string) (*services.RoomHistoryWithCursorResult, error) {
if m.GetRoomHistoryWithCursorFunc != nil {
return m.GetRoomHistoryWithCursorFunc(ctx, roomID, limit, cursor)
}
return &services.RoomHistoryWithCursorResult{Messages: nil, NextCursor: ""}, nil
}
func (m *MockRoomService) UpdateRoom(ctx context.Context, roomID uuid.UUID, userID uuid.UUID, req services.UpdateRoomRequest) (*services.RoomResponse, error) { func (m *MockRoomService) UpdateRoom(ctx context.Context, roomID uuid.UUID, userID uuid.UUID, req services.UpdateRoomRequest) (*services.RoomResponse, error) {
if m.UpdateRoomFunc != nil { if m.UpdateRoomFunc != nil {
return m.UpdateRoomFunc(ctx, roomID, userID, req) return m.UpdateRoomFunc(ctx, roomID, userID, req)

View file

@ -156,6 +156,7 @@ func (h *SocialHandler) AddComment(c *gin.Context) {
} }
// GetFeed récupère le feed global (S1.2, S1.4, S1.6: cursor, limit, type filter) // GetFeed récupère le feed global (S1.2, S1.4, S1.6: cursor, limit, type filter)
// v0.931: Cursor-based pagination via ?cursor=base64. Falls back to offset when cursor not provided.
func (h *SocialHandler) GetFeed(c *gin.Context) { func (h *SocialHandler) GetFeed(c *gin.Context) {
feedType := c.DefaultQuery("type", "all") // all | following | groups feedType := c.DefaultQuery("type", "all") // all | following | groups
if feedType != "all" && feedType != "following" && feedType != "groups" { if feedType != "all" && feedType != "following" && feedType != "groups" {
@ -170,15 +171,19 @@ func (h *SocialHandler) GetFeed(c *gin.Context) {
limit = 50 limit = 50
} }
} }
// cursor: timestamp or ID for pagination (S1.4) - for now we use offset via cursor
cursor := c.Query("cursor") cursor := c.Query("cursor")
offset := 0 offset := 0
if cursor != "" { if o, err := strconv.Atoi(c.Query("offset")); err == nil && o >= 0 {
// Simple cursor: if it's a number, use as offset; else ignore
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 {
offset = o offset = o
} }
if cursor != "" {
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 {
offset = o // Backward compat: cursor as number = offset
} }
}
// Use keyset pagination when cursor looks like base64 (Atoi fails)
_, numErr := strconv.Atoi(cursor)
useCursorKeyset := cursor != "" && numErr != nil
var userID *uuid.UUID var userID *uuid.UUID
if feedType == "following" { if feedType == "following" {
@ -188,6 +193,20 @@ func (h *SocialHandler) GetFeed(c *gin.Context) {
feedType = "all" // Fallback if not authenticated feedType = "all" // Fallback if not authenticated
} }
} }
if useCursorKeyset {
feed, nextCursor, err := h.service.GetGlobalFeedWithCursor(c.Request.Context(), limit, cursor, feedType, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get feed"})
return
}
RespondSuccess(c, http.StatusOK, gin.H{
"items": feed,
"next_cursor": nextCursor,
})
return
}
feed, err := h.service.GetGlobalFeed(c.Request.Context(), limit, offset, feedType, userID) feed, err := h.service.GetGlobalFeed(c.Request.Context(), limit, offset, feedType, userID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get feed"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get feed"})

View file

@ -48,6 +48,14 @@ func (m *MockSocialService) GetGlobalFeed(ctx context.Context, limit, offset int
return args.Get(0).([]social.FeedItem), args.Error(1) return args.Get(0).([]social.FeedItem), args.Error(1)
} }
func (m *MockSocialService) GetGlobalFeedWithCursor(ctx context.Context, limit int, cursor, feedType string, userID *uuid.UUID) ([]social.FeedItem, string, error) {
args := m.Called(ctx, limit, cursor, feedType, userID)
if args.Get(0) == nil {
return nil, "", args.Error(2)
}
return args.Get(0).([]social.FeedItem), args.String(1), args.Error(2)
}
func (m *MockSocialService) GetUserFeed(ctx context.Context, userID uuid.UUID, limit, offset int) ([]social.FeedItem, error) { func (m *MockSocialService) GetUserFeed(ctx context.Context, userID uuid.UUID, limit, offset int) ([]social.FeedItem, error) {
args := m.Called(ctx, userID, limit, offset) args := m.Called(ctx, userID, limit, offset)
if args.Get(0) == nil { if args.Get(0) == nil {

View file

@ -2,7 +2,10 @@ package repositories
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"strconv"
"strings"
"time" "time"
"veza-backend-api/internal/models" "veza-backend-api/internal/models"
@ -63,6 +66,61 @@ func (r *ChatMessageRepository) GetConversationMessages(ctx context.Context, con
return messages, nil return messages, nil
} }
// ConversationMessagesWithCursorResult holds messages and next cursor for cursor-based pagination (v0.931).
type ConversationMessagesWithCursorResult struct {
Messages []models.ChatMessage
NextCursor string
}
// GetConversationMessagesWithCursor uses keyset pagination on (created_at, id) for consistent performance.
// Cursor format: base64(created_at_unix_nano|uuid). Order DESC (newest first), so cursor yields older messages.
func (r *ChatMessageRepository) GetConversationMessagesWithCursor(ctx context.Context, conversationID uuid.UUID, limit int, cursor string) (*ConversationMessagesWithCursorResult, error) {
if limit <= 0 {
limit = 50
}
if limit > 100 {
limit = 100
}
query := r.db.WithContext(ctx).
Where("room_id = ? AND is_deleted = ?", conversationID, false)
var cursorCreatedAt int64
var cursorID uuid.UUID
if cursor != "" {
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
if err == nil {
parts := strings.SplitN(string(decoded), "|", 2)
if len(parts) == 2 {
if ts, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
cursorCreatedAt = ts
}
if uid, err := uuid.Parse(parts[1]); err == nil {
cursorID = uid
}
}
}
}
if cursor != "" && (cursorCreatedAt != 0 || cursorID != uuid.Nil) {
query = query.Where("(created_at, id) < (?, ?)", time.Unix(0, cursorCreatedAt), cursorID)
}
query = query.Order("created_at DESC, id DESC").Limit(limit + 1)
var messages []models.ChatMessage
if err := query.Find(&messages).Error; err != nil {
return nil, fmt.Errorf("failed to get conversation messages: %w", err)
}
var nextCursor string
if len(messages) > limit {
last := messages[limit-1]
nextCursor = base64.RawURLEncoding.EncodeToString([]byte(
fmt.Sprintf("%d|%s", last.CreatedAt.UnixNano(), last.ID.String())))
messages = messages[:limit]
}
return &ConversationMessagesWithCursorResult{Messages: messages, NextCursor: nextCursor}, nil
}
func (r *ChatMessageRepository) GetMessagesBefore(ctx context.Context, roomID uuid.UUID, beforeID uuid.UUID, limit int) ([]models.ChatMessage, error) { func (r *ChatMessageRepository) GetMessagesBefore(ctx context.Context, roomID uuid.UUID, beforeID uuid.UUID, limit int) ([]models.ChatMessage, error) {
var refMsg models.ChatMessage var refMsg models.ChatMessage
if err := r.db.WithContext(ctx).Where("id = ?", beforeID).First(&refMsg).Error; err != nil { if err := r.db.WithContext(ctx).Where("id = ?", beforeID).First(&refMsg).Error; err != nil {

View file

@ -357,6 +357,38 @@ func (s *RoomService) GetRoomHistory(ctx context.Context, roomID uuid.UUID, limi
return responses, nil return responses, nil
} }
// RoomHistoryWithCursorResult holds messages and next cursor for cursor-based pagination (v0.931).
type RoomHistoryWithCursorResult struct {
Messages []ChatMessageResponse
NextCursor string
}
// GetRoomHistoryWithCursor uses keyset pagination on (created_at, id) for consistent performance.
func (s *RoomService) GetRoomHistoryWithCursor(ctx context.Context, roomID uuid.UUID, limit int, cursor string) (*RoomHistoryWithCursorResult, error) {
result, err := s.messageRepo.GetConversationMessagesWithCursor(ctx, roomID, limit, cursor)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) || err.Error() == "conversation not found" {
return nil, ErrRoomNotFound
}
s.logger.Error("failed to get room history with cursor",
zap.Error(err),
zap.String("room_id", roomID.String()))
return nil, fmt.Errorf("failed to get room history: %w", err)
}
responses := make([]ChatMessageResponse, len(result.Messages))
for i, msg := range result.Messages {
responses[i] = ChatMessageResponse{
ID: msg.ID,
ConversationID: msg.ConversationID,
SenderID: msg.SenderID,
Content: msg.Content,
MessageType: msg.MessageType,
CreatedAt: msg.CreatedAt,
}
}
return &RoomHistoryWithCursorResult{Messages: responses, NextCursor: result.NextCursor}, nil
}
// DeleteRoom supprime une room (soft delete) // DeleteRoom supprime une room (soft delete)
// BE-API-010: Implement conversation delete endpoint // BE-API-010: Implement conversation delete endpoint
// Seul le créateur de la room ou un admin peut supprimer la room // Seul le créateur de la room ou un admin peut supprimer la room