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

Как контент попадает из Strapi на витрину

Витрина никогда не ходит в Strapi напрямую. Между ними стоит снимок (snapshot) в базе Medusa. Это даёт две вещи: витрина не падает, если Strapi недоступен, и покупатель никогда не видит полуопубликованное состояние.

До первой публикации /catalog/content/homepage отдаёт валидный пустой документ

На свежем развёртывании снимка в базе ещё нет — endpoint не 500-ит и не 404-ит, а собирает пустой, но корректный по форме документ (createEmptyCmsHomepage). Название магазина в нём берётся из STOREFRONT_COMPANY_NAME, а при отсутствии переменной — из встроенного значения «Магазин автокрасок». Пустая главная сразу после первого деплоя без каких-либо ошибок в логах — ожидаемое поведение, а не сбой чтения Strapi.

Путь публикации​

  1. Редактор нажимает «Опубликовать» в Strapi.
  2. Strapi вызывает вебхук POST /cms/webhook на backend.
  3. Backend перечитывает опубликованный контент из Strapi (STRAPI_INTERNAL_URL, STRAPI_READ_TOKEN), собирает целостный снимок и кладёт его в таблицу модуля cms-snapshot.
  4. Витрина читает GET /catalog/content/homepage — отдаёт снимок, Cache-Control: no-store.

Вебхук подписан: заголовки x-cms-timestamp и x-cms-signature, HMAC на STRAPI_WEBHOOK_SECRET, сравнение постоянного времени. Неверная подпись — 401, снимок не обновляется.

x-cms-timestamp дополнительно проверяется на окно допустимости в 5 минут (verifyCmsWebhook, lib/cms-snapshot.ts:71) — расхождение с серверным временем backend больше пяти минут в любую сторону тоже даёт 401, независимо от корректности подписи. Это защита от replay-атаки (повторной отправки перехваченного запроса), но при рассинхронизации часов между Strapi и backend это же условие даёт ложный 401 на честном вебхуке — при диагностике сверяйте время обоих контейнеров, не только сам секрет.

Подстраховка раз в минуту​

Задача cms-snapshot-refresh (apps/backend/src/jobs/cms-snapshot-refresh.ts) выполняется каждую минуту и делает то же, что вебхук. Поэтому потерянный или не дошедший вебхук — не авария: контент приедет в течение минуты. Вебхук нужен ради скорости, а не ради доставки.

Консистентное чтение публикации из Strapi​

fetchConsistentPublishedHomepage (lib/cms-content.ts:178) — прежде чем принять прочитанный контент как снимок, backend делает начальное чтение из Strapi, а затем до трёх проверочных чтений подряд, сравнивая каждое с предыдущим по ревизии (итого до четырёх HTTP-запросов к Strapi за один цикл). Совпали соседние ревизии — контент стабилен, можно строить снимок. Не совпали — значит публикация Strapi ещё не устаканилась (кэш, репликация, гонка внутри самого Strapi), и цикл продолжает проверочные чтения, пока не исчерпает лимит в три попытки. Если и после них ревизии не совпадают, обновление снимка не происходит — предыдущий снимок остаётся действующим.

Полная догрузка SEO-записей постранично​

fetchAllSeoEntries (lib/cms-content.ts:138) запрашивает первую страницу (лимит REST API Strapi — 100, см. выше), читает pageCount из ответа и, если он больше 1, параллельно догружает остальные страницы. Это значимо при изменении REST-контракта Strapi: наивный одиночный запрос обрежет снимок на первых 100 записях SEO entry, а не просто их не хватит.

Защита от отката и гонок​

persistCmsSnapshot сравнивает не только ревизию, но и content_updated_at источника:

  • ревизия совпала — ничего не пишем;
  • снимок в базе новее приходящего контента (по content_updated_at) — отказываемся записывать, даже если формально пришла другая ревизия;
  • запись идёт условным update по текущей ревизии и content_updated_at <= source, при проигранной гонке операция повторяется один раз.

Смысл: минутная задача и вебхук постоянно работают параллельно, и более старый ответ Strapi не должен затирать более свежий снимок.

Битые ссылки в баннерах и строгий разбор снимка​

Перед сохранением снимок прогоняется через filterInvalidCmsBanners: баннеры, ведущие на несуществующие товары или категории, из снимка вычищаются. Редактор в Strapi при этом видит их опубликованными — расхождение ожидаемое, витрина осознанно строже.

Проверка внутренних целей точная (lib/cms-commerce-targets.ts:20): товар должен быть опубликован, категория — активна и не служебная, коллекция — просто существовать. После отсеивания недействительных баннеров секция целиком исключается из снимка, если в ней ничего не осталось (:42).

