Files
2026-09-04 13:05:54 +03:30

240 lines
13 KiB
Python

"""
اتصال به API «پرداخت درون‌برنامه‌ای» کافه‌بازار (devapi v2) — گرفتن/تمدید
access_token و تایید خریدها.
دو روشِ کاملا متفاوت برای احراز هویت با همین API وجود دارد — قاطی‌کردنشان
دقیقا همان چیزی است که باعث خطا می‌شود، پس اینجا صریح از هم جدا شده‌اند:
۱) «روش جدید» (پیشخان → اپ → «API پیشخان بازار» → «دریافت توکن جدید»):
یک توکن ثابت (JWT، عملا بدون انقضا) می‌گیری و مستقیم در access_token
می‌چسبانی؛ access_token_expires_at را خالی می‌گذاری. این توکن باید در
هدر درخواست با کلید CAFEBAZAAR-PISHKHAN-API-SECRET فرستاده شود — نه
به‌صورت access_token در query string. هیچ OAuth/client_id لازم نیست.
۲) «روش قدیمی» (OAuth با client_id/client_secret، تمدیدشونده): با
build_authorize_url → exchange_code_for_tokens یک access_token
موقت (و refresh_token) می‌گیری؛ access_token_expires_at پر می‌شود.
این access_token باید به‌صورت query param (?access_token=...) فرستاده
شود. verify_purchase خودش پیش از انقضا با refresh_token تمدیدش می‌کند.
مستندات رسمی: https://developers.cafebazaar.ir/fa/guidelines/in-app-billing
"""
import logging
from datetime import timedelta
from urllib.parse import urlencode
import requests
from django.utils import timezone
from apps.billing.models import BazaarSettings
logger = logging.getLogger(__name__)
AUTHORIZE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth/authorize/"
TOKEN_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth/token/"
VALIDATE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}/inapp/{sku}/purchases/{token}/"
REQUEST_TIMEOUT = 15 # ثانیه
# اگر کمتر از این مدت تا انقضای access_token مانده باشد، پیش از استفاده
# با refresh_token تمدید می‌شود (به‌جای اینکه صبر کنیم واقعا 401 بگیریم).
REFRESH_MARGIN = timedelta(minutes=5)
# purchaseState طبق مستندات کافه‌بازار (هم‌شکل با Google Play):
# 0 = خریداری‌شده، 1 = لغوشده، 2 = در انتظار (pending)
PURCHASE_STATE_PURCHASED = 0
class BazaarAuthError(Exception):
"""خطای قابل‌نمایش مستقیم به ادمین (پیام‌اش با messages.error در admin.py نشان داده می‌شود)."""
def _force_https(url: str) -> str:
"""redirect_uri باید دقیقا با چیزی که موقع ساخت Client در پیشخان ثبت
شده یکی باشد — که تقریبا همیشه https است. اگر جنگو پشت ریورس‌پراکسی
باشد و SECURE_PROXY_SSL_HEADER تنظیم نشده باشد، request.build_absolute_uri
می‌تواند اشتباها http:// بسازد؛ این تابع همیشه https را تحمیل می‌کند تا
این باگِ رایج، دلیلِ خطای redirect_uri mismatch نشود.
اگر واقعا در محیط توسعه‌ی محلی روی http کار می‌کنی و Client هم با
http ثبت شده، این تابع را همین‌جا موقتا حذف کن — برای پروداکشن همیشه
باید https باشد."""
if url.startswith("http://"):
forced = "https://" + url[len("http://"):]
logger.warning("Bazaar OAuth callback_url ارسالی http بود، به https تبدیل شد: %s -> %s", url, forced)
return forced
return url
def build_authorize_url(callback_url: str) -> str:
settings_obj = BazaarSettings.get_solo()
if not settings_obj.client_id:
raise BazaarAuthError("ابتدا Client ID را در تنظیمات کافه‌بازار پر و ذخیره کن.")
params = {
"response_type": "code",
"access_type": "offline",
"redirect_uri": _force_https(callback_url),
"client_id": settings_obj.client_id,
}
return f"{AUTHORIZE_URL}?{urlencode(params)}"
def exchange_code_for_tokens(code: str, callback_url: str) -> None:
"""بعد از برگشت کاربر با ?code=... از پیشخان صدا زده می‌شود (oauth_callback_view)."""
settings_obj = BazaarSettings.get_solo()
if not settings_obj.client_id or not settings_obj.client_secret:
raise BazaarAuthError("Client ID و Client Secret باید پیش از اتصال پر و ذخیره شده باشند.")
response = _post_token_request(
{
"grant_type": "authorization_code",
"code": code,
"client_id": settings_obj.client_id,
"client_secret": settings_obj.client_secret,
"redirect_uri": _force_https(callback_url),
}
)
_save_token_response(settings_obj, response)
def _refresh_access_token(settings_obj: BazaarSettings) -> None:
if not settings_obj.refresh_token:
raise BazaarAuthError(
"access_token منقضی شده و refresh_token موجود نیست — از پنل ادمین دوباره «شروع اتصال به کافه‌بازار» را بزن."
)
response = _post_token_request(
{
"grant_type": "refresh_token",
"client_id": settings_obj.client_id,
"client_secret": settings_obj.client_secret,
"refresh_token": settings_obj.refresh_token,
}
)
# پاسخِ refresh_token معمولا خودِ refresh_token جدید را برنمی‌گرداند —
# یعنی همان قبلی همچنان معتبر می‌ماند؛ _save_token_response این را رعایت می‌کند.
_save_token_response(settings_obj, response)
def _post_token_request(data: dict) -> requests.Response:
try:
return requests.post(TOKEN_URL, data=data, timeout=REQUEST_TIMEOUT)
except requests.RequestException as exc:
logger.exception("درخواست توکن کافه‌بازار با خطای شبکه مواجه شد")
raise BazaarAuthError(f"اتصال به سرور کافه‌بازار برقرار نشد: {exc}") from exc
def _save_token_response(settings_obj: BazaarSettings, response: requests.Response) -> None:
if response.status_code >= 400:
try:
detail = response.json().get("error", response.text)
except ValueError:
detail = response.text
logger.warning("درخواست توکن کافه‌بازار رد شد: %s %s", response.status_code, detail)
# رایج‌ترین دلیلِ همین ارور: عدم تطابق redirect_uri با چیزی که در
# پیشخان ثبت شده (از نظر scheme/http-https یا اسلش انتهایی)، یا
# Client ID/Secret اشتباه.
raise BazaarAuthError(
f"کافه‌بازار درخواست را رد کرد ({response.status_code}): {detail}. "
"معمولا یعنی redirect_uri ثبت‌شده در پیشخان با آدرسی که سرور می‌فرستد یکی نیست "
"(https/http یا اسلش انتهایی را چک کن)، یا Client ID/Secret اشتباه است."
)
try:
data = response.json()
except ValueError as exc:
raise BazaarAuthError("پاسخ کافه‌بازار قابل‌خواندن (JSON) نبود.") from exc
settings_obj.access_token = data["access_token"]
settings_obj.access_token_expires_at = timezone.now() + timedelta(seconds=data.get("expires_in", 3600))
if data.get("refresh_token"):
settings_obj.refresh_token = data["refresh_token"]
settings_obj.save(update_fields=["access_token", "access_token_expires_at", "refresh_token"])
def _get_valid_settings() -> BazaarSettings:
"""BazaarSettings را با access_token معتبر برمی‌گرداند. اگر روش OAuth
باشد (access_token_expires_at پر است) و توکن نزدیک انقضا/منقضی باشد،
خودش با refresh_token تمدید می‌کند. اگر «روش جدید» (پیشخان، بدون
انقضا) باشد دست‌نخورده برمی‌گردد."""
settings_obj = BazaarSettings.get_solo()
if not settings_obj.package_name:
raise BazaarAuthError("Application id اپ در تنظیمات کافه‌بازار خالی است.")
if not settings_obj.access_token:
raise BazaarAuthError("هنوز هیچ access_token ای در تنظیمات کافه‌بازار ثبت نشده.")
if settings_obj.access_token_expires_at and timezone.now() >= (
settings_obj.access_token_expires_at - REFRESH_MARGIN
):
_refresh_access_token(settings_obj)
settings_obj.refresh_from_db()
return settings_obj
def _auth_kwargs(settings_obj: BazaarSettings) -> dict:
"""دو روش کاملا متفاوتِ ارسال توکن به API کافه‌بازار وجود دارد و
قاطی‌کردنشان دقیقا همان چیزی است که باعث خطا می‌شود:
- «روش جدید» (توکن ثابت پیشخان → «API پیشخان بازار» → «دریافت توکن
جدید»؛ در مدل با access_token_expires_at خالی مشخص می‌شود): باید در
هدر درخواست با کلید CAFEBAZAAR-PISHKHAN-API-SECRET فرستاده شود.
- «روش قدیمی» (OAuth با client_id/client_secret؛ access_token_expires_at
پر است): access_token به‌صورت query param فرستاده می‌شود.
فرستادنِ توکنِ روش جدید به‌صورت query param (یا برعکس) باعث رد شدن
درخواست توسط کافه‌بازار می‌شود، حتی اگر خودِ توکن کاملا معتبر باشد."""
if settings_obj.access_token_expires_at:
return {"params": {"access_token": settings_obj.access_token}}
return {"headers": {"CAFEBAZAAR-PISHKHAN-API-SECRET": settings_obj.access_token}}
def verify_purchase(sku: str, purchase_token: str) -> bool:
"""فقط وقتی True برمی‌گرداند که کافه‌بازار خرید را «معتبر و پرداخت‌شده»
تایید کند (purchaseState == 0). هر خطا یا وضعیت نامشخص → False (یعنی
VerifyPurchaseView در views.py آن را ناموفق حساب می‌کند و جمی اضافه
نمی‌شود — امن‌ترین رفتار پیش‌فرض)."""
try:
settings_obj = _get_valid_settings()
except BazaarAuthError:
logger.exception("verify_purchase: تنظیمات/توکن کافه‌بازار آماده نیست")
return False
url = VALIDATE_URL.format(package_name=settings_obj.package_name, sku=sku, token=purchase_token)
def _call(s: BazaarSettings) -> requests.Response:
return requests.get(url, timeout=REQUEST_TIMEOUT, **_auth_kwargs(s))
try:
response = _call(settings_obj)
except requests.RequestException:
logger.exception("تایید خرید کافه‌بازار با خطای شبکه مواجه شد")
return False
if response.status_code == 401:
# توکن بین گرفتنش و همین لحظه باطل شده — این فقط برای روش OAuth
# (که refresh_token دارد) قابل‌جبران است؛ توکن ثابت روش جدید اگر رد
# شود یعنی از پیشخان باطل/عوض شده و باید دستی جایگزین شود.
if not settings_obj.refresh_token:
logger.warning("کافه‌بازار توکن را رد کرد (401) — اگر توکن «روش جدید» است، از پیشخان توکن تازه بگیر و جایگزین کن")
return False
try:
_refresh_access_token(settings_obj)
settings_obj.refresh_from_db()
response = _call(settings_obj)
except BazaarAuthError:
logger.exception("تمدید access_token بعد از 401 هم ناموفق بود")
return False
if response.status_code != 200:
logger.warning("تایید خرید کافه‌بازار پاسخ غیرمنتظره داد: %s %s", response.status_code, response.text)
return False
try:
data = response.json()
except ValueError:
logger.warning("پاسخ تایید خرید کافه‌بازار JSON نبود: %s", response.text)
return False
return data.get("purchaseState") == PURCHASE_STATE_PURCHASED