Кастомные API
Роуты — file-based, apps/backend/src/api: папка = сегмент пути, [id] = параметр,
route.ts = обработчик.
Правило проекта: стандартное решение Medusa первым. Корзина и аккаунты идут через штатные
Store API; оформление и история заказов покупателя — кастомные (см. catalog/checkout и
store/account/orders/* ниже, детали оформления — Оплата). Кастомные роуты
появляются там, где штатного API не хватает.
catalog/* — публичные, без авторизации
| Роут | Зачем не штатный |
|---|---|
GET /catalog/search | поиск через Meilisearch, а не через БД |
GET /catalog/products/[id] | карточка + проверка доступности через read-path, чтобы снятый с публикации товар давал 404 даже при устаревшем индексе |
GET /catalog/categories/tree, /categories/[handle]/products | дерево категорий и выдача категории |
GET /catalog/brands, /new-arrivals, /promotions | подборки витрины |
POST /catalog/cart/validate | сверка цен и остатков перед оформлением (skus/items — от 1 до 100 элементов, validators.ts:119) |
POST /catalog/checkout | завершение корзины в заказ |
GET /catalog/config | publishable key в рантайме (см. ниже) |
GET /catalog/content/homepage, /homepage/preview | снимок контента и предпросмотр |
GET /catalog/site-content | контакты и тексты вкладок |
catalog/shipping/* | опции, ПВЗ, адрес, выбор способа доставки — устройство в Доставка (ApiShip, СДЭК) |
catalog/payment/initiate, /status | инициация оплаты и её статус — устройство в Оплата (ЮKassa) |
POST /catalog/webhooks/cdek | вебхук СДЭК |
На первом деплое ключа ещё не существует: сборка витрины происходит раньше первого старта
backend. Поэтому витрина не получает ключ на этапе сборки, а забирает его через
GET /catalog/config — там он привязан к store.default_sales_channel_id. Так один и тот же
образ разворачивается на новом сервере без ручных шагов.
Неудачное получение ключа намеренно не кэшируется (lib/api/runtime-config.ts:24) — витрина
сама восстанавливается после появления ключа без перезапуска процесса; следующий запрос просто
попробует снова.
Кэш публичных данных каталога на сервере витрины
lib/api/memory-cache.ts (loadWithMemoryCache) — module-level Map в серверном процессе
Next.js, 10 минут TTL, с дедупликацией одновременных запросов на один и тот же ключ (второй
и последующие ждут результат первого вместо повторного похода в backend). Используется только
для публичных, редко меняющихся данных каталога (категории, бренды, контент вкладок) — никаких
данных покупателя, сессии, корзины или авторизации в этом кэше быть не должно. Новым фичам с
похожей потребностью — переиспользовать его, не писать отдельный кэш.
Параметры поиска и контракт ответа
GET /catalog/search принимает q, category, category_id, brand, availability,
min_price, max_price, sort, page, limit (search/utils.ts:16):
limitограничен максимумом 100, по умолчанию 24.availability_rank— всегда первый ключ сортировки: недоступные товары не поднимаются выше доступных даже при явной сортировке по цене или названию (search/utils.ts:131).sort=relevanceбезqфактически сортирует по названию (после ранга наличия) — стабильный порядок без реальной релевантности Meilisearch, потому что релевантности без текстового запроса просто нет (:131).- Перевёрнутый диапазон цены (
min_priceбольшеmax_price) автоматически переставляется местами, а не отклоняется как ошибка (:65). - Ответ содержит
processing_time_ms— собственное время обработки на стороне backend, отдельно от общего HTTP-времени ответа (:38). - Общий SKU (
sku) в выдаче присутствует только если у товара единственный непустой SKU варианта; при нескольких разных SKU поле равноnull(:150).
GET /catalog/promotions: товар считается акционным, если состоит в первой коллекции с
точным названием «Акции» (catalog/utils.ts:537) — если таких коллекций несколько, работает
только первая. Endpoint читает не более 500 подходящих товаров до преобразования и сортировки
(:501) — скрытый предел, отдельный от лимита пагинации выдачи.
Выдача категории: GET /catalog/categories/[handle]/products
Фиксированный размер страницы — 24 товара (categories/[handle]/products/utils.ts:11).
- В выдачу попадают товары не только самой категории, но и всех её потомков рекурсивно
(
:47) — категория без собственных товаров, но с наполненными подкатегориями, не будет выглядеть пустой. - Категория (или любой её предок по пути к корню) со скрытым (
is_active: false) или служебным (is_internal: true) статусом даёт 404, а не выдачу с фильтрацией — недоступность одного предка делает недоступным весь поддерепо (:165,178). - Опциональный параметр
subcategoryдополнительно проверяется на принадлежность дереву запрошенной родительской категории —subcategory, не являющийся её потомком, отклоняется400(INVALID_DATA), а не молча трактуется как «нет такой категории» (:238). - Товары сортируются функцией
sortCatalogProducts— по наличию (в наличии → под заказ → нет в наличии), затем по названию в русском алфавитном порядке (../../utils.ts).
Устройство поискового документа и что реально ищется
Meilisearch индексирует товар целиком, но полнотекстовый поиск (searchableAttributes,
modules/meilisearch/service.ts:27) идёт только по трём полям: search_keywords, title,
articles — в этом порядке приоритета. Описание, бренд и категория в документе присутствуют,
но участвуют только в отображении карточки и в точных фильтрах (brand, category), не в
текстовом поиске.
Если новому полю нужно участвовать в q-поиске, его явно нужно добавить в
searchableAttributes — иначе оно будет присутствовать в документе и доступно как фильтр, но
текстовые запросы по нему находить ничего не будут.
Известные ограничения и баги каталога
| Где | Что |
|---|---|
GET /catalog/brands | (*) Бренды извлекаются только из первых 1000 опубликованных товаров без постраничного добора (catalog/brands/utils.ts:12). Ответ кэшируется на 60 сек с разрешением отдавать устаревшую версию ещё 300 сек (catalog/brands/route.ts:10) |
GET /catalog/new-arrivals | (*) Сначала читаются 12 сырых записей, неполные товары (без цены/категории) отбрасываются уже после применения лимита, без добора следующих — выдача может вернуть меньше 12 (catalog/utils.ts:568,570) |
GET /catalog/search, категория по несуществующему category_id или пустому category | (*) Не даёт пустую выдачу — фильтр по несуществующему ID или по имени категории без документов молча отбрасывается из активных фильтров, возвращается весь каталог (search/utils.ts:175) |
| Цена товара | (*) Минимальная положительная цена выбирается предпочтительно в RUB; при отсутствии RUB берётся минимальная в любой валюте, но код валюты витрине не передаётся — она всегда рисует знак ₽ (catalog/utils.ts:218,222) |
Полная переиндексация (reindex-catalog.ts) | (*) Справочник категорий читается одним запросом take: 1000 — категории сверх лимита не участвуют в построении поисковых путей (modules/meilisearch/sync.ts:42) |
| Промо-баннер с целью-коллекцией | (*) Ссылка не фильтрует каталог — подробности в «Контент из Strapi» |
Самовосстановление Meilisearch
Ошибка из-за неизвестного фильтра, фасета или сортировки запускает автоматическое обновление
настроек индекса и один повтор самого поиска (modules/meilisearch/service.ts:206) —
рассинхронизация настроек индекса лечится сама при следующем запросе, без ручного вмешательства.
Отсутствующий на свежей установке индекс трактуется как пустой каталог, а не как авария поиска
(:226).
Полная переиндексация запускается автоматически при структурных изменениях — удаление варианта,
смена категории, inventory level или резерва; простые правки товара обходятся точечным
обновлением (modules/meilisearch/catalog-index-sync.ts:91).
store/* — покупатель, поверх штатных Store API
store/account/orders/* (список, карточка, отмена, заявка на возврат, трекинг),
store/account/wishlist/* (избранное), store/carts/[id]/merge-into-customer (слияние гостевой
корзины при входе), store/unsubscribe/abandoned-cart (отписка из письма).
Авторизация: обязательная для аккаунта, опциональная для оформления
Два разных контракта в одном контуре store/*/catalog/* — не путать при интеграции нового
клиента:
- Заказы, избранное, слияние корзины (
store/account/orders*,store/account/wishlist*,store/carts/:id/merge-into-customer) требуют аутентификацию —authenticate("customer", ["session", "bearer"])принимает как cookie-сессию витрины, так и bearer-токен, симметрично (middlewares.ts:274,288,299). catalog/checkoutиcatalog/payment/initiate, напротив, разрешают гостя:auth_contextу них опционален,customer_idберётся из него если он есть, иначе остаётсяnull(catalog/checkout/route.ts:41,catalog/payment/initiate/route.ts:16) — оформление и оплата не требуют входа в аккаунт.
(*) Сессия создаётся раньше слияния корзины при входеВход на витрине сначала вызывает POST /auth/session (создание customer-сессии), и только потом
merge-into-customer (lib/api/customer-auth.ts:164). Если слияние корзины упадёт, UI покажет
пользователю ошибку входа, хотя customer-сессия к этому моменту уже реально создана — на сервере
человек уже авторизован, при этом видит сообщение о неудачном логине.
(*) Избранное принимает несуществующий product IDstore/account/wishlist/utils.ts:30 не проверяет существование и публикацию товара при
добавлении — API примет любой ID, даже несуществующий. Удалённые и снятые с публикации товары
исключаются только позже, из пагинации, до вычисления количества и страниц (:90).
Проигравший гонку параллельный запрос на добавление возвращает уже созданную запись вместо
ошибки уникального индекса — идемпотентно (:30).
POST /store/unsubscribe/abandoned-cart проверяет HMAC-токен, который зависит только от
customer ID и JWT_SECRET — у него нет отдельного срока действия и он остаётся валидным до
смены JWT_SECRET (lib/abandoned-cart-links.ts:16). Ротация JWT_SECRET (см.
Переменные окружения) как побочный эффект инвалидирует все уже разосланные
ссылки отписки, не только сессии.
addToCartWorkflow и updateLineItemInCartWorkflow из core-flows всегда проверяют остаток и
кидают INSUFFICIENT_INVENTORY. Для слияния корзин это неверное поведение — позиции пишутся
напрямую через сервис Modules.CART, затем вызывается refreshCartItemsWorkflow для пересчёта
сумм и налогов.
merge-guest-cart-into-customer (merge-guest-cart-into-customer.ts), вызывается при входе
или регистрации:
- Новая позиция переносит из гостевой корзины уже сохранённые название, SKU, изображение и цену
вместо повторного чтения карточки товара (
:120) — быстрее и не зависит от текущей публикации товара. - Если у покупателя несколько незавершённых корзин, для слияния выбирается последняя в
выдаче (
:62). - Корзина, уже принадлежащая тому же покупателю, сохраняется без повторного сложения позиций
(
:85) — повторный вызов на ту же пару гость/аккаунт идемпотентен. - Сохранённая локально корзина, уже принадлежащая другому покупателю, не переносится и не
читается — защита от общего устройства/браузера (
:92). - После объединения позиций заново пересчитываются налоги, промоакции и суммы корзины (
:149).
(*) Пустая гостевая корзина после слияния остаётся сиротскойЕсли у покупателя уже была аккаунтная корзина, а гостевая после слияния опустела, эта пустая
гостевая корзина не удаляется и не привязывается ни к чему (:111) — она просто остаётся в базе
осиротевшей записью. Не диагностическая проблема сама по себе, но стоит знать при расследовании
накопления «мусорных» корзин.
Валидация тела запроса
catalog/validators.ts — строгие Zod-схемы для тел оформления и инициации оплаты, неизвестные
поля отклоняются через .strict() (:111). Отличие для количества при валидации корзины:
некорректное значение (не целое или вне допустимого диапазона) не валит запрос ошибкой, а
заменяется единицей через .catch(1) (:91) — осознанный выбор мягкой деградации именно для
этого поля, не общее правило валидации в проекте.
admin/* — админка
| Группа | Роуты |
|---|---|
| МойСклад | admin/moysklad/config, /config/active, /order-syncs, admin/orders/[id]/moysklad-sync |
| Доставка | admin/shipping-providers, /cdek, /cdek/tariffs, /package |
| Отправления | admin/orders/[id]/apiship-fulfillment/{label,retry}, admin/orders/[id]/cdek-fulfillment/{,retry,label,label/file} |
| Оплата | admin/payment-providers, /[provider_id], /[provider_id]/status, admin/orders/[id]/catalog-refund (требует разрешение order:update, middlewares.ts:93) |
| Возвраты | admin/shipping-return-requests, /[id] |
| Контент | admin/site-content, /tabs, /links, /links/[id] (санация HTML — см. ниже) |
Кастомные страницы админки живут в apps/backend/src/admin/routes/*/page.tsx. Авторизация и
права у них те же, что у остальной админки, отдельной роли не заводится — кроме одного
исключения.
(*) Локализация кастомных страниц админки не подключенаadmin/i18n/index.ts экспортирует пустой объект — заявленная возможность локализации
пользовательских расширений админки фактически не содержит переводов.
role_super_adminПри включённом MEDUSA_FF_RBAC чтение и изменение admin/payment-providers/* разрешено только
роли role_super_admin — отдельная жёсткая проверка поверх обычной Admin-аутентификации
(api/admin/payment-providers/authorization.ts:11). Это единственная кастомная страница/роут
проекта с собственным ограничением по роли; остальные (МойСклад, Доставка, Возвраты) такой
проверки не имеют.
Вебхуки
POST /hooks/payment/yookassa_yookassa — платёжные уведомления ЮKassa.
POST /cms/webhook — публикация контента. POST /catalog/webhooks/cdek — статусы отправлений.
Что стоит знать перед правкой
Содержимое вкладок site-content санируется на сервере, независимо от клиентской валидации.
upsert-site-tab.ts прогоняет HTML через sanitize-html с жёстким allowlist: теги только
b/strong/i/em/ul/ol/li/a/p/br, у a — только атрибуты href/target/rel, разрешённые схемы
ссылок — http, https, mailto. Это security-контракт для любого кода, изменяющего вкладки, —
контент очищается всегда, даже если запрос пришёл в обход обычной формы редактирования.
fulfillment_status и payment_status заказа — вычисляемые поля Query, а не колонки.
Выставить их через updateOrders() нельзя. Классификацию по ним тестируйте отдельной чистой
функцией на синтетических данных, а не через реальные заказы.
Любая запись в Cart двигает его updated_at. Служебные отметки вроде «письмо об этой
корзине уже отправлено» должны жить в отдельной таблице по cart_id, иначе они сами ломают
определение бездействия.
customer.has_account, а не наличие customer_id, отличает зарегистрированного покупателя
от гостя. Гостевой чекаут тоже создаёт запись Customer с email, но с has_account: false.
Регистрация с email существующего гостя переиспользует его запись Customer
(register-customer-account.ts:30) — прежние гостевые заказы автоматически остаются в истории
нового аккаунта, потому что связь строится по одной и той же записи Customer, а не по новой.
Удаление товара чистит из S3 только «осиротевшие» документы.
delete-product-document-files.ts:43 удаляет из S3 только те документы, которые больше не
используются никаким другим активным товаром (:43) — общий для нескольких товаров файл
переживёт удаление одного из них.
Уникальность SKU проверяется в одном месте для всех путей изменения товара.
api/admin/products/sku-uniqueness.ts действует одинаково при создании/изменении товара,
отдельного варианта и batch-операциях, с обрезкой пробелов. Новый путь изменения SKU должен
использовать эту же проверку, а не дублировать логику — иначе гарантия уникальности,
критичная для сопоставления с МойСклад, перестанет быть общей.
Документы товара проверяются дважды: на загрузке и перед сохранением товара.
api/admin/uploads/document-validation.ts читает сигнатуру файла (magic bytes), а не только
заявленный MIME (:31). Перед сохранением товара каждый файл-документ повторно читается из
S3, проверяется заново и получает канонический публичный URL (:159) — защита от подмены файла
между загрузкой и сохранением карточки.
Служебные probe-маршруты
admin/custom и store/custom отвечают 200 без тела — используются для проверки
доступности file-based роутинга и самих Admin/Store-контуров, отдельно от /health (см.
Деплой), который проверяет здоровье приложения целиком.