Skip to content

ТЗ — Блог/статьи (cms/blog)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★★ · Донор: gulaev-dev, universal Статус: ТЗ к разработке

🔄 Ревизия (корпоративный MVP, 15.07.2026): блог реализуется как пресет типа контента на движке типов контента (записи + свойства + связи + шаблоны list/detail), а не отдельная «толстая» модель. Редактор статьи — двойной (tiptap + блоки). Разделы ниже описывают набор свойств/связей/шаблонов пресета; генерик хранение, админка и API — из движка.

⚠️ Статус тела ТЗ: раздел «Модель данных» и всё тело ТЗ ниже (модель данных cms_blog_*, роуты /api/v1/blog/*, Filament-ресурс, права blog.manage, тесты) — исторический слой, написанный до пивота 15.07.2026: фиксируют целевой набор свойств/связей/правил пресета через историческую реализацию собственными таблицами. При реализации модуль становится пресетом движка типов контента: хранение — cms_content_entries, свойства/связи — по карте пресетов (engine §13); актуальные админку/API даёт движок (ContentEntryResource, /api/v1/content/{slug}). Полная ревизия ТЗ — отдельная задача непосредственно перед реализацией пресета (content-types-engine §16, волна G корпоративного MVP (план docs/corporate-mvp/00-master-plan.md в репозитории ядра cms.rosveb.ru)).

Назначение и возможности

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

  • Посты на блочном контенте (BlockRegistry), черновики и ревизии — штатно через ядро
  • Рубрики и теги через таксономии ядра (без своей отдельной системы категорий)
  • Привязка автора (пользователь ядра), дата публикации, время чтения (авторасчёт)
  • Отложенная публикация по расписанию (черновик → scheduledpublished автоматически)
  • RSS/Atom-лента по всем постам и по рубрике, с лимитом записей
  • Связанные посты — по совпадению тегов/рубрики (авто) с ручным приоритетом
  • Schema.org Article/BlogPosting с автором и датами
  • Виджет «Последние статьи» для сайдбаров/лендингов

Зависимости и выключение

requires: ядро · suggests: cms/search — полнотекстовый поиск постов из индекса вместо PG tsvector ядра.

Поведение при выключении: блок «Список статей» и виджет «Последние статьи» отдают fallback-заглушку, RSS-лента возвращает 404, сами посты и таксономии не удаляются.

Модель данных

ТаблицаКлючевые поляПримечание
cms_blog_postsid, slug, title, author_id, status, published_at, reading_time, locale, city_id (nullable), lock_versionпост, контент — блоки ревизии
cms_blog_post_authorspost_id, user_id, role (enum: primary/co_author), sort_orderpivot соавторства (см. «Крайние случаи» — противоречие с единственным author_id)
cms_blog_post_relatedpost_id, related_post_id, sort_orderручные связанные посты (приоритет над авто)

author_idconstrained()->nullOnDelete()->index() на пользователя ядра, хранит денормализованного «основного» автора для быстрого рендера карточки (не источник истины при нескольких авторах — источник истины cms_blog_post_authors); status — PHP Enum (draft/scheduled/published/archived); published_at — индекс под сортировку списка и точку срабатывания отложенной публикации; lock_version — optimistic lock на конкурентное редактирование (§4 стандарта); измерения locale/city_id (nullable) — стандартные для контентных сущностей.

ПДн-паспорт: модуль не хранит собственных ПДн — author_id/cms_blog_post_authors только ссылаются на пользователя ядра (ФИО/email/аватар живут в cms/users, не здесь); у блога нет своей формы комментариев/гостевых полей. Декларируется «ПДн не храню», хуки «выгрузить/забыть по субъекту» из модуля не нужны — участие ограничивается тем, что при «забыть» пользователя-автора его записи в cms_blog_post_authors удаляются каскадно, а author_id уходит в NULL (см. «Крайние случаи»).

Входные и выходные данные

Входы

ИсточникПоляЧем валидируется
Admin-форма (Filament) CRUD постаtitle, slug, blocks(JSON), taxonomies[], authors[], published_at, status, seo.*BlogPostRequest + схема блоков через FieldTypeRegistry/BlockRegistry; authors[] — существующие пользователи, минимум один primary
Admin-форма связанных постов (RelationManager)post_id, related_post_id, sort_orderFK существует, related_post_id != post_id, дубли отклоняются
Событие таксономий ядра (изменение термина)term_idне валидируется повторно — тонкий слушатель, только ставит job инвалидации
Изменение настройки blog.reading_speed_wpm (SettingChanged)key, valueсхема настройки (settings-store ядра)
Импорт cms:blog:import-legacyдонорские таблицы постов/рубрик/авторовмаппинг по профилю + external_id, --dry-run

Выходы

ПотребительДанныеФормат
Публичный API GET /api/v1/blog/posts(/{slug})список/карточка постаконверт {data, meta}, keyset-пагинация
RSS/Atom-читателиGET /blog/rss(.xml), GET /blog/rss/{taxonomy_slug}.xmlXML-фид, лимит blog.rss_max_items
Блоки «Список статей»/«Карточка поста»/«Связанные статьи»рендер поста(ов)HTML-фрагмент через BlockRegistry
Виджет «Последние статьи»N последних постовHTML-фрагмент
Подписчики события BlogPostPublished/Unpublishedpost_id, city_id, localepayload события (канал 1)
sitemap.xml — регистрация URL постов/рубрик в SitemapRegistry ядра (ревизия 14.07.2026, п. 10)URL опубликованных постовXML urlset
Поисковые системы/ботыArticle/BlogPosting JSON-LD на карточкеSchema.org

Whitelist-принцип: неопубликованные/запланированные посты (status != published или published_at > now()) не отдаются публичным API/RSS/sitemap ни при каких query-параметрах.

Настройки (группа blog)

КлючТипДефолтaffectsPageCacheОписание
blog.per_pageint12даПостов на странице списка
blog.rss_enabledbooltrueнетВключение RSS/Atom-ленты
blog.rss_max_itemsint50нетМаксимум записей в RSS-фиде (лимит, не полный список постов)
blog.related_auto_countint3нетСколько авто-кандидатов подмешивать, если ручных связей меньше нормы
blog.reading_speed_wpmint200нетСлов в минуту для расчёта времени чтения
blog.max_co_authorsint5нетМаксимум соавторов на пост (cms_blog_post_authors)
blog.scheduled_publish_interval_minutesint1нетПериодичность прогона джобы отложенной публикации (ScheduleRegistrar)
blog.auto_related_enabledbooltrueнетKill-switch авто-подбора связанных постов; выключено — только ручные связи

API

МетодПутьДоступНазначение
GET/api/v1/blog/postspublicСписок постов (фильтр: рубрика, тег, пагинация)
GET/api/v1/blog/posts/{slug}publicПост с блочным контентом и связанными постами
GET/blog/rsspublicRSS-лента по всем постам (лимит rss_max_items)
GET/blog/rss/{taxonomy_slug}publicRSS-лента по рубрике/тегу; несуществующий термин — 404
GET/POST/PUT/DELETE/api/v1/admin/blog/posts…admin (blog.manage)CRUD постов, включая назначение соавторов и published_at

Список постов — keyset-пагинация по (published_at, id), не OFFSET: новый пост, опубликованный между запросами страниц архива, не сдвигает уже выданный курсор и не приводит к пропуску/дублю записей на границе страницы.

Компоненты

Блоки (BlockRegistry): «Список статей», «Карточка поста», «Связанные статьи». Виджеты: «Последние статьи». Filament: ресурс постов с блочным редактором (мультиселект соавторов, поле published_at с явным статусом scheduled), RelationManager связанных постов. Команды: cms:blog:reading-time:recalc --json, cms:blog:publish-scheduled --json (идемпотентный тик отложенной публикации, также вызываемый по расписанию).

Фронтенд-бюджет: изображения в «Списке статей»/«Карточке поста» — lazy-load по видимости, резерв размеров под обложку (без CLS); «Связанные статьи» рендерятся с плейсхолдером фиксированной высоты до подгрузки; пагинация архива и фильтры рубрик — доступны с клавиатуры, ссылки с понятным текстом (не «читать далее» без контекста).

Демо-контент: сидер — 10 демо-постов, 3 рубрики, набор тегов, 2 демо-автора (один соавтор), 1 запланированный пост — для показа блоков/виджетов в галерее /_gallery и playground без ручного ввода.

Эксплуатация: метрики — отставание очереди blog (recalc/scheduled-publish), длительность сборки RSS-фида, доля постов без тегов (не участвуют в авто-подборе связанных). Алерт — джоба publish-scheduled не отрабатывала дольше scheduled_publish_interval_minutes × 5 (пост завис в scheduled). Ранбук:

СимптомПроверитьКоманда
Запланированный пост не опубликовался вовремярегистрация в ScheduleRegistrar, отставание очереди blogcms:blog:publish-scheduled --dry-run --json
RSS отдаёт старые/неполные данныекеш тега blog, rss_max_itemscms:doctor --json
Время чтения не пересчиталось после смены wpmочередь blog, чанки батчаcms:blog:reading-time:recalc --json
Карточка поста без автора после удаления пользователяauthor_id = NULL, cms_blog_post_authorsручная проверка, деградация ожидаема (см. «Крайние случаи»)

Рестор из бэкапа: в бэкап попадают посты, ревизии блоков (через ядро), pivot соавторов и связанных постов. Денормализованное reading_time и агрегаты авто-подбора связанных пересчитываются командой cms:blog:reading-time:recalc после рестора.

События и обмен

СобытиеКогдаPayload
BlogPostPublishedпост опубликован (вручную или джобой отложенной публикации)post_id, city_id, locale
BlogPostUnpublishedпост снят с публикацииpost_id
BlogPostScheduledпост переведён в статус scheduled с будущим published_atpost_id, publish_at

Слушает: события таксономий ядра (изменение рубрики/тега) — для инвалидации кеша списков; событие ядра об удалении пользователя — для деградации author_id. Provides-контрактов не реализует, FilterBus не использует.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
ядро: таксономиисобытие изменения терминавходящееинвалидация кеша списков по рубрике/тегу
ядро: пользователиrequires (чтение через core-contracts)входящееимя/аватар автора и соавторов для карточки
ядро: пользователисобытие удаления/анонимизации пользователявходящееauthor_id → NULL, запись в cms_blog_post_authors удаляется
ядро: EventBusиздаёт BlogPostPublished/Unpublished/Scheduledисходящеесигнал sitemap, cms/search, уведомлениям
cms/search (suggests)событие BlogPostPublished/Unpublishedисходящееиндексация/деиндексация поста в поисковом провайдере
ядро: CacheTagsтег blogинвалидация карточек/списков/RSS
ядро: ScheduleRegistrarрегистрация reading-time:recalc + publish-scheduledпериодический прогон джоб
ядро: MediaServicerequiresвходящееобложки постов, конверсии изображений

Фоновая работа

Джоба cms:blog:reading-time:recalc (очередь blog) — пересчёт времени чтения при изменении blog.reading_speed_wpm, батчами Bus::batch на больших объёмах, запускается вручную или по расписанию через ScheduleRegistrar ядра.

Отложенная публикация: джоба cms:blog:publish-scheduled (очередь blog) регистрируется через ScheduleRegistrar с периодом blog.scheduled_publish_interval_minutes (дефолт — раз в минуту); выбирает посты status = scheduled AND published_at <= now(), переводит в published (через штатную публикацию ревизии ядра), издаёт BlogPostPublished. Джоба идемпотентна: повторный тик над уже опубликованным постом — no-op по status. Ручной перевод published_at в прошлое при status = scheduled подхватывается ближайшим тиком без ручного вмешательства.

Производительность и кеш

Ожидаемые объёмы. Типовой блог студийного проекта — сотни-низкие тысячи постов; контентных блогов-долгожителей — до нескольких тысяч. RSS не должен расти линейно с общим числом постов — лимит rss_max_items держит фид лёгким независимо от истории блога.

Горячие пути и бюджет запросов: карточка поста и список — 0 запросов на попадании в page-cache; список постов с тегами — ->with('taxonomies', 'authors'), без N+1; сборка RSS — из кешированной выборки (тег blog), не пересчёт на каждый заход читалки/бота; подбор связанных постов — сначала ручные (cms_blog_post_related), доборка авто — запрос по индексированным таксономиям, ограничен related_auto_count.

Критичные индексы: cms_blog_posts(published_at, status) составной под keyset-выборку опубликованных, cms_blog_posts(author_id), cms_blog_post_authors(post_id, user_id) уникальный, cms_blog_post_related(post_id).

Теги кеша и инвалидация: тег blog на списках, карточках постов и RSS; инвалидация по BlogPostPublished/Unpublished/Scheduled и событиям таксономий ядра — точечная по затронутому посту/рубрике, не полный сброс тега на каждое изменение.

Безопасность

Границы входа: FormRequest-whitelist на CRUD постов и назначение соавторов (админка). Блочный контент поста проходит санитизацию движка ревизий ядра двойным барьером (на сохранении и на выводе). RSS-лента — публичное чтение без rate-limit (кешируется тегом blog, лимит записей защищает от разрастания ответа).

Конкретные векторы:

  • доверенный HTML в блоках поста — только через штатный HTML-блок ядра под studio-ролью, не собственная санитизация модуля;
  • IDOR через числовой post_id в служебных путях — публичные API/URL используют slug, id виден только в admin-эндпоинтах под blog.manage;
  • назначение произвольного числа соавторов без лимита — ограничено blog.max_co_authors, превышение — 422, не тихое обрезание списка;
  • RSS для несуществующей/скрытой рубрики не должен молча отдавать общий фид (утечка структуры контента) — явный 404;
  • гонка при отложенной публикации и ручном редактировании поста одновременно — lock_version защищает форму от «последний победил», джоба публикации работает поверх актуальной версии.

Матрица ролей:

Рольblog.viewblog.manageДоверенный HTML (studio)Публикация напрямую минуя scheduled
studio
админ клиента
менеджер❌ (только просмотр)
редакторчастично (свои посты, без публикации)

UX-требования

Админ: пустое состояние списка постов — подсказка «создайте первый пост» со ссылкой на демо-шаблон; массовые действия на списке (bulk-публикация/снятие с публикации, bulk-присвоение тега); ошибки формы — человеческим языком («не выбран основной автор», не «validation.required»); удаление поста с входящими связями в cms_blog_post_related у других постов — предупреждение о числе затронутых карточек перед подтверждением (необратимая операция, §0 стандарта); удаление автора-пользователя с постами — предупреждение в UI пользователей о числе постов, которые останутся без основного автора.

Посетитель: форм на стороне посетителя у блога нет (только чтение — списки, карточки, RSS); скорость отклика — карточка и список из page-cache; доступность — семантические заголовки (h1 заголовок поста, h2 подзаголовки блоков), alt на изображениях, читаемый порядок табуляции пагинации архива; RSS — валидный XML даже при нуле постов (пустой <channel>, не ошибка).

Крайние случаи и типовые баги

  • Черновики и отложенная публикация → пост в status = scheduled с published_at в будущем публикуется джобой cms:blog:publish-scheduled на ближайшем тике (ScheduleRegistrar, дефолт раз в минуту), не в момент сохранения формы; ручной перенос published_at в прошлое подхватывается тем же механизмом без отдельного действия админа.
  • RSS-лента при большом числе постов → фид не растёт линейно с историей блога: жёсткий лимит blog.rss_max_items (дефолт 50), сортировка по published_at desc, без OFFSET.
  • Пагинация архива и keyset-курсор на границе страницы → новый пост, опубликованный между запросами двух соседних страниц архива, не сдвигает уже выданный курсор (published_at, id) — в отличие от OFFSET-пагинации, где он вызвал бы дубль/пропуск записи; курсор всегда указывает на конкретную пару значений, а не позицию.
  • Мультиавторство → ⚠️ Противоречие: исходная модель данных несла единственный author_id (constrained на пользователя ядра), а требование модуля — несколько авторов на пост; решение зафиксировано в «Модели данных» — добавлена pivot-таблица cms_blog_post_authors (post_id, user_id, role, sort_order) как источник истины, author_id остаётся денормализованным «основным автором» для быстрого рендера карточки без join и обратной совместимости API/Schema.org (единственный author).
  • Удаление автора-пользователя с постамиauthor_idnullOnDelete(): карточка деградирует до отображения «Автор: команда сайта» (не пустое поле, не ошибка рендера); соответствующая запись в cms_blog_post_authors удаляется каскадно; если у поста остаётся хотя бы один соавтор — он становится основным автоматически (не «без автора»).
  • Связанные посты: авто-подбор по тегам не находит достаточно кандидатов → если ручных связей меньше related_auto_count, доборка из авто-подбора по тегам/рубрике; если и авто не хватает (пост без тегов/уникальная рубрика) — блок «Связанные статьи» рендерит меньше карточек, не оставляет пустые плейсхолдеры и не падает; при auto_related_enabled = false — блок показывает только ручные связи либо не рендерится при их отсутствии.
  • RSS для несуществующей рубрикиGET /blog/rss/{taxonomy_slug} с неизвестным slug отдаёт 404, а не пустой валидный фид (иначе боты/читалки решат, что рубрика существует и просто пуста).
  • Конкурентное редактирование поста двумя редакторамиlock_version (optimistic lock, §4 стандарта): второй PUT с устаревшей версией получает 409 и сообщение «пост изменён другим редактором, перезагрузите форму», блочный контент не перетирается молча.
  • Пустой блог (0 постов) → список отдаёт пустое состояние без 500, RSS — валидный фид с нулём записей, sitemap.xml не включает раздел блога (не падает на пустой выборке).
  • Изменение reading_speed_wpm при большом числе постов → пересчёт идёт батчами Bus::batch через очередь blog, не синхронно в обработчике SettingChanged (не блокирует сохранение настройки).

Донорский код

Что взятьПуть
Модель постов, RSS-генерацияgulaev-dev (пути не выданы — только имя проекта)
Таксономии рубрик/тегов, связанные постыuniversal (пути не выданы — только имя проекта)

Легаси-импорт: cms:blog:import-legacy --source=<профиль> --dry-run — маппинг донорских таблиц постов/рубрик/тегов/авторов на новую схему (посты → cms_blog_posts + ревизия ядра, рубрики/теги → таксономии ядра, автор → cms_blog_post_authors с role = primary), ключ идемпотентности — external_id (повторный прогон обновляет, не дублирует). Отчёт: прочитано/создано/обновлено/пропущено с построчными причинами; прогон на копии боевых данных донора — часть приёмки модуля (§16 стандарта).

Тесты и приёмка

  • [ ] Контрактный тест /api/v1/blog/posts/{slug} возвращает пост с корректными связанными
  • [ ] При выключении модуля блоки и RSS деградируют без 500, данные сохраняются
  • [ ] Права blog.view/blog.manage разграничивают публичный доступ и CRUD
  • [ ] Пост со status = scheduled и published_at в прошлом публикуется на ближайшем тике publish-scheduled
  • [ ] Повторный тик publish-scheduled над уже опубликованным постом — no-op (идемпотентность)
  • [ ] RSS ограничен rss_max_items и не растёт линейно с общим числом постов
  • [ ] Keyset-курсор архива не пропускает и не дублирует посты при появлении нового между запросами
  • [ ] Соавторство: cms_blog_post_authors — источник истины, author_id синхронизирован как основной автор
  • [ ] Удаление автора-пользователя оставляет пост с деградацией («команда сайта») или переносом основного автора на соавтора
  • [ ] RSS /blog/rss/{taxonomy_slug} для несуществующей рубрики отдаёт 404
  • [ ] Конкурентное редактирование поста отдаёт 409 по lock_version, не «последний победил»
  • [ ] «Связанные статьи» деградируют по числу карточек без ошибки при нехватке авто-кандидатов
  • [ ] Нет N+1 при построении списка постов с тегами (->with('taxonomies', 'authors'))
  • [ ] Инвалидация page-cache по тегу blog при BlogPostPublished/Unpublished/Scheduled — точечная
  • [ ] Schema.org BlogPosting валиден на карточке поста, включает всех соавторов
  • [ ] cms:blog:import-legacy --dry-run идемпотентен и не создаёт дублей по external_id
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут (список, карточка, RSS, admin CRUD)
  • [ ] Тестовая БД только blog_test; migrate:fresh/refresh/reset/db:wipe запрещены

Внутренняя база знаний студии