Skip to content

ТЗ — FAQ (cms/faq)

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

🔄 Ревизия (корпоративный MVP, 15.07.2026): FAQ-наборы — кандидат в пресет типа контента на движке типов контента; доменный FAQ-блок остаётся точкой вывода на странице. Разделы ниже — свойства/шаблоны пресета. Почему только «кандидат», а не решённый пресет: cms/faq уже реализован в коде полигона (эталонный модуль фазы 3, рабочие таблицы cms_faq_*) — перенос на движок означает data-миграцию работающего модуля, а не переписывание ТЗ с нуля; решение — при ревизии пресетов (content-types-engine §16).

⚠️ Статус тела ТЗ: разделы ниже (модель cms_faq_*, роуты /api/v1/faq, Filament-ресурс, права faq.manage, тесты) описывают целевые правила через историческую реализацию собственными таблицами; актуальные хранение/админку/API даёт движок (cms_content_entries, ContentEntryResource, /api/v1/content/{slug}) — предмет ревизии (content-types-engine §16, Волна G). cms/faq — текущий эталон фабрики модуля другой архитектуры, поэтому переезд на движок здесь особенно аккуратен.

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

Вопрос-ответ с группами вопросов, привязкой к страницам и услугам, блок FAQ для вставки в контент и разметка Schema.org FAQPage для расширенных сниппетов в поиске.

  • Группы вопросов (категории FAQ) с сортировкой
  • Вопрос-ответ с rich-текстом в ответе (блочный контент или HTML из админки)
  • Привязка группы/вопроса к конкретной странице или услуге (полиморфно)
  • Глобальные вопросы (без привязки) для общей страницы «Частые вопросы»
  • Блок FAQ (BlockRegistry) для вставки в любую страницу через конструктор
  • Schema.org FAQPage автогенерация из вопросов текущей страницы
  • Поиск по вопросам (простой, без выделенного индекса)

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

requires: ядро. suggests: — (в перспективе cms/search для полнотекстового поиска по вопросам вместо ILIKE, см. «Крайние случаи»). provides: не реализует.

Поведение при выключении: блок FAQ на страницах отдаёт fallback-заглушку, разметка Schema.org FAQPage не генерируется — вопросы и группы сохраняются в БД.

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

ТаблицаКлючевые поляПримечание
cms_faq_groupsid, slug, title, sort_order, locale, city_id (nullable), lock_versionкатегория вопросов
cms_faq_itemsid, group_id, question, answer, sort_order, faqable_type (nullable), faqable_id (nullable), locale, external_id (nullable), lock_versionвопрос-ответ, опциональная полиморфная привязка; external_id — ключ идемпотентности legacy-импорта

group_idconstrained()->cascadeOnDelete()->index(); faqable_type+faqable_id — составной индекс (nullable — глобальные вопросы без привязки), без FK-constraint (чужая сущность из другого модуля, §4 «Обмена данными» — только чтение id, см. «Крайние случаи»); (group_id, sort_order) — индекс под сортировку списка; external_id — уникален в рамках source (частичный уникальный индекс, nullable). lock_version (optimistic lock) — на группах и вопросах для конкурентного редактирования.

ПДн-паспорт: модуль ПДн не хранит — вопросы и ответы справочные, привязок к персональным данным пользователей нет; хуки ядра «выгрузить всё по субъекту» / «забыть по запросу» (152-ФЗ) не применимы.

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

Входы (whitelist-принцип: всё, что не перечислено ниже, модуль обязан отвергать):

