Перейти к основному содержимому

Кастомные 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/configpublishable key в рантайме (см. ниже)
GET /catalog/content/homepage, /homepage/previewснимок контента и предпросмотр
GET /catalog/site-contentконтакты и тексты вкладок
catalog/shipping/*опции, ПВЗ, адрес, выбор способа доставки — устройство в Доставка (ApiShip, СДЭК)
catalog/payment/initiate, /statusинициация оплаты и её статус — устройство в Оплата (ЮKassa)
POST /catalog/webhooks/cdekвебхук СДЭК
Почему publishable key берётся в рантайме

На первом деплое ключа ещё не существует: сборка витрины происходит раньше первого старта 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 ID

store/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 (см. Деплой), который проверяет здоровье приложения целиком.