Skip to content

ТЗ — Цифровые товары (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_assetsid, variant_id, type (file|license_pool), storage_disk, path (nullable)привязка цифрового актива к ТП, type — PHP Enum
commerce_license_keysid, asset_id, key_value, status (available|issued), order_item_id (nullable)пул лицензионных ключей, status — PHP Enum
commerce_digital_deliveriesid, 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}/downloadAPIid выдачи из маршрута, Sanctum-токенFormRequest: владелец токена == user_id выдачи, ссылка не истекла, лимит скачиваний не исчерпан
POST /api/v1/admin/digital-deliveries/{id}/reissueAPIid выдачи, право commerce-digital.manageFormRequest + Policy; причина перевыдачи — опциональное поле, попадает в cms/audit

Whitelist-принцип (§11 стандарта): всё, что не перечислено в таблице, модуль отвергает — в частности, путь до файла на скачивание собирается только из commerce_digital_assets.path по id выдачи, никогда из параметра запроса; произвольные query-параметры вне filter/sort API-эндпоинтов отклоняются 422.

Выходы:

ПолучательКаналЧто уходитФормат
Покупатель (браузер)HTTP-редиректПодписанная ссылка на файл либо значение ключа302 на signed URL диска (файл) или {"data": {"key_value": …}} (лицензия)
Шина событийканал 1DigitalAssetDelivered, DigitalAssetReissued, LicensePoolExhaustedpayload — см. «События и обмен»
cms/auditканал 4, сервис-вызовЗапись о перевыдачеadmin_id, delivery_id, reason
Личный кабинет («мои покупки»)рендер виджетаСписок выдач с остатком скачиваний и сроком ссылкичерез сервис модуля, не запрос из шаблона

Настройки (группа commerce-digital)

КлючТипДефолтaffectsPageCacheОписание
commerce-digital.signed_url_ttl_hoursint72нетВремя жизни подписанной ссылки на скачивание
commerce-digital.download_limitint5нетЛимит числа скачиваний на одну выдачу
commerce-digital.auto_deliver_on_paymentbooltrueнетАвтовыдача сразу после PaymentSucceededkill-switch: выключение не останавливает модуль, только переводит выдачу в ручной режим (см. «Крайние случаи»)
commerce-digital.max_file_size_mbint2048нетМаксимальный размер загружаемого файла цифрового актива
commerce-digital.external_generator_rate_limit_per_minuteint30нетЛимит вызовов внешнего LicenseGenerator в минуту, если контракт резолвится во внешний сервис — защита от исчерпания тарифной квоты провайдера

Лимиты — явные настройки с дефолтами (§6 стандарта): достижение max_file_size_mb при загрузке в Filament — 422 с понятной ошибкой, не тихое обрезание файла; достижение external_generator_rate_limit_per_minute — задержка job (backoff в очереди), не потеря запроса на генерацию ключа.

API

МетодПутьДоступНазначение
GET/api/v1/account/digital-purchasesauthСписок цифровых покупок пользователя
GET/api/v1/account/digital-purchases/{id}/downloadauth (владелец)Получение актуальной подписанной ссылки
POST/api/v1/admin/digital-deliveries/{id}/reissueadmin (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-payments1, событиевходящее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/audit4, сервис-вызовисходящееперевыдача логируется с 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, дошло ли событие PaymentSucceededcms: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-backendhealth-чек диска, ручная проверка 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 ссылаются на снапшот версии на момент выдачи, не на live asset.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 запрещены

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