ИсточникПоляЧем валидируется
Admin-форма группы (Filament)title, slug, locale, city_id, sort_orderFormRequest-whitelist, движок полей
Admin-форма/API вопроса (/api/v1/admin/faq)group_id, question, answer (rich-text/HTML), sort_order, faqable_type, faqable_id, localeFormRequest-whitelist, санитайзер rich-text (двойной барьер), проверка совпадения site_id/locale вопроса и faqable-сущности
Drag&drop сортировка (RelationManager)массив {id, sort_order, lock_version}FormRequest-whitelist, optimistic lock (409 при конфликте версии)
Публичный GET /api/v1/faqfilter[faqable_type], filter[faqable_id], filter[locale], cursorwhitelist фильтров FormRequest; неизвестный параметр → 422
Блок FAQ (пропсы блока, конструктор страниц)group_id либо faqable-ссылка, заголовок, флаг Schema.orgсхема полей блока (FieldTypeRegistry), версия _v
Legacy-импорт cms:faq:import-legacyвопрос, ответ, группа, external_id из донорского экспортамаппер источника + идемпотентность по external_id, --dry-run отчёт расхождений

Выходы:

ПотребительДанныеФормат
Публичный GET /api/v1/faqгруппы + вопросы (опубликованные)конверт {data, meta}, keyset-пагинация
Блок FAQ (рендер на странице)аккордеон вопрос-ответHTML/Blade + инлайн JSON-LD FAQPage
Страница «Частые вопросы» (faq.global_page_slug)все глобальные группы/вопросыHTML + JSON-LD FAQPage
EventBusCacheTagsфакт FaqItemSavedсобытие (payload см. «События и обмен»)
Filament adminсписок/форма групп и вопросовHTML admin UI
cms:faq:import-legacy --dry-runотчёт импортаJSON/CLI: создано/обновлено/пропущено + причины

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

КлючТипДефолтaffectsPageCacheОписание
faq.schema_enabledbooltrueдаГенерация Schema.org FAQPage — kill-switch: аварийное отключение разметки без выключения модуля (блок и данные остаются)
faq.max_items_per_blockint20нетМаксимум вопросов в одном блоке FAQ (без пагинации внутри блока, см. «Крайние случаи»)
faq.global_page_slugstringfaqдаSlug общей страницы «Частые вопросы»
faq.max_groups_per_siteint50нетЛимит числа групп на сайт (защита админки от захламления)
faq.max_items_per_groupint200нетЛимит вопросов в одной группе; достижение — понятная ошибка в Filament, не тихое обрезание
faq.empty_group_alert_daysint30нетПорог алерта «пустая группа» (см. «Фоновая работа»)

API

МетодПутьДоступНазначение
GET/api/v1/faqpublicСписок групп с вопросами (фильтр по привязке к сущности)
GET/POST/PUT/DELETE/api/v1/admin/faq…admin (faq.manage)CRUD групп и вопросов; PUT на сортировку — 409 при конфликте lock_version

Компоненты

Блоки (BlockRegistry): «FAQ» (аккордеон вопрос-ответ с автогенерацией Schema.org) — демо-props и сидер demo-данных для галереи /_gallery и playground (2–3 группы, 5–8 вопросов, без ручного ввода).

Filament: ресурс групп с RelationManager вопросов, drag&drop сортировка (конфликт по lock_version — человеческое сообщение, не молчаливая перезапись), мягкое предупреждение при совпадении текста вопроса в одной группе (не блокирует сохранение), массовое перемещение вопросов между группами (bulk action), подтверждение удаления группы с превью затронутых вопросов.

Фронтенд-бюджет блока: аккордеон — CSS-transition (max-height/ grid-template-rows), без сторонних JS-библиотек, без CLS (резерв высоты заголовка вопроса до раскрытия); aria-expanded, aria-controls на каждом вопросе, фокус управляем с клавиатуры (Tab/Enter/Space); JSON-LD Schema.org — инлайн в разметку страницы, не блокирует рендер.

Команды: cms:faq:prune-orphans --dry-run --json (см. «Фоновая работа», «Крайние случаи»), cms:faq:import-legacy --source=<профиль> --dry-run --json (см. «Донорский код»).

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

СобытиеКогдаPayload
FaqItemSavedвопрос создан/изменёнfaq_item_id, group_id, faqable_type, faqable_id

Слушает: —. Provides-контрактов не реализует, FilterBus не использует.

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

