fix Bazaar purchase validation URLs and add the new pishkhan-token method
Read against Bazaar's own docs (wp-json of developers.cafebazaar.ir): both validation URLs were wrong, so no real purchase could ever verify. In-app is now api/validate/<pkg>/inapp/<sku>/purchases/<token>/, and subscriptions add the missing /purchases/ and judge validUntil, since that response has no purchaseState. Only 404 means "not purchased"; 401/403/5xx now raise 503 so our own key or quota trouble cannot mark a paid order failed. The client token is URL-quoted so it cannot steer the request to another API path. BAZAAR_PISHKHAN_API_SECRET (the documented new method, a static header token with no OAuth) takes precedence when set; /api/health gains integrations.bazaar. Not yet exercised by a real purchase. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,7 @@ import logging
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
@@ -220,49 +220,85 @@ def _get_access_token() -> str:
|
||||
return token
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""روشِ جدید (توکنِ ثابتِ پیشخان) یا روشِ قدیمی (refresh tokenِ OAuth)."""
|
||||
return bool(settings.BAZAAR_PISHKHAN_API_SECRET or get_refresh_token())
|
||||
|
||||
|
||||
def _auth_headers() -> dict:
|
||||
"""
|
||||
روشِ جدیدِ بازار (سندِ «راه اندازی API (روش جدید)»، مهر ۱۴۰۳): یک توکنِ
|
||||
ثابت از پیشخان ← «API پیشخان بازار» ← «دریافت توکن جدید»، در هدرِ
|
||||
CAFEBAZAAR-PISHKHAN-API-SECRET؛ نه redirect دارد نه انقضا. اگر ست باشد
|
||||
همان به کار میرود، وگرنه access tokenِ روشِ قدیمیِ OAuth.
|
||||
"""
|
||||
if settings.BAZAAR_PISHKHAN_API_SECRET:
|
||||
return {'CAFEBAZAAR-PISHKHAN-API-SECRET': settings.BAZAAR_PISHKHAN_API_SECRET}
|
||||
return {'Authorization': f'Bearer {_get_access_token()}'}
|
||||
|
||||
|
||||
def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: bool = False) -> BazaarPurchaseResult:
|
||||
"""
|
||||
صحتسنجی purchaseToken که کلاینت اندروید بعد از خرید از SDK بازار گرفته.
|
||||
is_subscription=True برای اشتراکهای دورهای بازار (endpoint جدا از خرید
|
||||
تکمحصولی)، طبق سند رسمی.
|
||||
|
||||
آدرسها از سندِ خودِ بازار (خواندهشده ۲۰۲۶-۰۹-۲۶ از wp-jsonِ
|
||||
developers.cafebazaar.ir؛ پیش از آن هر دو غلط بودند و هیچ خریدِ واقعیای
|
||||
تأیید نمیشد):
|
||||
تکخرید: api/validate/<pkg>/inapp/<sku>/purchases/<token>/
|
||||
اشتراک: api/applications/<pkg>/subscriptions/<sku>/purchases/<token>
|
||||
به گفتهی سند «تنها اگر error برابر not_found باشد خرید انجام نشده»؛ پس
|
||||
فقط ۴۰۴ نامعتبر است و بقیهی خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند
|
||||
تا یک خریدِ واقعی بهخاطرِ خطای ما failed نشود.
|
||||
"""
|
||||
if not get_refresh_token():
|
||||
# 🔒 fail-closed: بدون BAZAAR_REFRESH_TOKEN (کلید سرور-به-سرورِ
|
||||
# بازار)، امکان صحتسنجیِ واقعیِ purchaseToken با سرور بازار وجود
|
||||
# نداره. قبلاً اینجا هر purchaseToken غیرخالیِ ارسالی از کلاینت
|
||||
# مستقیماً valid فرض میشد (باگ fail-open) که یعنی یک کلاینت
|
||||
# میتونست با فرستادن هر مقدار غیرخالی، بدون خرید واقعی، سفارش رو
|
||||
# paid کنه. حالا در نبودِ کلیدهای بازار، هیچوقت به گفتهی کلاینت
|
||||
# اعتماد نمیکنیم و صریحاً invalid برمیگردونیم.
|
||||
if not is_configured():
|
||||
# 🔒 fail-closed: بدونِ کلیدِ بازار هیچوقت به گفتهی کلاینت اعتماد
|
||||
# نمیکنیم (قبلاً هر purchaseTokenِ غیرخالی valid فرض میشد).
|
||||
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}/'
|
||||
)
|
||||
# token از کلاینت میآید: quote با safe='' تا `/` یا `..` در آن مسیرِ
|
||||
# دیگری از API را با کلیدِ ما صدا نزند.
|
||||
pkg = quote(settings.BAZAAR_PACKAGE_NAME, safe='')
|
||||
sku = quote(product_sku, safe='')
|
||||
token = quote(purchase_token, safe='')
|
||||
if is_subscription:
|
||||
url = f'{settings.BAZAAR_API_BASE}/api/applications/{pkg}/subscriptions/{sku}/purchases/{token}'
|
||||
else:
|
||||
url = f'{settings.BAZAAR_API_BASE}/api/validate/{pkg}/inapp/{sku}/purchases/{token}/'
|
||||
|
||||
try:
|
||||
resp = requests.get(url, headers={'Authorization': f'Bearer {access_token}'}, timeout=10)
|
||||
resp = requests.get(url, headers=_auth_headers(), 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)
|
||||
data = None
|
||||
|
||||
if resp.status_code == 404:
|
||||
return BazaarPurchaseResult(is_valid=False, raw=data if isinstance(data, dict) else {'status_code': 404})
|
||||
if resp.status_code != 200 or not isinstance(data, dict):
|
||||
if resp.status_code == 401:
|
||||
cache.delete(ACCESS_TOKEN_CACHE_KEY)
|
||||
logger.error('Bazaar verify-purchase failed: status=%s error=%s', resp.status_code,
|
||||
data.get('error') if isinstance(data, dict) else None)
|
||||
raise BazaarAPIError()
|
||||
|
||||
purchase_state = data.get('purchaseState')
|
||||
if is_subscription:
|
||||
# پاسخِ اشتراک purchaseState ندارد و اشتراکِ منقضی را هم برمیگرداند؛
|
||||
# سند میگوید validUntil را با اکنون مقایسه کنید.
|
||||
valid_until = data.get('validUntilTimestampMsec') or 0
|
||||
is_valid = valid_until > timezone.now().timestamp() * 1000
|
||||
purchase_state = None
|
||||
else:
|
||||
purchase_state = data.get('purchaseState')
|
||||
is_valid = purchase_state == 0 # ۰ یعنی خریداریشده؛ ۱ یعنی refund
|
||||
return BazaarPurchaseResult(
|
||||
is_valid=purchase_state == 0, # ۰ یعنی خریداریشده و معتبر
|
||||
is_valid=is_valid,
|
||||
purchase_state=purchase_state,
|
||||
consumption_state=data.get('consumptionState'),
|
||||
developer_payload=data.get('developerPayload', ''),
|
||||
|
||||
Reference in New Issue
Block a user