Тема
ТЗ — Публикация по расписанию (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_publications | id, 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_at | permission на каждую запись индивидуально (см. «Безопасность») + лимит scheduled-publishing.bulk_max_items |
Cron (ScheduleRegistrar) | тик планировщика, без пользовательских данных | внутренний триггер, не пользовательский вход |
Событие PageSaved/ContentEntrySaved (ядро) | факт сохранения ревизии с заполненным publish_at/unpublish_at в будущем | доверенное событие ядра — поле уже прошло валидацию на границе ядра, модуль повторно не парсит пользовательский ввод |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
| Filament, календарь публикаций | список запланированных задач за период | таблица/сетка UI, keyset-пагинация по диапазону дат |
PageRepository/ContentRepository (ядро) | вызов «опубликовать ревизию X» / «снять с публикации» | вызов публичного метода репозитория/сервиса ядра, не запись в чужие таблицы |
CacheTags (ядро) | инвалидация тега целевой сущности | вызов контракта CacheTags |
| Подписчики событий | ScheduledPublicationExecuted/ScheduledPublicationFailed | payload события (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_minutes | int | 5 | нет | Периодичность проверки очереди планировщиком |
scheduled-publishing.calendar_default_range_days | int | 30 | нет | Диапазон календаря в админке по умолчанию |
scheduled-publishing.bulk_max_items | int | 100 | нет | Максимум записей за одну операцию массового планирования; выше — 422 с понятным сообщением |
scheduled-publishing.missed_tick_alert_multiplier | int | 3 | нет | Алерт в cms/health, если тик планировщика не выполнялся дольше N × check_interval_minutes |
scheduled-publishing.processing_paused | bool | false | нет | 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/health | health-чек ядра | out | Отдаёт метрики очереди и статус последнего тика в агрегат /api/v1/system/health |
Подписчики (cms/notifications-bus, если включён) | шина событий | out | ScheduledPublicationFailed / просроченный тик → алерт админам |
Фоновая работа
Джоба 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запрещены