Appearance
Ревью эндпоинта /store/products — поиск, фильтрация, сортировка
Общая архитектура
Функциональность разделена на два эндпоинта:
| Эндпоинт | Action | Назначение |
|---|---|---|
GET /store/products | ListProductsAction | Листинг + фильтрация + сортировка |
GET /store/products/search | SearchProductsAction | Полнотекстовый поиск (PostgreSQL tsvector) |
Оба эндпоинта публичные (без аутентификации), используют cursor-based пагинацию с HMAC-подписью.
Параметры запросов
GET /store/products
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
limit | int | 20 | Размер страницы (макс. 100) |
cursor | string | — | Подписанный курсор пагинации |
sort | string | date_desc | date_desc, price_asc, price_desc |
category_id | int | — | Фильтр по категории (many-to-many, product_categories) + все потомки |
primary_category_id | int | — | Фильтр по основной категории (p.primaryCategory) + все потомки |
make_id | int | — | Марка авто (join product_compatibility) |
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 | — | Максимальная цена |
GET /store/products/search
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
q | string | обязательный | Поисковый запрос |
limit | int | 20 | Размер страницы (макс. 100) |
cursor | string | — | Подписанный курсор пагинации |
city_ids | string | — | Список ID городов через запятую |
region_id | int | — | Регион (игнорируется если передан 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-параметров.
Следствие: пользователи видят нерелевантные результаты при поиске. Функция поиска на сайте фактически не работает.
Рекомендация (варианты):
- Объединить — добавить поддержку
qв/store/products. Еслиqпередан — выполнять полнотекстовый поиск с фильтрацией. Это решает также issue #4 (поиск и фильтрация взаимоисключающие). - Перенаправить фронтенд — исправить клиент, чтобы при наличии
qиспользовался/store/products/search. - Защитное решение — возвращать ошибку валидации при передаче неизвестных 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() выполняет два запроса:
- Raw SQL — возвращает ID, отсортированные по
ts_rank() DESC, p.id DESC - 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/productsprimary_category_id— удаляется. Фронтенд использует толькоcategory_id. Если нужна фильтрация по основной категории — это внутренняя логика, не параметр APIfindActive()— удаляется,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 (гео несогласованность) | Открыт — требует отдельного решения |