Формат цели баннера: поля взаимоисключающие по типу

validateCommerceTarget (apps/cms/src/content-validation.ts:41) требует ровно один набор полей в зависимости от type: external_url — только url, наличие medusaId при этом отклоняет запись; product/category/collection — только medusaId, наличие url тоже отклоняет запись. Это проверяется на обычных create/update записи (см. ниже), не только при публикации.

(*) Ссылка баннера на коллекцию не фильтрует каталог

commerceTargetHref на витрине формирует для цели-коллекции /catalog?collection_id=… (lib/api/homepage-content.ts:242), но ни страница /catalog, ни GET /catalog/search этот параметр не читают (см. «Параметры поиска») — цель проходит проверку существования (баннер не помечается недействительным), но клик по нему открывает нефильтрованную выдачу каталога. Диагностируя «баннер ведёт не туда» для коллекции — это известный баг, не поломанная цель.

Снятие товара с публикации меняет ревизию снимка без правки записи в Strapi

Хеш доступных товарных/категорийных/коллекционных целей входит в ревизию снимка (:51) — то есть смена публикации товара, на который ссылается баннер, сама по себе считается изменением контента и обновляет снимок при следующем цикле, хотя запись Promo banner в Strapi никто не трогал. Учитывайте это при диагностике «контент изменился на сайте, а в Strapi ничего не меняли».

Само чтение Homepage, Site settings и SEO entry разбирается как единая строгая операция: если хотя бы одна из трёх записей не проходит валидацию, обновление снимка целиком отклоняется и предыдущий снимок остаётся действующим — ошибка в одной записи не даёт частично обновить снимок остальными двумя (см. также предупреждение в «Содержимое главной страницы» для менеджерской точки зрения на этот же механизм).

Валидация срабатывает уже на обычном сохранении, не только на публикации

Middleware strapi.documents.use (apps/cms/src/index.ts:104) прогоняет Homepage, Site settings и SEO entry через validateHomepage/validateSiteSettings/validateSeoEntry на любом create/update — черновик с невалидными данными (например, некорректной целью баннера) не сохранится в принципе, задолго до попытки опубликовать его. Значимо при автоматизированной записи через API: ошибка валидации прилетит на сам запрос записи, не на публикацию.

Проверка целей баннеров на стороне Strapi (fail-closed)​

apps/cms/src/commerce-target-validation.ts — перед сохранением записи Strapi сам запрашивает у Medusa подтверждение, что цели баннеров (товар/категория/коллекция) существуют и доступны: запрос на MEDUSA_CMS_TARGETS_URL, авторизован тем же STRAPI_WEBHOOK_SECRET, что и вебхук публикации, ограничен таймаутом 5 секунд.

(*) Недоступность Medusa делает все цели «недействительными», а не «непроверенными»

Если MEDUSA_CMS_TARGETS_URL/STRAPI_WEBHOOK_SECRET не заданы, Medusa недоступна, запрос падает или не укладывается в 5 секунд — все проверяемые цели считаются недействительными (fail-closed), а не пропускаются как непроверенные. В баннер записывается validationWarning: "Цель недоступна и баннер будет скрыт на сайте"; после восстановления связи предупреждение сбрасывается при следующем сохранении. Разработчику это важно знать: временная проблема сети или конфигурации между CMS и Medusa выглядит для редактора контента как «внезапно все товары и категории пропали» — расследование стоит начинать с доступности MEDUSA_CMS_TARGETS_URL, а не с данных самих баннеров.

Исходящий вебхук публикации (Strapi → Medusa)​

apps/cms/src/publication-webhook.ts — после публикации записей Homepage, Site settings или SEO entry (список зафиксирован в EDITORIAL_UIDS, другие типы контента вебхук не шлют) Strapi сам отправляет подписанный запрос на MEDUSA_CMS_WEBHOOK_URL — это и есть шаг 2 из «Пути публикации» выше, если смотреть со стороны Strapi.

  • Подпись — HMAC-SHA256 от {timestamp}.{body} на STRAPI_WEBHOOK_SECRET, заголовки x-cms-timestamp/x-cms-signature (симметрично проверке на backend).
  • Если MEDUSA_CMS_WEBHOOK_URL или STRAPI_WEBHOOK_SECRET не заданы, отправка молча отключается — это штатный режим (например, локальная разработка CMS без backend), не ошибка.
  • Запрос ограничен таймаутом 5 секунд.
  • Ошибка отправки (сетевая или не-2xx ответ) только логируется предупреждением в Strapi — сама публикация записи к этому моменту уже прошла успешно и не откатывается. Минутный polling на стороне backend (см. «Подстраховка раз в минуту» выше) подхватит контент даже если этот вебхук ни разу не дошёл.

