Skip to content

Ревью эндпоинта /store/products — поиск, фильтрация, сортировка

Общая архитектура

Функциональность разделена на два эндпоинта:

ЭндпоинтActionНазначение
GET /store/productsListProductsActionЛистинг + фильтрация + сортировка
GET /store/products/searchSearchProductsActionПолнотекстовый поиск (PostgreSQL tsvector)

Оба эндпоинта публичные (без аутентификации), используют cursor-based пагинацию с HMAC-подписью.


Параметры запросов

GET /store/products

ПараметрТипПо умолчаниюОписание
limitint20Размер страницы (макс. 100)
cursorstringПодписанный курсор пагинации
sortstringdate_descdate_desc, price_asc, price_desc
category_idintФильтр по категории (many-to-many, product_categories) + все потомки
primary_category_idintФильтр по основной категории (p.primaryCategory) + все потомки
make_idintМарка авто (join product_compatibility)
model_idintМодель авто
generation_idintПоколение авто
modification_idintМодификация авто
city_idsstringСписок ID городов через запятую
region_idintРегион (игнорируется если передан city_ids)
district_idintРайон
metro_station_idintСтанция метро
steeringstringleft, right, both, universal (без серверной валидации)
price_minfloatМинимальная цена
price_maxfloatМаксимальная цена
ПараметрТипПо умолчаниюОписание
qstringобязательныйПоисковый запрос
limitint20Размер страницы (макс. 100)
cursorstringПодписанный курсор пагинации
city_idsstringСписок ID городов через запятую
region_idintРегион (игнорируется если передан city_ids)

Примечание: фильтры district_id, metro_station_id, category_id, primary_category_id, make_id, model_id, generation_id, modification_id, steering, price_min, price_max — поддерживаются только в /store/products, но не в /store/products/search.


Выявленные проблемы

1. [Критическая] Параметр q молча игнорируется на /store/products — фронтенд не выполняет поиск

Суть: фронтенд отправляет поисковые запросы на GET /store/products?q=пружина&sort=date_desc, но ListProductsAction не парсит параметр q. Параметр молча игнорируется, и клиент получает обычный листинг всех активных товаров по дате.

Воспроизведение:

GET /store/products?limit=20&q=пружина&sort=date_desc

Ожидание: товары, связанные с "пружина". Факт: "Вариатор", "название", "головка", "ппп", "www", "qwe", "чего", "Аккумулятор Volvo XC90" и т.д. — полностью нерелевантные результаты. Единственное случайное совпадение — "Пружины Lada Vesta" (id: 204) на 14-й позиции.

Причина: два отдельных эндпоинта — /store/products (листинг) и /store/products/search (поиск). Фронтенд вызывает листинг вместо поиска. Бэкенд не сигнализирует об ошибке при получении неизвестных query-параметров.

Следствие: пользователи видят нерелевантные результаты при поиске. Функция поиска на сайте фактически не работает.

Рекомендация (варианты):

  1. Объединить — добавить поддержку q в /store/products. Если q передан — выполнять полнотекстовый поиск с фильтрацией. Это решает также issue #4 (поиск и фильтрация взаимоисключающие).
  2. Перенаправить фронтенд — исправить клиент, чтобы при наличии q использовался /store/products/search.
  3. Защитное решение — возвращать ошибку валидации при передаче неизвестных query-параметров, чтобы подобные ситуации не проходили молча.

2. [Высокая] Cursor-пагинация в search несовместима с сортировкой по релевантности (баг)

Суть: метод search() в DoctrineProductRepository сортирует результаты по ts_rank() DESC, p.id DESC, но cursor-пагинация использует условие p.id < :afterId. Это условие корректно только при сортировке по p.id DESC.

При сортировке по релевантности товар с id=500 может быть релевантнее товара с id=100. Условие p.id < afterId на второй странице пропустит высокорелевантные товары с высокими ID и включит нерелевантные с низкими. Результат — пропуски и дубликаты между страницами.

Рекомендация: для релевантного поиска использовать offset-пагинацию (OFFSET/LIMIT) или составной курсор, включающий значение ts_rank (аналогично тому, как сортировка по цене уже использует составной курсор {price, id}).


3. [Высокая] Первая страница sort=price_* без фильтров сортируется неправильно (баг)

Суть: в ListProductsAction ветвление между findActive() и findWithFilters() определяется условием count($filters) > 1. Массив $filters всегда содержит ключ sort. При запросе ?sort=price_asc без дополнительных фильтров count($filters) === 1, и вызывается findActive().

findActive() всегда сортирует по p.id DESC, игнорируя параметр sort. Первая страница ?sort=price_asc показывает товары по дате, а не по цене.

На последующих страницах (с cursor) в $filters добавляются cursor_id и cursor_price, count становится > 1, и вызывается findWithFilters() с корректной сортировкой по цене. Итог — первая страница отсортирована иначе, чем остальные.

Рекомендация: унифицировать в один метод findWithFilters(). findActive() — частный случай с пустым набором фильтров. Это также устраняет issue #10.


4. [Высокая] Потеря порядка релевантности при поиске (баг)

