Как контент попадает из Strapi на витрину
Витрина никогда не ходит в Strapi напрямую. Между ними стоит снимок (snapshot) в базе Medusa. Это даёт две вещи: витрина не падает, если Strapi недоступен, и покупатель никогда не видит полуопубликованное состояние.
/catalog/content/homepage отдаёт валидный пустой документНа свежем развёртывании снимка в базе ещё нет — endpoint не 500-ит и не 404-ит, а собирает
пустой, но корректный по форме документ (createEmptyCmsHomepage). Название магазина в нём
берётся из STOREFRONT_COMPANY_NAME, а при отсутствии переменной — из встроенного значения
«Магазин автокрасок». Пустая главная сразу после первого деплоя без каких-либо ошибок в логах —
ожидаемое поведение, а не сбой чтения Strapi.
Путь публикации
- Редактор нажимает «Опубликовать» в Strapi.
- Strapi вызывает вебхук
POST /cms/webhookна backend. - Backend перечитывает опубликованный контент из Strapi (
STRAPI_INTERNAL_URL,STRAPI_READ_TOKEN), собирает целостный снимок и кладёт его в таблицу модуляcms-snapshot. - Витрина читает
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 этот
параметр не читают (см. «Параметры поиска») — цель проходит проверку существования
(баннер не помечается недействительным), но клик по нему открывает нефильтрованную выдачу
каталога. Диагностируя «баннер ведёт не туда» для коллекции — это известный баг, не поломанная
цель.
Хеш доступных товарных/категорийных/коллекционных целей входит в ревизию снимка (: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 есть.
Следующий рестарт CMS перезапишет права Editor/Publisher обратно к значениям из
editorial-roles.ts — если нужна другая роль или другой набор прав, править нужно код, а не
только настройки в самой Strapi.
Переменные
| Переменная | Где нужна |
|---|---|
STRAPI_INTERNAL_URL | backend читает контент |
STRAPI_READ_TOKEN | токен чтения Strapi |
STRAPI_WEBHOOK_SECRET | подпись вебхука, одинаковый в Strapi и backend |
CMS_PREVIEW_INTERNAL_SECRET | Strapi → backend, запрос ссылки предпросмотра |
CMS_PREVIEW_SIGNING_SECRET | подпись самого токена предпросмотра |
STOREFRONT_PUBLIC_URL | база для ссылки предпросмотра |