Skip to content

ТЗ — Публикация по расписанию (cms/scheduled-publishing)

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

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

Отложенная публикация и снятие ревизий страниц и записей контент-типов: publish_at/ unpublish_at на планировщике ядра, точная (в пределах check_interval_minutes) инвалидация кеша в момент публикации и календарь публикаций в админке. Новая разработка поверх системы ревизий ядра — модуль не хранит контент, только очередь и факт исполнения.

  • publish_at/unpublish_at на ревизии страницы или записи контент-типа
  • Планировщик ядра проверяет очередь и публикует/снимает точно в назначенное время (с точностью до тика check_interval_minutes)
  • Инвалидация page-cache по тегу сущности сразу в момент публикации/снятия
  • Календарь публикаций в Filament (список/сетка запланированного на период)
  • Отмена запланированной публикации до наступления времени
  • Перенос (reschedule) запланированной даты без пересоздания задачи
  • Массовое планирование (несколько записей на одну дату) для контент-кампаний, с явным лимитом на размер операции
  • Аварийная пауза обработки очереди (kill-switch) без выключения модуля

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

requires: ядро

Поведение при выключении: запланированные publish_at/unpublish_at не обрабатываются автоматически — публикация/снятие требует ручного действия редактора; уже сохранённые даты в записях и очереди не удаляются. При повторном включении модуль обрабатывает накопившуюся очередь по обычной логике тика планировщика — включая просроченные элементы (см. «Крайние случаи»: политика студии — просроченное не пропускается молча, § ниже), а не требует ручного подтверждения на каждую запись отдельно.

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

ТаблицаКлючевые поляПримечание
cms_scheduled_publicationsid, publishable_type, publishable_id, action, scheduled_at, status, processed_at, site_id, lock_versionэлемент очереди, action: publish/unpublish

publishable_type+publishable_id — составной индекс; scheduled_at+status — индекс под пакетную выборку due-элементов планировщиком; action/status — PHP Enum. site_id — денормализовано из целевой сущности (nullable — правило измерений §4 стандарта: сайт без cms/multisite работает без ветвления кода), нужно только для быстрой фильтрации календаря по текущему RequestContext.site_id без join к странице/ записи на каждый элемент списка. lock_version — optimistic lock (стандарт §4): отмена и перенос — редактируемые в админке операции, конфликт двух админов над одной задачей отдаёт 409, не «последний победил».

ПДн-паспорт. Таблица хранит только служебные ссылки (publishable_type, publishable_id) на уже существующие сущности ядра — сама не содержит персональных данных. ПДн не храню.

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

Входы

ИсточникДанные/поляЧем валидируется
Существующий FormRequest CRUD страниц/контент-типов ядра (pages.manage/content.{type}.manage)publish_at, unpublish_at (дата+время в локальном часовом поясе сайта)whitelist полей FormRequest ядра + правило модуля: unpublish_at строго позже publish_at, publish_at не может быть в прошлом на момент сохранения
Filament, календарь публикаций (admin)отмена запланированной задачи (id записи очереди, lock_version)permission scheduled-publishing.manage, whitelist действия (cancel)
Filament, карточка записи / календарь (admin)перенос даты (id, новая scheduled_at, lock_version)permission scheduled-publishing.manage, те же правила порядка дат, что при создании
Filament, массовое планирование (admin)список id записей + одна дата publish_atpermission на каждую запись индивидуально (см. «Безопасность») + лимит scheduled-publishing.bulk_max_items
Cron (ScheduleRegistrar)тик планировщика, без пользовательских данныхвнутренний триггер, не пользовательский вход
Событие PageSaved/ContentEntrySaved (ядро)факт сохранения ревизии с заполненным publish_at/unpublish_at в будущемдоверенное событие ядра — поле уже прошло валидацию на границе ядра, модуль повторно не парсит пользовательский ввод

Выходы

