Ниже — практические рекомендации и шаблоны кода для надёжной автоматической выдачи цифровых товаров (ключи/лицензии/файлы) на чистом 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).
Напишите, что из этого нужно в первую очередь — пришлю пример кода.