Контракт REST API Strapi​

config/api.ts — единая настройка для всех content type'ов Strapi, не только контента homepage/SEO: defaultLimit: 25, maxLimit: 100, withCount: true (пагинация всегда возвращает точное общее число), strictParams: true (неизвестный query-параметр — ошибка, не молчаливое игнорирование). Значимо при написании нового кода, читающего Strapi напрямую (а не через готовый снимок) — превышение maxLimit не даёт больше 100 записей за раз независимо от запрошенного pageSize.

Известное ограничение: SEO entry пока не читается витриной​

(*) Записи SEO entry можно создавать, валидировать и публиковать — они попадают в снимок контента, — но текущая витрина их не читает: заголовок, описание, canonical, robots и изображение для соцсетей формируются иначе (см. Известные особенности и «SEO страниц» для менеджерской точки зрения). Не диагностируйте заполненные, но «не работающие» SEO-записи как баг синхронизации снимка — синхронизация в порядке, витрина просто ещё не подключена к этим данным.

Предпросмотр​

Strapi Preview на стороне CMS настроен только для api::homepage.homepage (apps/cms/config/admin.ts:26) — handler для любого другого UID возвращает undefined, предпросмотр других content type'ов (например, отдельно SEO entry) не откроется, даже если редактор нажмёт «Preview» на такой записи.

Нажатие «Preview» в Strapi вызывает getHomepagePreviewUrl (apps/cms/src/preview-link.ts:7), который сам обращается к backend: POST {MEDUSA_CMS_PREVIEW_URL}/cms/preview-link (по умолчанию http://localhost:9000/cms/preview-link, за reverse proxy — обязательно переопределить), с таймаутом 5 секунд и заголовком x-cms-internal-secret: {CMS_PREVIEW_INTERNAL_SECRET}. Backend авторизует запрос по этому секрету, генерирует токен, подписанный CMS_PREVIEW_SIGNING_SECRET, и возвращает ссылку {STOREFRONT_PUBLIC_URL}/preview?token=… (10 минут жизни токена). STOREFRONT_PUBLIC_URL здесь двойного назначения: и хост ссылки, и единственный разрешённый origin в preview.config.allowedOrigins самого Strapi — по умолчанию http://localhost:9000.

Токен несёт уникальный nonce, не только exp (lib/cms-preview.ts) — исключает совпадение двух ссылок, выпущенных в одну и ту же миллисекунду.

Защитные заголовки — на ответе с контентом, не на выдаче самой ссылки

Cache-Control: private, no-store, max-age=0, Referrer-Policy: no-referrer и X-Robots-Tag: noindex, nofollow, noarchive (setPreviewResponseHeaders) ставятся на GET /catalog/content/homepage/preview — эндпоинт, который отдаёт сам черновик по токену. POST /cms/preview-link (выдача ссылки Strapi) отвечает обычным JSON без этих заголовков — защищать нечего, сам токен в ответе появляется только там, куда уже прошла авторизация по CMS_PREVIEW_INTERNAL_SECRET. При диагностике утечки токена или кэширования черновика проверяйте именно ответ /preview, а не запрос за ссылкой.

Редакционные роли Strapi​

apps/cms/src/editorial-roles.ts, вызывается при каждом старте CMS (index.ts:155). Роли Editor (strapi-editor) и Publisher (strapi-publisher) создаются, если их ещё нет, и в любом случае приводятся к заданному в коде набору прав — на Homepage, SEO entry и Site settings: у обеих есть создание/чтение/изменение и доступ к медиатеке, но нет удаления; разница только в праве публикации (plugin::content-manager.explorer.publish) — у Editor его нет, у Publisher есть.

Ручные изменения этих ролей в админке Strapi не сохранятся

Следующий рестарт CMS перезапишет права Editor/Publisher обратно к значениям из editorial-roles.ts — если нужна другая роль или другой набор прав, править нужно код, а не только настройки в самой Strapi.

Переменные​

ПеременнаяГде нужна
STRAPI_INTERNAL_URLbackend читает контент
STRAPI_READ_TOKENтокен чтения Strapi
STRAPI_WEBHOOK_SECRETподпись вебхука, одинаковый в Strapi и backend
CMS_PREVIEW_INTERNAL_SECRETStrapi → backend, запрос ссылки предпросмотра
CMS_PREVIEW_SIGNING_SECRETподпись самого токена предпросмотра
STOREFRONT_PUBLIC_URLбаза для ссылки предпросмотра