Перейти к содержанию

05. API каталога

Два независимых контура с разными требованиями:

Публичный Админский
Префикс /api/v1 /api/admin/v1
Аудитория сайт, мобильное приложение админка
Операции только чтение чтение и запись
Авторизация нет (rate limit по IP) JWT + RBAC
Кеширование агрессивное (снапшот, ETag, CDN) нет
Совместимость версионируется, ломать нельзя меняется вместе с админкой

5.1. Общие соглашения

Ошибки — RFC 7807 application/problem+json:

{
  "type": "https://panam.ru/errors/validation",
  "title": "Ошибка валидации",
  "status": 422,
  "detail": "Проверьте заполнение полей",
  "instance": "/api/admin/v1/products/42",
  "requestId": "01J8X…",
  "errors": [
    { "field": "slug", "code": "duplicate", "message": "Такой slug уже используется" }
  ]
}

Пагинация?page=1&size=50, ответ:

{ "items": [  ], "page": 1, "size": 50, "total": 243, "totalPages": 5 }

Сортировка?sort=name,asc&sort=createdAt,desc. Частичное обновлениеPATCH с семантикой merge, null означает «очистить». Конкурентность — тело содержит version, при расхождении 409 Conflict. Идемпотентность записи — заголовок Idempotency-Key на операциях создания. Даты — ISO-8601 с таймзоной. Деньги — число с двумя знаками (530.00), валюта RUB. Идентификаторы — витринные сущности: long; сущности iiko: uuid/строка iiko, как в источнике.

5.2. Публичное API витрины

GET /api/v1/menu

Полное меню одной организации одним запросом. Основной метод витрины.

GET /api/v1/menu?organizationId=1d4d1278-219d-4c72-b064-98d2f9e67d09
If-None-Match: "a3f1…"

200 OK / 304 Not Modified, заголовки: ETag, Cache-Control: public, max-age=60.

{
  "revision": 1892,
  "organizationId": "1d4d1278-219d-4c72-b064-98d2f9e67d09",
  "generatedAt": "2026-08-13T11:20:04Z",
  "categories": [
    {
      "id": 3, "slug": "pizzy", "name": "Пиццы", "parentId": null, "position": 10,
      "image": { "id": 812, "url": "/media/812/card.webp",
                 "srcset": "/media/812/card.webp 1x, /media/812/card@2x.webp 2x" },
      "productIds": [101, 102, 103]
    }
  ],
  "products": [
    {
      "id": 101,
      "slug": "panam",
      "name": "Панам",
      "subtitle": "Фирменная",
      "description": "Пепперони, моцарелла, соус томатный",
      "composition": "Пепперони, моцарелла, соус томатный",
      "categoryIds": [3],
      "primaryCategoryId": 3,
      "badges": ["hit"],
      "images": {
        "main": { "id": 900, "url": "/media/900/card.webp", "srcset": "…" },
        "hover": null,
        "gallery": []
      },
      "variantAxis": { "code": "pizza_size", "name": "Размер", "uiType": "SEGMENTED" },
      "variants": [
        {
          "id": 401,
          "option": { "code": "25", "name": "25 см", "shortName": "25" },
          "iikoItemId": "63ff2041-f358-4b8f-95e5-e41a84fbac23",
          "sku": "219971",
          "price": 530.00,
          "weightGrams": 480,
          "nutritionPer100g": { "proteins": 9.3, "fats": 8.6, "carbs": 19.6, "energy": 193.9 },
          "available": true,
          "isDefault": true,
          "modifierGroupIds": ["mg_7f1c", "mg_2a04"]
        },
        { "id": 402, "option": { "code": "30", "name": "30 см" }, "price": 730.00, "…": "…" }
      ],
      "allergens": []
    }
  ],
  "modifierGroups": [
    {
      "id": "mg_7f1c",
      "name": "Тесто",
      "uiType": "RADIO",
      "min": 1, "max": 1, "free": 1, "collapsed": false,
      "modifiers": [
        { "id": "8c4184d3-…", "name": "Традиционное", "price": 0.00,
          "image": null, "weightGrams": 0, "available": true, "isDefault": true },
        { "id": "1b844f34-…", "name": "Пышное", "price": 50.00, "available": true }
      ]
    }
  ]
}