Суть: метод search() выполняет два запроса:

  1. Raw SQL — возвращает ID, отсортированные по ts_rank() DESC, p.id DESC
  2. DQL — загружает entities по WHERE p.id IN (:ids) без ORDER BY

Порядок релевантности, вычисленный на первом шаге, теряется. Результаты приходят клиенту в произвольном порядке, определяемом СУБД.

Рекомендация: сортировать загруженные entities в PHP по массиву ID из первого запроса. Альтернативно — добавить ORDER BY FIELD(p.id, ...) или ORDER BY с CASE WHEN во второй запрос.


5. [Высокая] Поиск и фильтрация — взаимоисключающие операции

Суть: /store/products/search поддерживает только city_ids и region_id. Остальные фильтры (category_id, primary_category_id, make_id, model_id, price_min, price_max, steering и т.д.) отсутствуют.

Следствие: пользователь вынужден выбирать — либо искать текстом, либо фильтровать. Невозможно выполнить запрос «бампер в категории "Кузов" дешевле 5000₽».

Клиентская фильтрация результатов поиска ломает cursor-пагинацию — нельзя гарантировать limit элементов на странице.

Рекомендация: добавить фильтры в поисковый эндпоинт или объединить поиск и фильтрацию в один эндпоинт (параметр q в /store/products).


6. [Средняя] Разная структура ответа у search и list

Суть: ListProductsAction загружает изображения товаров через ProductImageRepositoryInterface и добавляет массив images в каждый элемент ответа. SearchProductsAction этого не делает — у результатов поиска отсутствует ключ images.

Следствие: клиент получает структурно разные ответы от двух эндпоинтов, возвращающих одну и ту же сущность. Это усложняет клиентский код и может приводить к ошибкам отображения.

Рекомендация: унифицировать формат ответа. Добавить загрузку изображений в SearchProductsAction.


7. [Средняя] Семантическая коллизия category_id vs primary_category_id

Суть: оба параметра фильтруют по категории с разрешением поддерева потомков через findDescendantIds(), но по-разному:

  • category_id — join через product_categories (many-to-many связь, все категории товара)
  • primary_category_id — прямое поле p.primaryCategory (основная категория)

Проблемы:

  • Для API-потребителя разница между параметрами неочевидна (оба разрешают поддерево)
  • При одновременной передаче обоих — оба условия применяются как AND, что может приводить к пустым или неожиданным результатам
  • Нет валидации на взаимоисключающее использование

Рекомендация: задокументировать разницу; рассмотреть валидацию — либо запрещать совместное использование, либо чётко описать семантику AND.


8. [Средняя] Тихое подавление region_id при наличии city_ids

Суть: если клиент передаёт оба параметра city_ids и region_id, то region_id молча игнорируется. Клиент не получает ни ошибки, ни предупреждения о том, что один из его фильтров не применён.

Рекомендация: либо возвращать ошибку валидации при одновременной передаче, либо документировать приоритет. Предпочтительнее — ошибка валидации, чтобы клиент не строил ложных ожиданий.


9. [Средняя] Поиск не поддерживает пользовательскую сортировку

Суть: /store/products/search всегда сортирует по релевантности (ts_rank). Параметр sort не принимается. Пользователь не может искать «бампер» и сортировать результаты по цене.

Рекомендация: добавить опциональный параметр sort в поисковый эндпоинт. При sort=relevance (или отсутствии параметра) — текущее поведение; при sort=price_asc/price_desc — соответствующая сортировка.


10. [Средняя] Два code path для листинга

Суть: в ListProductsAction если не передан ни один фильтр (кроме sort), вызывается метод findActive(). Если хотя бы один фильтр есть — findWithFilters(). Это два разных метода в репозитории с потенциально разным поведением.

Риск: при обновлении логики одного метода (например, добавлении нового условия) можно забыть обновить второй. Расхождение будет незаметным.

Связь с issue #3: именно эта дуальность вызывает баг с сортировкой на первой странице.

Рекомендация: унифицировать в один метод. Решает и эту проблему, и issue #3.


11. [Низкая] Отсутствие валидации параметра steering

Суть: параметр sort валидируется по списку допустимых значений (ALLOWED_SORTS), а steering передаётся в запрос без проверки. Некорректное значение (например, steering=foo) не вызовет ошибки, но вернёт пустой результат.

Рекомендация: валидировать steering по enum Steering (left, right, both, universal). Возвращать ошибку валидации при некорректном значении.


12. [Низкая] Гео-фильтры не валидируются на согласованность

Суть: можно передать city_ids=1 и district_id от другого города. Запрос отработает без ошибки, но вернёт пустой результат.

Рекомендация: рассмотреть серверную валидацию принадлежности district_id и metro_station_id к указанным городам. Альтернативно — оставить на совести клиента, но задокументировать.


Сводная таблица