ПотребительДанныеФормат
Filament, календарь публикацийсписок запланированных задач за периодтаблица/сетка UI, keyset-пагинация по диапазону дат
PageRepository/ContentRepository (ядро)вызов «опубликовать ревизию X» / «снять с публикации»вызов публичного метода репозитория/сервиса ядра, не запись в чужие таблицы
CacheTags (ядро)инвалидация тега целевой сущностивызов контракта CacheTags
Подписчики событийScheduledPublicationExecuted/ScheduledPublicationFailedpayload события (EventBus)
Команда cms:scheduled-publishing:process --jsonотчёт о прогоне (обработано/ошибок/пропущено)JSON
Команда cms:scheduled-publishing:rescan --jsonотчёт восстановления очереди по данным сущностейJSON

Whitelist-принцип: всё, что не перечислено во входной таблице (произвольные поля сущности, чужие статусы, действия помимо publish/cancel/reschedule), отвергается на границе FormRequest — модуль не вводит собственный неконтролируемый вход.

Настройки (группа scheduled-publishing)

КлючТипДефолтaffectsPageCacheОписание
scheduled-publishing.check_interval_minutesint5нетПериодичность проверки очереди планировщиком
scheduled-publishing.calendar_default_range_daysint30нетДиапазон календаря в админке по умолчанию
scheduled-publishing.bulk_max_itemsint100нетМаксимум записей за одну операцию массового планирования; выше — 422 с понятным сообщением
scheduled-publishing.missed_tick_alert_multiplierint3нетАлерт в cms/health, если тик планировщика не выполнялся дольше N × check_interval_minutes
scheduled-publishing.processing_pausedboolfalseнетKill-switch: аварийная пауза обработки очереди (тик не публикует и не снимает) без выключения модуля — календарь и планирование продолжают работать

API

Отдельного API нет — планирование даты публикации выполняется через существующие эндпоинты CRUD страниц/контент-типов ядра (POST/PUT /api/v1/admin/pages…, /api/v1/admin/content/{type}…), поле publish_at/unpublish_at добавлено в whitelist их FormRequest. Модуль не открывает /api/v1/scheduled-publishing/… — просмотр очереди и управление ею доступны только через Filament (см. «Компоненты»). Это осознанное решение: у функции нет headless-потребителей вне админки, лишний API-неймспейс — неиспользуемая поверхность (антипаттерн «свой мини-фреймворк там, где не нужен»).

Компоненты

Filament: календарь публикаций (список/сетка по датам), массовое планирование из списка записей (выбор чекбоксами + одна дата), возможность отменить или перенести запланированное действие из карточки записи и прямо из календаря. Команды: cms:scheduled-publishing:process --json (ручной прогон очереди, помимо планировщика), cms:scheduled-publishing:rescan --json (восстановление очереди по полям publish_at/ unpublish_at самих страниц/записей — см. «Эксплуатация» в «Фоновой работе»).

Фронтенд-бюджет. Не применимо — у модуля нет публичных блоков/виджетов, только Filament-интерфейс.

Демо-контент. Сидер создаёт несколько записей cms_scheduled_publications (публикация через N часов, снятие через N дней) на демо-страницах/записях playground, чтобы календарь и /_gallery показывали модуль без ручного ввода.

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

СобытиеКогдаPayload
ScheduledPublicationExecutedзапланированная публикация/снятие выполненыpublishable_type, publishable_id, action, scheduled_at, processed_at
ScheduledPublicationFailedпубликация не выполнена (например, сущность удалена)publishable_type, publishable_id, reason

FilterBus и provides-контракты не используются.

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

