Пейволл больших файлов: 99₽/файл или подписка 299₽/мес (Фаза 1)

- _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 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-03 08:45:27 +00:00
parent e5c4d3c949
commit 836784e56f
2 changed files with 179 additions and 37 deletions

View File

@@ -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:<user_id>."""
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