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

Кастомные 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/configpublishable 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.