Сущность/модульКаналНаправлениеЧто происходит
PageSaved/ContentEntrySaved (ядро)шина событий (канал 1)inПри установке publish_at/unpublish_at в будущем запись переводится в очередь вместо немедленной публикации
PageRepository/ContentRepository (cms/core-contracts)прямой вызов публичного сервиса (канал 4, requires: ядро)outТик планировщика вызывает метод «опубликовать ревизию»/«снять с публикации» — модуль не пишет в cms_pages/cms_content_entries напрямую
CacheTags (cms/core-contracts)сервис-вызов (канал 4)outИнвалидация тега целевой сущности синхронно в момент выполнения джобы
ScheduleRegistrar (cms/core-contracts)прямой вызов при boot()outРегистрация тика планировщика с интервалом check_interval_minutes
cms/healthhealth-чек ядраoutОтдаёт метрики очереди и статус последнего тика в агрегат /api/v1/system/health
Подписчики (cms/notifications-bus, если включён)шина событийoutScheduledPublicationFailed / просроченный тик → алерт админам

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

Джоба cms:scheduled-publishing:process (именованная очередь scheduled-publishing) — пакетная выборка due-элементов одним запросом (scheduled_at <= now() AND status = pending) по составному индексу (scheduled_at, status), обработка чанками (Bus::batch, §9 стандарта) — на крупном сайте одновременно в очереди могут стоять десятки-сотни запланированных публикаций. Джоба идемпотентна: повторный прогон не публикует дважды — перед фактической публикацией проверяется текущий статус ревизии в ядре, а не слепое исполнение по факту наличия задачи (см. «Крайние случаи»: конфликт с ручной публикацией). Тик защищён мьютексом (WithoutOverlapping по ключу scheduled-publishing:tick): при затянувшейся обработке предыдущего тика следующий не стартует поверх него. Расписание — через ScheduleRegistrar ядра с интервалом check_interval_minutes, прямой Schedule:: в boot() запрещён.

Эксплуатация. Метрики: размер очереди (pending), среднее и максимальное расхождение факт/план (processed_at - scheduled_at) за сутки. Алерт: тик не выполнялся дольше missed_tick_alert_multiplier × check_interval_minutes — видно в cms/health и алертится через cms/notifications-bus, если включён. Симптом → команда: «публикации не выполняются» → cms:scheduled-publishing:process --json вручную + проверить регистрацию в ScheduleRegistrar (cms:doctor --json); «очередь пуста после рестора БД» → cms:scheduled-publishing:rescan --json (перечитывает publish_at/unpublish_at, хранимые на самих страницах/записях, и пересобирает недостающие элементы очереди — рестор без прогона этой команды не считается завершённым); «календарь не совпадает с ожиданиями редактора» → сверить часовой пояс сайта (группа настроек site, ядро) с UTC-значением scheduled_at в журнале. Бэкап/рестор: cms_scheduled_publications — обычная таблица приложения, попадает в бэкап БД целиком (cms/backup, если включён); после рестора из более старой копии команда rescan дополняет очередь по актуальным данным сущностей, ничего не пересоздаётся вручную.

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

Ожидаемые объёмы: на крупном сайте студии — десятки-сотни запланированных публикаций одновременно в очереди, тик планировщика — раз в check_interval_minutes (дефолт 5 мин). Горячий путь — сам тик: одна пакетная выборка due-элементов по индексу (scheduled_at, status), без построчных запросов «есть ли ещё» (N+1 запрещён — [качество кода] (/specs/laravel_13/code-quality)); обработка due-элементов — чанками через Bus::batch для утилизации нескольких воркеров на большой партии.

Кеш: собственных тегов нет — инвалидирует page-cache по тегу целевой сущности (publishable_type) через CacheTags ядра. Важное уточнение к исходной формулировке: инвалидация происходит синхронно в момент выполнения джобы публикации/снятия, а не с отставанием на весь check_interval_minutes от scheduled_at — отставание от scheduled_at возможно (до одного тика), но между «джоба выполнила публикацию» и «тег инвалидирован» разрыва нет: это один и тот же вызов сервиса ядра.

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

Границы входа: планирование даты — через существующие FormRequest CRUD ядра (поле publish_at/unpublish_at), собственных публичных входных точек модуль не добавляет. Отмена/перенос из календаря — только под правами scheduled-publishing.manage.

