Тема
ТЗ — Блог/статьи (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), черновики и ревизии — штатно через ядро
- Рубрики и теги через таксономии ядра (без своей отдельной системы категорий)
- Привязка автора (пользователь ядра), дата публикации, время чтения (авторасчёт)
- Отложенная публикация по расписанию (черновик →
scheduled→publishedавтоматически) - RSS/Atom-лента по всем постам и по рубрике, с лимитом записей
- Связанные посты — по совпадению тегов/рубрики (авто) с ручным приоритетом
- Schema.org
Article/BlogPostingс автором и датами - Виджет «Последние статьи» для сайдбаров/лендингов
Зависимости и выключение
requires: ядро · suggests: cms/search — полнотекстовый поиск постов из индекса вместо PG tsvector ядра.
Поведение при выключении: блок «Список статей» и виджет «Последние статьи» отдают fallback-заглушку, RSS-лента возвращает 404, сами посты и таксономии не удаляются.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_blog_posts | id, slug, title, author_id, status, published_at, reading_time, locale, city_id (nullable), lock_version | пост, контент — блоки ревизии |
cms_blog_post_authors | post_id, user_id, role (enum: primary/co_author), sort_order | pivot соавторства (см. «Крайние случаи» — противоречие с единственным author_id) |
cms_blog_post_related | post_id, related_post_id, sort_order | ручные связанные посты (приоритет над авто) |
author_id — constrained()->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_order | FK существует, 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}.xml | XML-фид, лимит blog.rss_max_items |
| Блоки «Список статей»/«Карточка поста»/«Связанные статьи» | рендер поста(ов) | HTML-фрагмент через BlockRegistry |
| Виджет «Последние статьи» | N последних постов | HTML-фрагмент |
Подписчики события BlogPostPublished/Unpublished | post_id, city_id, locale | payload события (канал 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_page | int | 12 | да | Постов на странице списка |
blog.rss_enabled | bool | true | нет | Включение RSS/Atom-ленты |
blog.rss_max_items | int | 50 | нет | Максимум записей в RSS-фиде (лимит, не полный список постов) |
blog.related_auto_count | int | 3 | нет | Сколько авто-кандидатов подмешивать, если ручных связей меньше нормы |
blog.reading_speed_wpm | int | 200 | нет | Слов в минуту для расчёта времени чтения |
blog.max_co_authors | int | 5 | нет | Максимум соавторов на пост (cms_blog_post_authors) |
blog.scheduled_publish_interval_minutes | int | 1 | нет | Периодичность прогона джобы отложенной публикации (ScheduleRegistrar) |
blog.auto_related_enabled | bool | true | нет | Kill-switch авто-подбора связанных постов; выключено — только ручные связи |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/blog/posts | public | Список постов (фильтр: рубрика, тег, пагинация) |
| GET | /api/v1/blog/posts/{slug} | public | Пост с блочным контентом и связанными постами |
| GET | /blog/rss | public | RSS-лента по всем постам (лимит rss_max_items) |
| GET | /blog/rss/{taxonomy_slug} | public | RSS-лента по рубрике/тегу; несуществующий термин — 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, отставание очереди blog | cms:blog:publish-scheduled --dry-run --json |
| RSS отдаёт старые/неполные данные | кеш тега blog, rss_max_items | cms: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_at | post_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 | — | периодический прогон джоб |
ядро: MediaService | requires | входящее | обложки постов, конверсии изображений |
Фоновая работа
Джоба 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.view | blog.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_id—nullOnDelete(): карточка деградирует до отображения «Автор: команда сайта» (не пустое поле, не ошибка рендера); соответствующая запись в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запрещены