Files

240 lines
13 KiB
Python
Raw Permalink Normal View History

2026-08-24 15:54:08 +03:30
"""
2026-09-04 13:05:54 +03:30
اتصال به API «پرداخت درون‌برنامه‌ای» کافه‌بازار (devapi v2) — گرفتن/تمدید
access_token و تایید خریدها.
2026-08-24 15:54:08 +03:30
2026-09-04 13:05:54 +03:30
دو روشِ کاملا متفاوت برای احراز هویت با همین API وجود دارد — قاطی‌کردنشان
دقیقا همان چیزی است که باعث خطا می‌شود، پس اینجا صریح از هم جدا شده‌اند:
2026-08-24 15:54:08 +03:30
2026-09-04 13:05:54 +03:30
۱) «روش جدید» (پیشخان → اپ → «API پیشخان بازار» → «دریافت توکن جدید»):
یک توکن ثابت (JWT، عملا بدون انقضا) می‌گیری و مستقیم در access_token
می‌چسبانی؛ access_token_expires_at را خالی می‌گذاری. این توکن باید در
هدر درخواست با کلید CAFEBAZAAR-PISHKHAN-API-SECRET فرستاده شود — نه
به‌صورت access_token در query string. هیچ OAuth/client_id لازم نیست.
2026-08-24 15:54:08 +03:30
2026-09-04 13:05:54 +03:30
۲) «روش قدیمی» (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 تمدیدش می‌کند.
2026-08-31 15:11:52 +03:30
2026-09-04 13:05:54 +03:30
مستندات رسمی: https://developers.cafebazaar.ir/fa/guidelines/in-app-billing
2026-08-24 15:54:08 +03:30
"""
import logging
2026-09-04 13:05:54 +03:30
from datetime import timedelta
from urllib.parse import urlencode
2026-08-24 15:54:08 +03:30
import requests
2026-08-31 15:11:52 +03:30
from django.utils import timezone
2026-08-24 15:54:08 +03:30
from apps.billing.models import BazaarSettings
logger = logging.getLogger(__name__)
2026-09-04 13:05:54 +03:30
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 # ثانیه
2026-08-31 15:11:52 +03:30
2026-09-04 13:05:54 +03:30
# اگر کمتر از این مدت تا انقضای access_token مانده باشد، پیش از استفاده
# با refresh_token تمدید می‌شود (به‌جای اینکه صبر کنیم واقعا 401 بگیریم).
REFRESH_MARGIN = timedelta(minutes=5)
# purchaseState طبق مستندات کافه‌بازار (هم‌شکل با Google Play):
# 0 = خریداری‌شده، 1 = لغوشده، 2 = در انتظار (pending)
PURCHASE_STATE_PURCHASED = 0
2026-08-31 15:11:52 +03:30
class BazaarAuthError(Exception):
2026-09-04 13:05:54 +03:30
"""خطای قابل‌نمایش مستقیم به ادمین (پیام‌اش با 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
2026-08-31 15:11:52 +03:30
2026-09-04 13:05:54 +03:30
def build_authorize_url(callback_url: str) -> str:
2026-08-31 15:11:52 +03:30
settings_obj = BazaarSettings.get_solo()
if not settings_obj.client_id:
2026-09-04 13:05:54 +03:30
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)}"
2026-08-31 15:11:52 +03:30
2026-09-04 13:05:54 +03:30
def exchange_code_for_tokens(code: str, callback_url: str) -> None:
"""بعد از برگشت کاربر با ?code=... از پیشخان صدا زده می‌شود (oauth_callback_view)."""
2026-08-31 15:11:52 +03:30
settings_obj = BazaarSettings.get_solo()
if not settings_obj.client_id or not settings_obj.client_secret:
2026-09-04 13:05:54 +03:30
raise BazaarAuthError("Client ID و Client Secret باید پیش از اتصال پر و ذخیره شده باشند.")
2026-08-31 15:11:52 +03:30
2026-09-04 13:05:54 +03:30
response = _post_token_request(
{
2026-08-31 15:11:52 +03:30
"grant_type": "authorization_code",
"code": code,
"client_id": settings_obj.client_id,
"client_secret": settings_obj.client_secret,
2026-09-04 13:05:54 +03:30
"redirect_uri": _force_https(callback_url),
}
2026-08-31 15:11:52 +03:30
)
2026-09-04 13:05:54 +03:30
_save_token_response(settings_obj, response)
2026-08-31 15:11:52 +03:30
def _refresh_access_token(settings_obj: BazaarSettings) -> None:
2026-09-04 13:05:54 +03:30
if not settings_obj.refresh_token:
2026-08-31 15:11:52 +03:30
raise BazaarAuthError(
2026-09-04 13:05:54 +03:30
"access_token منقضی شده و refresh_token موجود نیست — از پنل ادمین دوباره «شروع اتصال به کافه‌بازار» را بزن."
2026-08-31 15:11:52 +03:30
)
2026-09-04 13:05:54 +03:30
response = _post_token_request(
{
2026-08-31 15:11:52 +03:30
"grant_type": "refresh_token",
"client_id": settings_obj.client_id,
"client_secret": settings_obj.client_secret,
"refresh_token": settings_obj.refresh_token,
2026-09-04 13:05:54 +03:30
}
2026-08-31 15:11:52 +03:30
)
2026-09-04 13:05:54 +03:30
# پاسخِ 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 ای در تنظیمات کافه‌بازار ثبت نشده.")
2026-08-31 15:11:52 +03:30
2026-09-04 13:05:54 +03:30
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()
2026-08-31 15:11:52 +03:30
2026-09-04 13:05:54 +03:30
return settings_obj
2026-08-31 15:11:52 +03:30
2026-09-04 13:05:54 +03:30
def _auth_kwargs(settings_obj: BazaarSettings) -> dict:
"""دو روش کاملا متفاوتِ ارسال توکن به API کافه‌بازار وجود دارد و
قاطی‌کردنشان دقیقا همان چیزی است که باعث خطا می‌شود:
2026-08-31 15:11:52 +03:30
2026-09-04 13:05:54 +03:30
- «روش جدید» (توکن ثابت پیشخان → «API پیشخان بازار» → «دریافت توکن
جدید»؛ در مدل با access_token_expires_at خالی مشخص می‌شود): باید در
هدر درخواست با کلید CAFEBAZAAR-PISHKHAN-API-SECRET فرستاده شود.
- «روش قدیمی» (OAuth با client_id/client_secret؛ access_token_expires_at
پر است): access_token به‌صورت query param فرستاده می‌شود.
2026-08-31 15:11:52 +03:30
2026-09-04 13:05:54 +03:30
فرستادنِ توکنِ روش جدید به‌صورت query param (یا برعکس) باعث رد شدن
درخواست توسط کافه‌بازار می‌شود، حتی اگر خودِ توکن کاملا معتبر باشد."""
if settings_obj.access_token_expires_at:
2026-08-31 15:11:52 +03:30
return {"params": {"access_token": settings_obj.access_token}}
2026-09-04 13:05:54 +03:30
return {"headers": {"CAFEBAZAAR-PISHKHAN-API-SECRET": settings_obj.access_token}}
2026-08-24 15:54:08 +03:30
2026-09-04 13:05:54 +03:30
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: تنظیمات/توکن کافه‌بازار آماده نیست")
2026-08-24 15:54:08 +03:30
return False
2026-09-04 13:05:54 +03:30
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))
2026-08-24 15:54:08 +03:30
try:
2026-09-04 13:05:54 +03:30
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)
2026-08-31 15:11:52 +03:30
return False
try:
2026-09-04 13:05:54 +03:30
data = response.json()
except ValueError:
logger.warning("پاسخ تایید خرید کافه‌بازار JSON نبود: %s", response.text)
2026-08-24 15:54:08 +03:30
return False
2026-09-04 13:05:54 +03:30
return data.get("purchaseState") == PURCHASE_STATE_PURCHASED