Массовое планирование не даёт обойти права на конкретную запись. Выбор чекбоксами + общая дата — это UI-удобство, не расширение прав: перед постановкой каждой записи в очередь модуль проверяет pages.publish/content.{type}.manage (право ядра) именно на эту запись отдельно; записи, на которые у пользователя нет права, молча пропускаются и попадают в отчёт операции как «пропущено — недостаточно прав», а не блокируют всю партию и не публикуются в обход проверки.

Матрица ролей (permission → штатная роль по умолчанию, стандарт §11):

ДействиеРедакторМенеджерАдминStudio
Просмотр календаря (scheduled-publishing.view)дададада
Установка publish_at/unpublish_at на своей записитолько если есть pages.publish/content.{type}.manage (ядро)да, в рамках прав ядрадада
Отмена/перенос (scheduled-publishing.manage)нетдадада
Массовое планированиенетда, в пределах bulk_max_itemsдада

Права модуля (scheduled-publishing.view, scheduled-publishing.manage) управляют доступом к интерфейсу очереди (календарь, отмена, перенос); право на саму публикацию конкретной сущности всегда остаётся за ядром (pages.publish/ content.{type}.manage) — модуль не вводит параллельную систему прав на контент, только на операции планирования вокруг него.

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

Админ. Пустой календарь без запланированных публикаций — подсказка «Здесь появятся запланированные публикации; чтобы создать первую, откройте страницу/запись и укажите дату публикации». Массовое планирование — выбор нескольких записей списком (чекбоксы) + одна общая дата в отдельной форме; лимит bulk_max_items показывается заранее, а не как ошибка после клика. Человеческая ошибка — попытка указать publish_at в прошлом — отклоняется валидацией с понятным сообщением («Дата публикации не может быть в прошлом»), не тихим округлением до «сейчас». Рядом с полем даты в форме — явное указание часового пояса сайта (например, «09:00 (часовой пояс сайта: Europe/Moscow)»), чтобы редактор не перепутал своё локальное время браузера с временем сайта.

Посетитель. Не применимо напрямую — у модуля нет публичного интерфейса. Посетитель видит только результат: страница/запись появляется или исчезает точно в срок (с точностью до одного тика — не позднее check_interval_minutes после scheduled_at), без собственного индикатора модуля на публичной стороне.

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

  • Часовой пояс. «Публикация в 9:00» — это 9:00 по часовому поясу сайта (настройка группы site ядра, резолвится через RequestContext), не часовой пояс браузера редактора и не UTC сервера. Редактор в форме видит и вводит локальное время сайта; в БД (publish_at на сущности и scheduled_at в очереди модуля) хранится UTC. Конвертация — на границе FormRequest при сохранении и при отображении в календаре, не в компонентах вручную.
  • Пропущенный тик планировщика (например, очередь легла на час) → элементы с scheduled_at в прошлом всё равно публикуются при следующем тике — просроченное не пропускается молча. ScheduledPublicationExecuted фиксирует фактическое processed_at, отличное от scheduled_at; расхождение сверх missed_tick_alert_multiplier × check_interval_minutes — алерт в cms/health (см. «Фоновая работа»), не тихая просадка SLA.
  • Отложенная публикация ревизии при уже опубликованной ревизии → в момент тика новая ревизия становится текущей опубликованной, прежняя опубликованная ревизия остаётся в истории ревизий ядра (откат по-прежнему доступен) — модуль не удаляет и не архивирует чужие данные, только вызывает штатную операцию публикации ядра.
  • Конфликт с ручной публикацией: редактор вручную опубликовал ревизию через pages.publish ДО срабатывания планировщика → в момент тика джоба проверяет фактический статус ревизии, видит «уже опубликована», не публикует повторно и не откатывает — задача закрывается как выполненная (идемпотентность по факту, не по слепому исполнению команды).
  • Удаление сущности с активной запланированной задачейScheduledPublicationFailed; в календаре задача не исчезает молча, а помечается статусом ошибки с причиной («сущность удалена»), пока админ не увидит и не разберётся.
  • Выключение модуля с задачами в очереди → задачи не теряются и обрабатываются при повторном включении на общих основаниях тика (включая просроченные — та же политика «не пропускать молча», что и при обычном пропущенном тике, без отдельного подтверждения на каждую запись). Обоснование: раз политика студии — просроченные публикуются автоматически при пропущенном тике, различать «тик опоздал на час» и «модуль был выключен неделю» было бы двойным стандартом; риск неожиданной публикации старых записей закрывается на входе — само выключение и включение управляемого модуля доступно только studio-роли (стандарт §3), а не рядовому редактору.
  • unpublish_at раньше publish_at (ошибка ввода) → отклоняется валидацией FormRequest ещё на сохранении сущности, задача в очередь не ставится.
  • Две задачи на одну ревизию в неверном порядке (publish и unpublish из-за массового планирования разными операциями) → перед постановкой новой задачи модуль проверяет существующие pending-задачи той же сущности: unpublish с датой раньше уже запланированного publish для той же ревизии — отклоняется с понятным сообщением, а не создаёт задачу, которая молча снимет ещё не опубликованный контент.
  • ⚠️ Противоречие: массовое планирование теоретически позволяет пользователю с правом scheduled-publishing.manage, но без pages.publish на конкретную страницу, выбрать эту страницу в списке и «запланировать» её публикацию — форма модуля видит только собственное право на управление очередью и не обязана знать про право ядра на публикацию. Разрешение: право на постановку в очередь конкретной записи проверяется не только по scheduled-publishing.manage, а дополнительно по pages.publish/ content.{type}.manage ядра на каждую запись индивидуально (см. «Безопасность» — «Массовое планирование не даёт обойти права…»); без этой проверки отложенная публикация стала бы каналом обхода прав ядра.

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

