Ver código fonte

feat: cursor-based pagination for places listing

- models/place.go: add CursorRating to PlaceFilter, json tags to PaginatedPlaces
- services/places.go: encodeCursor/DecodeCursor helpers (base64), List() returns *PaginatedPlaces
- repository/places.go: cursor WHERE clause for rating sort too
- handlers/places.go: parse ?cursor=<base64>, return {data, next_cursor, has_more}

Backward-compatible: clients without cursor get first page with has_more/next_cursor
neyrogovnarik 1 mês atrás
pai
commit
32e322a67f

+ 5 - 4
FINDINGS.md

@@ -19,6 +19,7 @@
 
 ### ✅ P1 — Исправлено
 9. **Теги и фичи в листинге мест** — `PlaceFilter.IncludeTagsFeatures` + batch-загрузка (`GetTagsBatch`/`GetFeaturesBatch`), запрос `?include=tags,features`. N+1 проблема решена.
+10. **Cursor-based пагинация** — `PlaceFilter.CursorRating`, `PaginatedPlaces` с json-тегами, сервис возвращает `*PaginatedPlaces`, репозиторий поддерживает курсор для `created_at` и `rating` сортировок. API: `?cursor=<base64>&limit=N`. Ответ: `{"data":[...], "next_cursor":"...", "has_more":true}`.
 
 ### ✅ P1 — Исправлено
 6. **Rate limiter** — Redis-based имплементация подключена в main.go, in-memory как fallback при недоступности Redis (реализован корректный fallback: при недоступности Redis автоматически используется in-memory limiter; в production `failOpen=false` — возвращается 503 только если оба лимитера недоступны).
@@ -66,10 +67,10 @@
 | `backend/internal/config/config.go` | JWT секреты обязательны в production, запрещены дефолты |
 | `backend/internal/handlers/errors.go` | **Новый**: единый writeError с логированием и скрытием деталей в prod |
 | `backend/internal/handlers/*.go` | Все хендлеры обновлены: writeError(msg, err) вместо err.Error() |
-| `backend/internal/models/place.go` | PlaceFilter.IncludeTagsFeatures |
-| `backend/internal/services/places.go` | List: batch-загрузка тегов/фич |
-| `backend/internal/repository/places.go` | GetTagsBatch, GetFeaturesBatch |
-| `backend/internal/handlers/places.go` | List: парсинг ?include=tags,features |
+| `backend/internal/models/place.go` | PlaceFilter.IncludeTagsFeatures, CursorRating; PaginatedPlaces json-теги |
+| `backend/internal/services/places.go` | List: batch-загрузка тегов/фич, encodeCursor/DecodeCursor, возвращает *PaginatedPlaces |
+| `backend/internal/repository/places.go` | GetTagsBatch, GetFeaturesBatch; курсор для rating sort |
+| `backend/internal/handlers/places.go` | List: парсинг ?include=tags,features и ?cursor= |
 | `docker-compose.yml` | Все пароли через ${VAR:-default} |
 | `deploy/env.prod` | Очищен Yandex-ключ |
 | `deploy/Caddyfile` | CSP + Permissions-Policy |

+ 17 - 6
backend/internal/handlers/places.go