Сущность/модульКаналНаправлениеЧто происходит
cms/faqCacheTags (свой кеш)событие FaqItemSaved (канал 1)faq → собственный подписчикинвалидация тега faq на блоке FAQ и странице «Частые вопросы»
Ядро (страницы/услуги через faqable_type/faqable_id)нет канала — полиморфная ссылка на id без FKfaq → ядро (только чтение id, без вызова сервиса владельца)связь не формализована через requires/событие — источник «сирот» при удалении родителя (см. «Крайние случаи», п.5)
Другие модули CMSпотребителей FaqItemSaved пока нетожидаемо на старте, не анти-паттерн: событие издаётся на будущее расширение (например, реиндексация в cms/search)

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

Фоновой работы (очередей, джобов) нет — все операции синхронные (CRUD, рендер блока).

Эксплуатационный ранбук (метрики и восстановительные команды — актуальны уже сейчас, без ожидания появления джобов):

  • Метрика: количество вопросов без Schema.org-покрытия (не входящих ни в рендер блока, ни в глобальную страницу) — сигнал «мёртвого» контента.
  • Алерт: группа пуста (0 вопросов) дольше faq.empty_group_alert_days (дефолт 30) — кандидат на удаление или наполнение.
  • Восстановительная команда cms:faq:prune-orphans --dry-run --json (см. «Крайние случаи», п.5) — идемпотентна, безопасна на живом сайте, без даунтайма.
  • Бэкап/рестор: cms_faq_groups/cms_faq_items — обычные таблицы, попадают в общий бэкап БД целиком; после рестора ничего не пересоздаётся (денормализованных агрегатов и поисковых индексов у модуля нет).

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

  • Ожидаемые объёмы: типовой сайт — 3–15 групп, 5–40 вопросов в группе (сотни вопросов суммарно); каталог услуг с FAQ на каждой услуге — до 1000+ вопросов на инсталляцию.
  • Горячие пути: рендер блока FAQ на странице (выборка группы/faqable + items), рендер страницы faq.global_page_slug (все глобальные группы разом).
  • Бюджет запросов: рендер одного блока — 1 запрос (->with('items'), без N+1); чтение настроек группы faq — 0 запросов (кеш settings-store).
  • Критичные индексы: group_id (FK+index), составной (faqable_type, faqable_id), (group_id, sort_order) под сортировку списка, частичный уникальный external_id в рамках source.
  • Теги кеша: faq — общий тег на блоке FAQ и странице «Частые вопросы»; инвалидация целиком по FaqItemSaved (при объёме 1000+ вопросов это даёт лишние пересчёты на несвязанных страницах — при росте объёма разумно перейти на точечные теги faq:group:{id} / faq:faqable:{type}:{id}, пока не реализовано, зафиксировано как направление доработки).
  • Влияет на page-cache страниц с блоком FAQ и на faq.global_page_slug (affectsPageCache: да у schema_enabled и global_page_slug).

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

Границы входа (5 границ ядра): FormRequest-whitelist на CRUD групп/вопросов (админка) и на публичный filter[…] (faqable_type, faqable_id, locale) — неизвестный параметр → 422. Rich-текст ответа — санитизация двойным барьером (на сохранении и на выводе, стандартный санитайзер ядра); доверенный HTML ({!! !!}) из админки — только под studio-ролью (матрица ниже). Поиск по вопросам — ILIKE/LIKE по полю question, не пользовательский regex — ReDoS-валидатор ядра не требуется; если поиск в будущем перейдёт на regex-фильтр по вопросам, обязателен RegexGuard::assertSafeRules (§11 стандарта). Публичный API — только чтение опубликованного контента, rate-limit не обязателен (нет мутаций, нет ПДн).

ПДн-паспорт: см. «Модель данных» — ПДн не хранит.

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

Permission / действиеАдминМенеджерРедакторStudio
faq.view (публичное + просмотр в админке)
faq.manage (CRUD групп/вопросов, drag&drop)
Массовое перемещение/удаление вопросов
Удаление группы с вопросами (cascade)
Доверенный HTML в ответе ({!! !!})

