240 lines
13 KiB
Python
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 |