Как на чистом PHP реализовать автоматическую выдачу цифровых товаров покупателю после успешной оплаты?

Я разрабатываю движок маркетплейса цифровых товаров (ключи, лицензии, файлы). Нужно реализовать надёжную автоматическую выдачу товара сразу после подтверждения оплаты. Планирую использовать связку ЮKassa + PHP + cron-задачи. <br/> <br/> Меня интересуют рекомендации по следующим вопросам: <br/> <br/> 1) Как оптимально хранить пулы ключей в базе данных и безопасно выдавать их только оплатившим пользователям? <br/> 2) Как предотвратить двойную выдачу при параллельных запросах или при повторных уведомлениях от платёжного шлюза? <br/> 3) Какие существуют best practices для защиты файлов от прямого скачивания по ссылке? <br/> <br/> Готового решения пока не нашёл; для вдохновения смотрел примеры вроде <a href="https://web-ds.ru/index.php" rel="nofollow">web-ds.ru</a>. Буду благодарен за советы, ссылки на документацию и примеры кода.
  • 20 апреля 2026 г. 19:01
Ответы на вопрос 3
Ниже — практические рекомендации и шаблоны кода для надёжной автоматической выдачи цифровых товаров (ключи/лицензии/файлы) на чистом PHP с учётом: webhook от платёжного шлюза (ЮKassa), cron‑задач для компенсации, защиты от повторной/параллельной выдачи и защиты файлов от прямого скачивания.

Ключевые принципы
- Все события оплаты обрабатывать идемпотентно (один и тот же payment_id приводил к одному результату).
- Выдачу ключа делать атомарно в БД (транзакция / блокировка строк / атомарный UPDATE).
- Хранить ключи и ссылки так, чтобы третьи лица не могли их получить напрямую (шифрование в БД, ссылки с одноразовыми токенами/еепросроченными presigned URL).
- Логировать и иметь cron, который повторно пытается выдать товары для оплаченных заказов, если webhook не дошёл/не обработан.

1) Как хранить пулы ключей и как безопасно выдавать
Рекомендуемая структура таблиц (пример для MySQL/InnoDB):

- products (id, name, ...)
- keys (id, product_id, key_enc, status, reserved_until, order_id, allocated_at, meta)
  - key_enc — зашифрованное значение ключа (AES-256 с серверным ключом)
  - status — enum('available','reserved','sold','revoked')
  - reserved_until — datetime (если хотите резерв на короткое время)
  - order_id — ссылка на заказ/покупку
  - allocated_at — дата выдачи
- orders (id, user_id, payment_id, amount, status, created_at, delivered_at)
- deliveries (id, order_id, key_id, download_token, token_expires, downloads_left, created_at)
- payments_notifications (id, payment_id, event_id, status, processed_at)

Почему:
- Храним ключи в зашифрованном виде (key_enc). Даже при утечке БД ключи не в открытом виде.
- Статусы позволяют контролировать жизненный цикл ключа.
- Таблица deliveries фиксирует выданный ключ и параметры доступа (токен, лимит скачиваний).

Шифрование ключей
- Используйте серверный ключ (env), AES‑GCM/AES‑CBC с HMAC для целостности.
- Никогда не храните master key в репозитории; используйте vault/HSM если возможно.
- Можно хранить в БД только часть ключа (mask) для отладки/поиска, а полное значение — в encrypted blob.

Пример SQL (упрощённо):
CREATE TABLE keys (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  product_id BIGINT NOT NULL,
  key_enc VARBINARY(1024) NOT NULL,
  status ENUM('available','reserved','sold','revoked') NOT NULL DEFAULT 'available',
  order_id BIGINT NULL,
  allocated_at DATETIME NULL,
  INDEX(product_id, status)
);

2) Как предотвратить двойную выдачу при параллельных запросах/повторных уведомлениях
Главная идея — атомарность и идемпотентность.