Донор: — (новая разработка).

Legacy-импорт: не применимо — донора с боевыми данными нет, миграция legacy-данных (§16 стандарта) для этого модуля не требуется (допустимая формулировка по матрице v2.2).

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

  • [ ] Контрактный тест: запись с publish_at в будущем не видна публично до наступления времени
  • [ ] Инвалидация кеша происходит именно в момент публикации, не с задержкой более check_interval_minutes
  • [ ] При выключении модуля запланированные записи требуют ручной публикации, без потери данных; при включении обрабатываются обычным тиком
  • [ ] Отмена запланированной публикации до наступления времени работает корректно
  • [ ] Перенос (reschedule) даты через Filament соблюдает optimistic lock (lock_version), конфликт двух админов отдаёт 409
  • [ ] Часовой пояс: publish_at, введённый как «9:00» в форме, публикует ровно в 9:00 по часовому поясу сайта (не UTC, не поясу браузера) — тест с сайтом в часовом поясе, отличном от UTC
  • [ ] Догоняющая публикация: элемент с scheduled_at в прошлом (пропущенный тик) публикуется на следующем тике, ScheduledPublicationExecuted.processed_at фиксирует реальное время выполнения
  • [ ] Конфликт с ручной публикацией: ревизия опубликована вручную до тика — джоба не публикует повторно и не откатывает, идемпотентна
  • [ ] Права scheduled-publishing.manage разграничены от просмотра календаря; массовое планирование проверяет pages.publish/content.{type}.manage на каждую запись отдельно, а не только право модуля
  • [ ] Превышение bulk_max_items при массовом планировании отклоняется с понятным сообщением, не тихим обрезанием списка
  • [ ] Нет N+1 при обработке очереди планировщиком (пакетная выборка due-элементов, чанки Bus::batch)
  • [ ] Удаление сущности с активной задачей публикации помечает задачу ScheduledPublicationFailed, не падает
  • [ ] cms:scheduled-publishing:rescan --json восстанавливает очередь по полям сущностей после потери журнала
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут админки (календарь, отмена, перенос, массовое планирование)
  • [ ] Тестовая БД только scheduled-publishing_test; migrate:fresh/refresh/reset/db:wipe запрещены

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