Тема
ТЗ — Цифровые товары (cms/commerce-digital)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Цифровой вариант товара без складского учёта: выдача файла по подписанной ссылке или генерация лицензионного ключа сразу после успешной оплаты. Модель согласована с /cms-v2/commerce-model, раздел «Цифровые товары».
- ТП с признаком
fulfilment: digital— без остатков и доставки - Файловый вариант: приватное хранилище, выдача по
PaymentSucceeded(job) - Лицензионный вариант: контракт
LicenseGenerator, выдача ключа из пула или генерация на лету - Signed URL на скачивание с TTL и лимитом числа скачиваний
- Страница «мои покупки» в личном кабинете со списком выданных файлов/ключей
- Повторная выдача администратором (новая ссылка/перевыдача ключа без повторной оплаты)
- Признак «цифровой товар» в чеке — не зона ответственности этого модуля: атрибут ТП
fulfilmentснапшотится в позицию заказа модулемcommerce-ordersна оформлении, независимо от выдачи (см. ⚠️ Противоречие в «События и обмен»)
Зависимости и выключение
requires: ядро, cms/commerce-model (каталог/заказы), cms/commerce-payments · suggests: cms/notifications-bus (письмо со ссылкой/ключом по DigitalAssetDelivered)
Поведение при выключении: товары с fulfilment: digital перестают автоматически выдаваться после оплаты — заказ оформляется, но выдачу файла/ключа требуется провести администратором вручную через тот же интерфейс повторной выдачи.
Стоимость внешних API. Если контракт LicenseGenerator резолвится во внешний сервис генерации ключей (а не пул в БД) — тариф/квота провайдера видны в админке актива (расход за период, остаток — если провайдер отдаёт эту информацию через свой API); исчерпание квоты — деградация в духе LicensePoolExhausted (заказ не зависает, админ уведомлён и видит остаток квоты), не 500 на странице оплаты.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_digital_assets | id, variant_id, type (file|license_pool), storage_disk, path (nullable) | привязка цифрового актива к ТП, type — PHP Enum |
commerce_license_keys | id, asset_id, key_value, status (available|issued), order_item_id (nullable) | пул лицензионных ключей, status — PHP Enum |
commerce_digital_deliveries | id, order_item_id, user_id, signed_url_expires_at, download_limit, download_count | выданные ссылки/ключи, лимит скачиваний |
variant_id/asset_id/order_item_id/user_id — FK constrained()->index(). Файлы — только приватное хранилище через медиатеку/storage disk, не публичная директория.
Входные и выходные данные
Входы:
| Источник | Канал | Что приходит | Валидация |
|---|---|---|---|
Событие PaymentSucceeded (cms/commerce-payments) | канал 1, событие | order_id, order_item_id, paid_at | слушатель тонкий: проверяет fulfilment: digital у позиции, кладёт job выдачи в очередь commerce-digital; сам факт не проверяет повторно (владелец факта — commerce-payments) |
GET /api/v1/account/digital-purchases/{id}/download | API | id выдачи из маршрута, Sanctum-токен | FormRequest: владелец токена == user_id выдачи, ссылка не истекла, лимит скачиваний не исчерпан |
POST /api/v1/admin/digital-deliveries/{id}/reissue | API | id выдачи, право commerce-digital.manage | FormRequest + Policy; причина перевыдачи — опциональное поле, попадает в cms/audit |
Whitelist-принцип (§11 стандарта): всё, что не перечислено в таблице, модуль отвергает — в частности, путь до файла на скачивание собирается только из commerce_digital_assets.path по id выдачи, никогда из параметра запроса; произвольные query-параметры вне filter/sort API-эндпоинтов отклоняются 422.
Выходы:
| Получатель | Канал | Что уходит | Формат |
|---|---|---|---|
| Покупатель (браузер) | HTTP-редирект | Подписанная ссылка на файл либо значение ключа | 302 на signed URL диска (файл) или {"data": {"key_value": …}} (лицензия) |
| Шина событий | канал 1 | DigitalAssetDelivered, DigitalAssetReissued, LicensePoolExhausted | payload — см. «События и обмен» |
cms/audit | канал 4, сервис-вызов | Запись о перевыдаче | admin_id, delivery_id, reason |
| Личный кабинет («мои покупки») | рендер виджета | Список выдач с остатком скачиваний и сроком ссылки | через сервис модуля, не запрос из шаблона |
Настройки (группа commerce-digital)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-digital.signed_url_ttl_hours | int | 72 | нет | Время жизни подписанной ссылки на скачивание |
commerce-digital.download_limit | int | 5 | нет | Лимит числа скачиваний на одну выдачу |
commerce-digital.auto_deliver_on_payment | bool | true | нет | Автовыдача сразу после PaymentSucceeded — kill-switch: выключение не останавливает модуль, только переводит выдачу в ручной режим (см. «Крайние случаи») |
commerce-digital.max_file_size_mb | int | 2048 | нет | Максимальный размер загружаемого файла цифрового актива |
commerce-digital.external_generator_rate_limit_per_minute | int | 30 | нет | Лимит вызовов внешнего LicenseGenerator в минуту, если контракт резолвится во внешний сервис — защита от исчерпания тарифной квоты провайдера |
Лимиты — явные настройки с дефолтами (§6 стандарта): достижение max_file_size_mb при загрузке в Filament — 422 с понятной ошибкой, не тихое обрезание файла; достижение external_generator_rate_limit_per_minute — задержка job (backoff в очереди), не потеря запроса на генерацию ключа.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/account/digital-purchases | auth | Список цифровых покупок пользователя |
| GET | /api/v1/account/digital-purchases/{id}/download | auth (владелец) | Получение актуальной подписанной ссылки |
| POST | /api/v1/admin/digital-deliveries/{id}/reissue | admin (commerce-digital.manage) | Повторная выдача файла/ключа |
Компоненты
Виджеты: страница «мои покупки» в личном кабинете. Filament: пул лицензионных ключей с статусами, список выдач с кнопкой перевыдачи. Команды: cms:commerce-digital:deliver --json (обработка очереди выдачи по PaymentSucceeded).
Демо-контент: сидер commerce-digital создаёт 1–2 демо-цифровых товара (файл-плейсхолдер и демо-пул из нескольких лицензионных ключей) для галереи блоков/виджетов и playground — без обращения к внешнему LicenseGenerator.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
DigitalAssetDelivered | файл/ключ выдан покупателю | order_item_id, user_id, delivery_id |
DigitalAssetReissued | администратор перевыдал доступ | delivery_id, user_id |
LicensePoolExhausted | в пуле не осталось свободных ключей | asset_id |
Слушает: PaymentSucceeded из cms/commerce-payments (запуск job выдачи, с оговоркой про гонку — см. «Крайние случаи»). Реализует контракт LicenseGenerator для лицензионного варианта, резолвится DI при выдаче ключа.
Взаимодействия:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-payments | 1, событие | входящее | PaymentSucceeded запускает job выдачи; до появления записи в commerce_digital_deliveries доступ не выдаётся |
cms/commerce-orders | — (нет прямой связи) | — | признак «цифровой товар» в чеке — атрибут ТП fulfilment, снапшотится в позицию заказа модулем commerce-orders/commerce-model независимо от этого модуля |
cms/commerce-receipts | — (нет прямой связи) | — | признак предмета расчёта берёт из снапшота позиции заказа (владелец — commerce-orders), не из событий commerce-digital |
cms/notifications-bus (suggests) | 1, событие | исходящее | слушает DigitalAssetDelivered/DigitalAssetReissued, отправляет письмо со ссылкой/ключом [контракт 3] |
LicenseGenerator (provides-контракт) | 3 | потребитель | этот модуль резолвит реализацию через DI при выдаче ключа «на лету» внешним сервисом |
cms/audit | 4, сервис-вызов | исходящее | перевыдача логируется с admin_id, delivery_id, reason |
storage-backend (provides-контракт) | 3 | потребитель | приватный диск для файлов — через контракт, не хардкод диска |
⚠️ Противоречие: версия ТЗ до этой ревизии заявляла, что признак «цифровой товар» передаётся в чек через cms/commerce-receipts силами этого модуля. По commerce-model.md, раздел «Цифровые товары» признак предмета расчёта — атрибут ТП fulfilment, который снапшотится в позицию заказа модулем commerce-orders ещё на оформлении, до и независимо от выдачи файла/ключа. commerce-digital с commerce-receipts напрямую не взаимодействует. Разрешение: suggests: cms/commerce-receipts в манифесте заменён на suggests: cms/notifications-bus (канал 1 — письмо со ссылкой/ключом по DigitalAssetDelivered/DigitalAssetReissued); формулировка в «Назначение и возможности» уточнена.
Фоновая работа
Именованная очередь commerce-digital: job выдачи по PaymentSucceeded (cms:commerce-digital:deliver), идемпотентен — повторная доставка события не создаёт вторую выдачу (уникальность по order_item_id). Генерация лицензионного ключа «на лету» — синхронно в рамках job (без внешних HTTP-вызовов); если генератор ключей — внешний сервис, вызов только из очереди с backoff, ограниченный external_generator_rate_limit_per_minute.
Эксплуатация (ранбук). Метрики: доля заказов с задержкой выдачи свыше 5 минут (симптом гонки/просевшей очереди), число активов с LicensePoolExhausted за сутки, доля неудачных генераций ключей у внешнего LicenseGenerator (timeout/ошибка). Алерты — через cms/health при превышении порогов (пороги — настройки, не хардкод).
Типовые инциденты:
| Симптом | Что проверить | Чем чинить |
|---|---|---|
| Заказ оплачен, файл/ключ не пришёл | статус job в очереди commerce-digital, failed_jobs, дошло ли событие PaymentSucceeded | cms:commerce-digital:deliver --json вручную по order_item_id; при неуспехе — reissue |
Массовые LicensePoolExhausted по активу | остаток commerce_license_keys со status='available' | пополнить пул (импорт CSV/Filament) или переключить актив на генератор «на лету» |
| Скачивания 403 у всех покупателей актива | signed_url_ttl_hours/download_limit не равны 0, доступность storage-backend | health-чек диска, ручная проверка cms:commerce-digital:deliver --dry-run |
Очередь commerce-digital растёт (отставание) | метрика длины очереди в cms/health, живы ли воркеры | масштабировать воркер, проверить внешний LicenseGenerator на таймауты |
Расхождение download_count и фактических скачиваний | лог атомарных инкрементов, повторные job | сверка счётчиков командой cms:commerce-digital:recount --dry-run |
Бэкап/рестор: в бэкап попадают все таблицы модуля (commerce_digital_assets, commerce_license_keys, commerce_digital_deliveries) и файлы приватного хранилища — пул лицензионных ключей относится к критичным данным (его потеря = невозможность повторно довыдать уже проданные лицензии без обращения к внешнему генератору или продавцу). После рестора ничего не пересчитывается автоматически (денормализованных агрегатов нет); cms:commerce-digital:recount — опциональная сверка счётчиков скачиваний, допустима к запуску на живом сайте без даунтайма.
Производительность и кеш
Объёмы (типовой магазин цифровых товаров): десятки–сотни тысяч записей commerce_digital_deliveries в год, пул commerce_license_keys — от сотен до сотен тысяч ключей на популярный продукт.
Горячий путь — GET .../download: резолв актуальной подписанной ссылки и редирект (302) на неё; сам файл никогда не проксируется через PHP-воркер — отдача идёт диском/ storage-backend (S3 presigned URL или local через X-Accel-Redirect), иначе большой файл держит PHP-FPM воркер на время скачивания и валит бюджет запросов сайта под нагрузкой.
Индексы:
| Таблица | Индекс | Зачем |
|---|---|---|
commerce_digital_deliveries | (user_id, order_item_id) | список «мои покупки» и поиск выдачи по позиции заказа без table scan |
commerce_license_keys | (asset_id, status) | быстрая выборка WHERE status='available' LIMIT 1 FOR UPDATE SKIP LOCKED под лок при выдаче ключа |
Теги commerce-digital:asset:<id>, commerce-digital:delivery:<id>. Инвалидация — событиями DigitalAssetDelivered/DigitalAssetReissued. Подписанные ссылки, ключи и список «мои покупки» — персональные данные, не попадают в общий page-cache (§10 стандарта); страница «мои покупки» рендерится per-user, кешируется только справочная часть (описание актива).
Безопасность
Скачивание — только по подписанной ссылке с TTL (signed_url_ttl_hours) и лимитом (download_limit), счётчик download_count инкрементируется атомарно (без гонки при параллельных запросах). Файлы отдаются из приватного хранилища, не из публичной директории. Перевыдача — под правом commerce-digital.manage, логируется в cms/audit. Разграничение прав: commerce-digital.view, commerce-digital.manage. Пользовательских regex модуль не принимает — правило ReDoS-валидатора ядра не применимо. Rate-limit на GET .../download (per-пользователь) — защита от перебора id выдач сверх штатной проверки владения.
Матрица ролей:
| Действие | Клиент (владелец покупки) | Менеджер | Admin (commerce-digital.manage) | Studio |
|---|---|---|---|---|
| Скачать свой файл / увидеть свой ключ | ✅ (только свой) | — | ✅ (просмотр) | ✅ |
| Просмотр списка выдач и пула ключей | — | ✅ (commerce-digital.view) | ✅ | ✅ |
| Перевыдача (reissue) одной выдачи | — | — | ✅ | ✅ |
| Массовая перевыдача пачки заказов | — | — | ✅ (с подтверждением) | ✅ |
| Загрузка/замена файла актива, пополнение пула ключей | — | — | ✅ | ✅ |
ПДн-паспорт: commerce_digital_deliveries хранит user_id и факт «что скачал и когда» (download_count, updated_at) — умеренно чувствительные данные (история покупок цифрового контента). Срок хранения — вместе с заказом (ретеншн заказов ядра), отдельного джоба очистки нет: запись — часть истории покупки, не журнал доступа. 152-ФЗ: «выгрузить всё по субъекту» отдаёт список выдач без самого файла/ключа (только метаданные); «забыть по запросу» — анонимизация (user_id → системный «удалённый пользователь»), факт покупки и её сумма сохраняются отдельно для бухгалтерии (чек — таблица cms/commerce-receipts, этим модулем не трогается). Ключи commerce_license_keys анонимизации не подлежат — при «забыть по запросу» связь с order_item_id разрывается, сам ключ остаётся в пуле как использованный.
UX-требования
Покупатель (страница «мои покупки» в личном кабинете):
- по каждой покупке видно: название товара, оставшееся число скачиваний (
download_limit - download_count), срок действия текущей ссылки (signed_url_expires_at) человекочитаемой датой (translatedFormat()); - истёкшая ссылка — не «страница не найдена»: доступна кнопка «получить новую ссылку» без обращения в поддержку (перевыпуск подписанной ссылки на тот же файл в пределах оставшегося
download_limit, без создания новой выдачи и без повторной оплаты); - лимит скачиваний исчерпан — понятная причина («лимит скачиваний исчерпан, обратитесь в поддержку для перевыдачи») и явная ссылка «написать в поддержку», не тихая 403;
- для лицензионного варианта — ключ показан текстом с кнопкой «скопировать», не только отправлен в письме.
Админ:
- пустое состояние пула лицензионных ключей («ключей нет — загрузите файл со списком или настройте генератор») с прямой ссылкой на форму загрузки;
- массовая перевыдача — по выборке заказов/выдач в списке Filament, с подтверждением количества затронутых записей (операция необратима относительно счётчиков — сброс
download_count/новый TTL); - при исчерпании пула — на карточке актива виден индикатор «свободно 0 из N», не только запись в логе события
LicensePoolExhausted; - перевыдача — с обязательной причиной для
cms/auditи подтверждающим диалогом (имя покупателя и товара), защита от ошибки «нажал не туда».
Крайние случаи и типовые баги
- Гонка с колбэком шлюза. Покупатель после оплаты сразу переходит на страницу скачивания, а job выдачи (запущенный по
PaymentSucceeded) ещё не отработал (вебхук в очереди/обрабатывается) → покаcommerce_digital_deliveriesне создана, эндпоинт скачивания отвечает не 404, а200/202сmeta.degraded: trueи доменнымcode: "delivery_pending"— фронт показывает «файл готовится, обновите через минуту». Создание выдачи до фактаPaymentSucceededзапрещено — сервис не резолвит доступ по одному наличию оплаченного заказа, только по существующей записи выдачи. - Лимит скачиваний исчерпан раньше TTL.
download_count == download_limit,signed_url_expires_atещё не наступил → блокировка с причиной «лимит скачиваний исчерпан» (свойcode), не «ссылка недействительна». - TTL истёк раньше исчерпания лимита.
signed_url_expires_at < now(),download_count < download_limit→ блокировка с причиной «срок ссылки истёк» (свойcode); владелец сам перевыпускает ссылку на тот же файл в пределах оставшегося лимита (см. UX-требования), новая выдача не создаётся. - Повторная выдача при потере доступа. Покупатель потерял письмо/закладку, выдача жива и не исчерпана → доступ через личный кабинет без повторной оплаты; если лимит исчерпан или доступ утрачен полностью — обращение в поддержку → перевыдача администратором (
reissue), без создания нового заказа. - Возврат/отмена заказа с цифровым товаром. Ключ ещё не активирован (для лицензий с признаком активации у
LicenseGenerator) → ключ отзывается (возвращается в пул соstatus: availableили помечается недействительным у внешнего генератора), выдача аннулируется. Файл уже скачан или ключ уже активирован → действует конфиг политики возврата (по умолчанию невозвратны после выдачи, commerce-model) — возврат за эту позицию не оформляется автоматически, решение — вручную у менеджера с пометкой в заказе. - Двойная доставка
PaymentSucceeded(at-least-once). Очередь может доставить событие повторно → job идемпотентен по ключуorder_item_id(уникальный индекс наcommerce_digital_deliveries.order_item_id): повторный запуск находит существующую выдачу и не создаёт вторую,DigitalAssetDeliveredповторно не издаётся. - Пул лицензионных ключей закончился в момент оплаты.
WHERE status='available' LIMIT 1 FOR UPDATE SKIP LOCKEDне находит ключ → заказ не зависает: job публикуетLicensePoolExhausted, покупатель в «моих покупках» видит статус «выдача обрабатывается» (не ошибку), администратор получает алерт (cms/health) и пополняет пул — перевыдача вручную закрывает статус после пополнения. - Параллельные запросы на скачивание с одной ссылки. Два одновременных запроса при
download_limit - download_count == 1→ инкрементdownload_countатомарный (UPDATE ... SET download_count = download_count + 1 WHERE id = ? AND download_count < download_limit, проверка affected rows) — только один запрос получает файл, второй видит «лимит исчерпан»; без атомарности оба прошли бы (типовой race condition). - Файл актива заменён/обновлён после того, как ссылки уже выданы. Обновление файла в
commerce_digital_assetsменяетpath, но уже выданныеcommerce_digital_deliveriesссылаются на снапшот версии на момент выдачи, не на liveasset.path— старые ссылки не подменяются новым файлом молча, новая покупка получает актуальную версию. - Параллельная перевыдача одним/двумя админами. Двойной клик по «Перевыдать» не создаёт вторую параллельно активную ссылку без инвалидации старой: UI блокирует повторный клик до ответа,
reissueинвалидирует предыдущую подписанную ссылку при выпуске новой. - Пустой/нулевой TTL или лимит в настройках.
signed_url_ttl_hours: 0илиdownload_limit: 0— ошибка конфигурации, не спецрежим «без ограничений»: значения валидируются схемой настройки (min: 1), 0/отрицательное отклоняется на уровне settings-store. - Выключенный
auto_deliver_on_payment. Оплата проходит, job выдачи не запускается автоматически: заказ остаётся в статусе «оплачен, ожидает выдачи», админ видит его в списке и выдаёт вручную черезreissue— согласуется с kill-switch (см. «Настройки»).
Донорский код
Донор: — (новая разработка)
Миграция legacy-данных. Команда cms:commerce-digital:import-legacy --source=<профиль> переносит историю цифровых покупок и уже выданных ключей со старой платформы клиента: маппинг внешний_заказ_id → order_item_id, внешний_ключ → commerce_license_keys.key_value с ключом идемпотентности external_id (повторный прогон обновляет, не дублирует), --dry-run — отчёт расхождений (сколько ключей/выдач не нашли соответствия по заказу и почему). Прогон на копии данных — часть приёмки при наличии у клиента истории цифровых продаж (§16 стандарта).
Тесты и приёмка
- [ ] Контрактный тест: job выдачи по
PaymentSucceededидемпотентен — повторная доставка события не создаёт вторую выдачу - [ ] Подписанная ссылка недействительна после истечения
signed_url_ttl_hours(403 при попытке скачать) - [ ] Скачивание сверх
download_limitотклоняется, счётчикdownload_countинкрементируется атомарно - [ ] Выдача лицензионного ключа не выдаёт один и тот же ключ дважды (уникальность
order_item_id) - [ ] Исчерпание пула ключей публикует
LicensePoolExhausted, не блокируя обработку остальных заказов - [ ] При выключении модуля заказ на цифровой товар оформляется, но выдача требует ручного действия администратора
- [ ] Права
commerce-digital.manageразграничивают просмотр покупок и операцию перевыдачи - [ ] Скачивание до создания записи выдачи (гонка с вебхуком) отдаёт
meta.degraded/code: delivery_pending, не 404 - [ ] «Лимит исчерпан» и «TTL истёк» — разные машиночитаемые
codeв конверте ошибок - [ ] Возврат заказа с неактивированным лицензионным ключом отзывает ключ; с активированным — не трогает выдачу автоматически
- [ ] Атомарный инкремент
download_countне пропускает два параллельных запроса при остатке в 1 скачивание (тест на гонку) - [ ] Замена файла актива не меняет уже выданные ссылки прежним покупателям
- [ ]
cms:commerce-digital:import-legacy --dry-runидемпотентен, повторный прогон не дублирует ключи/выдачи - [ ] ПДн: «выгрузить всё по субъекту» и «забыть по запросу» реализованы для
commerce_digital_deliveries - [ ] Матрица ролей: клиент видит только свои выдачи, менеджер — список без права перевыдачи, admin — полный доступ
- [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API
- [ ] Тестовая БД — только
commerce-digital_test;migrate:fresh/refresh/resetзапрещены