#СерьёзностьТипПроблема
1КритическаяБагПараметр q молча игнорируется на /store/products — поиск на сайте не работает
2ВысокаяБагCursor-пагинация в search() несовместима с ts_rank-сортировкой — пропуски/дубликаты
3ВысокаяБагПервая страница sort=price_* без фильтров сортируется по p.id DESC вместо цены
4ВысокаяБагПотеря порядка релевантности в search() после re-fetch entities
5ВысокаяАрхитектураПоиск и фильтрация взаимоисключающие — нельзя искать с фильтрами
6СредняяАрхитектураРазная структура ответа у search и list (images отсутствуют в search)
7СредняяСемантикаcategory_id vs primary_category_id — неочевидная разница, нет защиты от AND
8СредняяСемантикаregion_id тихо игнорируется при наличии city_ids
9СредняяОграничениеПоиск не поддерживает пользовательскую сортировку
10СредняяАрхитектураДва code path для листинга (findActive / findWithFilters) — корень issue #3
11НизкаяВалидацияПараметр steering не валидируется по enum
12НизкаяВалидацияГео-фильтры не проверяются на принадлежность друг другу

Рекомендация по редизайну API

Текущее состояние (проблема)

Два эндпоинта с разными возможностями, разной структурой ответа, фронтенд путает один с другим:

GET /store/products         — листинг + фильтры + сортировка (без поиска)
GET /store/products/search  — поиск + city/region (без фильтров, без сортировки, без images)

Целевое состояние (один эндпоинт)

Объединить в один GET /store/products с опциональным q:

GET /store/products?q=пружина&category_id=5&price_max=5000&sort=price_asc&limit=20

Правила:

Параметр qПоведение
ОтсутствуетЛистинг всех активных товаров (текущий findWithFilters)
ПрисутствуетПолнотекстовый поиск + все фильтры применяются к результатам

Сортировка при поиске:

Параметр sortПоведение при наличии q
Отсутствует / relevanceСортировка по ts_rank (релевантность)
date_descПо дате
price_asc / price_descПо цене

Значение relevance допускается только при наличии q. Без q — ошибка валидации.

Параметры целевого эндпоинта

GET /store/products

q                  string   опциональный   Поисковый запрос (FTS)
limit              int      20             Размер страницы (макс. 100)
cursor             string   —              Подписанный курсор пагинации
sort               string   date_desc      relevance¹, date_desc, price_asc, price_desc
category_id        int      —              Категория (many-to-many) + потомки
make_id            int      —              Марка авто
model_id           int      —              Модель авто
generation_id      int      —              Поколение авто
modification_id    int      —              Модификация авто
city_ids           string   —              ID городов через запятую
region_id          int      —              Регион (взаимоисключающий с city_ids)
district_id        int      —              Район
metro_station_id   int      —              Станция метро
steering           string   —              left, right, both, universal
price_min          float    —              Мин. цена
price_max          float    —              Макс. цена

¹ relevance доступен только при наличии q

Что убирается

  • /store/products/search — удаляется, вся логика переносится в /store/products
  • primary_category_id — удаляется. Фронтенд использует только category_id. Если нужна фильтрация по основной категории — это внутренняя логика, не параметр API
  • findActive() — удаляется, findWithFilters() обрабатывает все случаи включая пустые фильтры

Валидация (что добавляется)

ПравилоОшибка
sort=relevance без q"relevance sort requires q parameter"
city_ids + region_id одновременно"city_ids and region_id are mutually exclusive"
Невалидное значение steering"Invalid steering. Allowed: left, right, both, universal"
Невалидное значение sort"Invalid sort. Allowed: ..." (уже есть)
Неизвестный query-параметр"Unknown parameter: foo"

Cursor-пагинация (что фиксится)

Режим сортировкиКурсорУсловие следующей страницы
date_desc{id}p.id < :afterId
price_asc{price, id}(price > :p OR (price = :p AND id > :id))
price_desc{price, id}(price < :p OR (price = :p AND id < :id))
relevance{rank, id}(rank < :r OR (rank = :r AND id < :id))

Единый формат ответа

json
{
  "data": [
    {
      "id": 204,
      "title": "Пружины Lada Vesta",
      "description": "...",
      "price": 3500,
      "steering": "universal",
      "seller_id": 20,
      "city_id": 2,
      "images": [
        { "id": 1, "url": "...", "position": 0 }
      ]
    }
  ],
  "meta": {
    "has_more": true,
    "next_cursor": "<signed-base64>"
  }
}

images всегда присутствует (массив, может быть пустым).

Какие issues это закрывает

IssueСтатус
#1 (q игнорируется)Закрыт — q поддерживается
#2 (cursor + relevance)Закрыт — составной курсор {rank, id}
#3 (price sort на 1й странице)Закрыт — один code path
#4 (потеря порядка ts_rank)Закрыт — сортировка в PHP по массиву ID
#5 (поиск без фильтров)Закрыт — один эндпоинт
#6 (images в search)Закрыт — единый формат ответа
#7 (category_id vs primary)Закрыт — primary_category_id удалён
#8 (region_id подавление)Закрыт — ошибка валидации
#9 (sort в search)Закрыт — sort поддерживается
#10 (два code path)Закрыт — один метод
#11 (steering невалидный)Закрыт — валидация по enum
#12 (гео несогласованность)Открыт — требует отдельного решения