a) Идемпотентность webhook'а
- Сохраняйте все приходящие уведомления (например payments_notifications с уникальным event_id/payment_id).
- При получении webhook проверяйте: если запись с таким event_id уже обработана — вернуть 200 и ничего не делать.
- При возможности перед выдачей получать статус платежа по API YooKassa (вдобавок к webhook), чтобы убедиться, что платёж действительно подтверждён (paid).

b) Атомарная выдача ключа — блокировка или атомарный UPDATE
Два часто используемых подхода:

Подход A — SELECT ... FOR UPDATE (реляционный lock)
- Открыть транзакцию.
- SELECT id, key_enc FROM keys WHERE product_id=? AND status='available' LIMIT 1 FOR UPDATE;
- Если есть строка — UPDATE keys SET status='sold', order_id=?, allocated_at=NOW() WHERE id=?;
- Записать delivery/лог и commit.
Этот способ надёжен для InnoDB и PostgreSQL.

Подход B — атомарный UPDATE + affected_rows (без FOR UPDATE)
- UPDATE keys SET status='sold', order_id=?, allocated_at=NOW()
  WHERE id = (
    SELECT id FROM keys WHERE product_id=? AND status='available' LIMIT 1
  ) LIMIT 1;
  Проверяете affected_rows == 1. Получаете id и потом SELECT ключ по id.
(У MySQL бывают нюансы с подзапросами; можно делать UPDATE ... WHERE product_id=? AND status='available' LIMIT 1 и затем SELECT LAST_INSERT_ID-подобно — но лучше FOR UPDATE.)

Также: поставьте уникальные ограничения, которые защищают от двойного присваивания (например delivery уникален по order_id), и обрабатывайте ошибку уникальности как признак того, что другой процесс уже выдал товар.

Пример PHP/PDO (упрощённо) — SELECT FOR UPDATE:
<?php
$pdo->beginTransaction();
// 1) пометьте платеж как "обрабатывается" (чтобы другие обработчики не брались)
$stmt = $pdo->prepare("SELECT id, key_enc FROM keys WHERE product_id=? AND status='available' LIMIT 1 FOR UPDATE");
$stmt->execute([$productId]);
$key = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$key) {
    $pdo->rollBack();
    // нет доступных ключей
    return;
}
// 2) обновляем статус
$update = $pdo->prepare("UPDATE keys SET status='sold', order_id=?, allocated_at=NOW() WHERE id=?");
$update->execute([$orderId, $key['id']]);
// 3) создаём запись delivery, создаём токен для скачивания
// 4) помечаем заказ как delivered и коммитим
$pdo->commit();
// расшифруем ключ и отдадим покупателю по защищённому каналу
?>

c) Обработка повторных вебхуков:
- Если webhook пришёл второй раз — при старте обработчика делаете SELECT payments WHERE payment_id=?.
- Если запись уже помечена как processed — ответ 200.
- Если она в состоянии processing (только что начали), вы можете вернуть 200 или 202 в зависимости от логики, но лучше позволить второй обработке завершиться быстро, не выполнять выдачу ещё раз.

d) Механизм резервирования
- Можно временно помечать ключ как reserved на N минут (reserved_until), чтобы покупка могла завершиться, а в случае сбоев резерв снимался автоматически cron‑ом.

3) Best practices для защиты файлов от прямого скачивания
Варианты защиты (лучше комбинировать):

a) Хранение вне web-root
- Файлы хранятся в каталоге, недоступном напрямую через HTTP, или в частном S3-бакете.

b) Выдача через контроллер (PHP) с проверками
- Ссылка ведёт на endpoint типа /download?token=XXX
- Проверяете token в БД: принадлежит ли он заказу/пользователю, не истёк ли, сколько раз скачан.
- После проверки — отдать файл через PHP (readfile) либо через X-Sendfile / X-Accel-Redirect (рекомендуется для производительности).