Права: faq.view, faq.manage.

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

Админ:

  • Пустой список групп → «Ещё нет вопросов, добавьте первый» с кнопкой создания.
  • Пустая группа (0 вопросов) — видна в списке с пометкой «0 вопросов» и подсказкой наполнить, но на публичной странице/блоке не рендерится (см. «Крайние случаи», п.7).
  • Массовые действия: массовое перемещение вопросов между группами (bulk action в RelationManager), массовое удаление с подтверждением.
  • Подтверждение удаления группы с вопросами: модальное окно «В группе N вопросов, они будут удалены безвозвратно» — с точным числом (не общей фразой).
  • Человеческие ошибки: конфликт lock_version при параллельном drag&drop → «Кто-то уже изменил порядок вопросов, обновите список» (409), не молчаливая перезапись; невалидный HTML в ответе → «Тег <script> запрещён», не общий «422 Unprocessable Entity».

Посетитель:

  • Аккордеон доступен с клавиатуры: aria-expanded, aria-controls на каждом вопросе, фокус — по Tab, раскрытие — Enter/Space.
  • Раскрытие/закрытие без layout shift (CSS-transition высоты, не JS-reflow всего блока); воспринимаемый отклик — мгновенный (<100мс).
  • JSON-LD Schema.org не блокирует рендер видимого контента.

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

  1. Дубли вопросов в одной группе → модуль не проверяет уникальность question — осознанное решение (ответственность редактора, не платформы); в Filament — мягкое предупреждение (не блокирующее) при совпадении текста вопроса в той же группе.
  2. Schema.org FAQPage шире показанных вопросов → разметка обязана строго соответствовать вопросам, реально отрендеренным в блоке (с учётом faq.max_items_per_block): если группа содержит больше вопросов, чем показано, в JSON-LD попадают только показанные, не вся группа — иначе разметка не соответствует видимому контенту (риск ручных санкций поисковика).
  3. Гонка drag&drop у двух админовsort_order правится параллельно двумя сессиями → сущности несут lock_version (optimistic lock, §4 стандарта); конфликт отдаёт 409 и сообщение «кто-то уже изменил порядок, обновите список», не тихая перезапись «последний победил».
  4. Удаление группы с вопросамиcascadeOnDelete() удаляет все вопросы группы; если часть вопросов привязана (faqable_*) к живым опубликованным страницам, блок FAQ на них теряет контент без предупреждения — Filament обязан показать явное предупреждение с числом вопросов и списком затронутых faqable-сущностей перед подтверждением.
  5. Вопрос привязан к удалённой сущностиfaqable_type/faqable_id — полиморфная связь без FK constraint (нельзя иначе: сущность из чужого модуля, §4 «Обмена данными» — «FK на чужую сущность допустим только на её первичный id»); при удалении родительской страницы/услуги вопрос становится сиротой в БД. События удаления PageDeleted/ContentEntryDeleted канонизированы ревизией ядра 14.07.2026 (п. 3): слушатель модуля переводит вопросы удалённой сущности в глобальные (дефолт) либо удаляет — настройка faq.orphan_strategy; команда cms:faq:prune-orphans --dry-run остаётся страховкой для пропущенных событий.
  6. Глобальный вопрос одновременно на общей странице и в блоке другой страницы → вопрос без faqable_* попадает и на faq.global_page_slug, и как часть блока «FAQ» на другой странице (блок настроен по group_id, не по конкретной сущности) → Schema.org FAQPage генерируется на обоих URL с одинаковым вопросом — потенциальный дубль для поисковика. Решение: на «неканонических» страницах (не faq.global_page_slug) генерировать JSON-LD с mainEntityOfPage, явно указывающим на канонический URL глобальной страницы, а не на текущий.
  7. Пустая группа (0 вопросов) → показывается в списке групп в админке (чтобы её можно было наполнить), но не рендерится на публичной странице/в блоке — пустой аккордеон и пустой Schema.org хуже, чем отсутствие блока.
  8. Огромная группа (200+ вопросов) при max_items_per_block=20 → в блок попадают первые 20 по sort_order (ASC); пагинации внутри блока нет — зафиксировано как ограничение: для показа всех вопросов группы редактор использует отдельную страницу (global_page_slug или свою страницу-справочник), не блок.
  9. Смена faq.global_page_slug на уже проиндексированный slug → старый URL уходит в 404 без редиректа, если не создать 301. RedirectService ядра канонизирован ревизией 14.07.2026 (п. 12): слушатель SettingChanged на смену faq.global_page_slug создаёт 301-редирект через него; собственная таблица редиректов модулю запрещена.
  10. Поиск по вопросам «простой»ILIKE/LIKE без индекса на больших объёмах (1000+ вопросов) деградирует линейно; заявлено как осознанное ограничение («без выделенного индекса»). При росте объёма — не дорабатывать ILIKE, а подключать suggests: cms/search через provides-контракт search-provider.
  11. faqable-привязка к сущности другой локали/сайта (мультисайт/мультиязычность) → вопрос с locale=ru, а faqable указывает на сущность с другой locale или site_id — измерения обязаны совпадать; FormRequest валидирует совпадение site_id/locale вопроса и faqable-сущности при сохранении, иначе 422, не тихая рассинхронизация.
  12. Доверенный HTML, сохранённый под studio-ролью, редактируется не-studio-ролью → HTML в ответе остаётся как есть при чтении, но каждое новое сохранение (независимо от роли автора предыдущей версии) проходит санитайзер заново — редактор без права на {!! !!} не может ни добавить новый небезопасный HTML, ни случайно «прокинуть» его повторным сохранением формы.

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

