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:
2026-09-26 21:44:14 +03:30
parent 64f88f0bce
commit bdd5aba985
6 changed files with 151 additions and 29 deletions

View File

@@ -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', ''),