Тема
ТЗ — Глоссарий/справочник (cms/glossary)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: 3 проекта (Dictionary) Статус: ТЗ к разработке
Назначение и возможности
Термины с определениями, алфавитный указатель и ограниченная автолинковка терминов в тексте контента по whitelist. SEO-страницы отдельных терминов для органического трафика по словарным запросам.
- Термин + определение (rich-текст), опциональные синонимы
- Алфавитный указатель (навигация по буквам, группировка)
- Автолинковка: первое вхождение термина в контенте оборачивается ссылкой на страницу термина — только по whitelist терминов, не тотальный автоматический поиск по тексту
- SEO-страница термина (собственный slug, meta, Schema.org
DefinedTerm) - Категории терминов (опционально, через таксономии ядра)
- Ограничение частоты автолинковки (не более N ссылок на одну страницу)
Зависимости и выключение
requires: ядро
Поведение при выключении: автолинковка терминов в контенте отключается (текст остаётся plain, без ссылок), страницы терминов и указатель отдают 404 — сами термины и определения не удаляются.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_glossary_terms | id, slug, term, definition, synonyms (json), locale, city_id (nullable), external_id (nullable), lock_version | термин + определение; external_id — ключ идемпотентности legacy-импорта |
cms_glossary_autolink_log | entity_type, entity_id, term_id, occurrences | учёт автолинковки для контроля частоты |
synonyms — JSONB с GIN-индексом под поиск по синонимам; entity_type+entity_id+ term_id — составной индекс на cms_glossary_autolink_log; term_id — constrained()->cascadeOnDelete() (удаление термина каскадно чистит журнал автолинковки — см. «Крайние случаи»). external_id — nullable, уникален в рамках locale (частичный индекс WHERE external_id IS NOT NULL), используется только импортёром, не публичным API. lock_version — optimistic lock по правилу §4 стандарта (термин редактируется в админке двумя людьми одновременно — см. «Крайние случаи»).
ПДн-паспорт (матрица v2.2): модуль ПДн не хранит — термины и определения это публичный справочный контент, не персональные данные посетителей; хуки «выгрузить/забыть по субъекту» не применимы, декларация — «ПДн не храню».
Входные и выходные данные
Входы
| Источник | Поля | Чем валидируется |
|---|---|---|
| Admin-форма/API создание/правка термина | term, slug, definition, synonyms[], locale, city_id, lock_version | FormRequest-whitelist, санитизация rich-text определения двойным барьером (сохранение + вывод), уникальность slug в рамках locale, lock_version — сравнение при сохранении (409 при расхождении) |
API DELETE /api/v1/admin/glossary/{slug} | slug | permission glossary.manage; подтверждение с числом уже проставленных автоссылок (см. «UX-требования») |
| Рендер-конвейер ядра (чтение контента для автолинковки) | опубликованный текст блока/записи через ContentRepository | не пользовательский вход для модуля — модуль только читает, не принимает и не изменяет чужой контент |
Legacy-импорт cms:glossary:import-legacy --source=<профиль> | строки донора: term, definition, synonyms/aliases, external_id | тот же FormRequest/сервисный слой валидации, что и админ-форма; построчный отчёт расхождений, --dry-run |
Всё, что не перечислено в этой таблице, модуль обязан отвергать (whitelist-принцип §11 стандарта) — например произвольные HTML-атрибуты в определении сверх разрешённого набора санитайзера или неизвестные поля в теле запроса (422).
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
Публичный API /api/v1/glossary, /api/v1/glossary/{slug} | алфавитный список / термин с определением | JSON-конверт {data, meta} |
| Блоки «Алфавитный указатель», «Карточка термина» | whitelist терминов через GlossaryService | рендер блока — данные из сервиса модуля, не запрос из шаблона |
| Автолинковка (вызывается рендер-конвейером ядра при отдаче страницы/записи) | обёрнутые в <a> вхождения терминов | inline-ссылки внутри HTML текста блока |
| Filament: отчёт по частоте автолинковки | агрегаты cms_glossary_autolink_log | таблица в UI + экспорт CSV (permission glossary.manage) |
Событие GlossaryTermSaved / GlossaryTermDeleted | факт для подписчиков | payload term_id, slug, locale |
| Legacy-импорт: отчёт команды | прочитано/создано/обновлено/пропущено, построчные ошибки | консольный вывод + --json |
Настройки (группа glossary)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
glossary.autolink_enabled | bool | true | да | Kill-switch автолинковки терминов в контенте (матрица v2.2) |
glossary.autolink_max_per_page | int | 5 | нет | Максимум автоссылок на одну страницу |
glossary.autolink_first_occurrence_only | bool | true | нет | Линковать только первое вхождение термина |
glossary.autolink_max_content_length | int | 50000 | нет | Лимит длины обрабатываемого текста (символов) на одну единицу контента — защита от дорогого прохода по аномально большим текстам |
glossary.definition_max_length | int | 5000 | нет | Максимальная длина определения термина (символов) |
glossary.max_synonyms_per_term | int | 10 | нет | Максимум синонимов на один термин |
glossary.autolink_enabled — явный kill-switch (матрица v2.2): отключает автолинковку без выключения модуля целиком — термины, страницы указателя и CRUD остаются доступны. Достижение лимитов (max_per_page, definition_max_length, max_synonyms_per_term) — понятная ошибка на форме (422) либо тихая остановка добавления ссылок для autolink_max_per_page, не 500 и не молчаливое обрезание (§6 стандарта).
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/glossary | public | Алфавитный список терминов |
| GET | /api/v1/glossary/{slug} | public | Страница термина с определением |
| GET/POST/PUT/DELETE | /api/v1/admin/glossary… | admin (glossary.manage) | CRUD терминов |
Алфавитный список — keyset-пагинация по (term, id), не OFFSET. PUT несёт lock_version: расхождение с текущей версией записи — 409 с актуальным значением термина (оптимистичная блокировка, не «последний победил»). DELETE термина, уже использованного в автолинковке, требует подтверждения на стороне UI (см. «UX-требования») — сам API отвечает 200 и каскадно чистит журнал.
Компоненты
Блоки (BlockRegistry): «Алфавитный указатель», «Карточка термина» — данные из сервиса модуля, без прямых запросов из шаблона; вставка не создаёт CLS (резерв высоты списка), вес — только текст и ссылки, без дополнительного JS/CSS. Filament: ресурс терминов с указанием синонимов, массовые действия (удаление, экспорт CSV), отчёт по частоте автолинковки. Демо-сидер: набор тестовых терминов для playground//_gallery (матрица v2.2, «Демо-контент»).
Команды: cms:glossary:relink --json (пересчёт автолинковки после изменения whitelist терминов), cms:glossary:import-legacy --source=<профиль> (см. «Донорский код»).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
GlossaryTermSaved | термин создан/изменён | term_id, slug, locale |
GlossaryTermDeleted | термин удалён | term_id, slug, locale |
Слушает: —. Provides-контрактов не реализует, FilterBus не использует.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро: ContentRepository | сервис-вызов (канал 4, core-contracts) | in | При рендере страницы/записи модуль читает опубликованный текст блока, чтобы найти совпадения whitelist терминов — не raw SQL в чужие таблицы |
Ядро: SettingsStore | сервис-вызов | in | Чтение группы glossary из кеша (0 запросов на горячем пути) |
Ядро: RequestContext | сервис-вызов | in | Скоуп locale/city_id для выбора применимых терминов при рендере |
| Блоки «Алфавитный указатель»/«Карточка термина» | сервис модуля (GlossaryService) | out | Рендер получает whitelist терминов из сервиса, не из шаблона напрямую |
Событие GlossaryTermSaved/GlossaryTermDeleted | событие (канал 1) | out | Подписчики: собственный слушатель кеш-инвалидации модуля, потенциально cms/audit |
Очередь glossary | очередь (канал 5) | out | cms:glossary:relink чанками |
cms/search (suggests) | provides-контракт search-provider (канал 3) | — | Не используется: термины глоссария сейчас не индексируются и не участвуют в подсказках поиска; добавление в качестве searchable-типа — вне рамок этого ТЗ |
Фоновая работа
Джоба cms:glossary:relink (очередь glossary) — пересчёт автолинковки контента после изменения whitelist терминов, запускается вручную, по GlossaryTermSaved/ GlossaryTermDeleted или чанками через Bus::batch на больших объёмах контента. Идемпотентна: повторный запуск не создаёт дублей записей cms_glossary_autolink_log (upsert по entity_type+entity_id+term_id).
Эксплуатация (матрица v2.2, §15 стандарта): метрики — число терминов, число автоссылок за период, длительность последнего relink; алерт — relink не завершился/завис дольше разумного окна (через cms/health, если включён).
| Симптом | Что проверить | Команда |
|---|---|---|
| Автоссылки не появляются после добавления термина | Не выполнен пересчёт, кеш страниц не инвалидирован | cms:glossary:relink --json |
| После restore БД список автоссылок пуст/устарел | Журнал автолинковки и кеш не входят в дамп в актуальном виде | cms:glossary:relink --json пересчитывает журнал; сами термины восстанавливаются из бэкапа, не пересоздаются |
| Дубли/расхождения терминов после legacy-импорта | Повторный прогон без external_id или другим --source | cms:glossary:import-legacy --source=<профиль> --dry-run — отчёт расхождений |
Бэкап/рестор: в бэкап попадает таблица cms_glossary_terms (сами термины и определения); cms_glossary_autolink_log и связанная часть page-cache пересоздаются штатной командой cms:glossary:relink — рестор без её запуска не считается завершённым.
Производительность и кеш
Ожидаемые объёмы: десятки–сотни терминов на типичном сайте (справочник тематики, не энциклопедия); автолинковкой за один прогон relink может обрабатываться от нескольких десятков до тысяч страниц/записей контента — отсюда чанкование через Bus::batch.
Горячий путь — рендер страницы с автолинковкой: whitelist терминов читается из кеша списка терминов/группы настроек, 0 дополнительных запросов к БД на конкретной странице (контрактный тест, §10 стандарта); совпадения ищутся одним проходом по тексту компилированным regex-объединением экранированных (preg_quote) терминов, отсортированных по убыванию длины (длинное совпадение приоритетнее короткого — см. «Крайние случаи»), а не циклом «на каждый термин — свой поиск по тексту». glossary.autolink_max_content_length ограничивает стоимость обработки аномально больших текстов сверху.
Индексы: GIN на synonyms; составной (entity_type, entity_id, term_id) на cms_glossary_autolink_log; частичный уникальный (locale, external_id) на cms_glossary_terms под идемпотентность импорта.
Кеш: тег glossary на странице термина и указателе, инвалидация по GlossaryTermSaved/ GlossaryTermDeleted. Автолинковка встраивается в HTML блока на этапе рендера и попадает в page-cache вместе со страницей — изменение whitelist требует либо инвалидации затронутых страниц по тегу, либо явного cms:glossary:relink (см. «Крайние случаи» — противоречие вокруг affectsPageCache двух настроек ниже).
Безопасность
Границы входа: FormRequest-whitelist на CRUD терминов (админка). Rich-текст определения — санитизация двойным барьером (сохранение + вывод). Автолинковка работает только по whitelist терминов — защита от неконтролируемой подмены произвольного текста ссылками.
Векторы атак, специфичные для модуля:
- ReDoS при матчинге терминов в тексте: термины и синонимы — не пользовательский regex, а литеральные строки, экранируемые
preg_quoteперед сборкой единого alternation-паттерна; ReDoS-валидатор ядра для пользовательских regex (§11 стандарта) здесь формально не применим, но паттерн всё равно строится один раз на whitelist (не на запрос) и прогоняется одним линейным проходом, что исключает катастрофический бэктрекинг на практике — контрактный тест фиксирует этот инвариант на whitelist из сотен терминов и большом тексте; - санитизация rich-text определения: двойной барьер (на сохранении и на выводе) — защита от XSS через
{!! !!}-подобный вывод; доверенный HTML в определении не предусмотрен (в отличие от HTML-блока ядра, доступного только studio-роли); - дубликат
slug: уникальность в рамкахlocaleна уровне БД и FormRequest — защита от подмены страницы термина.
ПДн-паспорт — см. «Модель данных» (ПДн не хранятся).
Матрица ролей:
| Роль | Просмотр | CRUD термина | Удаление термина | Экспорт отчёта автолинковки |
|---|---|---|---|---|
| редактор | ✅ | ✅ (создание/правка) | ❌ | ❌ |
| менеджер | ✅ | ✅ | ✅ | ✅ |
| админ | ✅ | ✅ | ✅ | ✅ |
| studio | ✅ | ✅ | ✅ | ✅ |
Права: glossary.view, glossary.manage (удаление и экспорт — часть manage, как более опасные действия, недоступные редактору по умолчанию — §11 стандарта).
UX-требования
Для админа:
- пустое состояние списка терминов — «Ещё нет терминов, добавьте первый» с кнопкой действия вместо тихой пустой таблицы;
- массовые действия: массовое удаление и экспорт выбранных терминов (CSV);
- человеческие ошибки: попытка сохранить дублирующийся
slug— «Термин с таким адресом уже существует» вместо сырой ошибки уникальности БД; - подтверждение удаления термина, уже использованного в автолинковке — диалог «Термин использован в автоссылках N раз на M страницах, удалить?» (счётчик из
cms_glossary_autolink_log), а не безусловное удаление; - конфликт одновременного редактирования — «Термин изменён другим пользователем, обновите страницу» с текущим значением записи вместо молчаливой перезаписи (
409).
Для посетителя:
- страница термина участвует в общем page-cache (нет персональных данных — сегментация не требуется), отклик — как у любой статической страницы ядра;
- автоссылки — обычные inline
<a>без дополнительной вёрстки/скриптов, не влияют на CLS (не переносят текст, не подгружаются асинхронно).
Крайние случаи и типовые баги
- Производительность автолинковки на очень больших текстах (тысячи слов) →
glossary.autolink_max_content_lengthограничивает объём обрабатываемого текста; свыше лимита — «хвост» контента не проходит автолинковку (лучше частичная, чем зависший рендер), совпадения ищутся единым линейным проходом, не циклом «термин × позиция в тексте». - Зацикливание/повторная обработка внутри уже вставленной ссылки → термин внутри текста уже слинкованного фрагмента (например «дом» внутри только что вставленной ссылки на «загородный дом») повторно не линкуется — обработка идёт по исходным текстовым узлам до вставки ссылок, а не по результату с уже вставленными
<a>. - Превышение
autolink_max_per_page→ добавление новых автоссылок на странице просто останавливается по достижении лимита, без ошибки и без частичного отката уже вставленных ссылок. - Пересечение терминов («дом» — подстрока «загородный дом») → whitelist сортируется по убыванию длины термина перед сборкой паттерна, длинное совпадение всегда приоритетнее короткого — «загородный дом» линкуется целиком, «дом» внутри него повторно не обрабатывается (см. пункт про зацикливание).
- Удаление термина, уже использованного в
cms_glossary_autolink_log→ журнал чистится каскадно (cascadeOnDelete), событиеGlossaryTermDeletedинвалидирует тегglossary; уже отрендеренные и закешированные страницы со старой ссылкой перестают показывать её после инвалидации/следующегоrelink, а не ведут на404— страница термина в моменте между удалением и инвалидацией кеша посетителю, попавшему по устаревшей закешированной ссылке, отдаёт честный404(термина больше нет), не 500. - Конкурентное редактирование термина двумя админами →
lock_version(optimistic lock, §4 стандарта): второйPUTбез актуальной версии получает409с текущим значением записи, а не молчаливую перезапись. - Выключение модуля посреди
cms:glossary:relink→ незавершённые чанки не довыполняются (модуль выключен — публичные страницы термина/указателя уже отдают404); при повторном включении и повторном запускеrelink— идемпотентный upsert поentity_type+entity_id+term_idне создаёт дублей журнала независимо от того, где прервался предыдущий прогон. - Отсутствие измерения city/locale →
city_id/localeв модели — nullable по правилу измерений §4 стандарта: на одноязычном/одногородском сайте термины и автолинковка работают без отдельной ветки кода, контрактный тест гоняется в обоих режимах. - Пустой whitelist терминов (0 терминов в базе) → автолинковка не падает, просто ничего не линкует; алфавитный указатель отдаёт пустой список с UX-подсказкой, не
500. - Повторный прогон legacy-импорта → идемпотентность по
external_id: повторныйcms:glossary:import-legacyобновляет существующие термины, а не дублирует их;--dry-runпоказывает расхождения без записи. - ⚠️ Противоречие:
affectsPageCacheуautolink_max_per_pageиautolink_first_occurrence_only. Автолинковка встраивается в HTML блока на этапе рендера и попадает в page-cache вместе со страницей — изменение любой из этих двух настроек меняет фактическую разметку уже закешированных страниц так же, как иautolink_enabled(который помеченaffectsPageCache: да), но в текущей таблице настроек они помечены «нет». Предложение разрешения: не расширятьaffectsPageCacheна обе настройки (полная инвалидация page-cache сайта по каждому изменению лимита — избыточно дорого), а явно зафиксировать это ТЗ как эксплуатационное правило: модуль подписывает свой слушатель наSettingChangedдля этих двух ключей и автоматически ставит в очередьcms:glossary:relink(тот же механизм, что и ручной пересчёт после правки термина) — расхождение исчезает за время обработки job, без штормовой инвалидации всего сайта разом.
Донорский код
| Что взять | Путь |
|---|---|
| Модель терминов, алфавитный указатель | 3 проекта (Dictionary), пути не выданы |
Миграция legacy-данных (§16 стандарта, матрица v2.2): команда cms:glossary:import-legacy --source=<профиль> маппит донорские таблицы Dictionary (term/title, definition/description, synonyms/aliases) на cms_glossary_terms; идемпотентна по external_id (повторный прогон обновляет существующие термины, не дублирует), поддерживает --dry-run с построчным отчётом расхождений. Прогон на копии данных донора — часть приёмки модуля.
Тесты и приёмка
- [ ] Контрактный тест: автолинковка оборачивает только первое вхождение термина из whitelist
- [ ] При выключении модуля контент рендерится без автоссылок, без 500
- [ ] Лимит
autolink_max_per_pageреально ограничивает число ссылок на странице - [ ] Права
glossary.manageразграничены от публичного чтения; матрица ролей соблюдена - [ ] Нет N+1 при пересчёте автолинковки для набора страниц (
cms:glossary:relink) - [ ] Инвалидация кеша страницы термина по тегу
glossaryприGlossaryTermSaved/GlossaryTermDeleted - [ ] Длинный термин линкуется приоритетнее короткой подстроки (пересечение терминов)
- [ ] Термин внутри уже вставленной автоссылки повторно не линкуется
- [ ]
glossary.autolink_max_content_lengthограничивает обработку аномально больших текстов без деградации времени ответа - [ ] Удаление термина каскадно чистит
cms_glossary_autolink_log, старые ссылки не ведут на 500/бесконечный редирект - [ ] Конкурентное редактирование термина даёт
409поlock_version, не «последний победил» - [ ] Повторный запуск
cms:glossary:relinkпосле прерывания не создаёт дублей журнала (идемпотентность) - [ ] Пустой whitelist терминов не ломает рендер и указатель
- [ ] Legacy-импорт:
--dry-runпоказывает расхождения без записи; повторный прогон идемпотентен поexternal_id - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут (
/api/v1/glossary,/api/v1/glossary/{slug}, admin CRUD) - [ ] Тестовая БД только
glossary_test;migrate:fresh/refresh/reset/db:wipeзапрещены