adding
This commit is contained in:
+179
-106
@@ -1,35 +1,28 @@
|
||||
# apps/billing/providers/bazaar.py
|
||||
"""
|
||||
تایید خرید کافهبازار — سمت سرور، طبق مستندات رسمی کافهبازار
|
||||
(developers.cafebazaar.ir/fa/guidelines/in-app-billing). این تنها API ای
|
||||
است که اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»،
|
||||
تا این تابع True برنگرداند هیچ جمی اضافه نمیشود.
|
||||
اتصال به API «پرداخت درونبرنامهای» کافهبازار (devapi v2) — گرفتن/تمدید
|
||||
access_token و تایید خریدها.
|
||||
|
||||
GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name}
|
||||
/inapp/{product_id}/purchases/{purchase_token}/
|
||||
دو روشِ کاملا متفاوت برای احراز هویت با همین API وجود دارد — قاطیکردنشان
|
||||
دقیقا همان چیزی است که باعث خطا میشود، پس اینجا صریح از هم جدا شدهاند:
|
||||
|
||||
پاسخ موفق شامل purchaseState=0 (خریداریشده، نه refundشده) است.
|
||||
۱) «روش جدید» (پیشخان → اپ → «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 تمدیدش میکند.
|
||||
|
||||
«روش جدید» — توکن ثابتِ پیشخان (.../api/setting-up-new)، در هدر:
|
||||
CAFEBAZAAR-PISHKHAN-API-SECRET: <token>
|
||||
این توکن API رفرش ندارد (اگر باطل شود باید دستی از پیشخان توکن تازه گرفت).
|
||||
|
||||
«روش قدیمی» — OAuth2 (.../api/setting-up)، در پارامتر URL:
|
||||
?access_token=<token>
|
||||
با client_id/client_secret/refresh_token قابل تمدید خودکار است — دقیقا
|
||||
همین رفرش خودکار اینجا با auto_refresh_access_token_if_needed پیاده شده.
|
||||
|
||||
تشخیص اینکه کدام روش فعال است صرفا از روی BazaarSettings.access_token_
|
||||
expires_at: خالی=روش جدید (هدر)، پرشده=روش قدیمی/OAuth (پارامتر URL، با
|
||||
رفرش خودکار پیش از انقضا).
|
||||
|
||||
همهچیز از BazaarSettings (پنل ادمین) خوانده میشود، نه .env — چون باید بدون
|
||||
ریدیپلوی قابلتعویض باشد.
|
||||
مستندات رسمی: 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
|
||||
@@ -38,130 +31,210 @@ from apps.billing.models import BazaarSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REQUEST_TIMEOUT_SECONDS = 15
|
||||
DEVAPI_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api"
|
||||
OAUTH_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth"
|
||||
PISHKHAN_TOKEN_HEADER = "CAFEBAZAAR-PISHKHAN-API-SECRET"
|
||||
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}/"
|
||||
|
||||
# پیش از این فاصله تا انقضا، توکن OAuth را تمدید میکنیم تا وسط یک درخواست
|
||||
# واقعی منقضی نشود (access_token های کافهبازار معمولا ۳۶۰۰ ثانیه عمر دارند).
|
||||
REFRESH_SAFETY_MARGIN_SECONDS = 120
|
||||
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 build_authorize_url(redirect_uri: str) -> str:
|
||||
"""لینک شروع فلوی OAuth (روش قدیمی) — کاربر باید یکبار در مرورگر باز و
|
||||
تایید کند. redirect_uri باید دقیقا همانی باشد که موقع ساخت Client در
|
||||
پیشخان ثبت شده."""
|
||||
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 در تنظیمات کافهبازار خالی است.")
|
||||
return (
|
||||
f"{OAUTH_BASE_URL}/authorize/"
|
||||
f"?response_type=code&access_type=offline"
|
||||
f"&redirect_uri={redirect_uri}&client_id={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, redirect_uri: str) -> None:
|
||||
"""گام آخر فلوی OAuth — code گرفتهشده از ریدایرکت (یا دستی کپیشده) را
|
||||
به access_token/refresh_token تبدیل و در BazaarSettings ذخیره میکند."""
|
||||
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 در تنظیمات کافهبازار خالی است.")
|
||||
raise BazaarAuthError("Client ID و Client Secret باید پیش از اتصال پر و ذخیره شده باشند.")
|
||||
|
||||
response = requests.post(
|
||||
f"{OAUTH_BASE_URL}/token/",
|
||||
data={
|
||||
response = _post_token_request(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": settings_obj.client_id,
|
||||
"client_secret": settings_obj.client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
"redirect_uri": _force_https(callback_url),
|
||||
}
|
||||
)
|
||||
if not response.ok:
|
||||
raise BazaarAuthError(f"تبادل کد ناموفق بود: {response.status_code} {response.text}")
|
||||
|
||||
_save_token_response(settings_obj, response.json())
|
||||
_save_token_response(settings_obj, response)
|
||||
|
||||
|
||||
def _refresh_access_token(settings_obj: BazaarSettings) -> None:
|
||||
if not settings_obj.client_id or not settings_obj.client_secret or not settings_obj.refresh_token:
|
||||
if not settings_obj.refresh_token:
|
||||
raise BazaarAuthError(
|
||||
"برای تمدید خودکار access_token باید client_id/client_secret/refresh_token هر سه ست باشند."
|
||||
"access_token منقضی شده و refresh_token موجود نیست — از پنل ادمین دوباره «شروع اتصال به کافهبازار» را بزن."
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
f"{OAUTH_BASE_URL}/token/",
|
||||
data={
|
||||
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,
|
||||
},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
}
|
||||
)
|
||||
if not response.ok:
|
||||
raise BazaarAuthError(f"تمدید access_token ناموفق بود: {response.status_code} {response.text}")
|
||||
|
||||
_save_token_response(settings_obj, response.json())
|
||||
# پاسخِ refresh_token معمولا خودِ refresh_token جدید را برنمیگرداند —
|
||||
# یعنی همان قبلی همچنان معتبر میماند؛ _save_token_response این را رعایت میکند.
|
||||
_save_token_response(settings_obj, response)
|
||||
|
||||
|
||||
def _save_token_response(settings_obj: BazaarSettings, body: dict) -> None:
|
||||
settings_obj.access_token = body["access_token"]
|
||||
# رفرشگرنت گاهی refresh_token جدید برنمیگرداند — قبلی را نگه میداریم.
|
||||
if body.get("refresh_token"):
|
||||
settings_obj.refresh_token = body["refresh_token"]
|
||||
expires_in = body.get("expires_in", 3600)
|
||||
settings_obj.access_token_expires_at = timezone.now() + timezone.timedelta(seconds=expires_in)
|
||||
settings_obj.save(
|
||||
update_fields=["access_token", "refresh_token", "access_token_expires_at", "updated_at"]
|
||||
)
|
||||
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 _auth_kwargs_for_request(settings_obj: BazaarSettings) -> dict:
|
||||
"""توکنِ سالم برای یک درخواست devapi را برمیگرداند — هدر یا پارامتر URL،
|
||||
بسته به اینکه توکن ثابتِ پیشخان است یا OAuth (و اگر OAuth و نزدیک
|
||||
انقضاست، همینجا اول تمدیدش میکند)."""
|
||||
is_oauth_managed = settings_obj.access_token_expires_at is not None
|
||||
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 اشتباه است."
|
||||
)
|
||||
|
||||
if is_oauth_managed:
|
||||
margin = timezone.timedelta(seconds=REFRESH_SAFETY_MARGIN_SECONDS)
|
||||
if timezone.now() >= settings_obj.access_token_expires_at - margin:
|
||||
_refresh_access_token(settings_obj)
|
||||
return {"params": {"access_token": settings_obj.access_token}}
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
raise BazaarAuthError("پاسخ کافهبازار قابلخواندن (JSON) نبود.") from exc
|
||||
|
||||
return {"headers": {PISHKHAN_TOKEN_HEADER: settings_obj.access_token}}
|
||||
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 verify_purchase(product_id: str, purchase_token: str) -> bool:
|
||||
def _get_valid_settings() -> BazaarSettings:
|
||||
"""BazaarSettings را با access_token معتبر برمیگرداند. اگر روش OAuth
|
||||
باشد (access_token_expires_at پر است) و توکن نزدیک انقضا/منقضی باشد،
|
||||
خودش با refresh_token تمدید میکند. اگر «روش جدید» (پیشخان، بدون
|
||||
انقضا) باشد دستنخورده برمیگردد."""
|
||||
settings_obj = BazaarSettings.get_solo()
|
||||
if not settings_obj.access_token or not settings_obj.package_name:
|
||||
logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده")
|
||||
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 = f"{DEVAPI_BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/"
|
||||
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:
|
||||
auth_kwargs = _auth_kwargs_for_request(settings_obj)
|
||||
except BazaarAuthError as exc:
|
||||
logger.error(f"❌ احراز هویت کافهبازار ناموفق: {exc}")
|
||||
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:
|
||||
response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS, **auth_kwargs)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
purchase_state = body.get("purchaseState")
|
||||
is_valid = purchase_state == 0
|
||||
if not is_valid:
|
||||
logger.warning(f"❌ خرید کافهبازار نامعتبر: {body}")
|
||||
return is_valid
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
logger.error(f"❌ خطا در تایید خرید کافهبازار ({product_id}/{purchase_token}): {exc}")
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
logger.warning("پاسخ تایید خرید کافهبازار JSON نبود: %s", response.text)
|
||||
return False
|
||||
|
||||
return data.get("purchaseState") == PURCHASE_STATE_PURCHASED
|
||||
Reference in New Issue
Block a user