From 836784e56fcc0ef16ed7942d58fa1ba0542e1f69 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 3 Jul 2026 08:45:27 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D0=B9=D0=B2=D0=BE=D0=BB=D0=BB=20?= =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B8=D1=85=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2:=2099=E2=82=BD/=D1=84=D0=B0=D0=B9=D0=BB=20?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20299=E2=82=BD/=D0=BC=D0=B5=D1=81=20(=D0=A4=D0=B0=D0=B7?= =?UTF-8?q?=D0=B0=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _finalize_submit держит большой файл (awaiting_payment) без активной подписки; account из cookie-сессии (Yandex/VK) или tg user_id - Оплата purpose=file (снимает задание с холда) / sub (+30 дней, снимает холд) - Фронт: карточка пейволла (99₽/подписка), обработка возврата, single+batch - Старый auth-баннер больших файлов заменён пейволлом - Включается флагом PAYWALL_ENABLED (в .env) Co-Authored-By: Claude Opus 4.8 --- app/main.py | 43 +++++++---- app/static/index.html | 173 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 179 insertions(+), 37 deletions(-) diff --git a/app/main.py b/app/main.py index 61714b7..befcd91 100644 --- a/app/main.py +++ b/app/main.py @@ -847,15 +847,18 @@ def _write_chunk_at(path: str, data: bytes, offset: int) -> None: def _finalize_submit(job_id, file_path, original_name, stitching, title, numeration, flip_side, color, pdfa, quality, - size_mb, pages, user_id, trusted=False, ocr=0, web_owner_id=None): + size_mb, pages, user_id, trusted=False, ocr=0, web_owner_id=None, + account_id=None): """Общий хвост постановки задания: проверка авторизации, запись в БД, _meta. trusted=True (бизнес-API по ключу) — пропускает пейволл. + account_id — аккаунт запроса (из сессии/TG) для проверки подписки. web_owner_id — TG id владельца для истории «Мои документы» (по умолчанию = user_id).""" # Пейволл: ≤50МБ и ≤100стр — бесплатно; больше — нужна активная подписка, # иначе задание держим (url_res_f='awaiting_payment') до разовой оплаты 99₽. + acc = account_id or _account_id(user_id) held = False if not trusted and (size_mb > AUTH_REQUIRED_MB or pages > AUTH_REQUIRED_PAGES): - if _db_sub_active(_account_id(user_id)): + if _db_sub_active(acc): pass # активная подписка — бесплатно elif PAYWALL_ENABLED: held = True # держим до оплаты 99₽ или подписки @@ -926,6 +929,7 @@ def _build_zip(items) -> str: @app.post("/api/submit") async def submit( + request: Request, file: UploadFile = File(...), stitching: str = Form("без сшивки"), title: int = Form(0), @@ -982,7 +986,7 @@ async def submit( return _finalize_submit( job_id, file_path, original_name, stitching, title, numeration, flip_side, color, pdfa, quality, size_mb, pages, user_id, - ocr=ocr, + ocr=ocr, account_id=_resolve_account(request, user_id), ) @@ -1009,6 +1013,7 @@ async def upload_chunk( @app.post("/api/submit-chunked") async def submit_chunked( + request: Request, upload_id: str = Form(...), filename: str = Form("document.pdf"), total_size: int = Form(0), @@ -1061,7 +1066,7 @@ async def submit_chunked( return _finalize_submit( job_id, file_path, original_name, stitching, title, numeration, flip_side, color, pdfa, quality, size_mb, pages, user_id, - ocr=ocr, + ocr=ocr, account_id=_resolve_account(request, user_id), ) @@ -1593,7 +1598,7 @@ def _db_record_payment(payment_id, amount, currency, status, purpose, api_key, e def _account_id(user_id) -> str | None: - """Внутренний id аккаунта. Пока источник — Telegram; позже yandex:/vk:/email:.""" + """Внутренний id аккаунта из Telegram user_id.""" try: uid = int(user_id or 0) except (TypeError, ValueError): @@ -1601,6 +1606,17 @@ def _account_id(user_id) -> str | None: return f"tg:{uid}" if uid else None +def _resolve_account(request, user_id=0) -> str | None: + """Аккаунт запроса: сначала cookie-сессия (Yandex/VK), иначе tg:.""" + try: + sess = _db_get_session(request.cookies.get(SESSION_COOKIE)) if request else None + except Exception: + sess = None + if sess and sess.get("account_id"): + return sess["account_id"] + return _account_id(user_id) + + def _db_sub_active(account_id: str | None) -> bool: if not account_id: return False @@ -1644,23 +1660,22 @@ def _db_mark_payment_paid(payment_id, purpose, api_key, extra=None): con.execute("UPDATE WEB_API_KEYS SET plan='business', paid_until=? WHERE api_key=?", (until, api_key)) con.commit() - # Разовая оплата файла → снимаем задание с холда (в очередь бота) - if purpose == "file": - job_id = extra.get("job_id") - if job_id and _db_get_url_res_f(job_id) == "awaiting_payment": - _db_set_url_res_f(job_id, "-") - # Подписка → продлеваем аккаунт + освобождаем его задания на холде + # Подписка → продлеваем аккаунт if purpose == "sub": acc = extra.get("account_id") if acc: _db_activate_sub(acc, SUB_DAYS) + # И file, и sub: если платёж привязан к заданию — снимаем его с холда + job_id = extra.get("job_id") + if job_id and _db_get_url_res_f(job_id) == "awaiting_payment": + _db_set_url_res_f(job_id, "-") except Exception as e: print(f"mark paid error: {e}") @app.post("/api/pay/create") -async def pay_create(payload: dict): - """Создаёт платёж ЮKassa. body: {amount, purpose, email?, api_key?} → confirmation_url.""" +async def pay_create(payload: dict, request: Request): + """Создаёт платёж ЮKassa. body: {amount, purpose, email?, api_key?, job_id?, user_id?} → confirmation_url.""" if not YOOKASSA_SHOP_ID or not YOOKASSA_SECRET_KEY: raise HTTPException(status_code=503, detail="Приём платежей не настроен.") if not isinstance(payload, dict): @@ -1669,7 +1684,7 @@ async def pay_create(payload: dict): email = (payload.get("email") or "").strip()[:128] api_key = (payload.get("api_key") or "").strip()[:80] or None job_id = (payload.get("job_id") or "").strip()[:128] or None - account_id = _account_id(payload.get("user_id")) + account_id = _resolve_account(request, payload.get("user_id")) if purpose == "api_pro": amount = API_PRO_PRICE_RUB diff --git a/app/static/index.html b/app/static/index.html index ce75f48..bb11cb4 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -1011,8 +1011,8 @@ - -
+ + + + +
@@ -1240,6 +1254,13 @@ pay_error: 'Не удалось создать платёж', pay_thanks: '✅ Спасибо за поддержку! Оплата прошла.', pay_canceled: 'Платёж отменён', + paywall_title: 'Файл больше бесплатного лимита', + paywall_desc: 'Бесплатно — до 50 МБ и 100 страниц. Для файлов больше — разовая оплата или подписка.', + paywall_pay_file: 'Оплатить файл', + paywall_pay_sub: 'Подписка', + paywall_month: 'мес', + paywall_cancel: 'Отмена', + paywall_need_login: 'Для подписки войдите в аккаунт', seo_title: 'Как работает pdf2scan', seo_step1_title: 'Загрузите файл', seo_step1_desc: 'PDF или Word — перетащите или выберите с устройства', @@ -1348,6 +1369,13 @@ pay_error: 'Failed to create payment', pay_thanks: '✅ Thank you for your support! Payment received.', pay_canceled: 'Payment canceled', + paywall_title: 'File exceeds the free limit', + paywall_desc: 'Free up to 50 MB and 100 pages. Larger files — one-time payment or subscription.', + paywall_pay_file: 'Pay for file', + paywall_pay_sub: 'Subscription', + paywall_month: 'mo', + paywall_cancel: 'Cancel', + paywall_need_login: 'Log in to subscribe', seo_title: 'How pdf2scan works', seo_step1_title: 'Upload your file', seo_step1_desc: 'PDF or Word — drag & drop or choose from device', @@ -1456,6 +1484,13 @@ pay_error: 'Zahlung konnte nicht erstellt werden', pay_thanks: '✅ Danke für die Unterstützung! Zahlung erhalten.', pay_canceled: 'Zahlung storniert', + paywall_title: 'Datei über dem Gratis-Limit', + paywall_desc: 'Kostenlos bis 50 MB und 100 Seiten. Größere Dateien — Einmalzahlung oder Abo.', + paywall_pay_file: 'Datei bezahlen', + paywall_pay_sub: 'Abo', + paywall_month: 'Mon', + paywall_cancel: 'Abbrechen', + paywall_need_login: 'Zum Abonnieren anmelden', seo_title: 'So funktioniert pdf2scan', seo_step1_title: 'Datei hochladen', seo_step1_desc: 'PDF oder Word — per Drag & Drop oder vom Gerät', @@ -1564,6 +1599,13 @@ pay_error: 'Échec de création du paiement', pay_thanks: '✅ Merci pour votre soutien ! Paiement reçu.', pay_canceled: 'Paiement annulé', + paywall_title: 'Fichier au-delà de la limite gratuite', + paywall_desc: 'Gratuit : 50 Mo et 100 pages max. Au-delà — paiement unique ou abonnement.', + paywall_pay_file: 'Payer le fichier', + paywall_pay_sub: 'Abonnement', + paywall_month: 'mois', + paywall_cancel: 'Annuler', + paywall_need_login: 'Connectez-vous pour vous abonner', seo_title: 'Comment fonctionne pdf2scan', seo_step1_title: 'Téléchargez votre fichier', seo_step1_desc: 'PDF ou Word — glisser-déposer ou choisir depuis l\'appareil', @@ -1672,6 +1714,13 @@ pay_error: 'Impossibile creare il pagamento', pay_thanks: '✅ Grazie per il supporto! Pagamento ricevuto.', pay_canceled: 'Pagamento annullato', + paywall_title: 'File oltre il limite gratuito', + paywall_desc: 'Gratis fino a 50 MB e 100 pagine. File più grandi — pagamento singolo o abbonamento.', + paywall_pay_file: 'Paga file', + paywall_pay_sub: 'Abbonamento', + paywall_month: 'mese', + paywall_cancel: 'Annulla', + paywall_need_login: 'Accedi per abbonarti', seo_title: 'Come funziona pdf2scan', seo_step1_title: 'Carica il file', seo_step1_desc: 'PDF o Word — trascina o scegli dal dispositivo', @@ -1780,6 +1829,13 @@ pay_error: 'No se pudo crear el pago', pay_thanks: '✅ ¡Gracias por tu apoyo! Pago recibido.', pay_canceled: 'Pago cancelado', + paywall_title: 'Archivo supera el límite gratis', + paywall_desc: 'Gratis hasta 50 MB y 100 páginas. Archivos mayores — pago único o suscripción.', + paywall_pay_file: 'Pagar archivo', + paywall_pay_sub: 'Suscripción', + paywall_month: 'mes', + paywall_cancel: 'Cancelar', + paywall_need_login: 'Inicia sesión para suscribirte', seo_title: 'Cómo funciona pdf2scan', seo_step1_title: 'Sube tu archivo', seo_step1_desc: 'PDF o Word — arrastra o elige desde el dispositivo', @@ -1888,6 +1944,13 @@ pay_error: 'Falha ao criar o pagamento', pay_thanks: '✅ Obrigado pelo apoio! Pagamento recebido.', pay_canceled: 'Pagamento cancelado', + paywall_title: 'Arquivo acima do limite grátis', + paywall_desc: 'Grátis até 50 MB e 100 páginas. Arquivos maiores — pagamento único ou assinatura.', + paywall_pay_file: 'Pagar arquivo', + paywall_pay_sub: 'Assinatura', + paywall_month: 'mês', + paywall_cancel: 'Cancelar', + paywall_need_login: 'Entre para assinar', seo_title: 'Como funciona o pdf2scan', seo_step1_title: 'Envie seu arquivo', seo_step1_desc: 'PDF ou Word — arraste ou escolha do dispositivo', @@ -2021,12 +2084,52 @@ } catch (e) { msg.textContent = t('pay_error'); btn.disabled = false; } } - // Возврат с ЮKassa: ?paid=... → опрашиваем статус и показываем результат + // ── Пейволл больших файлов ──────────────────────────────────────────────── + let paywallJob = null, paywallFilePrice = 99, paywallSubPrice = 299; + function showPaywall(jobId, filePrice, subPrice) { + paywallJob = jobId; + paywallFilePrice = filePrice || 99; + paywallSubPrice = subPrice || 299; + document.getElementById('paywall-file-price').textContent = paywallFilePrice; + document.getElementById('paywall-sub-price').textContent = paywallSubPrice; + const emailEl = document.getElementById('paywall-email'); + if (sessionUser && sessionUser.email && !emailEl.value) emailEl.value = sessionUser.email; + document.getElementById('progress-card').style.display = 'none'; + document.getElementById('result-card').style.display = 'none'; + submitBtn.style.display = 'none'; + document.getElementById('paywall-card').style.display = 'block'; + } + async function _payViaKassa(purpose) { + const email = (document.getElementById('paywall-email').value || '').trim(); + const msg = document.getElementById('paywall-msg'); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { msg.textContent = t('pay_need_email'); return; } + const body = { purpose, job_id: paywallJob, email }; + if (tgUser) body.user_id = tgUser.user_id || tgUser.id || 0; + const fb = document.getElementById('paywall-file-btn'), sb = document.getElementById('paywall-sub-btn'); + fb.disabled = true; sb.disabled = true; msg.textContent = t('pay_creating'); + try { + const r = await fetch('/api/pay/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const d = await r.json(); + if (d.confirmation_url) { + localStorage.setItem('pdf2scan_pay_id', d.payment_id); + localStorage.setItem('pdf2scan_pay_job', paywallJob || ''); + location.href = d.confirmation_url; + } else { msg.textContent = d.detail || t('pay_error'); fb.disabled = false; sb.disabled = false; } + } catch (e) { msg.textContent = t('pay_error'); fb.disabled = false; sb.disabled = false; } + } + function payForFile() { _payViaKassa('file'); } + function payForSub() { + if (!tgUser && !sessionUser) { document.getElementById('paywall-msg').textContent = t('paywall_need_login'); return; } + _payViaKassa('sub'); + } + + // Возврат с ЮKassa: ?paid=... → опрашиваем статус, для file/sub запускаем обработку (function handlePayReturn() { const params = new URLSearchParams(location.search); if (!params.has('paid')) return; + const paid = params.get('paid'); const pid = localStorage.getItem('pdf2scan_pay_id'); - // чистим URL, чтобы не срабатывало при обновлении + const payJob = localStorage.getItem('pdf2scan_pay_job'); try { history.replaceState({}, '', location.pathname); } catch (_) {} if (!pid) return; let tries = 0; @@ -2037,11 +2140,21 @@ const d = await r.json(); if (d.status === 'succeeded') { localStorage.removeItem('pdf2scan_pay_id'); - showInfo(t('pay_thanks')); return; + localStorage.removeItem('pdf2scan_pay_job'); + if ((paid === 'file' || paid === 'sub') && payJob) { + const pc = document.getElementById('paywall-card'); if (pc) pc.style.display = 'none'; + currentJobId = payJob; batchMode = false; + setUiState('processing'); startPolling(); + } + showInfo(t('pay_thanks')); + return; + } + if (d.status === 'canceled') { + localStorage.removeItem('pdf2scan_pay_id'); localStorage.removeItem('pdf2scan_pay_job'); + showError(t('pay_canceled')); return; } - if (d.status === 'canceled') { localStorage.removeItem('pdf2scan_pay_id'); showError(t('pay_canceled')); return; } } catch (_) {} - if (tries <= 20) setTimeout(check, 2000); + if (tries <= 25) setTimeout(check, 2000); }; check(); })(); @@ -2442,11 +2555,18 @@ el.innerHTML = `${t('st_queued')}${j.queuePos ? ' · ' + j.queuePos : ''}`; } else if (j.status === 'uploading') { el.innerHTML = `${t('st_uploading')}`; + } else if (j.status === 'awaiting_payment') { + el.innerHTML = `💳 ${(j.filePrice||99)} ₽`; } else { el.innerHTML = ``; } } + function payBatchRow(idx) { + const j = batchJobs.find(x => x.idx === idx); + if (j && j.jobId) showPaywall(j.jobId, j.filePrice, j.subPrice); + } + async function handleFile(f) { selectedFile = f; fileBadge.textContent = f.name; @@ -2477,23 +2597,18 @@ } function renderAuthBanner() { + // Старый баннер авторизации больше не используется — гейт больших файлов + // теперь на сервере (пейволл: оплата 99₽ или подписка после отправки). const banner = document.getElementById('auth-banner'); - if (authRequired && !tgUser) { - banner.style.display = 'flex'; - } else { - banner.style.display = 'none'; - } + if (banner) banner.style.display = 'none'; } function updateSubmitBtn() { - renderAuthBanner(); // держим баннер и кнопку в одном состоянии (одинаковое условие) + renderAuthBanner(); const hasSelection = batchMode ? batchJobs.length > 0 : !!selectedFile; if (!hasSelection) { submitBtn.disabled = true; submitBtn.textContent = t('btn_select_file'); - } else if (authRequired && !tgUser) { - submitBtn.disabled = true; - submitBtn.textContent = t('btn_auth_required'); } else { submitBtn.disabled = false; submitBtn.textContent = batchMode ? t('btn_process_n')(batchJobs.length) : t('btn_process'); @@ -2581,8 +2696,7 @@ if (tgUser) ff.append('user_id', tgUser.user_id || tgUser.id || 0); const fr = await fetchRetry('/api/submit-chunked', { method: 'POST', body: ff }, 3); if (!fr.ok) throw new Error(await _detailOf(fr)); - const data = await fr.json(); - return data.job_id; + return await fr.json(); // {job_id, requires_payment?, file_price?, sub_price?} } async function submitBatch() { @@ -2619,8 +2733,14 @@ async function uploadOne(j, s) { j.status = 'uploading'; j.error = null; updateRow(j); try { - j.jobId = await uploadFileChunked(j.file, s, null); - j.status = 'queued'; j.error = null; updateRow(j); + const data = await uploadFileChunked(j.file, s, null); + j.jobId = data.job_id; + if (data.requires_payment) { + j.status = 'awaiting_payment'; j.filePrice = data.file_price; j.subPrice = data.sub_price; + } else { + j.status = 'queued'; + } + j.error = null; updateRow(j); } catch (e) { j.status = 'error'; j.error = (e && e.message) ? e.message : t('err_network'); @@ -2773,6 +2893,7 @@ const data = JSON.parse(xhr.responseText); currentJobId = data.job_id; document.getElementById('progress-speed').textContent = ''; + if (data.requires_payment) { showPaywall(data.job_id, data.file_price, data.sub_price); return; } setUiState('processing'); startPolling(); if (filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10) { @@ -2809,13 +2930,15 @@ document.getElementById('progress-status').textContent = t('progress_loading'); document.getElementById('progress-speed').textContent = ''; try { - currentJobId = await uploadFileChunked(selectedFile, collectSettings(), (up, tot) => { + const data = await uploadFileChunked(selectedFile, collectSettings(), (up, tot) => { const pct = Math.round(up / tot * 100); setProgressPct(pct); document.getElementById('progress-status').textContent = pct < 100 ? t('progress_loading') : t('progress_uploaded'); }); + currentJobId = data.job_id; document.getElementById('progress-speed').textContent = ''; + if (data.requires_payment) { showPaywall(data.job_id, data.file_price, data.sub_price); return; } setUiState('processing'); startPolling(); if (filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10) showDonateCard(true); @@ -2827,7 +2950,7 @@ function onUploadError(msg) { showError('⚠ ' + msg); document.getElementById('progress-card').style.display = 'none'; - submitBtn.disabled = !selectedFile || (authRequired && !tgUser); + submitBtn.disabled = !selectedFile; submitBtn.textContent = t('btn_process'); } @@ -2881,6 +3004,10 @@ stopProgressSim(); setProgressPct(100); setTimeout(() => setUiState('done'), 300); + } else if (data.status === 'awaiting_payment') { + clearInterval(pollInterval); + stopProgressSim(); + showPaywall(currentJobId, data.file_price, data.sub_price); } else if (data.status === 'error') { clearInterval(pollInterval); stopProgressSim(); @@ -2919,7 +3046,7 @@ submitBtn.textContent = t('btn_process'); showDonateCard(filePages > DONATE_PAGE_THRESHOLD || fileSizeMb > 10); } else { - submitBtn.disabled = !selectedFile || (authRequired && !tgUser); + submitBtn.disabled = !selectedFile; submitBtn.textContent = selectedFile ? t('btn_process') : t('btn_select_file'); progressCard.style.display = 'none'; }