Устройство ответа — прямое следствие разбора выгрузки iiko:

  • Модификаторы вынесены в словарь modifierGroups, товары ссылаются по id. В исходном ответе iiko 21 231 повторяющаяся ссылка на 237 уникальных модификаторов даёт 19 МБ. После нормализации — сотни килобайт.
  • Идентификатор группы синтетический (mg_7f1c = хеш от group_key + состава + цен), потому что itemGroupId в iiko бывает null и один и тот же id имеет разный состав и разные цены у разных блюд. Группы с одинаковым составом и ценами схлопываются в одну запись словаря.
  • Цена — на варианте, конечная для этой организации. Витрина не считает цены сама.
  • available учитывает стоп-лист и наличие цены.
  • Категории отдают только productIds — товар не дублируется, находясь в нескольких категориях.

Остальные публичные методы

Метод Назначение
GET /api/v1/organizations активные организации: id, название, адрес, координаты
GET /api/v1/categories?organizationId= только дерево категорий (для навигации и SSR-меню)
GET /api/v1/products?organizationId=&categoryId=&page= постраничный список (мобильное приложение, ленивая подгрузка)
GET /api/v1/products/{slug}?organizationId= карточка товара со всеми модификаторами инлайн
GET /api/v1/availability?organizationId= лёгкий ответ: {revision, unavailableItemIds:[…]}, опрос раз в минуту без перекачки меню
GET /api/v1/search?q=&organizationId= поиск по названию/составу, полнотекстовый
GET /api/v1/menu/revision?organizationId= только {revision} — дешёвая проверка на изменения

Витрина работает так: один раз GET /api/v1/menu, дальше опрос availability раз в минуту и menu/revision — при изменении ревизии перезапрашивается меню. Так стоп-лист приезжает почти мгновенно, а трафик остаётся минимальным.

5.3. Админский API

Аутентификация

POST /api/admin/v1/auth/login      { email, password } → { accessToken, expiresIn }
                                     + refreshToken в httpOnly Secure SameSite=Strict cookie
POST /api/admin/v1/auth/refresh    → новый accessToken
POST /api/admin/v1/auth/logout
GET  /api/admin/v1/me              → { id, email, fullName, roles, permissions[] }

Категории

GET    /api/admin/v1/categories?tree=true          дерево целиком
GET    /api/admin/v1/categories/{id}
POST   /api/admin/v1/categories
PATCH  /api/admin/v1/categories/{id}
DELETE /api/admin/v1/categories/{id}               архивирование, 409 если есть товары
PUT    /api/admin/v1/categories/tree               массовое сохранение порядка и вложенности
PUT /api/admin/v1/categories/tree
{ "nodes": [
    { "id": 3, "parentId": null, "position": 0 },
    { "id": 7, "parentId": 3,    "position": 0 },
    { "id": 8, "parentId": 3,    "position": 1 }
] }

Товары

GET    /api/admin/v1/products
         ?q=&categoryId=&status=&hasImage=&hasVariants=&badgeId=
         &organizationId=&iikoIssue=true&page=&size=&sort=
GET    /api/admin/v1/products/{id}
POST   /api/admin/v1/products
PATCH  /api/admin/v1/products/{id}
POST   /api/admin/v1/products/{id}/publish        → 422 со списком нарушенных правил
POST   /api/admin/v1/products/{id}/unpublish
POST   /api/admin/v1/products/{id}/archive
POST   /api/admin/v1/products/bulk                массовые операции
PUT    /api/admin/v1/categories/{id}/products/order   сортировка внутри категории
POST /api/admin/v1/products/bulk
{
  "productIds": [101, 102, 103],
  "action": "ADD_TO_CATEGORY",          // PUBLISH | UNPUBLISH | ARCHIVE | ADD_TO_CATEGORY |
  "payload": { "categoryId": 3 }        // REMOVE_FROM_CATEGORY | SET_BADGES | SET_VISIBILITY
}
 { "succeeded": [101,102], "failed": [ {"id":103, "reason":"NO_MAIN_IMAGE"} ] }

