Тема
ТЗ — Связанный контент/рекомендации (cms/related-content)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Ручные связи «похожее / с этим смотрят» между любыми сущностями (полиморфно), автоматические кандидаты по совпадению таксономий, блок «похожие материалы» для вставки в любую страницу. Новая разработка, общий движок рекомендаций для контент-модулей студии.
- Ручные связи между сущностями (полиморфно, в обе стороны или односторонне — конфигурируется)
- Авто-кандидаты по пересечению таксономий (рубрика/тег) при недостатке ручных связей
- Приоритет: ручные связи всегда выше авто-кандидатов в выдаче
- Блок «Похожие материалы» (BlockRegistry) — универсальный для постов, услуг, товаров
- Ограничение количества рекомендаций (конфигурируемый лимит на блок) и лимит числа ручных связей на одну сущность (защита от неконтролируемого разрастания)
- Выборка без N+1 (батч-загрузка связей и авто-кандидатов одним проходом)
- Кеш результата подбора по тегам сущности и её таксономий
- Опциональное улучшение релевантности авто-кандидатов через
cms/search(suggests, не жёсткая зависимость) — при отсутствии деградация до пересечения таксономий - Выдача — минимальный набор полей для карточки (не полная модель сущности)
Зависимости и выключение
requires: ядро (cms/core-contracts: PageRepository, ContentRepository, TaxonomyRepository, BlockRegistry, CacheTags, EventBus, ScheduleRegistrar).
suggests: cms/search — авто-кандидаты подбираются из поискового индекса (релевантнее простого пересечения таксономий); модуль резолвит provides-контракт search-provider, при его отсутствии в контейнере молча использует собственный алгоритм пересечения таксономий. conflicts: нет. provides: не реализует provides-контрактов — сам является только потребителем.
Поведение при выключении: блок «Похожие материалы» отдаёт fallback-заглушку (пусто), ручные связи сохраняются в БД и восстанавливаются при повторном включении. Ни одна сторонняя сущность не хранит FK на таблицу модуля — выключение related-content не блокирует удаление постов/товаров/услуг других модулей.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_related_content | id, subject_type, subject_id, related_type, related_id, sort_order, created_at | ручная связь (полиморфная пара) |
Своих таблиц под авто-кандидатов нет — используется существующая связь сущностей с таксономиями ядра (пересечение по taxonomy_terms) либо поисковый индекс cms/search (если включён). subject_type+subject_id и related_type+related_id — составные индексы под обе стороны выборки (обязательны в обе стороны, т.к. связь может запрашиваться из любой роли пары).
subject_type/related_type — не произвольная строка: значения ограничены whitelist типов сущностей, зарегистрированных модулями-поставщиками контента (см. «Безопасность»).
Реальный constrained()-FK на конкретную таблицу здесь технически невозможен: пара полиморфна и может указывать на любую из нескольких таблиц-владельцев (страницы, записи контент-типов, товары и т.д.), а FK допустим только на первичный id одной конкретной таблицы (§4 standard.md). Поэтому целостность при удалении сущности-владельца поддерживается не FK-каскадом, а событием + джобой (см. «Фоновая работа» и «Крайние случаи»). Строк без lock_version — модуль сознательно не вводит optimistic lock на уровне пары (обоснование — «Крайние случаи», сценарий про конкурентное редактирование).
ПДн-паспорт: модуль не хранит персональных данных. cms_related_content — это пары идентификаторов контентных сущностей (id+id), не пользовательские данные; хуки «выгрузить всё по субъекту» / «забыть по запросу» не применимы.
Входные и выходные данные
Входы:
| Источник | Что | Чем валидируется |
|---|---|---|
| Admin-форма/RelationManager (создание связи) | subject_type, subject_id, related_type, related_id, sort_order | FormRequest: subject_type/related_type — whitelist зарегистрированных типов; subject != related (запрет self-link); существование обеих сущностей — через репозиторий владельца, не raw SQL |
| Admin — массовое добавление | список related_id[] для одного subject | тот же FormRequest на каждый элемент + проверка лимита max_manual_links_per_entity |
GET /api/v1/related-content | query: subject_type, subject_id (оба обязательны) | FormRequest: subject_type — whitelist, subject_id — существующий id (иначе 404, не 500) |
| CLI | cms:related-content:cleanup-orphans, cms:related-content:recompute [--all] | аргументы команды, --dry-run/--json |
| Ядро (канал 1, событие) | PageDeleted, ContentEntryDeleted, MediaDeleted, UserDeleted | payload события ядра (id + тип удалённой сущности) |
| Ядро (канал 1, событие) | PageSaved/ContentEntrySaved | триггер инкрементального пересчёта авто-кандидатов для сохранённой сущности |
| Ядро (канал 4, сервис-вызов) | таксономии/статус публикации сущности через TaxonomyRepository/ContentRepository/PageRepository | скоуп RequestContext (locale/city/site) уже применён владельцем |
cms/search (канал 3, provides, если включён) | кандидаты по релевантности индекса | контракт search-provider, таймаут + graceful fallback |
Выходы:
| Получатель | Что | Формат |
|---|---|---|
GET /api/v1/related-content (public) | подборка рекомендаций (ручные + авто, до max_items) | {"data":[{"type","id","slug","title","excerpt","image"},…],"meta":{"source":"manual|auto","degraded"?:bool}} — минимальный набор полей карточки, не полная модель |
| Блок «Похожие материалы» (рендер) | та же подборка, для Blade/Vue-компонента | массив DTO карточек (тот же контракт, что и API) |
| Событие (канал 1, издаёт) | RelatedContentLinked | subject_type, subject_id, related_type, related_id |
| Событие (канал 1, издаёт) | RelatedContentUnlinked | subject_type, subject_id, related_type, related_id |
| CLI-отчёт | cleanup-orphans/recompute | JSON: обработано/удалено/пропущено + причины |
| Admin CRUD API | конверт {data, meta} / {message, code, errors} | по конвенциям ядра (§7 standard.md) |
Всё, что не перечислено во «Входах», модуль обязан отвергать (whitelist-принцип §11 standard.md) — в первую очередь произвольные subject_type/related_type.
Настройки (группа related-content)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
related-content.max_items | int | 4 | да | Максимум рекомендаций в блоке. 0/отрицательное — некорректно, деградирует до дефолта, не ошибка рендера |
related-content.auto_fallback_enabled | bool | true | да | Подмешивать авто-кандидаты при нехватке ручных связей |
related-content.bidirectional | bool | true | нет | Ручная связь видна с обеих сторон пары |
related-content.max_manual_links_per_entity | int | 30 | нет | Лимит ручных связей на одну сущность (защита от разрастания); достижение — понятная ошибка 422, не тихое обрезание |
related-content.block_hidden | bool | false | да | Kill-switch: полностью скрыть блок «Похожие материалы» на всём сайте без выключения модуля (например, при проблеме с качеством подборки) |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/related-content | public | Подобранные рекомендации для сущности (ручные + авто) |
| GET/POST/PUT/DELETE | /api/v1/admin/related-content… | admin (related-content.manage + право на subject-тип, см. «Безопасность») | CRUD ручных связей |
Ответы — по конвенциям ядра (§7 standard.md): конверт {data, meta}, коды 401/403/404/422/429; 404, если subject не существует; 422 при self-link, невалидном subject_type или превышении max_manual_links_per_entity. Публичный GET не требует rate-limit (чтение, без побочных эффектов), деградация при недоступном cms/search — 200 с meta.degraded: true только если cms/search установлен, но отвечает с ошибкой/таймаутом (отсутствие модуля — штатный режим, не деградация).
Компоненты
Блоки (BlockRegistry): «Похожие материалы» (универсальный, работает с любой полиморфной сущностью) — карточки переиспользуют медиа исходных сущностей через MediaService (srcset/адаптивные изображения), lazy-load ниже первого экрана, зарезервированное место под карточки (без CLS даже при пустой/долгой подборке).
Filament: RelationManager связей на карточке сущности (посты, услуги, товары) — с массовым добавлением (multi-select нескольких сущностей за раз) и подтверждением удаления.
Команды: cms:related-content:cleanup-orphans --json, cms:related-content:recompute [--all] --json (обе с --dry-run).
Демо-контент: сидер связей между несколькими демо-сущностями (посты/услуги playground) — галерея /_gallery и playground показывают блок с реальной подборкой без ручного ввода.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
RelatedContentLinked | создана ручная связь | subject_type, subject_id, related_type, related_id |
RelatedContentUnlinked | удалена ручная связь | subject_type, subject_id, related_type, related_id |
Таблица взаимодействий (сущность/модуль → канал → направление → что происходит):
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
related-content | 1 (событие) | издаёт | RelatedContentLinked/RelatedContentUnlinked при CRUD ручной связи |
Ядро: TaxonomyRepository/ContentRepository/PageRepository | 4 (сервис-вызов ядра) | related-content читает | таксономии и статус публикации сущностей для авто-кандидатов и фильтрации выдачи — только через сервис, не raw SQL |
cms/search (suggests) | 3 (provides-контракт search-provider) | related-content резолвит через DI | при наличии — авто-кандидаты из поискового индекса (релевантнее); при отсутствии — деградация до пересечения таксономий, приоритет «ручные > авто» сохраняется |
| Ядро | 1 (событие) | related-content слушает | PageDeleted/ContentEntryDeleted/MediaDeleted/UserDeleted → джоба удаляет обе стороны связи по (type, id) |
| Ядро | 1 (событие) | related-content слушает | PageSaved/ContentEntrySaved → инкрементальный пересчёт авто-кандидатов только для сохранённой сущности |
Ядро: репозитории с RequestContext | 4 (сервис-вызов, на чтении) | related-content ← ядро | фильтрация неопубликованного происходит на чтении каждой выдачи — отдельного канонического события «снят с публикации» в ядре нет (см. core.md, канон событий), поэтому событийного канала для этого случая не заведено намеренно |
Provides-контрактов не реализует, FilterBus не использует — подбор не встраивается в чужой конвейер, а формирует собственную выдачу.
Фоновая работа
cms:related-content:cleanup-orphans(очередьrelated-content) — периодическая подстраховочная чистка связей на удалённые сущности черезScheduleRegistrarядра; нужна, т.к. очередь ядра гарантирует «хотя бы одну» доставку события, а не «ровно одну» — редкий пропуск события удаления не должен оставлять битые связи навсегда.- Пересчёт авто-кандидатов инкрементальный по умолчанию: слушатель
PageSaved/ContentEntrySavedкладёт job пересчёта кандидатов только для изменённой сущности — полный пересчёт всего каталога на каждое сохранение не выполняется. - Полный пересчёт (
cms:related-content:recompute --all) — редкая ручная операция (смена алгоритма подбора, восстановление после сбоя): чанками черезBus::batch, с прогрессом, видимым в админке, не блокирует запись контента (авто-кандидаты для ещё не пересчитанных сущностей отдаются по старым данным до завершения батча). - Обе джобы идемпотентны — повторный прогон не создаёт дублей связей и не портит
sort_order.
Производительность и кеш
Ожидаемые объёмы — potentially сотни тысяч пар ручных связей и записей контента на крупном каталожном/блоговом сайте (highload-требования §10 standard.md, тот же порядок, что и у коммерческого каталога).
Горячий путь — рендер блока «Похожие материалы» на каждой странице сайта. Бюджет: 1 закешированный результат подбора на запрос страницы — не 1 запрос в БД на каждый рендер. Механика:
- Ручные связи и авто-кандидаты (таксономии/поиск) выбираются одним проходом — батч-запрос по
subject_type+subject_id, без отдельного запроса на каждую сущность выдачи (N+1 запрещён, §10 standard.md); - Результат подбора (ручные + авто, уже с приоритетом и лимитом
max_items) кешируется целиком под тегомrelated-content:{subject_type}:{subject_id}; - Инвалидация — точечная, по факту:
RelatedContentLinked/RelatedContentUnlinked(свои связи) иPageSaved/ContentEntrySaved/событиям удаления (авто-кандидаты и «протухшие» ссылки на изменившуюся сущность) — не полный flush тега модуля.
Критичные индексы — составные (subject_type, subject_id) и (related_type, related_id) на cms_related_content под обе стороны выборки (обязательны в паре, иначе выборка «с обратной стороны» связи уходит в full scan на крупной таблице).
Тяжёлая операция — полный пересчёт связанности (recompute --all): характеристики вынесены из «Фоновой работы» сюда, т.к. это прямой highload-риск — десятки/сотни тысяч сущностей × пересечение таксономий или запрос к индексу cms/search. Ограничена: Bus::batch чанками (контролируемая утилизация ядер БД/очереди), прогресс доступен админке, не блокирует запись контента, инкрементальный путь (по PageSaved/ContentEntrySaved) покрывает штатный режим — полный прогон нужен только при смене алгоритма или восстановлении после сбоя.
Безопасность
Границы входа: FormRequest-whitelist на CRUD ручных связей (админка) — subject_type/ related_type принимают только значения из whitelist типов сущностей, зарегистрированных декларативно модулями-поставщиками контента (посты, услуги, товары регистрируют себя как допустимую сторону полиморфной пары через реестр типов ядра); произвольная строка от клиента API отклоняется 422, а не проходит как «неизвестный, но допустимый» тип. Публичный API — только чтение, rate-limit не требуется (побочных эффектов нет).
Права: related-content.view (просмотр списка связей в админке), related-content.manage (CRUD ручных связей). CRUD связи для конкретного subject_type дополнительно требует права на управление самой сущностью этого типа (content.{type}.manage/pages.manage) — иначе редактор без прав на товары мог бы через related-content создавать связи для чужого контент-типа в обход контроля доступа к нему.
Матрица ролей:
| Роль | Просмотр подборки (public) | CRUD ручных связей | recompute/cleanup-orphans | Kill-switch (block_hidden) |
|---|---|---|---|---|
| Посетитель | ✅ (только опубликованное) | — | — | — |
| Редактор | ✅ | ✅, только для типов, где есть content.{type}.manage/pages.manage + related-content.manage | — | — |
| Менеджер | ✅ | ✅, те же ограничения по типу контента | — | — |
| Админ | ✅ | ✅ все зарегистрированные типы | ✅ | ✅ (settings.manage) |
| Studio | ✅ | ✅ все зарегистрированные типы | ✅ | ✅ |
В логи/метрики модуля не попадают ПДн (их и так нет в данных модуля) и секреты; идентификаторы сущностей — не автоинкрементный id наружу (API отдаёт slug, id используется только внутри для join'ов и в admin-URL).
UX-требования
Админ:
- Пустое состояние карточки сущности без связей — подсказка «связи не заданы — посетителям показываются авто-подборки по тегам» (или «похожих материалов нет», если
auto_fallback_enabledвыключен), не пустая таблица без объяснения; - Массовое добавление связей — RelationManager позволяет выбрать несколько сущностей сразу (multi-select), а не по одной;
- Попытка связать сущность саму с собой — валидация отклоняет с человекочитаемым сообщением («нельзя связать материал с самим собой»), не generic 422;
- Достижение
max_manual_links_per_entity— сообщение с текущим значением лимита, не просто «ошибка сохранения»; - Удаление связи — подтверждение (необратимая по факту операция для админа, хоть данные восстановимы из бэкапа).
Посетитель:
- Блок «Похожие материалы» не создаёт CLS — место под карточки зарезервировано заранее (skeleton/фиксированная высота), независимо от того, сколько карточек в итоге придёт;
- Пустая подборка (fallback) не ломает вёрстку страницы — блок скрывается или показывает нейтральную заглушку, а не пустой прямоугольник или сдвиг соседних блоков;
- Карточки используют lazy-loading изображений и
srcset— блок обычно ниже первого экрана, не должен тянуть вес на LCP.
Крайние случаи и типовые баги
- Рекомендация указывает на удалённую сущность — до отработки
cleanup-orphans/ событийной чистки подборка фильтруется на чтении: сервис молча пропускает битую ссылку, не отдаёт 404-карточку в списке и не падает 500. - Рекомендация указывает на неопубликованную/снятую с публикации сущность (черновик, скрытая запись) — фильтрация на чтении тем же механизмом:
PageRepository/ContentRepositoryотдают только опубликованное, публичный API никогда не покажет связь на черновик, даже если строка вcms_related_contentсуществует. - Отдельного события «снят с публикации» в каноне ядра нет (см.
core.md: канон фиксируетPageSaved/PagePublished,ContentEntrySavedи события удаления, но не «unpublish» как отдельный факт) — поэтому related-content не пытается инвалидировать кеш по несуществующему событию, а полагается на фильтрацию каждого чтения через скоупленные репозитории ядра; кеш подборки при этом может on-read вернуть чуть устаревший состав до истечения TTL/следующей инвалидации — не отдать неопубликованное. - Полный пересчёт авто-кандидатов на большом каталоге — потенциально тяжёлая операция (десятки-сотни тысяч сущностей × пересечение таксономий/запрос к индексу). Ограничена: инкрементальный пересчёт по
PageSaved/ContentEntrySavedконкретной сущности — штатный путь; полный пересчёт всего сайта — отдельная редкая команда сBus::batchчанками и прогрессом (см. «Производительность и кеш»). cms/searchне установлен/выключен — деградация до пересечения таксономий описана в «События и обмен» как штатный режим: подборка менее релевантна (грубее ранжирование), но работает и не помечаетсяmeta.degraded(это конфигурация, не сбой).max_items= 0 или отрицательное значение (некорректная настройка) — схема настройки отклоняет такие значения при сохранении в админке; если в БД всё же оказалось невалидное значение (например, после ручной правки), сервис подбора деградирует до дефолта (4), не рендерит пустой блок и не падает.- Конкурентное редактирование связей одной сущности двумя админами — строки
cms_related_contentнезависимы: создание/удаление конкретной пары атомарно на уровне БД, гонки между двумя админами, добавляющими разные связи, не портят данные без всякого lock. Единственное разделяемое поле —sort_orderпри переупорядочивании: здесь принято «последняя транзакция побеждает» безlock_version/409, т.к. потеря порядка не искажает сами данные и не используется в денежных/статусных решениях (в отличие от кейсов, ради которых введено правило optimistic lock). ⚠️ Противоречие: §4 standard.md требуетlock_versionна сущности, редактируемые в админке; для набора независимых pivot-строк такой lock избыточен и не защищает ничего, кроме визуального порядка карточек — отступление сознательное, фиксируется вdocs/module.mdкак обоснованное исключение, не пример для других модулей с полноценными редактируемыми сущностями. - Удаление сущности каскадно чистит обе стороны связи — механизм не FK
cascadeOnDelete()(полиморфная пара не может ссылаться реальным FK сразу на несколько таблиц-владельцев, §4 standard.md допускает FK только на первичный id одной конкретной таблицы), а событие + джоба: слушательPageDeleted/ContentEntryDeleted/MediaDeleted/UserDeletedудаляет строкиcms_related_contentпо(type, id)с обеих сторон пары;cleanup-orphans— периодическая подстраховка на случай пропущенной доставки события (очередь ядра гарантирует «хотя бы одну» доставку, не «ровно одну»). - Пустой результат подбора (ни ручных связей, ни авто-кандидатов) — блок отдаёт пустой fallback (см. «UX-требования»), не ошибку и не 500.
- Межсайтовая/межъязыковая ручная связь (мультисайт/мультиязычность, §4 standard.md про измерения) — сама пара
cms_related_contentне несётlocale/site_id(это измерения сущностей по обе стороны, не связи), поэтому технически связь между сущностями разных сайтов/локалей может быть создана. На публичном чтении она не проявится: обе стороны читаются черезRequestContext-скоупленные репозитории ядра, и сущность «не из текущего скоупа» отфильтровывается так же, как удалённая или неопубликованная. Admin UI обязан предупреждать при создании связи между сущностями разных сайтов/локалей (человеческая ошибка, не техническая) — без предупреждения админ не поймёт, почему созданная им связь нигде не отображается.
Донорский код
Донор: — (новая разработка). Легаси-импорт: не применимо — донора с боевыми данными нет, cms:related-content:import-legacy не требуется.
Тесты и приёмка
- [ ] Контрактный тест: при наличии ручных связей авто-кандидаты не подмешиваются сверх лимита
- [ ] При выключении модуля блок «Похожие материалы» отдаёт пустой fallback без 500
- [ ] Права
related-content.manageразграничены от публичного чтения подборки; CRUD связи дляsubject_typeбез права на сам этот тип контента запрещён (403) - [ ] Нет N+1 при подборе рекомендаций (батч-загрузка связей и таксономий одним проходом)
- [ ] Удаление сущности (любого модуля-поставщика) чистит её записи в
cms_related_contentв обе стороны через событие+джобу;cleanup-orphansподхватывает пропущенные случаи - [ ] Инвалидация кеша блока по тегу сущности при
RelatedContentLinked/RelatedContentUnlinked - [ ] Публичная выдача никогда не содержит неопубликованные/черновые/удалённые сущности (фильтрация на чтении), даже если строка связи существует
- [ ]
subject_type/related_typeвне whitelist — 422, не молчаливый игнор - [ ] Self-link (
subject == related) отклоняется 422 с понятным сообщением - [ ] Превышение
max_manual_links_per_entity— 422, не тихое обрезание списка - [ ]
max_items <= 0в настройках — деградация до дефолта, не 500 и не пустой рендер - [ ] Полный пересчёт (
recompute --all) идёт чанкамиBus::batch, не блокирует параллельную запись контента (feature-тест с конкурентной записью во время батча) - [ ] Деградация без
cms/search— авто-кандидаты собираются пересечением таксономий,meta.degradedне устанавливается (штатный режим, не сбой) - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут (
/api/v1/related-content, admin CRUD) - [ ] Тестовая БД только
related-content_test;migrate:fresh/refresh/reset/db:wipeзапрещены
🔄 Ревизия (корпоративный MVP, 15.07.2026). Движок типов контента ведёт собственные типизированные связи
cms_content_relations(content-types-engine §5.2) — это не тот же механизм, что необязательные рекомендательные парыrelated-content(сам движок явно это фиксирует в §5.2). Открытый вопрос движка (content-types-engine §16) — использовать ли типизированные связи записей как дополнительный источник кандидатов для подбора рекомендаций этого модуля; решение — при ревизииrelated-content.