The Bazaar account owner registered /api/payment/bazaar/verify as the redirect URI. POST stays the app's purchase check; GET now takes Bazaar's ?code=, exchanges it for a refresh token and stores it in the new BazaarCredential row, so connecting needs no SSH or .env edit. The GET is unauthenticated, so it only acts inside a window opened from the admin (or bazaar_authorize_link): 72 h by default, closed by the first successful exchange, state checked when Bazaar echoes it. Authorize/token URL shapes follow the known devapi v2 flow; not checked against Bazaar's current docs, which did not load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
271 lines
13 KiB
Python
271 lines
13 KiB
Python
"""
|
||
کلاینت سرور-به-سرورِ پرداخت درونبرنامهای بازار (REST API v2)، طبق داکیومنت
|
||
رسمی: developers.cafebazaar.ir/fa/guidelines/in-app-billing
|
||
|
||
جریان رسمی:
|
||
۱) یکبار: صاحبِ حسابِ بازار لینکِ start_authorization را باز میکند و
|
||
«اجازه» میزند؛ بازار با `?code=` به Redirect URI (همان
|
||
`/api/payment/bazaar/verify`، GET) برمیگردد و complete_authorization
|
||
آن را با REFRESH_TOKEN عوض میکند و در BazaarCredential نگه میدارد.
|
||
۲) بکاند با همون REFRESH_TOKEN، هر بار (یا هر بار که access_token منقضی
|
||
شده) یک ACCESS_TOKEN تازه میگیره (POST /auth/token با
|
||
grant_type=refresh_token).
|
||
۳) با همون access_token، وضعیت خرید/اشتراک از endpoint purchase/subscription
|
||
خونده میشه (GET .../applications/{package}/inapp/{sku}/purchases/{token}/).
|
||
|
||
⚠️ کافهبازار روش صحتسنجی رو تغییر داده (نگاه کنید به
|
||
developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api-new)؛
|
||
قبل از اتصال واقعی به production، دقیقترین مسیر endpoint را از همون صفحه یا
|
||
پنل devapi خودتون تایید کنید.
|
||
|
||
🔒 بدون BAZAAR_REFRESH_TOKEN (کلیدهای بازار ست نشده)، verify_purchase همیشه
|
||
fail-closed عمل میکنه و invalid برمیگردونه؛ هیچوقت به purchaseToken
|
||
ارسالیِ کلاینت، بدون صحتسنجیِ واقعی با سرور بازار، اعتماد نمیشه.
|
||
"""
|
||
import logging
|
||
import secrets
|
||
from dataclasses import dataclass
|
||
from datetime import timedelta
|
||
from urllib.parse import urlencode
|
||
|
||
import requests
|
||
from django.conf import settings
|
||
from django.core.cache import cache
|
||
from django.utils import timezone
|
||
from rest_framework.exceptions import APIException
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
ACCESS_TOKEN_CACHE_KEY = 'bazaar_iap_access_token'
|
||
|
||
|
||
class BazaarAPIError(APIException):
|
||
"""خطای موقتِ سرویسِ بازار (شبکه/HTTP/پاسخِ نامعتبر). عمداً is_valid=False
|
||
برنمیگردونیم چون نبودِ پاسخِ قابلاعتماد بهمعنیِ خریدِ نامعتبر نیست —
|
||
ممکنه یک خریدِ واقعاً معتبر رو غلط failed کنه."""
|
||
|
||
status_code = 503
|
||
default_detail = 'در حال حاضر امکان تایید خرید با سرور بازار وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.'
|
||
default_code = 'bazaar_api_unavailable'
|
||
|
||
|
||
@dataclass
|
||
class BazaarPurchaseResult:
|
||
is_valid: bool
|
||
purchase_state: int | None = None # ۰=خریداریشده، ۱=لغوشده (طبق سند بازار)
|
||
consumption_state: int | None = None
|
||
developer_payload: str = ''
|
||
raw: dict | None = None
|
||
|
||
|
||
def _authorize_window() -> timedelta:
|
||
# پنجرهی اتصال: از ساختِ لینک تا برگشتن از بازار. پیشفرضِ سه روز چون
|
||
# صاحبِ حسابِ بازار آدمِ دیگری است و ممکن است یکی دو روز طول بکشد
|
||
# (تصمیمِ صاحبِ پروژه، ۲۰۲۶-۰۹-۲۶). بعد از اتصالِ موفق پنجره خودش بسته
|
||
# میشود، پس بلندیاش فقط تا همان لحظه در معرض است.
|
||
return timedelta(hours=settings.BAZAAR_AUTHORIZE_WINDOW_HOURS)
|
||
|
||
|
||
def get_refresh_token() -> str:
|
||
"""مقدارِ `.env` اگر باشد، وگرنه آنچه بعد از «اجازه» در دیتابیس ذخیره شد."""
|
||
if settings.BAZAAR_REFRESH_TOKEN:
|
||
return settings.BAZAAR_REFRESH_TOKEN
|
||
from .models import BazaarCredential
|
||
cred = BazaarCredential.objects.filter(pk=1).first()
|
||
return cred.refresh_token if cred else ''
|
||
|
||
|
||
def start_authorization() -> str:
|
||
"""
|
||
پنجرهی اتصال را باز میکند و لینکی برمیگرداند که صاحبِ حسابِ بازار باید
|
||
باز کند و «اجازه» بزند. بازار بعدش به BAZAAR_REDIRECT_URI (همان verify)
|
||
با `?code=` برمیگردد.
|
||
|
||
⚠️ شکلِ این آدرس از روندِ شناختهشدهی devapi v2 است؛ سندِ فعلیِ بازار
|
||
(صفحهی JS) خوانده نشد. اینکه بازار `state` را برمیگرداند یا نه هم
|
||
دیده نشده — برای همین complete_authorization نبودِ state را تحمل میکند.
|
||
"""
|
||
from .models import BazaarCredential
|
||
state = secrets.token_urlsafe(24)
|
||
BazaarCredential.objects.update_or_create(
|
||
pk=1, defaults={'pending_state': state, 'pending_since': timezone.now()},
|
||
)
|
||
query = urlencode({
|
||
'response_type': 'code',
|
||
'access_type': 'offline',
|
||
'redirect_uri': settings.BAZAAR_REDIRECT_URI,
|
||
'client_id': settings.BAZAAR_CLIENT_ID,
|
||
'state': state,
|
||
})
|
||
return f'{settings.BAZAAR_API_BASE}/auth/authorize/?{query}'
|
||
|
||
|
||
class BazaarAuthorizationError(Exception):
|
||
"""پیامش مستقیم به کسی که در مرورگر است نشان داده میشود؛ رازی در آن نیست."""
|
||
|
||
|
||
def complete_authorization(*, code: str, state: str = '') -> None:
|
||
"""
|
||
codeی که بازار به Redirect URI فرستاد را با refresh token عوض و ذخیره میکند.
|
||
فقط داخلِ پنجرهی باز کار میکند؛ اگر بازار state را برگرداند باید با
|
||
همانی که ساختیم یکی باشد. پنجره فقط با اتصالِ موفق بسته میشود، تا یک
|
||
درخواستِ خراب (یا عمدی با codeِ بیمعنی) پنجرهی چندروزه را نسوزاند؛
|
||
تلاشها با throttleِ `bazaar_oauth` محدودند.
|
||
"""
|
||
from django.db import transaction
|
||
|
||
from .models import BazaarCredential
|
||
|
||
if not (settings.BAZAAR_CLIENT_ID and settings.BAZAAR_CLIENT_SECRET):
|
||
raise BazaarAuthorizationError('کلیدهای بازار (CLIENT_ID/SECRET) روی سرور تنظیم نشدهاند.')
|
||
|
||
cred = BazaarCredential.objects.filter(pk=1).first()
|
||
if (
|
||
not cred or not cred.pending_since
|
||
or timezone.now() - cred.pending_since > _authorize_window()
|
||
):
|
||
raise BazaarAuthorizationError('پنجرهی اتصال باز نیست؛ لینکِ تازه بسازید.')
|
||
if state and not secrets.compare_digest(state, cred.pending_state):
|
||
raise BazaarAuthorizationError('state با لینکی که ساخته شد یکی نیست.')
|
||
opened_at = cred.pending_since
|
||
|
||
try:
|
||
resp = requests.post(
|
||
f'{settings.BAZAAR_API_BASE}/auth/token/',
|
||
data={
|
||
'grant_type': 'authorization_code',
|
||
'code': code,
|
||
'client_id': settings.BAZAAR_CLIENT_ID,
|
||
'client_secret': settings.BAZAAR_CLIENT_SECRET,
|
||
'redirect_uri': settings.BAZAAR_REDIRECT_URI,
|
||
},
|
||
timeout=10,
|
||
)
|
||
except requests.RequestException:
|
||
logger.exception('Bazaar code exchange failed')
|
||
raise BazaarAuthorizationError('بازار در دسترس نبود؛ دوباره لینک بسازید و امتحان کنید.')
|
||
|
||
try:
|
||
data = resp.json()
|
||
except ValueError:
|
||
data = {}
|
||
refresh_token = data.get('refresh_token') if isinstance(data, dict) else None
|
||
if resp.status_code != 200 or not refresh_token:
|
||
# بدنه را لاگ نمیکنیم: ممکن است توکن داشته باشد.
|
||
logger.error('Bazaar code exchange rejected: status=%s keys=%s',
|
||
resp.status_code, sorted(data) if isinstance(data, dict) else '?')
|
||
raise BazaarAuthorizationError(f'بازار code را نپذیرفت (HTTP {resp.status_code}).')
|
||
|
||
# بستنِ پنجره شرطی است: از دو درخواستِ همزمان فقط اولی ذخیره میکند.
|
||
with transaction.atomic():
|
||
stored = BazaarCredential.objects.filter(pk=1, pending_since=opened_at).update(
|
||
refresh_token=refresh_token, authorized_at=timezone.now(),
|
||
pending_state='', pending_since=None,
|
||
)
|
||
if not stored:
|
||
raise BazaarAuthorizationError('این لینک همین الان یک بار استفاده شد.')
|
||
cache.delete(ACCESS_TOKEN_CACHE_KEY)
|
||
if data.get('access_token'):
|
||
cache.set(ACCESS_TOKEN_CACHE_KEY, data['access_token'],
|
||
timeout=max(int(data.get('expires_in', 3600)) - 60, 60))
|
||
logger.info('Bazaar API authorized; refresh token stored')
|
||
|
||
|
||
def _get_access_token() -> str:
|
||
"""
|
||
ACCESS_TOKEN رو کش میکنه (توی cache framework جنگو) تا هر verify یک
|
||
درخواست OAuth جدا نزنه؛ اگه کش نداشت یا منقضی شده بود، با REFRESH_TOKEN
|
||
یکی تازه میگیره.
|
||
"""
|
||
cached = cache.get(ACCESS_TOKEN_CACHE_KEY)
|
||
if cached:
|
||
return cached
|
||
|
||
refresh_token = get_refresh_token()
|
||
if not refresh_token:
|
||
# بدون REFRESH_TOKEN، امکان گرفتن access_token واقعی از بازار نیست؛
|
||
# قبلاً اینجا یک 'stub-access-token' برمیگشت که باعث میشد
|
||
# verify_purchase هر purchaseToken غیرخالی رو valid فرض کنه
|
||
# (باگ fail-open). این تابع نباید در این حالت صدا زده بشه —
|
||
# verify_purchase قبل از فراخوانیِ این تابع، خودش fail-closed
|
||
# برمیگرده — ولی برای دفاع در عمق، اینجا هم صراحتاً خطا میده.
|
||
raise RuntimeError('BAZAAR_REFRESH_TOKEN is not configured; cannot obtain a Bazaar access token.')
|
||
|
||
try:
|
||
resp = requests.post(
|
||
f'{settings.BAZAAR_API_BASE}/auth/token/',
|
||
data={
|
||
'grant_type': 'refresh_token',
|
||
'client_id': settings.BAZAAR_CLIENT_ID,
|
||
'client_secret': settings.BAZAAR_CLIENT_SECRET,
|
||
'refresh_token': refresh_token,
|
||
},
|
||
timeout=10,
|
||
)
|
||
resp.raise_for_status()
|
||
except requests.RequestException:
|
||
logger.exception('Bazaar access-token request failed')
|
||
raise BazaarAPIError()
|
||
|
||
try:
|
||
data = resp.json()
|
||
token = data['access_token']
|
||
except (ValueError, KeyError, TypeError):
|
||
logger.error('Bazaar access-token response malformed: status=%s', resp.status_code)
|
||
raise BazaarAPIError()
|
||
|
||
# طبق سند بازار access_token معمولاً ~۱ ساعت اعتباره؛ کمی زودتر منقضی
|
||
# میکنیم تا لبهی زمانی مشکل نسازه.
|
||
cache.set(ACCESS_TOKEN_CACHE_KEY, token, timeout=int(data.get('expires_in', 3600)) - 60)
|
||
return token
|
||
|
||
|
||
def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: bool = False) -> BazaarPurchaseResult:
|
||
"""
|
||
صحتسنجی purchaseToken که کلاینت اندروید بعد از خرید از SDK بازار گرفته.
|
||
is_subscription=True برای اشتراکهای دورهای بازار (endpoint جدا از خرید
|
||
تکمحصولی)، طبق سند رسمی.
|
||
"""
|
||
if not get_refresh_token():
|
||
# 🔒 fail-closed: بدون BAZAAR_REFRESH_TOKEN (کلید سرور-به-سرورِ
|
||
# بازار)، امکان صحتسنجیِ واقعیِ purchaseToken با سرور بازار وجود
|
||
# نداره. قبلاً اینجا هر purchaseToken غیرخالیِ ارسالی از کلاینت
|
||
# مستقیماً valid فرض میشد (باگ fail-open) که یعنی یک کلاینت
|
||
# میتونست با فرستادن هر مقدار غیرخالی، بدون خرید واقعی، سفارش رو
|
||
# paid کنه. حالا در نبودِ کلیدهای بازار، هیچوقت به گفتهی کلاینت
|
||
# اعتماد نمیکنیم و صریحاً invalid برمیگردونیم.
|
||
return BazaarPurchaseResult(
|
||
is_valid=False,
|
||
raw={'error': 'bazaar_credentials_not_configured'},
|
||
)
|
||
|
||
access_token = _get_access_token()
|
||
kind = 'subscriptions' if is_subscription else 'purchases'
|
||
url = (
|
||
f'{settings.BAZAAR_API_BASE}/api/applications/'
|
||
f'{settings.BAZAAR_PACKAGE_NAME}/{kind}/{product_sku}/{purchase_token}/'
|
||
)
|
||
try:
|
||
resp = requests.get(url, headers={'Authorization': f'Bearer {access_token}'}, timeout=10)
|
||
except requests.RequestException:
|
||
logger.exception('Bazaar verify-purchase request failed')
|
||
raise BazaarAPIError()
|
||
|
||
if resp.status_code != 200:
|
||
return BazaarPurchaseResult(is_valid=False, raw={'status_code': resp.status_code})
|
||
|
||
try:
|
||
data = resp.json()
|
||
except ValueError:
|
||
logger.error('Bazaar verify-purchase response malformed: status=%s', resp.status_code)
|
||
raise BazaarAPIError()
|
||
|
||
purchase_state = data.get('purchaseState')
|
||
return BazaarPurchaseResult(
|
||
is_valid=purchase_state == 0, # ۰ یعنی خریداریشده و معتبر
|
||
purchase_state=purchase_state,
|
||
consumption_state=data.get('consumptionState'),
|
||
developer_payload=data.get('developerPayload', ''),
|
||
raw=data,
|
||
)
|