Реализация ограничения скачиваний (по количеству/времени) для цифровых товаров
Ограничения на скачивание — не просто защита от нелегального распространения, но и коммерческий инструмент: разные тарифы могут предоставлять разное количество скачиваний или срок доступа. Реализация должна обрабатывать пограничные случаи: одновременные клики, переподключения, закрытый браузер в середине загрузки.
Типы ограничений
| Тип | Описание | Пример |
|---|---|---|
| По количеству | N скачиваний на одну покупку | 3 скачивания |
| По времени | Доступ до определённой даты | 30 дней после оплаты |
| Комбинированный | И то, и другое | 5 скачиваний или 90 дней |
| По IP | Только с зарегистрированного IP | Корпоративные лицензии |
| По устройствам | Привязка к fingerprint | Для ПО |
Модель ограничений
Schema::create('digital_order_downloads', function (Blueprint $table) {
$table->id();
$table->foreignId('order_item_id')->constrained();
$table->foreignId('digital_product_id')->constrained();
$table->string('token', 64)->unique();
$table->integer('downloads_count')->default(0);
$table->integer('downloads_limit')->nullable(); // NULL = без лимита
$table->timestamp('expires_at')->nullable(); // NULL = бессрочно
$table->boolean('is_revoked')->default(false); // ручная блокировка
$table->timestamps();
$table->index(['token', 'is_revoked']);
});
Сервис проверки ограничений
class DownloadLimitGuard
{
/**
* Проверить доступность скачивания и вернуть причину отказа (если есть)
*/
public function check(DigitalOrderDownload $download): DownloadCheckResult
{
if ($download->is_revoked) {
return DownloadCheckResult::denied('revoked', 'Доступ отозван');
}
if ($download->expires_at && $download->expires_at->isPast()) {
return DownloadCheckResult::denied(
'expired',
'Срок действия ссылки истёк ' . $download->expires_at->format('d.m.Y'),
);
}
if ($download->downloads_limit !== null
&& $download->downloads_count >= $download->downloads_limit) {
return DownloadCheckResult::denied(
'limit_reached',
"Лимит скачиваний исчерпан ({$download->downloads_limit} из {$download->downloads_limit})",
);
}
return DownloadCheckResult::allowed(
remainingDownloads: $download->downloads_limit !== null
? $download->downloads_limit - $download->downloads_count
: null,
expiresAt: $download->expires_at,
);
}
}
Атомарный инкремент счётчика
Проблема: два одновременных запроса могут оба пройти проверку лимита до того, как счётчик обновится. Решение — пессимистическая блокировка строки:
class RecordDownloadAction
{
public function execute(DigitalOrderDownload $download, Request $request): void
{
DB::transaction(function () use ($download, $request) {
// Блокируем строку для обновления — SELECT ... FOR UPDATE
$locked = DigitalOrderDownload::lockForUpdate()->findOrFail($download->id);
// Повторная проверка лимита внутри транзакции
if ($locked->downloads_limit !== null
&& $locked->downloads_count >= $locked->downloads_limit) {
throw new DownloadLimitExceededException();
}
$locked->increment('downloads_count');
DownloadEvent::create([
'digital_order_download_id' => $locked->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'referer' => $request->header('Referer'),
'downloaded_at' => now(),
]);
});
}
}
Обработка прерванных загрузок
HTTP Range requests — механизм докачки. Браузер или загрузчик отправляет заголовок Range: bytes=1048576-, запрашивая продолжение. Если каждый Range-запрос считать отдельным скачиванием, лимит исчерпается при одной загрузке крупного файла.
Решение: считать скачиванием только первый запрос без Range-заголовка или запрос с Range: bytes=0-:
public function download(string $token, Request $request): Response
{
$download = DigitalOrderDownload::where('token', $token)->firstOrFail();
$guard = app(DownloadLimitGuard::class);
$result = $guard->check($download);
if (!$result->isAllowed()) {
return response()->view('digital.download-denied', ['reason' => $result->reason], 403);
}
$rangeHeader = $request->header('Range');
$isFirstRequest = !$rangeHeader || $rangeHeader === 'bytes=0-';
if ($isFirstRequest) {
app(RecordDownloadAction::class)->execute($download, $request);
}
return $this->streamFile($download->digitalProduct);
}
Конфигурация лимитов по тарифу
Разные продукты могут иметь разные лимиты по умолчанию, а тариф при покупке переопределяет их:
// digital_products.download_limit — дефолтный лимит для продукта
// Переопределяется при создании DigitalOrderDownload в зависимости от тарифа
class CreateDigitalDownloadAction
{
public function execute(OrderItem $item): DigitalOrderDownload
{
$dp = $item->product->digitalProduct;
$plan = $item->plan_id ? Plan::find($item->plan_id) : null;
$limit = $plan?->download_limit ?? $dp->download_limit;
$validityDays = $plan?->validity_days ?? $dp->validity_days;
return DigitalOrderDownload::create([
'order_item_id' => $item->id,
'digital_product_id' => $dp->id,
'token' => bin2hex(random_bytes(32)),
'downloads_limit' => $limit,
'expires_at' => $validityDays ? now()->addDays($validityDays) : null,
]);
}
}
Административный контроль
В панели управления доступны действия:
- Сбросить счётчик — восстановить лимит при технической проблеме у покупателя
-
Продлить срок — изменить
expires_atвручную -
Отозвать доступ — установить
is_revoked = true -
Добавить скачиваний — увеличить
downloads_limit
// Пример: продление срока через артисан-команду или API
$download->update(['expires_at' => $download->expires_at->addDays(30)]);
Уведомления об истечении
За 3 дня до истечения срока отправляется email-напоминание:
$schedule->command('digital:notify-expiring --days=3')->dailyAt('10:00');
Сроки
Базовые ограничения (счётчик + срок) с атомарным инкрементом — 2–3 рабочих дня. Обработка Range-запросов, ограничения по IP/устройству, тарифные планы — ещё 2–3 дня.







