Skip to content

ТЗ — Экспорт данных (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_jobsid, exporter, format, params (json), status, file_path, expires_atзадания на экспорт и ссылки на файл
cms_export_profilesid, 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/jobsexporter (имя зарегистрированного экспортёра), format (xlsx/csv), params (json) или profile_idFormRequest 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_formatstringxlsxнетФормат по умолчанию (xlsx/csv)
export.allowed_formatsarray["xlsx","csv"]нетWhitelist форматов, доступных на создание задания
export.signed_url_ttl_minutesint60нетСрок жизни подписанной ссылки на скачивание (обычный канал)
export.bulk_signed_url_ttl_daysint7нетTTL подписанной ссылки на JSONL-файл bulk-канала (api-lessons §8)
export.chunk_sizeint1000нетРазмер чанка при потоковой генерации
export.file_retention_hoursint24нетХранение сгенерированного файла до автоочистки
export.max_rows_per_exportint1000000нетЗаградительный лимит строк на одно задание — свыше отклоняется до постановки в очередь
export.pii_permissionstring"export.manage-pii"нетДополнительное право, требуемое для экспортёров с contains_pii=true
export.notify_on_completionbooltrueнетУведомление инициатора по завершении задания
export.new_jobs_pausedboolfalseнетKill-switch (матрица v2.2): приостановка новых заданий без выключения модуля

API

МетодПутьДоступНазначение
POST/api/v1/admin/export/jobsadmin (export.manage, доп. export.manage-pii для ПДн-экспортёров)Постановка задания экспорта в очередь
GET/api/v1/admin/export/jobsadmin (export.view)Список своих заданий (keyset-пагинация)
GET/api/v1/admin/export/jobs/{id}admin (export.view)Статус задания и signed URL при готовности
GET/api/v1/admin/export/exportersadmin (export.view)Реестр зарегистрированных экспортёров: имя, схема params, contains_pii
GET/POST/PUT/DELETE/api/v1/admin/export/profilesadmin (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 URLexport_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)insite_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
Экспортёр «пропал» из спискавладелец выключен/упал при bootstrapcms: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.view studio-уровня, не любой аутентифицированный админ по угадываемому 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 запрещён

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