@@ -83,19 +83,30 @@ func (h *PlaceHandler) List(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	places, err := h.placeSvc.List(r.Context(), filter)
+	if cursor := q.Get("cursor"); cursor != "" {
+		id, createdAt, rating, err := services.DecodeCursor(cursor)
+		if err != nil {
+			writeError(w, http.StatusBadRequest, "invalid cursor", err)
+			return
+		}
+		filter.Cursor = id
+		filter.CursorCreatedAt = &createdAt
+		if q.Get("sort") == "rating" {
+			filter.CursorRating = &rating
+		}
+	}
+
+	result, err := h.placeSvc.List(r.Context(), filter)
 	if err != nil {
 		writeError(w, http.StatusInternalServerError, "failed to list places", err)
 		return
 	}
 
-	if places == nil {
-		places = []*models.Place{}
+	if result.Data == nil {
+		result.Data = []*models.Place{}
 	}
 
-	writeJSON(w, http.StatusOK, map[string]interface{}{
-		"data": places,
-	})
+	writeJSON(w, http.StatusOK, result)
 }
 
 func (h *PlaceHandler) GetByID(w http.ResponseWriter, r *http.Request) {

+ 9 - 0
backend/internal/models/place.go

@@ -55,6 +55,15 @@ type PlaceFilter struct {
 	UserLng           *float64
 	Status            string
 	IncludeTagsFeatures bool
+	Cursor            string
+	CursorCreatedAt   *time.Time
+	CursorRating      *float64
+}
+
+type PaginatedPlaces struct {
+	Data       []*Place `json:"data"`
+	NextCursor string   `json:"next_cursor"`
+	HasMore    bool     `json:"has_more"`
 }
 
 type Bounds struct {

+ 18 - 4
backend/internal/repository/places.go

@@ -96,16 +96,30 @@ func (r *PlaceRepo) List(ctx context.Context, filter models.PlaceFilter) ([]*mod
 	if len(filter.TagIDs) > 0 { q += ` AND pt.tag_id = ANY(@tag_ids)`; args["tag_ids"] = filter.TagIDs }
 	if len(filter.FeatureIDs) > 0 { q += ` AND pf.feature_id = ANY(@feature_ids)`; args["feature_ids"] = filter.FeatureIDs }
 
+	// Cursor-based pagination
+	if filter.Cursor != "" && filter.CursorCreatedAt != nil {
+		if filter.Sort == "rating" && filter.CursorRating != nil {
+			q += ` AND (p.rating, p.created_at, p.id) < (@cursor_rating, @cursor_created_at, @cursor_id)`
+			args["cursor_rating"] = *filter.CursorRating
+			args["cursor_created_at"] = *filter.CursorCreatedAt
+			args["cursor_id"] = filter.Cursor
+		} else {
+			q += ` AND (p.created_at, p.id) < (@cursor_created_at, @cursor_id)`
+			args["cursor_created_at"] = *filter.CursorCreatedAt
+			args["cursor_id"] = filter.Cursor
+		}
+	}
+
 	if filter.Sort == "rating" {
-		q += ` ORDER BY p.rating DESC`
+		q += ` ORDER BY p.rating DESC, p.created_at DESC, p.id`
 	} else if filter.Sort == "distance" && filter.UserLat != nil && filter.UserLng != nil {
-		q += ` ORDER BY p.coordinates <-> ST_SetSRID(ST_MakePoint(@user_lng, @user_lat), 4326)`
+		q += ` ORDER BY p.coordinates <-> ST_SetSRID(ST_MakePoint(@user_lng, @user_lat), 4326), p.created_at DESC, p.id`
 		args["user_lng"] = *filter.UserLng; args["user_lat"] = *filter.UserLat
 	} else {
-		q += ` ORDER BY p.created_at DESC`
+		q += ` ORDER BY p.created_at DESC, p.id`
 	}
 	q += ` LIMIT @lim`
-	args["lim"] = filter.Limit()
+	args["lim"] = filter.Limit() + 1 // fetch one extra to detect hasMore
 
 	rows, err := r.pool.Query(ctx, q, args)
 	if err != nil {

+ 47 - 2
backend/internal/services/places.go

@@ -2,12 +2,42 @@ package services
 
 import (
 	"context"
+	"encoding/base64"
 	"errors"
 	"fmt"
+	"strconv"
+	"strings"
+	"time"
 
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/models"
 )
 
+func encodeCursor(p *models.Place) string {
+	s := p.ID + "|" + strconv.FormatInt(p.CreatedAt.UnixNano(), 10) + "|" + fmt.Sprintf("%.6f", p.Rating)
+	return base64.RawURLEncoding.EncodeToString([]byte(s))
+}
+
+func DecodeCursor(cursor string) (id string, createdAt time.Time, rating float64, err error) {
+	b, err := base64.RawURLEncoding.DecodeString(cursor)
+	if err != nil {
+		return "", time.Time{}, 0, fmt.Errorf("decode cursor: %w", err)
+	}
+	parts := strings.SplitN(string(b), "|", 3)
+	if len(parts) < 2 {
+		return "", time.Time{}, 0, fmt.Errorf("invalid cursor format")
+	}
+	id = parts[0]
+	createdAtUnix, err := strconv.ParseInt(parts[1], 10, 64)
+	if err != nil {
+		return "", time.Time{}, 0, fmt.Errorf("parse cursor created_at: %w", err)
+	}
+	createdAt = time.Unix(0, createdAtUnix)
+	if len(parts) > 2 {
+		rating, _ = strconv.ParseFloat(parts[2], 64)
+	}
+	return
+}
+
 var (
 	ErrNotYourPlace = errors.New("not your place")
 	ErrPlaceNotFound = errors.New("place not found")
@@ -105,7 +135,7 @@ func (s *PlaceService) GetByID(ctx context.Context, id string, fetchTags bool) (
 	return place, nil
 }
 
-func (s *PlaceService) List(ctx context.Context, filter models.PlaceFilter) ([]*models.Place, error) {
+func (s *PlaceService) List(ctx context.Context, filter models.PlaceFilter) (*models.PaginatedPlaces, error) {
 	places, err := s.placeRepo.List(ctx, filter)
 	if err != nil {
 		return nil, err
@@ -137,7 +167,22 @@ func (s *PlaceService) List(ctx context.Context, filter models.PlaceFilter) ([]*
 		}
 	}
 
-	return places, nil
+	limit := filter.Limit()
+	hasMore := len(places) > limit
+	if hasMore {
+		places = places[:limit]
+	}
+
+	var nextCursor string
+	if hasMore && len(places) > 0 {
+		nextCursor = encodeCursor(places[len(places)-1])
+	}
+
+	return &models.PaginatedPlaces{
+		Data:       places,
+		NextCursor: nextCursor,
+		HasMore:    hasMore,
+	}, nil
 }
 
 type UpdatePlaceInput struct {