Ответ POST /products/{id}/publish при неготовности:

{
  "type": "https://panam.ru/errors/publish-blocked", "status": 422,
  "title": "Товар нельзя опубликовать",
  "violations": [
    { "code": "NO_MAIN_IMAGE",   "message": "Не загружено главное изображение" },
    { "code": "NO_PLACEMENT",    "message": "Товар не размещён ни в одной активной категории" }
  ]
}

Варианты и привязка к iiko

GET    /api/admin/v1/products/{id}/variants
POST   /api/admin/v1/products/{id}/variants        привязка позиции iiko
PATCH  /api/admin/v1/variants/{variantId}
DELETE /api/admin/v1/variants/{variantId}
PUT    /api/admin/v1/products/{id}/variants/order
POST /api/admin/v1/products/101/variants
{ "iikoItemId": "63ff2041-…", "iikoItemSizeId": 512,
  "variantOptionId": 1, "isDefault": true }

409 Conflict  { "code": "ITEM_ALREADY_BOUND",
                 "boundProduct": { "id": 77, "name": "Панам классический" } }

Инциденты привязок

GET  /api/admin/v1/binding-incidents?status=OPEN&severity=&productId=&page=
GET  /api/admin/v1/binding-incidents/{id}
POST /api/admin/v1/binding-incidents/{id}/dismiss     { note }
POST /api/admin/v1/binding-incidents/{id}/snooze      { until }
POST /api/admin/v1/variants/{id}/rebind               { iikoItemSizeId }
GET  /api/admin/v1/variants/{id}/rebind-candidates
GET /api/admin/v1/binding-incidents?status=OPEN
{ "items": [
  {
    "id": 1204,
    "type": "ITEM_MISSING", "severity": "CRITICAL", "status": "OPEN",
    "detectedAt": "2026-08-12T04:30:11Z", "detectedByRunId": 8891,
    "product": { "id": 101, "name": "Панам", "status": "PUBLISHED" },
    "variant": { "id": 402, "optionName": "30 см" },
    "lastKnown": { "iikoItemId": "a1b2…", "sku": "219972",
                   "name": "Панам 30 см", "price": 730.00,
                   "lastSeenAt": "2026-08-12T04:12:00Z" },
    "candidates": [
      { "iikoItemId": "f9e8…", "iikoItemSizeId": 981, "sku": "219972",
        "name": "Панам 30 см", "matchedBy": "SKU", "confidence": 0.95,
        "firstSeenAt": "2026-08-12T04:30:00Z" }
    ]
  }
] }

POST /variants/{id}/rebind меняет позицию iiko у существующего варианта: товар, изображения, тексты, SEO и размещение сохраняются, прежний itemId дописывается в историю привязки, связанные инциденты закрываются с резолюцией REBOUND. Это и есть ответ на случайное удаление блюда в iiko.

Позиции iiko и подсказки

GET  /api/admin/v1/iiko/items
       ?q=&bound=false&type=DISH&productCategoryId=&menuCategoryId=&triage=NEW&page=
GET  /api/admin/v1/iiko/items/{itemId}                 карточка с ценами и модификаторами
GET  /api/admin/v1/iiko/items/{itemId}/variant-suggestions
POST /api/admin/v1/iiko/items/{itemId}/triage          { status: IGNORED, note }
POST /api/admin/v1/iiko/items/create-product           создать товар из набора позиций
GET /api/admin/v1/iiko/items/{itemId}/variant-suggestions
{
  "baseName": "панам",
  "detectedAxis": "pizza_size",
  "candidates": [
    { "itemId": "63ff2041-…", "itemSizeId": 512, "name": "Панам 25 см", "sku": "219971",
      "detectedOption": {"code": "25", "name": "25 см"}, "confidence": 0.97, "bound": false },
    { "itemId": "a1b2…",      "itemSizeId": 513, "name": "Панам 30 см", "sku": "219972",
      "detectedOption": {"code": "30", "name": "30 см"}, "confidence": 0.97, "bound": false }
  ]
}

