Кастомные API
Роуты — file-based, apps/backend/src/api: папка = сегмент пути, [id] = параметр,
route.ts = обработчик.
Правило проекта: стандартное решение Medusa первым. Корзина, чекаут, заказы, аккаунты идут через штатные Store API. Кастомные роуты появляются там, где штатного 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 | сверка цен и остатков перед оформлением |
POST /catalog/checkout | завершение корзины в заказ |
GET /catalog/config | publishable key в рантайме (см. ниже) |
GET /catalog/content/homepage, /homepage/preview | снимок контента и предпросмотр |
GET /catalog/site-content | контакты и тексты вкладок |
catalog/shipping/* | опции, ПВЗ, адрес, выбор способа доставки |
catalog/payment/initiate, /status | инициация оплаты и её статус |
POST /catalog/webhooks/cdek | вебхук СДЭК |
:::note Почему publishable key берётся в рантайме
На первом деплое ключа ещё не существует: сборка витрины происходит раньше первого старта
backend. Поэтому витрина не получает ключ на этапе сборки, а забирает его через
GET /catalog/config — там он привязан к store.default_sales_channel_id. Так один и тот же
образ разворачивается на новом сервере без ручных шагов.
:::
store/* — покупатель, поверх штатных Store API
store/account/orders/* (список, карточка, отмена, заявка на возврат, трекинг),
store/account/wishlist/* (избранное), store/carts/[id]/merge-into-customer (слияние гостевой
корзины при входе), store/unsubscribe/abandoned-cart (отписка из письма).
:::warning Слияние корзин обходит проверку остатка намеренно
addToCartWorkflow и updateLineItemInCartWorkflow из core-flows всегда проверяют остаток и
кидают INSUFFICIENT_INVENTORY. Для слияния корзин это неверное поведение — позиции пишутся
напрямую через сервис Modules.CART, затем вызывается refreshCartItemsWorkflow для пересчёта
сумм и налогов.
:::
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 |
| Возвраты | admin/shipping-return-requests, /[id] |
| Контент | admin/site-content, /tabs, /links, /links/[id] |
Кастомные страницы админки живут в apps/backend/src/admin/routes/*/page.tsx. Авторизация и
права у них те же, что у остальной админки, отдельной роли не заводится.
Вебхуки
POST /hooks/payment/yookassa_yookassa — платёжные уведомления ЮKassa.
POST /cms/webhook — публикация контента. POST /catalog/webhooks/cdek — статусы отправлений.
Что стоит знать перед правкой
fulfillment_status и payment_status заказа — вычисляемые поля Query, а не колонки.
Выставить их через updateOrders() нельзя. Классификацию по ним тестируйте отдельной чистой
функцией на синтетических данных, а не через реальные заказы.
Любая запись в Cart двигает его updated_at. Служебные отметки вроде «письмо об этой
корзине уже отправлено» должны жить в отдельной таблице по cart_id, иначе они сами ломают
определение бездействия.
customer.has_account, а не наличие customer_id, отличает зарегистрированного покупателя
от гостя. Гостевой чекаут тоже создаёт запись Customer с email, но с has_account: false.