Что взятьПуть
Модель групп/вопросов, блок FAQнесколько проектов (пути не выданы)

Legacy-импорт

cms:faq:import-legacy --source=<профиль> [--dry-run] [--json] — маппинг вопросов-ответов из донорских проектов (несколько CMS/самописных таблиц) на cms_faq_groups/ cms_faq_items; идемпотентен по external_id (nullable-колонка на cms_faq_items, частичный уникальный индекс в рамках source, повторный прогон обновляет существующие записи, не дублирует). --dry-run — отчёт: сколько групп/вопросов создано/обновлено/ пропущено и почему, построчные ошибки скачиваемы, молчаливый пропуск запрещён (§16 стандарта). Прогон на копии донорских данных — часть приёмки модуля.

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

  • [ ] Контрактный тест /api/v1/faq с фильтром по привязанной сущности
  • [ ] При выключении модуля блок FAQ отдаёт fallback без 500
  • [ ] Schema.org FAQPage валиден и соответствует ровно вопросам, реально показанным в блоке (не всей группе при max_items_per_block)
  • [ ] Права faq.manage разграничены от публичного чтения; доверенный HTML — только studio-роль
  • [ ] Нет N+1 при построении блока FAQ (->with('items'))
  • [ ] Инвалидация кеша страницы по тегу faq при FaqItemSaved
  • [ ] Параллельный drag&drop у двух сессий → второй запрос получает 409 по lock_version, не тихую перезапись sort_order
  • [ ] Удаление группы с вопросами: Filament показывает предупреждение с числом затронутых вопросов перед подтверждением
  • [ ] faqable-валидация: сущность из другого site_id/locale отклоняется 422
  • [ ] cms:faq:prune-orphans --dry-run находит вопросы-сироты (несуществующий faqable_id) без изменения данных
  • [ ] cms:faq:import-legacy --dry-run даёт корректный отчёт; повторный прогон не дублирует записи (идемпотентность по external_id)
  • [ ] Пустая группа не рендерится в блоке/на публичной странице, но видна в админке
  • [ ] Rich-text HTML санитизируется на каждом сохранении независимо от роли автора предыдущей версии
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут (/api/v1/faq, admin CRUD)
  • [ ] Тестовая БД только faq_test; migrate:fresh/refresh/reset/db:wipe запрещены

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