POST /api/admin/v1/iiko/items/create-product
{
  "name": "Панам",
  "variantAxisCode": "pizza_size",
  "categoryIds": [3],
  "variants": [ { "iikoItemSizeId": 512, "variantOptionCode": "25", "isDefault": true },
                { "iikoItemSizeId": 513, "variantOptionCode": "30" } ]
}
 201 { "id": 101, "slug": "panam", "status": "DRAFT" }

Модификаторы (витринные профили)

GET   /api/admin/v1/modifier-groups?q=&page=          сводный список групп из iiko + профили
PATCH /api/admin/v1/modifier-groups/{groupKey}        displayName, uiType, isHidden, isCollapsed
GET   /api/admin/v1/modifiers?q=&groupKey=&page=
PATCH /api/admin/v1/modifiers/{iikoItemId}            displayName, imageId, position, isHidden
PUT   /api/admin/v1/modifier-groups/{groupKey}/order  порядок модификаторов внутри группы

Медиа

POST   /api/admin/v1/media                  multipart, ≤ 10 МБ, jpeg/png/webp
         → { id, url, width, height, renditions: {…} }   ренditions генерируются синхронно
GET    /api/admin/v1/media?folder=&q=&page=
GET    /api/admin/v1/media/{id}/usages      где используется
DELETE /api/admin/v1/media/{id}             409, если используется
PATCH  /api/admin/v1/media/{id}             alt, focalPoint

Справочники и настройки

GET   /api/admin/v1/organizations
GET   /api/admin/v1/terminals?organizationId=
GET   /api/admin/v1/stop-lists?organizationId=&terminalId=
GET   /api/admin/v1/external-menus
PATCH /api/admin/v1/external-menus/{id}       priceCategoryId, isEnabled, organizationIds
GET   /api/admin/v1/price-categories
GET   /api/admin/v1/variant-axes  · POST · PATCH  (+ /options)
GET   /api/admin/v1/badges        · POST · PATCH · DELETE

Импорты

GET  /api/admin/v1/sync/runs?job=&status=&page=
GET  /api/admin/v1/sync/runs/{id}
POST /api/admin/v1/sync/run           { "job": "MENU", "scope": "41188", "mode": "FULL" }
                                       202 Accepted + { runId }, 409 если уже выполняется
GET  /api/admin/v1/sync/status        сводка: последний успех по каждой задаче, «свежесть» данных

Аудит и пользователи

GET  /api/admin/v1/audit?entityType=&entityId=&userId=&from=&to=&page=
GET  /api/admin/v1/users · POST · PATCH · DELETE
GET  /api/admin/v1/roles · POST · PATCH

5.4. Версионирование и совместимость

  • Публичное API версионируется в пути (/api/v1). В рамках версии допускаются только аддитивные изменения: новые поля, новые методы. Удаление или смена типа поля — новая версия.
  • Админский API живёт вместе с админкой и версионируется формально (/api/admin/v1), но меняется свободно — оба артефакта деплоятся вместе.
  • OpenAPI генерируется из кода, спецификация публикуется в CI; ломающее изменение публичного контракта ловится diff-проверкой спецификации и валит сборку.

5.5. Производительность и защита

Механизм Публичное API Админское
Кеш снапшот в Redis + ETag + CDN нет
Rate limit 60 запросов/мин на IP на /menu, 600/мин на остальное 600/мин на пользователя
Сжатие gzip/br обязательно gzip
Максимальный размер ответа контроль в тесте: /menu ≤ 1 МБ несжатым
Пагинация обязательна везде, кроме /menu и /categories обязательна везде
CORS список доменов витрины домен админки