X-Sendfile / X-Accel-Redirect
- Nginx: используйте X-Accel-Redirect — сервер отдаёт файл, PHP лишь аутентифицирует.
- Apache: X-Sendfile модуль.
Это минимизирует нагрузку PHP и не раскрывает реального пути.

c) Одноразовые/временные ссылки
- Генерируйте одноразовый токен (crypto-random), сохраняйте в deliveries с expires_at и downloads_left.
- Ссылки действуют короткое время (например 10–60 минут) или до N скачиваний (например 3).
- Токен нельзя предсказать.

d) Подпись/Presigned URL (если S3)
- Генерируйте presigned URL с коротким TTL через SDK (AWS S3, MinIO).
- Подпись генерируется сервером после проверки прав пользователя.

e) Заголовки безопасности
- Отдавайте Content-Disposition: attachment; filename="..." и Content-Type корректный.
- Устанавливайте заголовок Cache-Control в зависимости от требований.
- Не передавайте открытые ссылки в письмах — помещайте ссылку на вашу защищённую страницу, которая авторизует пользователя и затем выдаёт токен.

f) Ограничение доступа
- Проверяйте совпадение user_id в session/authorization и order.user_id.
- Логируйте все выдачи, IP, user-agent, timestamp.

Пример простого handler-а выдачи файла с X-Accel-Redirect (Nginx)
PHP:
<?php
// /download.php?token=... 
$token = $_GET['token'] ?? '';
$row = findDeliveryByToken($token); // проверка в БД: expires, downloads_left, order->user и т.д.
if (!$row) {
  http_response_code(404); exit;
}
// декрементируем downloads_left атомарно
$pdo->beginTransaction();
$dec = $pdo->prepare("UPDATE deliveries SET downloads_left = downloads_left - 1 WHERE id = ? AND downloads_left > 0");
$dec->execute([$row['id']]);
if ($dec->rowCount() == 0) {
    $pdo->rollBack();
    http_response_code(403); exit;
}
$pdo->commit();
// отдать заголовок для nginx, путь — внутренний путь, не публичный
header("X-Accel-Redirect: /protected_files/{$row['file_path']}");
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="'.$row['filename'].'"');
exit;
?>

Nginx config:
location /protected_files/ {
    internal;
    alias /var/storage/private_files/;
}

