Реалізація обмежень завантажень (за кількістю/часом) для цифрових товарів
Обмеження на завантаження — не просто захист від нелегального розповсюдження, а й комерційний інструмент: різні тарифи можуть надавати різну кількість завантажень або строк доступу. Реалізація має обробляти граничні випадки: одночасні клики, переконнекти, закритий браузер посередині завантаження.
Типи обмежень
| Тип | Опис | Приклад |
|---|---|---|
| За кількістю | 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_count} з {$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 запити — механізм докачування. Браузер надсилає заголовок 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
// Приклад: продовження строку через artisan-команду або 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 дні.







