Тема
ТЗ — Галереи/портфолио (cms/galleries)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: artel-23ru, universal Статус: ТЗ к разработке
🔄 Ревизия (корпоративный MVP, 15.07.2026): галереи/портфолио — пресет типа контента на движке типов контента (элементы-изображения + типизированная связь с услугой; город — nullable-измерение ядра, не связь), а не отдельная модель. Кейсы — отдельный пресет того же семейства (в карте пресетов движка
galleriesиcases— разные строки); при ревизии решить, где живёт функциональность «до/после» — вgalleriesили вcases. Разделы ниже — свойства/связи/шаблоны пресета.⚠️ Статус тела ТЗ: раздел «Модель данных» и всё тело ТЗ ниже (модель
cms_galleries*/cms_gallery_items, роуты/api/v1/galleries/*, Filament-ресурс, праваgalleries.manage, тесты) — исторический слой, написанный до пивота 15.07.2026: описывают целевые правила через историческую реализацию собственными таблицами. При реализации хранение переезжает наcms_content_entries, свойства/связи — по карте пресетов (engine §13, ссылка выше); актуальные админку/API даёт движок (ContentEntryResource,/api/v1/content/{slug}). Полная ревизия ТЗ — отдельная задача непосредственно перед реализацией пресета (content-types-engine §16, Волна G).
Назначение и возможности
Коллекции работ/изображений на медиатеке ядра с категориями, привязкой к услугам и кейсами «до/после». Лайтбокс-блок для публичного вывода. Используется как самостоятельное портфолио или как галерея карточки услуги.
- Коллекции (галереи) с категориями (через таксономии ядра)
- Элементы галереи — фото/видео из медиатеки ядра, без дублирования файлов
- Привязка коллекции к услуге (
cms/services) — опционально, коллекция может жить отдельно - Кейсы «до/после» — парные изображения с подписью в одном элементе
- Лайтбокс-блок (BlockRegistry) с полноэкранным просмотром и навигацией по коллекции
- Массовая загрузка файлов в коллекцию через медиатеку ядра (прогресс, построчные ошибки)
- Сортировка элементов drag&drop
- Метаданные кейса (город, тип объекта, метрики) как произвольный JSON
Публичные ответы (API и рендер блока) не отдают элемент по его автоинкрементному id — только позицию (position = порядковый номер в коллекции) и ссылки на конверсии медиа; сырой id строки виден только в админском API под galleries.manage.
Зависимости и выключение
requires: ядро · suggests: cms/services (привязка галереи к карточке услуги)
Поведение при выключении: блок «Галерея»/лайтбокс на страницах отдаёт fallback-заглушку, коллекции и элементы сохраняются в БД. Модуль обязан устанавливаться и работать и тогда, когда cms/services вообще не установлен (не только выключен) — связь с ним не через FK-constraint на его таблицу (см. «Крайние случаи», ⚠️ Противоречие №1), а через мягкую ссылку на id, проверяемую на уровне приложения.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_galleries | id, slug, title, service_id (nullable), locale, city_id (nullable), site_id (nullable), lock_version, external_id (nullable) | коллекция работ |
cms_gallery_items | id, gallery_id, media_id, media_before_id (nullable), caption, sort_order, external_id (nullable) | элемент, media_before_id — для пар «до/после» |
cms_gallery_meta | gallery_id, key, value | произвольные метаданные кейса (город, тип объекта, метрики) |
gallery_id — constrained()->cascadeOnDelete(), составной индекс (gallery_id, sort_order) — критичен для горячего пути рендера коллекции по порядку. service_id (nullable) — безconstrained(): unsignedBigInteger('service_id')->nullable()->index(), существование проверяется сервисом модуля при сохранении, а не FK на чужую таблицу (см. «Крайние случаи»). lock_version — optimistic lock коллекции (§4 стандарта); на уровне элементов отдельного lock нет — сортировка и точечное редактирование элемента атомарны по своей строке. external_id (nullable, уникален в рамках источника импорта) — ключ идемпотентности legacy-импорта на обеих таблицах. cms_gallery_meta — key-value, при росте объёма кандидат на JSONB с GIN. Измерения locale/city_id/site_id — nullable, модуль обязан работать и при их отсутствии (сайт без мультиязычности/мультигорода/мультисайта).
ПДн-паспорт. Модуль не собирает ПДн посетителей автоматически. caption — свободный текст, который редактор может вписать (например, имя клиента в кейсе) — это редакционная ответственность контент-менеджера, а не системный сбор персональных данных; отдельного срока хранения/ретеншна для ПДн не заводится. Модуль декларирует «ПДн не собирает» и не реализует хуки «выгрузить всё по субъекту»/«забыть по запросу» — обращение по 152-ФЗ относительно случайно попавшего в подпись имени обрабатывается общередакционно (правка текста подписи), как с любым другим полем контента.
Входные и выходные данные
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
| Форма массовой загрузки (админка) | gallery_id, files[] (multipart) | FormRequest: число файлов ≤ galleries.max_upload_batch_size, каждый файл — через MediaService (MIME-whitelist и лимит размера — настройки группы media ядра, не свои проверки); право galleries.manage |
| CRUD коллекции (Filament/API) | title, slug, service_id (nullable), locale, city_id, meta[], заголовок If-Match/lock_version | FormRequest-whitelist, service_id (если указан) проверяется на существование через сервис модуля cms/services (если он установлен), при рассинхроне версии — 409; право galleries.manage |
| CRUD элемента (Filament/API) | media_id, media_before_id (nullable), caption, sort_order | media_id/media_before_id — обязаны существовать в медиатеке ядра (проверка через MediaService, не по номеру напрямую — защита от подстановки чужого file id); caption — санитизация двойным барьером; право galleries.manage |
| Drag&drop сортировка (админка) | массив {item_id, sort_order} | FormRequest: все item_id принадлежат одной gallery_id из запроса, право galleries.manage |
Событие MediaUploaded (ядро, канал 1) | media_id, путь, признак «файл перезаписан» | тонкий слушатель: если media_id уже используется в cms_gallery_items — инвалидирует тег galleries для затронутых коллекций, бизнес-логики не выполняет |
POST /api/v1/admin/galleries/{id}/items/bulk-upload | см. форму массовой загрузки | то же, что форма — API и админ-форма используют один FormRequest/сервис |
cms:galleries:import-legacy --source=<профиль> | экспорт донора (CaseStudy/GalleryItem) | маппинг полей источника, идемпотентность по external_id, --dry-run без записи |
Всё, что не перечислено выше как вход, модуль обязан отвергать (whitelist-принцип §11 стандарта) — в частности, произвольный media_id, не существующий в медиатеке или не принадлежащий текущему сайту, отклоняется 422, а не тихо сохраняется как «битая» ссылка.
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
GET /api/v1/galleries, /galleries/{slug} | список/карточка коллекции с элементами | конверт {data, meta}, элемент без сырого id (только position + ссылки на конверсии) |
| Блок «Галерея»/лайтбокс (BlockRegistry) | данные рендера коллекции | JSON от сервиса модуля во view (не запросы из шаблона), включает флаг готовности превью на элемент |
Модули-подписчики / cms/audit (если включён) | событие GallerySaved | payload события (см. «События и обмен») |
| Массовая загрузка (ответ на bulk-upload) | построчный отчёт | на файл: filename, status (created/rejected), item_id?, reason? — не общий success/fail на всю пачку |
cms:galleries:cleanup-orphans --json / import-legacy --json | отчёт команды | число обработанных/удалённых/пропущенных строк, построчные причины |
cms/health / GET /api/v1/system/health | отставание очереди конверсий, доля orphan-элементов | агрегированный health-чек JSON |
Настройки (группа galleries)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
galleries.lightbox_enabled | bool | true | да | Включение лайтбокса; также kill-switch — при инциденте с JS-библиотекой лайтбокса выключается без выключения модуля, деградация до простой сетки изображений |
galleries.items_per_page | int | 24 | да | Элементов на странице коллекции |
galleries.before_after_enabled | bool | true | нет | Разрешить парные элементы «до/после» |
galleries.max_upload_batch_size | int | 50 | нет | Максимум файлов за одну массовую загрузку; лимит собственный (число), лимит размера/MIME одного файла — настройки группы media ядра, модуль их не дублирует |
galleries.max_items_per_collection | int | 500 | нет | Максимум элементов в одной коллекции; достижение — понятная ошибка при загрузке, не тихое обрезание |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/galleries | public | Список коллекций (фильтр: категория, услуга) |
| GET | /api/v1/galleries/{slug} | public | Коллекция с элементами (медиа, подписи) |
| GET/POST/PUT/DELETE | /api/v1/admin/galleries… | admin (galleries.manage/galleries.delete) | CRUD коллекций и элементов |
| POST | /api/v1/admin/galleries/{id}/items/bulk-upload | admin (galleries.manage) | Массовая загрузка файлов в коллекцию через MediaService |
| POST | /api/v1/admin/galleries/{id}/items/reorder | admin (galleries.manage) | Пакетное сохранение порядка после drag&drop |
Список коллекций — keyset-пагинация по (sort_order, id), не OFFSET. Мутации коллекции несут lock_version/If-Match: конфликт — 409, не молчаливая перезапись.
Компоненты
Блоки (BlockRegistry): «Галерея» (лайтбокс), «Кейс до/после» — версия _v, demo-props, fallback при выключении модуля. Filament: ресурс коллекций с RelationManager элементов, drag&drop сортировка без перезагрузки страницы, форма массовой загрузки с прогресс-баром и построчными ошибками, загрузка — только через медиатеку ядра.
Команды: cms:galleries:cleanup-orphans --json (элементы без медиа-файла), cms:galleries:import-legacy --source=<профиль> [--dry-run] --json (см. «Донорский код»).
Фронтенд-бюджет: JS лайтбокса грузится лениво — по клику на элемент или попаданию блока в viewport, не в общем бандле темы; изображения — через srcset/адаптивные конверсии MediaService, с зарезервированными width/height (0 CLS); полная клавиатурная доступность (Tab/Enter открывает, Esc закрывает, стрелки листают, фокус заперт внутри лайтбокса на время просмотра).
Демо-контент: сидер коллекции из 8–12 элементов, включая одну пару «до/после», для playground//_gallery и превью блока в редакторе — без ручного ввода.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
GallerySaved | коллекция создана/изменена | gallery_id, service_id, city_id, site_id, lock_version |
Слушает: MediaUploaded из ядра (канал 1) — тонкий слушатель, не создаёт своих файлов и не создаёт элементов автоматически, только инвалидирует свой тег при обнаружении использования перезаписанного media_id. Provides-контрактов не реализует, FilterBus не использует. Загрузка/конверсии файлов — через core-контракт MediaService (прямая DI-зависимость от cms/core-contracts, не входит в нумерацию 5 module-каналов — см. core.md).
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
core: медиатека (MediaUploaded) | событие (канал 1) | in | тонкая проверка использования media_id, инвалидация тега galleries при замене файла |
core: MediaService | core-contract (DI, вне 5 каналов) | out | загрузка, MIME-whitelist, постановка конверсий в очередь при массовой загрузке и CRUD элемента |
cms/services (suggests) | мягкая ссылка на id (nullable-поле без FK-constraint) | out | коллекция опционально привязывается к карточке услуги; существование проверяется сервисом при сохранении, не БД |
cms/services (suggests, если включён) | слушает событие GallerySaved (канал 1) | in для services | cms/services сам инвалидирует свой тег services — galleries не пишет в чужой тег напрямую (см. ⚠️ Противоречие №2) |
cms/audit (если включён) | событие GallerySaved (канал 1) | out | запись изменений коллекции в аудит-журнал |
cms/health | health-чек (агрегат /api/v1/system/health) | out | отставание очереди конверсий/массовой загрузки, доля orphan-элементов |
Любая связь вне этой таблицы — скрытая зависимость и анти-паттерн.
Фоновая работа
Джоба cms:galleries:cleanup-orphans (очередь galleries) — периодическая чистка элементов без медиа-файла через ScheduleRegistrar ядра; идемпотентна (повторный прогон без новых orphan ничего не меняет).
Массовая загрузка: каждый файл валидируется и создаёт строку cms_gallery_items синхронно в рамках HTTP-запроса (быстро — MIME/размер делегированы MediaService); сама генерация конверсий (thumb, webp/avif) уходит в очередь MediaService асинхронно и не блокирует ни ответ на загрузку, ни сохранение элемента. Крупный import-legacy (тысячи элементов донора) — Bus::batch чанками (например, по 50 элементов) с прогрессом, доступным админке, а не одной синхронной командой.
Производительность и кеш
- Ожидаемые объёмы: десятки-сотни коллекций, сотни-тысячи элементов на активный портфолио-сайт.
- Горячий путь: рендер коллекции с превью — 1 запрос
->with('items.media'), не N+1; бюджет — контрактный тест. - Конверсии: генерируются асинхронно очередью
MediaService, не блокируют сохранение элемента (см. «Фоновая работа») и не входят в бюджет запросов синхронного пути. - Индексы: составной
(gallery_id, sort_order)— критичен для порядка вывода; индекс наservice_id(фильтр админки/публичного списка по услуге); уникальныйexternal_idв рамках источника (идемпотентность импорта). - Списки: keyset-пагинация коллекций и элементов admin-RelationManager при больших объёмах (не «показать все 2000 элементов» одним запросом).
- Теги и инвалидация: тег
galleries— на коллекции и её элементах, инвалидируется собственным событиемGallerySavedи тонким слушателемMediaUploaded. Тегservicesкарточки услуги модуль не трогает напрямую — его инвалидируетcms/services, подписавшись наGallerySaved(см. таблицу взаимодействий и ⚠️ Противоречие №2).
Безопасность
Границы входа: FormRequest-whitelist на CRUD коллекций/элементов и на массовую загрузку (админка). Подпись к элементу — санитизация двойным барьером (сохранение + вывод). Загрузка файлов — только через медиатеку ядра (MediaService): MIME-whitelist и лимиты размера — зона ответственности ядра (группа настроек media), модуль их не дублирует и не изобретает собственную проверку типов. Привязка media_id/media_before_id к элементу проверяется на существование через MediaService при каждом сохранении — защита от подстановки в форму чужого/несуществующего file id (перебор идентификаторов).
Матрица ролей:
| Роль | Просмотр | CRUD коллекции/элементов | Загрузка медиа | Удаление | Сортировка |
|---|---|---|---|---|---|
| Посетитель | ✅ (публичное) | — | — | — | — |
| Редактор | ✅ | ✅ | ✅ | — | ✅ |
| Менеджер | ✅ | ✅ | ✅ | ✅ | ✅ |
| Админ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Studio | ✅ | ✅ | ✅ | ✅ | ✅ (+ снятие «зависшего» lock при аварии) |
Права: galleries.view (публичное чтение через API/рендер не требует токена), galleries.manage (CRUD, загрузка, сортировка), galleries.delete (удаление коллекции/элемента — опасное действие, отдельная повышенная роль). Пользовательские regex модуль не использует. В логи и метрики не попадают ПДн — только gallery_id/item_id/агрегаты.
UX-требования
Админ:
- Пустое состояние коллекции без элементов — подсказка «Добавьте первые фото — кнопка «Загрузить»» вместо пустой таблицы.
- Массовая загрузка — прогресс-бар по файлам и построчные ошибки: какие файлы не загрузились и почему (
превышен размер,недопустимый MIME,лимит пачки исчерпан) — не общий отказ на всю пачку. - Подтверждение удаления медиафайла, который используется в галерее — модальное окно показывает, в скольких элементах (и каких коллекциях) он применяется, до удаления.
- Drag&drop сортировка сохраняется без перезагрузки страницы (пакетный PATCH позиций, оптимистичное обновление UI с откатом при ошибке сети).
- Человеческие ошибки конфликта версий — «Коллекцию изменил другой администратор, обновите страницу», не трассировка исключения.
Посетитель:
- Лайтбокс полностью доступен с клавиатуры:
Escзакрывает, стрелки листают элементы, фокус не уходит за пределы диалога, пока он открыт. - Изображения не создают CLS — зарезервированные размеры под превью ещё до загрузки файла.
- Быстрая загрузка первого экрана: первые превью — eager, остальные элементы коллекции — lazy по видимости.
Крайние случаи и типовые баги
- Массовая загрузка превышает лимиты — число файлов больше
galleries.max_upload_batch_sizeили конкретный файл больше лимитаmedia-группы ядра → 422 с построчным списком отклонённых файлов и причиной по каждому; принятые файлы получаютsort_orderпо порядку загрузки в пачке, не случайный/по имени файла. - Конверсии в очереди, галерея уже видна посетителю — до готовности превью элемент не скрывается и не отдаёт оригинал в полный размер «на лету» (дорого по трафику): рендер показывает лёгкий плейсхолдер (skeleton/blur-up низкого разрешения из миниатюры, сгенерированной синхронно при загрузке), реальный thumbnail подставляется, когда конверсия готова и кеш страницы обновится по TTL/инвалидации.
- Удаление медиафайла, используемого в галерее, из медиатеки ядра — связь
media_idстановится «висячей»: рендер защищён отnull-отношения и не падает 500, элемент помечается в админке «медиа отсутствует» и скрывается из публичного вывода;cms:galleries:cleanup-orphansпериодически убирает такие строки — это регулярная чистка, а не механизм, от которого зависит корректность рендера здесь и сейчас. cleanup-orphansпри повторном запуске — идемпотентна: без новых orphan-строк ничего не меняется, повторный прогон безопасен. При выключении модуля посреди выполнения — уже начатый чанк батча доигрывается, новый батч по расписанию не планируется (снят изScheduleRegistrarпри disable), сайт не падает ни в одном из состояний.- Пара «до/после» с одним отсутствующим изображением (например,
media_before_idудалили из медиатеки) → деградация до одиночного изображения (доступного), без ошибки и без плашки «до/после» — не показывать пустой слот. - Конкурентное редактирование коллекции двумя админами →
lock_version, конфликт — 409 с человеческим сообщением («коллекцию изменили, обновите страницу»), не «последний победил» молча. cms/servicesне установлен или выключен →service_idвсегда допускаетnull; UI просто не показывает поле выбора услуги, коллекция полноценно существует и работает отдельно от каталога услуг.- Пустая коллекция (0 элементов) → блок не падает, показывает fallback «пока нет фото» вместо пустого контейнера.
- Огромная коллекция после legacy-импорта (тысячи элементов) → admin-RelationManager и публичный список элементов работают keyset-пагинацией, а не «показать все» одним запросом.
- Отсутствие измерений locale/city/site → модуль работает и без мультиязычности/ мультигорода/мультисайта (nullable-поля, скоуп из
RequestContext) — контрактный тест гоняется в обоих режимах. - ⚠️ Противоречие №1: прямой
constrained()-FK отcms_galleries.service_idна таблицуcms/servicesтребовал бы существования этой таблицы в момент миграцииgalleries— ноcms/servicesобъявлен только какsuggests, то есть может быть не установлен вовсе (не просто выключен), и его таблицы физически не существует. Жёсткий FK в этом случае уронит миграцию модуля при установке безcms/services, что нарушает инвариант §0 «модуль не может уронить сайт установкой». Разрешение:service_id— обычный nullable-столбец без БД-уровня FK; существование ссылки проверяется сервисом модуля в момент записи (еслиcms/servicesустановлен), при выключении/отсутствии модуля-соседа поле просто не заполняется через UI, старые значения не каскадируются автоматически (см. правило §4 data-exchange.md: «FK на чужую сущность допустим только на её первичный id», но без принудительногоconstrained(), ломающего порядок установки). - ⚠️ Противоречие №2: ранняя версия этого ТЗ описывала, что
galleriesсама «инвалидирует page-cache карточки услуги по тегуservices» приGallerySaved— это прямая запись в чужой тег кеша, что противоречит правилу §10 стандарта «модуль инвалидирует свои теги своими событиями» (там же обнаружено рассогласование: раздел «Кеш» называл тегservices, а чеклист тестов — тегgalleriesдля той же операции). Разрешение:galleriesинвалидирует только свой тегgalleries;cms/services(если включён) сам подписывается наGallerySavedи инвалидирует свой тегservices— межмодульная связь идёт через событие (канал 1), не через прямую запись в чужойCacheTags.
Донорский код
| Что взять | Путь |
|---|---|
| Портфолио/кейсы, pivot к услугам | universal/src/app/Models/CaseStudy.php (свежая версия) · первоисточник artel-23ru/src/app/Models/CaseStudy.php |
| Галереи и категории медиа | universal/src/app/Models/{GalleryItem,SiteMedia}.php |
Legacy-импорт. cms:galleries:import-legacy --source=<профиль> [--dry-run] — маппинг CaseStudy → cms_galleries (поля title, slug, service_id?, meta[], external_id = PK донора) и GalleryItem → cms_gallery_items (caption, sort_order, привязка к медиа через повторную загрузку файла в MediaService, external_id = PK донора). Идемпотентна: повторный прогон по тому же external_id обновляет запись, а не дублирует; --dry-run выводит отчёт расхождений без записи. Прогон на копии данных universal/artel-23ru — часть приёмки модуля.
Тесты и приёмка
- [ ] Контрактный тест
/api/v1/galleries/{slug}возвращает элементы в заданном порядке - [ ] При выключении модуля лайтбокс-блок отдаёт fallback без 500
- [ ] Пара «до/после» рендерится корректно при заполненных обоих
media_id; при отсутствии одного из них деградирует до одиночного изображения без ошибки - [ ] Права
galleries.manage/galleries.deleteразграничены от публичного чтения - [ ] Нет N+1 при выводе коллекции с элементами (
->with('items.media')) - [ ] Массовая загрузка: превышение
galleries.max_upload_batch_sizeи лимита размера файла отдаёт 422 с построчным списком отклонённых файлов; принятые файлы получаютsort_orderпо порядку загрузки - [ ] Удаление используемого медиафайла из медиатеки ядра не роняет рендер коллекции (элемент помечается «медиа отсутствует»,
cleanup-orphansубирает строку) - [ ]
cleanup-orphansидемпотентна при повторном запуске и безопасна при выключении модуля посреди выполнения (доигрывает начатый чанк, новый не планирует) - [ ] Конкурентное редактирование коллекции двумя запросами отдаёт 409 по
lock_version - [ ] Установка модуля без
cms/servicesне падает на миграции (service_idбезconstrained()-FK на чужую отсутствующую таблицу) - [ ]
GallerySavedинвалидирует только тегgalleries; инвалидация тегаservicesпроверяется тестом на сторонеcms/services(слушатель события), не здесь - [ ]
cms:galleries:import-legacy --dry-runне пишет в БД; повторный прогон без--dry-runпо тем жеexternal_idне создаёт дублей - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут (список, карточка коллекции, admin CRUD, bulk-upload, reorder)
- [ ] Тестовая БД только
galleries_test;migrate:fresh/refresh/reset/db:wipeзапрещены