This commit is contained in:
2026-09-04 13:05:54 +03:30
parent 463b812e3e
commit 17082a5ea9
43 changed files with 5663 additions and 285 deletions
+179 -106
View File
@@ -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