Skip to content

ТЗ — Глоссарий/справочник (cms/glossary)

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

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

Термины с определениями, алфавитный указатель и ограниченная автолинковка терминов в тексте контента по whitelist. SEO-страницы отдельных терминов для органического трафика по словарным запросам.

  • Термин + определение (rich-текст), опциональные синонимы
  • Алфавитный указатель (навигация по буквам, группировка)
  • Автолинковка: первое вхождение термина в контенте оборачивается ссылкой на страницу термина — только по whitelist терминов, не тотальный автоматический поиск по тексту
  • SEO-страница термина (собственный slug, meta, Schema.org DefinedTerm)
  • Категории терминов (опционально, через таксономии ядра)
  • Ограничение частоты автолинковки (не более N ссылок на одну страницу)

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

requires: ядро

Поведение при выключении: автолинковка терминов в контенте отключается (текст остаётся plain, без ссылок), страницы терминов и указатель отдают 404 — сами термины и определения не удаляются.

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

ТаблицаКлючевые поляПримечание
cms_glossary_termsid, slug, term, definition, synonyms (json), locale, city_id (nullable), external_id (nullable), lock_versionтермин + определение; external_id — ключ идемпотентности legacy-импорта
cms_glossary_autolink_logentity_type, entity_id, term_id, occurrencesучёт автолинковки для контроля частоты

synonyms — JSONB с GIN-индексом под поиск по синонимам; entity_type+entity_id+ term_id — составной индекс на cms_glossary_autolink_log; term_idconstrained()->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_versionFormRequest-whitelist, санитизация rich-text определения двойным барьером (сохранение + вывод), уникальность slug в рамках locale, lock_version — сравнение при сохранении (409 при расхождении)
API DELETE /api/v1/admin/glossary/{slug}slugpermission 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_enabledbooltrueдаKill-switch автолинковки терминов в контенте (матрица v2.2)
glossary.autolink_max_per_pageint5нетМаксимум автоссылок на одну страницу
glossary.autolink_first_occurrence_onlybooltrueнетЛинковать только первое вхождение термина
glossary.autolink_max_content_lengthint50000нетЛимит длины обрабатываемого текста (символов) на одну единицу контента — защита от дорогого прохода по аномально большим текстам
glossary.definition_max_lengthint5000нетМаксимальная длина определения термина (символов)
glossary.max_synonyms_per_termint10нетМаксимум синонимов на один термин

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/glossarypublicАлфавитный список терминов
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)outcms: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 или другим --sourcecms: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/localecity_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 запрещены

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