Реализация продажи онлайн-курсов (доступ после оплаты) на сайте
Платформа продажи курсов — это не «добавить товар в WooCommerce». Это связка из платёжного шлюза, системы управления доступом, видеохостинга и прогресс-трекинга. Каждый слой имеет собственную логику отказа, и ошибка в любом из них означает либо потерю денег, либо утечку контента к неоплатившим пользователям.
Архитектурный скелет
Типовая схема выглядит так:
[Пользователь] → [Checkout] → [Payment Gateway]
↓
[Webhook Handler]
↓
[Enrollment Service] → [DB: enrollments]
↓
[Access Control Layer]
↓
[LMS / Video Delivery / Downloads]
Критически важен webhook handler — именно он создаёт запись о доступе после подтверждения оплаты от шлюза. Попытка выдать доступ синхронно в момент редиректа после оплаты — классическая ошибка, приводящая к гонкам (race conditions) при сбоях сети.
Платёжные шлюзы и интеграция
Stripe — предпочтительный вариант для международных платежей. Используем stripe.checkout.sessions.create с mode: 'payment' или mode: 'subscription' для подписочной модели.
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: 'usd',
product_data: { name: course.title },
unit_amount: course.price_cents,
},
quantity: 1,
}],
metadata: { course_id: course.id, user_id: user.id },
success_url: `${BASE_URL}/courses/${course.slug}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/courses/${course.slug}`,
});
После оплаты Stripe отправляет событие checkout.session.completed на webhook-эндпоинт. Там валидируем подпись (stripe.webhooks.constructEvent), извлекаем metadata.course_id и metadata.user_id, создаём enrollment.
Для рунета подключаем ЮKassa (YooKassa) или Robokassa. Оба работают через аналогичную схему: уведомление о платеже → проверка подписи → выдача доступа.
Idempotency: webhook может прийти дважды. Enrollment-запись создаётся с уникальным индексом по (user_id, course_id) или по payment_id, второй вызов просто возвращает существующую запись.
Управление доступом
Таблица enrollments:
| Поле | Тип | Описание |
|---|---|---|
| id | uuid | первичный ключ |
| user_id | bigint | FK → users |
| course_id | bigint | FK → courses |
| payment_id | varchar | ID транзакции из шлюза |
| expires_at | timestamp | NULL = бессрочно |
| status | enum | active / suspended / refunded |
| created_at | timestamp | дата покупки |
Middleware на каждом защищённом маршруте проверяет наличие активной записи:
// Laravel Gate
Gate::define('access-course', function (User $user, Course $course) {
return $user->enrollments()
->where('course_id', $course->id)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->exists();
});
Видеодоставка и защита контента
Прямые ссылки на видео нельзя давать пользователям — они расшарятся. Варианты:
Signed URLs (AWS S3 / CloudFront):
$url = $s3->createPresignedRequest(
$s3->getCommand('GetObject', [
'Bucket' => 'courses-bucket',
'Key' => "courses/{$courseId}/lesson-{$lessonId}.mp4",
]),
'+2 hours'
)->getUri();
Ссылка живёт 2 часа и привязана к конкретному файлу. После истечения — 403.
Vimeo Private / Mux: для продакшена с большим трафиком лучше использовать специализированные видеоплатформы. Mux предоставляет адаптивный стриминг (HLS), аналитику просмотров и защиту через signed playback tokens:
const playbackId = lesson.mux_playback_id;
const token = await signMuxToken(playbackId, 'video', {
expiration: '2h',
params: { user_id: userId },
});
const src = `https://stream.mux.com/${playbackId}.m3u8?token=${token}`;
Для PDF/EPUB — аналогично: генерируем signed URL на скачивание, логируем каждую загрузку в content_access_logs.
Прогресс и сертификаты
Прогресс по урокам хранится в lesson_progress(user_id, lesson_id, completed_at, watch_percent). Фронтенд отправляет события завершения:
// Каждые 30 секунд или при pause/end
videoPlayer.on('timeupdate', debounce(() => {
api.post('/progress', {
lesson_id: lessonId,
watch_percent: Math.round((player.currentTime / player.duration) * 100),
});
}, 5000));
Сертификат генерируется автоматически, когда watch_percent >= 80 для всех уроков курса. Для генерации PDF используем puppeteer (рендер HTML-шаблона) или PDFKit для простых случаев.
Пробный доступ (preview)
Первые 1-2 урока обычно открыты без оплаты. Флаг is_free_preview на уровне урока, проверка в middleware:
if ($lesson->is_free_preview || Gate::allows('access-course', $course)) {
return $next($request);
}
return redirect()->route('course.buy', $course);
Возвраты
При возврате платежа Stripe отправляет charge.refunded. Меняем enrollments.status = 'refunded', пользователь теряет доступ мгновенно. Для ЮKassa — аналогичный webhook payment.canceled.
Сроки реализации
| Этап | Время |
|---|---|
| Базовая интеграция Stripe + enrollment | 2–3 дня |
| Защищённая видеодоставка (S3 signed URL) | 1–2 дня |
| Прогресс-трекинг + UI прогресс-бара | 1–2 дня |
| Сертификаты (PDF-генерация) | 1 день |
| Интеграция ЮKassa / второй шлюз | 1–2 дня |
| Административный дашборд (enrollments, revenue) | 2–3 дня |
Итого минимальная рабочая версия — 7–10 рабочих дней.
Типичные ошибки
Выдача доступа до подтверждения от шлюза. Пользователь нажал кнопку оплаты → попал на success-страницу → получил доступ. Платёж при этом мог не пройти. Доступ выдаётся только через webhook.
Хранение видео на том же сервере что и приложение. Bandwidth убьёт сервер при 50+ одновременных просмотрах. Видео — только в объектном хранилище или у специализированного провайдера.
Отсутствие логов доступа. При спорной ситуации с пользователем («я заплатил, доступ не дали») нет возможности восстановить цепочку событий. Логировать payment_id, webhook timestamp, enrollment_created_at обязательно.







