chore(release): v0.931 — Cursor (cursor-based pagination, performance baseline)
This commit is contained in:
parent
2a0a6a1ec9
commit
1318a53a64
12 changed files with 422 additions and 28 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.922
|
||||
0.931
|
||||
|
|
|
|||
41
docs/PERFORMANCE_BASELINE.md
Normal file
41
docs/PERFORMANCE_BASELINE.md
Normal 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.
|
||||
|
|
@ -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/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-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/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
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/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
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/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y=
|
||||
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/phpdave11/gofpdi v1.0.13 h1:o61duiW8M9sMlkVXWlvP92sZJtGKENvW3VExs6dZukQ=
|
||||
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/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ package social
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
|
@ -17,6 +20,7 @@ import (
|
|||
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, 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)
|
||||
|
||||
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 {
|
||||
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
|
||||
actorIDs := make(map[uuid.UUID]bool)
|
||||
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 })
|
||||
if len(actorIDs) > 0 {
|
||||
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)
|
||||
if len(trackIDs) > 0 {
|
||||
var tr []models.Track
|
||||
|
|
@ -187,7 +254,6 @@ func (s *Service) GetGlobalFeed(ctx context.Context, limit, offset int, feedType
|
|||
|
||||
feed = append(feed, item)
|
||||
}
|
||||
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@ package track
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings" // Removed strconv
|
||||
"time" // MOD-P2-008: Ajouté pour timeout asynchrone
|
||||
"strconv"
|
||||
"strings"
|
||||
"time" // MOD-P2-008: Ajouté pour timeout asynchrone
|
||||
|
||||
"veza-backend-api/internal/database"
|
||||
"veza-backend-api/internal/models"
|
||||
|
|
@ -554,6 +556,7 @@ func (s *TrackService) GetUserQuota(ctx context.Context, userID uuid.UUID) (*Use
|
|||
type TrackListParams struct {
|
||||
Page int
|
||||
Limit int
|
||||
Cursor string // v0.931: opaque cursor for keyset pagination (base64)
|
||||
UserID *uuid.UUID
|
||||
Genre *string
|
||||
Format *string
|
||||
|
|
@ -561,6 +564,13 @@ type TrackListParams struct {
|
|||
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
|
||||
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é)
|
||||
|
|
@ -633,6 +643,103 @@ func (s *TrackService) ListTracks(ctx context.Context, params TrackListParams) (
|
|||
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
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -47,8 +47,11 @@ type BatchUpdateRequest struct {
|
|||
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) {
|
||||
cursor := c.Query("cursor")
|
||||
page := c.DefaultQuery("page", "1")
|
||||
limit := c.DefaultQuery("limit", "20")
|
||||
userIDStr := c.Query("user_id")
|
||||
|
|
@ -57,19 +60,15 @@ func (h *TrackHandler) ListTracks(c *gin.Context) {
|
|||
sortBy := c.DefaultQuery("sort_by", "created_at")
|
||||
sortOrder := c.DefaultQuery("sort_order", "desc")
|
||||
|
||||
var pageInt, 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
|
||||
}
|
||||
var limitInt int
|
||||
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
|
||||
}
|
||||
|
||||
params := TrackListParams{
|
||||
Page: pageInt,
|
||||
Limit: limitInt,
|
||||
Cursor: cursor,
|
||||
SortBy: sortBy,
|
||||
SortOrder: sortOrder,
|
||||
}
|
||||
|
|
@ -85,6 +84,36 @@ func (h *TrackHandler) ListTracks(c *gin.Context) {
|
|||
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)
|
||||
if err != nil {
|
||||
response.InternalServerError(c, "failed to list tracks")
|
||||
|
|
@ -92,7 +121,6 @@ func (h *TrackHandler) ListTracks(c *gin.Context) {
|
|||
}
|
||||
|
||||
pagination := handlers.BuildPaginationData(pageInt, limitInt, total)
|
||||
_, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
for _, t := range tracks {
|
||||
t.StreamManifestURL = ""
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type RoomServiceInterface interface {
|
|||
AddMember(ctx context.Context, roomID, userID uuid.UUID) error
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -245,6 +246,7 @@ func (h *RoomHandler) AddMember(c *gin.Context) {
|
|||
|
||||
// GetRoomHistory récupère l'historique des messages d'une room
|
||||
// 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) {
|
||||
conversationIDStr := c.Param("id")
|
||||
conversationID, err := uuid.Parse(conversationIDStr)
|
||||
|
|
@ -254,8 +256,6 @@ func (h *RoomHandler) GetRoomHistory(c *gin.Context) {
|
|||
}
|
||||
|
||||
limit := c.DefaultQuery("limit", "50")
|
||||
offset := c.DefaultQuery("offset", "0")
|
||||
|
||||
limitInt, err := strconv.Atoi(limit)
|
||||
if err != nil || limitInt <= 0 {
|
||||
limitInt = 50
|
||||
|
|
@ -263,6 +263,30 @@ func (h *RoomHandler) GetRoomHistory(c *gin.Context) {
|
|||
if 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)
|
||||
if err != nil || offsetInt < 0 {
|
||||
offsetInt = 0
|
||||
|
|
|
|||
|
|
@ -17,14 +17,15 @@ import (
|
|||
|
||||
// MockRoomService implements RoomServiceInterface for testing
|
||||
type MockRoomService struct {
|
||||
CreateRoomFunc func(ctx context.Context, userID uuid.UUID, req services.CreateRoomRequest) (*services.RoomResponse, error)
|
||||
GetUserRoomsFunc func(ctx context.Context, userID uuid.UUID) ([]*services.RoomResponse, error)
|
||||
GetRoomFunc func(ctx context.Context, roomID uuid.UUID) (*services.RoomResponse, error)
|
||||
UpdateRoomFunc func(ctx context.Context, roomID uuid.UUID, userID uuid.UUID, req services.UpdateRoomRequest) (*services.RoomResponse, error)
|
||||
AddMemberFunc 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)
|
||||
DeleteRoomFunc func(ctx context.Context, roomID uuid.UUID, userID uuid.UUID) error
|
||||
CreateRoomFunc func(ctx context.Context, userID uuid.UUID, req services.CreateRoomRequest) (*services.RoomResponse, error)
|
||||
GetUserRoomsFunc func(ctx context.Context, userID uuid.UUID) ([]*services.RoomResponse, error)
|
||||
GetRoomFunc func(ctx context.Context, roomID uuid.UUID) (*services.RoomResponse, error)
|
||||
UpdateRoomFunc func(ctx context.Context, roomID uuid.UUID, userID uuid.UUID, req services.UpdateRoomRequest) (*services.RoomResponse, error)
|
||||
AddMemberFunc 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)
|
||||
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
|
||||
}
|
||||
|
||||
func (m *MockRoomService) CreateRoom(ctx context.Context, userID uuid.UUID, req services.CreateRoomRequest) (*services.RoomResponse, error) {
|
||||
|
|
@ -62,6 +63,13 @@ func (m *MockRoomService) GetRoomHistory(ctx context.Context, roomID uuid.UUID,
|
|||
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) {
|
||||
if m.UpdateRoomFunc != nil {
|
||||
return m.UpdateRoomFunc(ctx, roomID, userID, req)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// v0.931: Cursor-based pagination via ?cursor=base64. Falls back to offset when cursor not provided.
|
||||
func (h *SocialHandler) GetFeed(c *gin.Context) {
|
||||
feedType := c.DefaultQuery("type", "all") // all | following | groups
|
||||
if feedType != "all" && feedType != "following" && feedType != "groups" {
|
||||
|
|
@ -170,15 +171,19 @@ func (h *SocialHandler) GetFeed(c *gin.Context) {
|
|||
limit = 50
|
||||
}
|
||||
}
|
||||
// cursor: timestamp or ID for pagination (S1.4) - for now we use offset via cursor
|
||||
cursor := c.Query("cursor")
|
||||
offset := 0
|
||||
if o, err := strconv.Atoi(c.Query("offset")); err == nil && o >= 0 {
|
||||
offset = o
|
||||
}
|
||||
if cursor != "" {
|
||||
// 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 // 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
|
||||
if feedType == "following" {
|
||||
|
|
@ -188,6 +193,20 @@ func (h *SocialHandler) GetFeed(c *gin.Context) {
|
|||
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)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get feed"})
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ func (m *MockSocialService) GetGlobalFeed(ctx context.Context, limit, offset int
|
|||
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) {
|
||||
args := m.Called(ctx, userID, limit, offset)
|
||||
if args.Get(0) == nil {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ package repositories
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"veza-backend-api/internal/models"
|
||||
|
||||
|
|
@ -63,6 +66,61 @@ func (r *ChatMessageRepository) GetConversationMessages(ctx context.Context, con
|
|||
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) {
|
||||
var refMsg models.ChatMessage
|
||||
if err := r.db.WithContext(ctx).Where("id = ?", beforeID).First(&refMsg).Error; err != nil {
|
||||
|
|
|
|||
|
|
@ -357,6 +357,38 @@ func (s *RoomService) GetRoomHistory(ctx context.Context, roomID uuid.UUID, limi
|
|||
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)
|
||||
// BE-API-010: Implement conversation delete endpoint
|
||||
// Seul le créateur de la room ou un admin peut supprimer la room
|
||||
|
|
|
|||
Loading…
Reference in a new issue