Дополнительные рекомендации
- Логи и мониторинг: ведите лог успешных/неуспешных доставок, число оставшихся ключей в пулах (чтобы пополнять).
- Уведомления и retry: webhook должен отвечать 200 только если успешно обработан; иначе платёжный шлюз повторит. Но лучше сразу возвращать 200 после постановки задачи в очередь, а обработку делать асинхронно.
- Очередь: если выдача занимает время (запрос к API поставщика ключей), ставьте задачу в очередь/DB и обрабатывайте воркер/cron, чтобы webhook отрабатывал быстро.
- Cron резерва/компенсации: периодически (каждую минуту/пару минут) сканируйте orders с paid=true и delivered=false и пытайтесь выполнить выдачу (в случае пропущенных webhook'ов).
- Безопасность API и webhook: проверяйте подпись/токен webhook (YooKassa предоставляет способы подтверждения) и используйте HTTPS.
  - Сохраняйте payment_id и event_id; помечайте уведомления как processed.
  - Для надёжности дополнительно вызывайте API YooKassa (получить статус платежа) перед выдачей.

YooKassa — что учесть
- Используйте официальную документацию/SDK: https://yookassa.ru/docs/
- Проверяйте статус платежа через API (payment.status) — ищите "succeeded"/"succeeded" / "paid" в актуальной терминологии.
- Реализуйте в webhook быструю проверку и постановку в очередь/вызов обработчика.

Пример обработчика webhook (логика)
1. Получили webhook (post JSON).
2. Валидация подписи/тела.
3. Если event_id уже в payments_notifications => return 200.
4. Вставить запись payments_notifications с status='processing'.
5. Запросить у YooKassa по payment_id реальный статус (опционально).
6. Если paid:
   - В транзакции пометить order как paid (если ещё не).
   - Попытаться выдать ключ (SELECT FOR UPDATE -> UPDATE keys -> INSERT delivery).
   - Пометить payments_notifications processed.
7. Commit.
8. Отправить письмо покупателю с ссылкой/токеном.

Пример обработчика webhook — упрощённый PHP (псевдокод)
<?php
$input = json_decode(file_get_contents('php://input'), true);
$paymentId = $input['object']['id'] ?? null;
$eventId = $input['id'] ?? null;
// 1) check signature...
if (notification_exists($eventId)) { http_response_code(200); exit; }
$pdo->beginTransaction();
insert_notification($eventId, 'processing');
$status = checkPaymentStatusViaAPI($paymentId);
if ($status === 'succeeded') {
    if (!order_already_delivered($orderId)) {
        // allocate key atomically (см. выше)
        allocateKeyToOrder($pdo, $orderId, $productId);
    }
}
mark_notification_processed($eventId);
$pdo->commit();
http_response_code(200);
?>

Заключение — практические шаги для реализации
1. Спроектировать таблицы: keys, orders, deliveries, payments_notifications.
2. Реализовать шифрование ключей при записи/чтении.
3. Сделать webhook‑endpoint: проверка подписи, запись event, вызов атомарной процедуры выдачи.
4. В выдаче использовать SELECT FOR UPDATE / транзакции либо атомарный UPDATE.
5. Добавить cron/воркер для повторных попыток выдачи и очистки резервов.
6. Для файлов — хранить вне web-root, отдавать через X-Accel-Redirect/пресайнд URL или через PHP с одноразовыми токенами.
7. Логировать, мониторить запасы ключей и ошибки выдачи.

Полезные ссылки
- YooKassa docs: https://yookassa.ru/docs/ (ознакомьтесь с разделом notifications/webhooks и payments API)
- X-Accel-Redirect (nginx): https://nginx.org/en/docs/http/ngx_http_core_module.html#internal
- X-Sendfile (Apache): https://tn123.org/mod_xsendfile/
- AWS S3 presigned URLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
- Общие best practices по idempotency/webhooks: статьи про надёжную обработку webhook (поиск по "webhook idempotency design")

Если хотите, могу:
- прислать готовый пример кода webhook handler + функция allocateKeyToOrder с учётом транзакций и расшифровки ключа;
- предложить конкретную реализацию схемы БД (SQL DDL);
- показать пример шифрования/дешифрования ключей на PHP (openssl с AES‑GCM).

Напишите, что из этого нужно в первую очередь — пришлю пример кода.
у нас в похожем проекте лечилось так: webhook сразу делаешь идемпотентным — INSERT processed_payments(payment_id) с UNIQUE key, дубль = уже обработано, шлёшь 200 и уходишь. Для выдачи ключа без гонки — одним запросом: <pre><code>UPDATE digital_keys SET status='issued', user_id=? WHERE status='available' LIMIT 1</code></pre> , смотришь affected_rows. Файлы отдавай через PHP-скрипт с проверкой сессии, без прямых путей в webroot — стандартный readfile() после auth-check.
Избежать дубля довольно просто, уникальный ключ заказа, uuid, колбэк от платёжки потом его обратно передаст, ну и в юкассе есть Idempotency-Key от двойного списания, но зачем? Ещё можно использовать двухстадийную систему, например когда клиент перешел по ссылке за ключиком запускается вторая стадия, т.е. списание. Если за ключом ни кто не пришёл, холдирование само снимется через какое-то время. <br/> <br/> Зачем вообще выдавать файлы не понятно, если нужно выдавать ключи, а этом можно делать по уникальной ссылке, даже без авторизации, которая живёт, скажем, сутки, может больше. Как делает тот-же гугл спокойно даёт доступ "для всех у кого есть ссылка" потому что подобрать её практически невозможно, по сути это такое-же зашифрованный hash объекта, как например jwt токен.
Похожие вопросы