Explorar el Código

feat: add tags/features to places listing with batch loading

- models/place.go: add IncludeTagsFeatures to PlaceFilter
- services/places.go: List() fetches tags/features in batch when flag set
- repository/places.go: GetTagsBatch/GetFeaturesBatch (single query for all places)
- handlers/places.go: List() parses ?include=tags,features query param
- avoids N+1 problem: 2 queries total instead of 2*N

FINDINGS.md: updated P1 status (#9)
neyrogovnarik hace 1 mes
padre
commit
37baf7d4a5

+ 7 - 0
FINDINGS.md

@@ -17,6 +17,9 @@
 7. **Token Reuse Detection** — уже реализован: `GetRevoked` в репозитории, проверка в `RefreshSession`, отзыв всех токенов пользователя при обнаружении, обработка в хендлере.
 8. **Скрытие внутренних ошибок в production** — все хендлеры используют единый `writeError` с параметром `err` для логирования; в production 500 ошибки возвращают "internal server error" вместо деталей БД/логики.
 
+### ✅ P1 — Исправлено
+9. **Теги и фичи в листинге мест** — `PlaceFilter.IncludeTagsFeatures` + batch-загрузка (`GetTagsBatch`/`GetFeaturesBatch`), запрос `?include=tags,features`. N+1 проблема решена.
+
 ### ✅ P1 — Исправлено
 6. **Rate limiter** — Redis-based имплементация подключена в main.go, in-memory как fallback при недоступности Redis (реализован корректный fallback: при недоступности Redis автоматически используется in-memory limiter; в production `failOpen=false` — возвращается 503 только если оба лимитера недоступны).
 7. **`string(rune(line))`** — код в `log.go` уже использует `strconv.Itoa`. Баг был исправлен до аудита.
@@ -63,6 +66,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 |
 | `docker-compose.yml` | Все пароли через ${VAR:-default} |
 | `deploy/env.prod` | Очищен Yandex-ключ |
 | `deploy/Caddyfile` | CSP + Permissions-Policy |

+ 11 - 0
backend/internal/handlers/places.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"net/http"
 	"strconv"
+	"strings"
 
 	"github.com/go-chi/chi/v5"
 	"gogs.fxtmmsk.ru/foxtime/photoplaces/backend/internal/middleware"
@@ -72,6 +73,16 @@ func (h *PlaceHandler) List(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
+	// Include tags and features if requested (e.g., ?include=tags,features)
+	if include := q.Get("include"); include != "" {
+		for _, v := range strings.Split(include, ",") {
+			if v == "tags" || v == "features" {
+				filter.IncludeTagsFeatures = true
+				break
+			}
+		}
+	}
+
 	places, err := h.placeSvc.List(r.Context(), filter)
 	if err != nil {
 		writeError(w, http.StatusInternalServerError, "failed to list places", err)

+ 13 - 12
backend/internal/models/place.go

@@ -42,18 +42,19 @@ type PlaceImage struct {
 }
 
 type PlaceFilter struct {
-	Type     string
-	TagIDs   []string
-	FeatureIDs []string
-	MinRating float64
-	PriceMin *int
-	PriceMax *int
-	Bounds   *Bounds
-	Limit_   int
-	Sort     string
-	UserLat  *float64
-	UserLng  *float64
-	Status   string
+	Type              string
+	TagIDs            []string
+	FeatureIDs        []string
+	MinRating         float64
+	PriceMin          *int
+	PriceMax          *int
+	Bounds            *Bounds
+	Limit_            int
+	Sort              string
+	UserLat           *float64
+	UserLng           *float64
+	Status            string
+	IncludeTagsFeatures bool
 }
 
 type Bounds struct {

+ 50 - 0
backend/internal/repository/places.go

@@ -244,6 +244,56 @@ func (r *PlaceRepo) GetFeatures(ctx context.Context, placeID string) ([]models.F
 	return features, nil
 }
 
+func (r *PlaceRepo) GetTagsBatch(ctx context.Context, placeIDs []string) (map[string][]models.Tag, error) {
+	if len(placeIDs) == 0 {
+		return map[string][]models.Tag{}, nil
+	}
+	rows, err := r.pool.Query(ctx,
+		`SELECT pt.place_id, t.id, t.name, t.category FROM tags t
+		 JOIN place_tags pt ON pt.tag_id = t.id
+		 WHERE pt.place_id = ANY($1)`, placeIDs)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	result := make(map[string][]models.Tag)
+	for rows.Next() {
+		var placeID string
+		var t models.Tag
+		if err := rows.Scan(&placeID, &t.ID, &t.Name, &t.Category); err != nil {
+			return nil, err
+		}
+		result[placeID] = append(result[placeID], t)
+	}
+	return result, nil
+}
+
+func (r *PlaceRepo) GetFeaturesBatch(ctx context.Context, placeIDs []string) (map[string][]models.Feature, error) {
+	if len(placeIDs) == 0 {
+		return map[string][]models.Feature{}, nil
+	}
+	rows, err := r.pool.Query(ctx,
+		`SELECT pf.place_id, f.id, f.name, f.category, f.icon FROM features f
+		 JOIN place_features pf ON pf.feature_id = f.id
+		 WHERE pf.place_id = ANY($1)`, placeIDs)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	result := make(map[string][]models.Feature)
+	for rows.Next() {
+		var placeID string
+		var f models.Feature
+		if err := rows.Scan(&placeID, &f.ID, &f.Name, &f.Category, &f.Icon); err != nil {
+			return nil, err
+		}
+		result[placeID] = append(result[placeID], f)
+	}
+	return result, nil
+}
+
 func scanPlace(row interface{ Scan(dest ...any) error }) (*models.Place, error) {
 	var p models.Place
 	err := row.Scan(

+ 34 - 1
backend/internal/services/places.go

@@ -24,6 +24,8 @@ type PlaceRepo interface {
 	SetFeatures(ctx context.Context, placeID string, features []models.Feature) error
 	GetTags(ctx context.Context, placeID string) ([]models.Tag, error)
 	GetFeatures(ctx context.Context, placeID string) ([]models.Feature, error)
+	GetTagsBatch(ctx context.Context, placeIDs []string) (map[string][]models.Tag, error)
+	GetFeaturesBatch(ctx context.Context, placeIDs []string) (map[string][]models.Feature, error)
 }
 
 type PlaceService struct {
@@ -104,7 +106,38 @@ func (s *PlaceService) GetByID(ctx context.Context, id string, fetchTags bool) (
 }
 
 func (s *PlaceService) List(ctx context.Context, filter models.PlaceFilter) ([]*models.Place, error) {
-	return s.placeRepo.List(ctx, filter)
+	places, err := s.placeRepo.List(ctx, filter)
+	if err != nil {
+		return nil, err
+	}
+
+	if filter.IncludeTagsFeatures && len(places) > 0 {
+		placeIDs := make([]string, len(places))
+		for i, p := range places {
+			placeIDs[i] = p.ID
+		}
+
+		tagsMap, err := s.placeRepo.GetTagsBatch(ctx, placeIDs)
+		if err != nil {
+			return nil, err
+		}
+
+		featuresMap, err := s.placeRepo.GetFeaturesBatch(ctx, placeIDs)
+		if err != nil {
+			return nil, err
+		}
+
+		for _, p := range places {
+			if tags, ok := tagsMap[p.ID]; ok {
+				p.Tags = tags
+			}
+			if features, ok := featuresMap[p.ID]; ok {
+				p.Features = features
+			}
+		}
+	}
+
+	return places, nil
 }
 
 type UpdatePlaceInput struct {