Тема
ТЗ — Экспорт данных (cms/export)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★★ · Донор: universal (
universal/src/app/Services/PriceListExporter.php) Статус: ТЗ к разработке
Назначение и возможности
Выгрузка данных в XLSX/CSV на базе openspout (потоковая запись без загрузки всего датасета в память). Используется для прайс-листов, отчётов и выгрузок из cms/audit. Генерация выполняется в очереди, готовый файл отдаётся по signed URL.
- Форматы XLSX и CSV через openspout (потоковая генерация)
- Прайс-листы с настраиваемым набором колонок, сохраняемым как профиль (
cms_export_profiles) — переиспользуемый пресет колонок/фильтров, не переввод параметров на каждый запуск - Произвольные отчёты по зарегистрированным экспортёрам модулей
- Bulk-канал JSONL для машинных интеграций (по образцу api-lessons §8) — отдельно от XLSX/CSV для людей
- Генерация в очереди, без блокировки запроса пользователя
- Ссылка на скачивание — подписанный временный URL (signed URL)
- Прогресс генерации виден пользователю (для крупных выгрузок)
- Автоочистка сгенерированных файлов по истечении срока
Не путать с cms:export/cms:import ядра (data-exchange.md, раздел «Перенос типов контента») — та пара переносит схемы типов контента между инсталляциями (dev → prod, manifest.json
- apply-токен, sha256 схемы).
cms/export— про выгрузку пользовательских данных в файл для скачивания администратором (прайсы, отчёты, аудит-выгрузки) внутри одной инсталляции, механизм и модель данных полностью разные.
Зависимости и выключение
requires: ядро
Поведение при выключении: кнопки экспорта в Filament скрываются, прежде сгенерированные файлы остаются доступными до истечения срока — деградация, не поломка. Модули, регистрирующие свои экспортёры (например cms/audit), при выключенном cms/export теряют возможность выгрузки в файл — их UI обязан скрывать кнопку «Экспорт» вместо ошибки при клике (проверка доступности cms/export через requires, не try/catch вокруг вызова).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_export_jobs | id, exporter, format, params (json), status, file_path, expires_at | задания на экспорт и ссылки на файл |
cms_export_profiles | id, name, exporter, params (json) | сохранённые пресеты колонок/фильтров как отдельная сущность |
Заметки: status/format — PHP Enum; params — JSONB с касом 'array'; индекс по expires_at под задачу автоочистки (WHERE expires_at < now()). Индекс (status, created_at) — под дашборд/health-чек «зависшие задания». exporter индексируется под фильтр «мои экспорты по конкретному отчёту». cms_export_profiles.params валидируется той же схемой экспортёра, что и cms_export_jobs.params, — профиль лишь фиксирует набор параметров под именем, не собственная модель данных; уникальный индекс (name).
Входные и выходные данные
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
Filament/POST /api/v1/admin/export/jobs | exporter (имя зарегистрированного экспортёра), format (xlsx/csv), params (json) или profile_id | FormRequest whitelist: exporter — только из реестра зарегистрированных экспортёров, format — whitelist export.allowed_formats, params — по схеме, объявленной конкретным экспортёром при регистрации (набор колонок, фильтры), незаявленный параметр → 422 |
POST /api/v1/admin/export/profiles (сохранение профиля) | name, exporter, params (json) | FormRequest whitelist, params — та же схема экспортёра |
POST /api/v1/admin/export/bulk (bulk-канал) | resource, filter (по образцу api-lessons §8) | FormRequest whitelist: resource — из реестра экспортёров, filter — по схеме экспортёра, лимит 1 активная bulk-операция на клиента |
| Регистрация экспортёра модулем (bootstrap, не HTTP-вход) | имя, схема params, флаг contains_pii | валидируется при регистрации: имя уникально в реестре, contains_pii обязателен (bool, не nullable) — без него регистрация отклоняется |
GET /api/v1/admin/export/jobs/{id} (опрос статуса) | id задания | Policy: запрашивающий видит только задания, на создание которых у него было право (см. Безопасность) |
Всё, что не перечислено как вход (произвольные параметры вне схемы экспортёра, неизвестный exporter, неразрешённый format) — отвергается на границе 422, а не проходит с дефолтами.
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
Filament UI / /api/v1/admin/export/jobs* | статус задания, file_size_bytes, signed URL при готовности | конверт {data, meta} |
| Скачивание файла | сгенерированная выгрузка | XLSX/CSV, отдаётся по подписанному временному URL, не напрямую с диска |
| Bulk-канал | JSONL-выгрузка, иерархии — плоскими строками с __parentId | вебхук bulk.finished с signed URL (TTL 7 дней) либо partial_data_url при частичном сбое, прогресс — object_count |
События ExportJobCompleted/ExportJobFailed | факты для подписчиков (например cms/audit) | payload — раздел «События и обмен» |
Модуль-владелец экспортёра (например cms/audit) | статус завершения своего отчёта | сервис-вызов/событие — экспортёр узнаёт, что его отчёт готов, чтобы отразить это в своём UI |
Настройки (группа export)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
export.default_format | string | xlsx | нет | Формат по умолчанию (xlsx/csv) |
export.allowed_formats | array | ["xlsx","csv"] | нет | Whitelist форматов, доступных на создание задания |
export.signed_url_ttl_minutes | int | 60 | нет | Срок жизни подписанной ссылки на скачивание (обычный канал) |
export.bulk_signed_url_ttl_days | int | 7 | нет | TTL подписанной ссылки на JSONL-файл bulk-канала (api-lessons §8) |
export.chunk_size | int | 1000 | нет | Размер чанка при потоковой генерации |
export.file_retention_hours | int | 24 | нет | Хранение сгенерированного файла до автоочистки |
export.max_rows_per_export | int | 1000000 | нет | Заградительный лимит строк на одно задание — свыше отклоняется до постановки в очередь |
export.pii_permission | string | "export.manage-pii" | нет | Дополнительное право, требуемое для экспортёров с contains_pii=true |
export.notify_on_completion | bool | true | нет | Уведомление инициатора по завершении задания |
export.new_jobs_paused | bool | false | нет | Kill-switch (матрица v2.2): приостановка новых заданий без выключения модуля |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/admin/export/jobs | admin (export.manage, доп. export.manage-pii для ПДн-экспортёров) | Постановка задания экспорта в очередь |
| GET | /api/v1/admin/export/jobs | admin (export.view) | Список своих заданий (keyset-пагинация) |
| GET | /api/v1/admin/export/jobs/{id} | admin (export.view) | Статус задания и signed URL при готовности |
| GET | /api/v1/admin/export/exporters | admin (export.view) | Реестр зарегистрированных экспортёров: имя, схема params, contains_pii |
| GET/POST/PUT/DELETE | /api/v1/admin/export/profiles | admin (export.manage) | CRUD сохранённых профилей колонок/фильтров |
| POST | /api/v1/admin/export/bulk | токен интеграции (export.manage) | Bulk-выгрузка JSONL: {resource, filter} → 202 + id |
| GET | /api/v1/admin/export/bulk/{id} | токен интеграции (export.view) | Статус bulk-задания, object_count, ссылка при готовности |
Мутации с внешним эффектом (POST /jobs, POST /bulk) — с Idempotency-Key (§7 стандарта): повторный запрос с тем же ключом не запускает вторую генерацию того же отчёта. Bulk-канал вне общего rate-limit — ограничен «1 активная операция на клиента» (api-lessons §8).
Компоненты
Filament: кнопка «Экспорт» на таблицах ресурсов (с выбором сохранённого профиля или разовых параметров), страница истории экспортов, CRUD профилей. Команды: cms:export:run --json, cms:export:cleanup --json (автоочистка просроченных файлов). Демо-контент (матрица v2.2): не применимо — публичных блоков/виджетов нет, admin-only инструмент.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
ExportJobCompleted | файл сгенерирован и доступен по signed URL | export_job_id, format, file_size_bytes |
ExportJobFailed | генерация завершилась ошибкой | export_job_id, error |
FilterBus не используется.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Модуль-владелец экспортёра (например cms/audit) | прямой сервис-вызов по requires модуля-потребителя на cms/export (канал 4) | in | Владелец регистрирует экспортёр (имя, схема параметров, contains_pii) при своём bootstrap, дальше не реализует собственную генерацию XLSX/CSV |
RequestContext ядра | сервис-вызов по requires (канал 4) | in | site_id/locale/city_id для скоупирования выборки, если данные экспортёра несут эти измерения |
| Медиатека/хранилище ядра | сервис-вызов по requires (канал 4) | out | Сгенерированный файл кладётся в файловое хранилище ядра, отдаётся по signed URL, не напрямую с диска приложения |
Событие ExportJobCompleted/ExportJobFailed | канал 1 (событие) | out | Модуль-владелец экспортёра слушает факт завершения своего отчёта, чтобы отразить статус в своём UI |
cms/notifications-bus (если включён, export.notify_on_completion) | сервис-вызов (канал 3, notification-channel) | out | Уведомление инициатора о готовности выгрузки; выключен — log-fallback |
cms/audit | слушает ExportJobCompleted/ExportJobFailed, а также сам — потребитель регистрации экспортёра | in/out (см. выше) | Фиксирует факт выгрузки в журнале аудита (кто, что, когда экспортировал — особенно важно для ПДн) |
Фоновая работа
Именованная очередь export — вся генерация (потоковая запись openspout по export.chunk_size) выполняется в очереди, никогда синхронно в HTTP-запросе. Автоочистка просроченных файлов — расписание через ScheduleRegistrar ядра по export.file_retention_hours. Bulk-канал JSONL пишет отчёт построчно по мере обработки в ту же очередь export, сигнал готовности — вебхук bulk.finished (не поллинг), при частичном сбое — partial_data_url вместо провала всего задания.
Мини-ранбук (§15 стандарта):
| Симптом | Что проверить | Команда |
|---|---|---|
| Задание зависло в очереди | глубина export, живость воркера | cms:export:run --json (ручной прогон) |
| Signed URL не открывается | не истёк ли TTL, доступность хранилища | проверка expires_at в cms_export_jobs |
Файлы копятся сверх file_retention_hours | расписание автоочистки | cms:export:cleanup --json --dry-run |
| Экспортёр «пропал» из списка | владелец выключен/упал при bootstrap | cms:map --json (секция provides/consumers) |
| Bulk-выгрузка не шлёт вебхук | доставка bulk.finished, health получателя | GET /bulk/{id} (fallback на поллинг) |
Производительность и кеш
Ожидаемые объёмы (managed-парк студии): прайс-листы — от сотен до десятков тысяч позиций, отчёты cms/audit — от тысяч до сотен тысяч записей за период, крупные выгрузки каталогов — до export.max_rows_per_export (1000000) строк. openspout пишет потоково построчно/чанками (export.chunk_size), память воркера не растёт с размером датасета — контрактный тест фиксирует константный профиль памяти независимо от total_rows.
Горячие пути и бюджет запросов: создание задания — 1 запрос (insert cms_export_jobs) + постановка в очередь export, без синхронной выборки данных в HTTP-запросе; статус задания — 1 запрос по PK; список экспортёров — из реестра (регистрация в памяти при bootstrap), 0 запросов к БД. Выборка данных экспортёром — постранично/курсором по export.chunk_size, не Model::all()/get() целиком (иначе openspout получает уже раздутую коллекцию в память до своей потоковой записи — типовая ошибка, сводящая на нет пользу потокового райтера).
При выгрузках, близких к export.max_rows_per_export, таймаут очереди export обязан быть рассчитан на худший случай (чанк × ожидаемое время записи чанка), иначе задание падает по таймауту воркера на последних чанках — не ошибка данных, а неверно сконфигурированный лимит времени джобы.
Что кешируется: реестр зарегистрированных экспортёров (имя, схема параметров, contains_pii) — статичен в рамках деплоя, читается из памяти при bootstrap, отдельного тега кеша не требует. Задания и файлы — не кешируются, потому что статус читается точечно по PK с высокой свежестью (поллинг UI во время генерации), а сам файл — одноразовая выгрузка с TTL, кеширование которой не даёт выигрыша и продлевает жизнь потенциально ПДн-содержащего файла сверх export.file_retention_hours.
Безопасность
Границы входа: постановка задания экспорта — только под export.manage, набор колонок/параметров — FormRequest-whitelist по зарегистрированному экспортёру, без произвольных полей. Скачивание файла — только по signed URL с ограниченным export.signed_url_ttl_minutes, прямого публичного доступа к файлам на диске нет.
Векторы, специфичные для модуля:
- экспорт персональных данных — экспортёр обязан явно объявлять
contains_pii: boolпри регистрации;cms/exportотказывает в постановке задания (403), если у инициатора нет дополнительного праваexport.pii_permission— этого требования нет в возможностях экспортёра «по умолчанию» (безопасный дефолт — отсутствие права, не наличие); - утечка signed URL — ссылка не персонализирована сверх подписи; при компрометации ссылки (пересылка, логирование прокси) доступ к файлу с ПДн получает любой обладатель ссылки до истечения
export.signed_url_ttl_minutes— TTL для ПДн-экспортов должен быть короче дефолтного (отдельная настройка/минимизация на уровне экспортёра, не общийsigned_url_ttl_minutes); - параметры вне схемы экспортёра —
paramsвалидируется по схеме, заявленной конкретным экспортёром при регистрации, а не произвольным JSON; попытка передать фильтр/колонку вне схемы → 422, не игнор незнакомого поля; - flood создания заданий — rate-limit на
POST /jobs(per-граница ядра), иначе очередьexportзаваливается фиктивными тяжёлыми выгрузками (DoS через собственный функционал); - чтение чужого задания по id — Policy на
GET /jobs/{id}: видеть статус/ссылку может только инициатор задания либоexport.viewstudio-уровня, не любой аутентифицированный админ по угадываемому id.
ПДн-паспорт (матрица v2.2). Собственные таблицы модуля (cms_export_jobs, cms_export_profiles) ПДн не хранят напрямую (только имя экспортёра/параметры), но сгенерированный файл — при contains_pii=true — по определению содержит ПДн выгружаемых сущностей; ретеншн — export.file_retention_hours (дефолт 24 ч), после чего файл удаляется физически, не только теряет ссылку. Доступ — двойной барьер: export.manage-pii на постановку задания и короткий TTL signed URL на скачивание. Модуль не участвует в «выгрузить/забыть по запросу» напрямую — это инструмент выгрузки чужих данных по требованию администратора, не хранилище; выгрузка «по субъекту» для 152-ФЗ реализуется конкретным ПДн-экспортёром модуля-владельца данных.
Матрица ролей:
| Роль | Просмотр статуса/истории | Экспорт (без ПДн) | Экспорт с contains_pii=true |
|---|---|---|---|
| studio | ✅ | ✅ | ✅ |
| админ | ✅ | ✅ | ✅ |
| менеджер | ✅ | ✅ | ❌ (нужен export.manage-pii) |
| редактор | ❌ | ❌ | ❌ |
Права: export.view, export.manage, export.manage-pii (дополнительное право для экспортёров, объявивших contains_pii=true).
UX-требования
Админ:
- пустое состояние истории экспортов — «Экспортов ещё не было» с указанием, где искать кнопку «Экспорт» на нужном ресурсе, не пустая таблица;
- массовое действие — на таблицах ресурсов кнопка «Экспорт выбранных» применяет текущий фильтр/ выделение как
paramsзадания, не требует отдельного экрана настройки для типового случая; - прогресс генерации — виден для крупных выгрузок (доля обработанных строк/чанков), не «крутилка» без деталей, особенно при выгрузках, близких к
export.max_rows_per_export; - ошибки — на человеческом языке: «не удалось сформировать файл — источник данных недоступен», а не текст исключения openspout;
- для ПДн-экспортёров — явная пометка в UI («содержит персональные данные») до клика «Экспорт», не только скрытая проверка права на сервере — пользователь должен понимать последствия скачивания;
- подтверждение перед запуском не требуется для обычного экспорта (операция не разрушительна и обратима — файл просто перегенерируется), но требуется при выгрузке, помеченной
contains_pii, и при параметрах, приближающихся кexport.max_rows_per_export(предупреждение о времени генерации).
Посетитель: прямого взаимодействия с модулем нет — экспорт целиком admin-only функциональность, публичный сайт не обращается к cms/export ни в одном сценарии.
Крайние случаи и типовые баги
- экспорт миллиона строк → потоковая запись openspout по
export.chunk_sizeбез накопления датасета в памяти, выборка данных экспортёром — курсором/постранично, неget()целиком; контрактный тест фиксирует константный профиль памяти при ростеtotal_rows; - выборка, близкая к
export.max_rows_per_export→ таймаут очередиexportрассчитан на худший случай по чанкам, а не на средний; превышение лимита строк — отказ 422 при постановке задания (после предварительного подсчёта), не обрыв уже выполняющейся генерации на середине; - ПДн в экспорте без права → постановка задания под экспортёром с
contains_pii=trueбезexport.pii_permission— 403 до постановки в очередь, не после генерации файла; - двойной сабмит кнопки «Экспорт» →
Idempotency-KeyнаPOST /jobsне даёт запустить вторую генерацию того же отчёта с теми же параметрами при повторном клике/ретрае клиента; - параллельные задания одного и того же тяжёлого отчёта → не блокируются искусственно (разные
export_job_id, независимые файлы), но UI предупреждает о уже выполняющемся идентичном задании, чтобы не плодить дублирующую нагрузку на очередь без необходимости; - повтор джобы после сбоя (at-least-once доставка) → генерация идемпотентна по
export_job_id: повторный прогон перезаписывает тот жеfile_path, не создаёт второй файл с новым URL для того же задания; - выключение
cms/exportпосреди генерации → уже запущенная джоба в очередиexportдоигрывает до конца независимо от состояния модуля, кнопки постановки новых заданий скрываются сразу; - экспортёр-владелец отключён после регистрации, но до/во время выполнения задания → задание завершается статусом «провал» с понятной причиной («источник данных недоступен»), не падением воркера с необработанным исключением;
- пустая выборка → файл с одними заголовками колонок, статус «успех», не ошибка и не бесконечное «processing»;
- отсутствие измерения site/locale/city у экспортируемых данных → см. отдельное противоречие ниже —
cms_export_jobsне несёт этих измерений явно; - сбой хранилища при записи файла (диск заполнен, недоступен) →
ExportJobFailedс понятной причиной, частично записанный файл не остаётся доступным по signed URL; - bulk-выгрузка обрывается на середине → вебхук
bulk.finishedуходит сpartial_data_url(не полным файлом) и статусом частичного успеха,object_countотражает реально записанное — клиент решает, повторять ли операцию целиком, а не получает молчаливо неполный файл как полный; - вторая bulk-операция от того же клиента, пока первая не завершена →
POST /bulkотклоняется 409 (лимит «1 активная операция на клиента», api-lessons §8), не ставится в очередь молча; export.new_jobs_paused=trueво время активной генерации → уже идущие задания доигрывают, новые (включая bulk) отклоняются с понятным сообщением, кнопки в Filament скрываются сразу;- ⚠️ Противоречие: §4 стандарта требует, чтобы контентные сущности несли измерения
locale/city_id/site_id(nullable), а скоуп брался изRequestContext. Таблицаcms_export_jobsне хранит ни одного из этих полей — при включённомcms/multisiteзадание экспорта, поставленное с одного сайта, не имеет декларативной защиты от выборки данных другого сайта, если экспортёр-владелец сам забудет применить скоуп поRequestContextвнутри своей выборки. Разрешение: либо явно зафиксировать, что ответственность за скоупирование по измерениям полностью на экспортёре-владельце (и тогда добавить это требование текстом в договор регистрации экспортёра, а не полагаться на общее правило §4), либо добавитьsite_id(nullable) вcms_export_jobsи валидировать его на границеcms/exportперед постановкой задания; - ⚠️ Противоречие: исходный текст ТЗ описывал регистрацию экспортёра как «Provides: сервис-вызов «зарегистрировать экспортёр»», но канонический реестр provides-контрактов (§2 стандарта:
payment-gateway,delivery-provider, … ) не включает такой контракт, и по смыслу это не канал 3 (потребитель не резолвит абстрактную реализацию через DI) — модуль-потребитель (напримерcms/audit) зависит от конкретногоcms/export, что соответствует каналу 4 (requires→ прямой сервис-вызов публичногоExporterRegistry-сервиса), а не провайдес-контракту. Разрешение: в манифестах модулей, регистрирующих экспортёры, использоватьrequires: {"cms/export": "^1.0"}, в тексте ТЗ и коде — говорить о прямом сервис-вызове, не о provides.
Донорский код
| Что взять | Путь |
|---|---|
| Логика генерации прайс-листа (потоковая выгрузка) | universal/src/app/Services/PriceListExporter.php |
Миграция legacy-данных (§16 стандарта, матрица v2.2): не применимо — модуль не хранит пользовательских данных, только задания и профили; переносить с донора нечего, кроме самой практики потоковой генерации (уже отражена в разделе выше).
Тесты и приёмка
- [ ] Контрактный тест: генерация XLSX/CSV не грузит весь датасет в память (потоковая запись)
- [ ] Health-чек модуля отражает зависшие задания экспорта
- [ ] При выключении модуля ранее сгенерированные файлы остаются скачиваемыми до истечения TTL
- [ ] Signed URL невалиден после истечения
export.signed_url_ttl_minutes(тест) - [ ] Автоочистка удаляет только просроченные файлы, не активные задания
- [ ] Права
export.view/export.manageразграничивают просмотр статуса и запуск экспорта - [ ]
export.manage-piiобязателен для постановки задания под экспортёром сcontains_pii=true(403 без права, до постановки в очередь) - [ ] Регистрация экспортёра без
contains_piiотклоняется на этапе bootstrap - [ ] Профиль памяти воркера константный при росте
total_rows(тест на выборку, кратно большуюexport.chunk_size) - [ ] Выборка сверх
export.max_rows_per_exportотклоняется до постановки задания в очередь - [ ] Двойной сабмит
POST /jobsсIdempotency-Keyне запускает вторую генерацию - [ ] Повторный прогон джобы после сбоя не создаёт второй файл/второй signed URL для того же задания
- [ ] Отключение экспортёра-владельца во время выполнения задания даёт понятный
ExportJobFailed, не необработанное исключение воркера - [ ] Пустая выборка формирует файл с заголовками и статус «успех», не зависает
- [ ] Профиль (
cms_export_profiles) переиспользуется в новом задании без повторного ввода параметров - [ ] Bulk
POST /bulk: вторая операция того же клиента отклоняется 409, пока первая не завершена - [ ] Bulk-отчёт JSONL корректен, частичный сбой даёт
partial_data_url, не выдаёт неполный файл как успешный - [ ]
export.new_jobs_paused=trueблокирует создание новых заданий, не прерывает уже идущие - [ ] Контрактный набор
cms-testingпройден, пакет протестирован в testbench-изоляции - [ ] Feature-тест на каждый роут модуля; тестовая БД только
export_test,migrate:freshзапрещён