Skip to content

ТЗ — Галереи/портфолио (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_galleriesid, slug, title, service_id (nullable), locale, city_id (nullable), site_id (nullable), lock_version, external_id (nullable)коллекция работ
cms_gallery_itemsid, gallery_id, media_id, media_before_id (nullable), caption, sort_order, external_id (nullable)элемент, media_before_id — для пар «до/после»
cms_gallery_metagallery_id, key, valueпроизвольные метаданные кейса (город, тип объекта, метрики)

gallery_idconstrained()->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_versionFormRequest-whitelist, service_id (если указан) проверяется на существование через сервис модуля cms/services (если он установлен), при рассинхроне версии — 409; право galleries.manage
CRUD элемента (Filament/API)media_id, media_before_id (nullable), caption, sort_ordermedia_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 (если включён)событие GallerySavedpayload события (см. «События и обмен»)
Массовая загрузка (ответ на 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_enabledbooltrueдаВключение лайтбокса; также kill-switch — при инциденте с JS-библиотекой лайтбокса выключается без выключения модуля, деградация до простой сетки изображений
galleries.items_per_pageint24даЭлементов на странице коллекции
galleries.before_after_enabledbooltrueнетРазрешить парные элементы «до/после»
galleries.max_upload_batch_sizeint50нетМаксимум файлов за одну массовую загрузку; лимит собственный (число), лимит размера/MIME одного файла — настройки группы media ядра, модуль их не дублирует
galleries.max_items_per_collectionint500нетМаксимум элементов в одной коллекции; достижение — понятная ошибка при загрузке, не тихое обрезание

API

МетодПутьДоступНазначение
GET/api/v1/galleriespublicСписок коллекций (фильтр: категория, услуга)
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-uploadadmin (galleries.manage)Массовая загрузка файлов в коллекцию через MediaService
POST/api/v1/admin/galleries/{id}/items/reorderadmin (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: MediaServicecore-contract (DI, вне 5 каналов)outзагрузка, MIME-whitelist, постановка конверсий в очередь при массовой загрузке и CRUD элемента
cms/services (suggests)мягкая ссылка на id (nullable-поле без FK-constraint)outколлекция опционально привязывается к карточке услуги; существование проверяется сервисом при сохранении, не БД
cms/services (suggests, если включён)слушает событие GallerySaved (канал 1)in для servicescms/services сам инвалидирует свой тег servicesgalleries не пишет в чужой тег напрямую (см. ⚠️ Противоречие №2)
cms/audit (если включён)событие GallerySaved (канал 1)outзапись изменений коллекции в аудит-журнал
cms/healthhealth-чек (агрегат /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] — маппинг CaseStudycms_galleries (поля title, slug, service_id?, meta[], external_id = PK донора) и GalleryItemcms_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 запрещены

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