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

@@ -426,7 +426,10 @@ Redirect URIِ ثبت‌شده در پنلِ بازار خودِ `/api/payment/b
می‌گیرد، POST خریدِ اپ. لینکِ «اجازه» ۲۶ سپتامبر ۲۱:۳۳ ساخته شد و ۷۲ ساعت باز است؛ تا می‌گیرد، POST خریدِ اپ. لینکِ «اجازه» ۲۶ سپتامبر ۲۱:۳۳ ساخته شد و ۷۲ ساعت باز است؛ تا
صاحبِ حسابِ بازار بازش نکند refresh token نداریم. **هنوز نداریم:** اسمِ پکیجِ اپ (کد حدسِ صاحبِ حسابِ بازار بازش نکند refresh token نداریم. **هنوز نداریم:** اسمِ پکیجِ اپ (کد حدسِ
`ir.tadrisino.app` را دارد)، SKUِ پلن‌ها، و اپِ اندرویدیِ دارای Poolakey. آدرسِ سنجشِ خرید با `ir.tadrisino.app` را دارد)، SKUِ پلن‌ها، و اپِ اندرویدیِ دارای Poolakey. آدرسِ سنجشِ خرید با
سندِ فعلیِ بازار مقایسه نشده. جزئیات در `docs/operations.md`. سندِ فعلیِ بازار مقایسه شد و **هر دو آدرسِ سنجشِ خرید غلط بودند** (یعنی هیچ خریدِ واقعی‌ای
تأیید نمی‌شد)؛ درست شدند، به‌علاوه‌ی روشِ جدیدِ بازار (توکنِ ثابتِ پیشخان در
`BAZAAR_PISHKHAN_API_SECRET`، بی‌OAuth) که ترجیحی است. سندِ بازار JS است ولی متنش از
`developers.cafebazaar.ir/wp-json/wp/v2/document?search=...` خوانده می‌شود. جزئیات در `docs/operations.md`.
## وضعیت ۲۰۲۶-۰۹-۲۵ (عصر) — پیامکِ واقعی روشن شد ## وضعیت ۲۰۲۶-۰۹-۲۵ (عصر) — پیامکِ واقعی روشن شد

View File

@@ -116,12 +116,21 @@ def _check_integrations() -> dict:
'qdrant': bool(getattr(settings, 'QDRANT_API_KEY', '')), 'qdrant': bool(getattr(settings, 'QDRANT_API_KEY', '')),
'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')), 'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')),
'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')), 'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')),
'bazaar': _bazaar_configured(),
'aiProvider': _ai_provider_state(), 'aiProvider': _ai_provider_state(),
'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''), 'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''),
'chatModelUpper': getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or None, 'chatModelUpper': getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or None,
} }
def _bazaar_configured() -> bool:
try:
from payment.bazaar import is_configured
return is_configured()
except Exception:
return False
def _ai_provider_state() -> dict: def _ai_provider_state() -> dict:
try: try:
from statuslog.models import StatusChangeLog from statuslog.models import StatusChangeLog

View File

@@ -723,6 +723,9 @@ BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app')
BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '') BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '')
BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '') BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '') BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
# روشِ جدیدِ بازار: توکنِ ثابتِ پیشخان (API پیشخان بازار ← دریافت توکن جدید).
# اگر ست باشد بر OAuthِ بالا (CLIENT_ID/SECRET/REFRESH_TOKEN) مقدم است.
BAZAAR_PISHKHAN_API_SECRET = os.environ.get('BAZAAR_PISHKHAN_API_SECRET', '').strip()
BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2') BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2')
# Redirect URI که در پنل بازار ثبت شده؛ باید با آن *عیناً* یکی باشد. به # Redirect URI که در پنل بازار ثبت شده؛ باید با آن *عیناً* یکی باشد. به
# خواستِ مدیرِ حسابِ بازار (۲۰۲۶-۰۹-۲۶) همان آدرسِ verify است: GETِ آن codeِ # خواستِ مدیرِ حسابِ بازار (۲۰۲۶-۰۹-۲۶) همان آدرسِ verify است: GETِ آن codeِ

View File

@@ -108,21 +108,27 @@ curl https://api.math.tadrisino2.ir/api/health
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed می‌ماند (`503`) | | `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed می‌ماند (`503`) |
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشه‌ی کاری می‌خواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ هم‌زمانِ دستیار کلِ API را قفل می‌کرد | | `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشه‌ی کاری می‌خواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ هم‌زمانِ دستیار کلِ API را قفل می‌کرد |
| `FRONTEND_URL` · `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `https://math.tadrisino2.ir/?orderId=…&status=paid\|failed` برمی‌گردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی | | `FRONTEND_URL` · `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `https://math.tadrisino2.ir/?orderId=…&status=paid\|failed` برمی‌گردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی |
| `BAZAAR_CLIENT_ID` · `BAZAAR_CLIENT_SECRET` · `BAZAAR_PACKAGE_NAME` | `POST /api/payment/bazaar/verify` هر خرید را نامعتبر می‌داند (fail-closed). refresh token در `.env` لازم نیست — بخشِ «اتصال به بازار» | | `BAZAAR_PISHKHAN_API_SECRET` (روشِ جدید) یا `BAZAAR_CLIENT_ID` · `BAZAAR_CLIENT_SECRET` (روشِ قدیمی)، به‌علاوه‌ی `BAZAAR_PACKAGE_NAME` | `POST /api/payment/bazaar/verify` هر خرید را نامعتبر می‌داند (fail-closed)؛ `/api/health` → `bazaar: false` |
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را می‌دهد | | `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را می‌دهد |
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) | | `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |
| `HEARTS_ENFORCED` | خاموش: با صفر قلب هم می‌شود جواب داد. `1` وقتی فرانت برای ۴۰۳ِ `no_hearts` صفحه‌ی «قلب‌ها تمام شد» دارد | | `HEARTS_ENFORCED` | خاموش: با صفر قلب هم می‌شود جواب داد. `1` وقتی فرانت برای ۴۰۳ِ `no_hearts` صفحه‌ی «قلب‌ها تمام شد» دارد |
| `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بسته‌اند | | `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بسته‌اند |
**اتصال به بازار (یک‌بار):** Redirect URIِ ثبت‌شده در پنلِ بازار همان **اتصال به بازار — روشِ جدید (ترجیحی):** در پیشخانِ بازار روی برنامه ← «API پیشخان
بازار» ← «دریافت توکن جدید» (از حسابِ ناشر، نه کارپرداز). همان را در `.env` به‌عنوانِ
`BAZAAR_PISHKHAN_API_SECRET` بگذارید؛ در هدرِ `CAFEBAZAAR-PISHKHAN-API-SECRET` فرستاده
می‌شود و redirect و انقضا ندارد. اگر ست باشد روشِ قدیمیِ زیر نادیده گرفته می‌شود.
**اتصال به بازار — روشِ قدیمی (OAuth، یک‌بار):** Redirect URIِ ثبت‌شده در پنلِ بازار همان
`https://api.math.tadrisino2.ir/api/payment/bazaar/verify` است (`BAZAAR_REDIRECT_URI` `https://api.math.tadrisino2.ir/api/payment/bazaar/verify` است (`BAZAAR_REDIRECT_URI`
باید عیناً همان باشد). در ادمین، «اتصال به API بازار» → actionِ «ساختِ لینکِ باید عیناً همان باشد). در ادمین، «اتصال به API بازار» → actionِ «ساختِ لینکِ
اتصال» (یا `python manage.py bazaar_authorize_link`). صاحبِ حسابِ بازار آن لینک را اتصال» (یا `python manage.py bazaar_authorize_link`). صاحبِ حسابِ بازار آن لینک را
ظرفِ سه روز (`BAZAAR_AUTHORIZE_WINDOW_HOURS`، پیش‌فرض ۷۲) باز می‌کند و «اجازه» می‌زند؛ بازار با `?code=` به همان آدرس برمی‌گردد ظرفِ سه روز (`BAZAAR_AUTHORIZE_WINDOW_HOURS`، پیش‌فرض ۷۲) باز می‌کند و «اجازه» می‌زند؛ بازار با `?code=` به همان آدرس برمی‌گردد
و GETش refresh token را می‌گیرد و در دیتابیس نگه می‌دارد؛ پنجره با همین اتصالِ موفق و GETش refresh token را می‌گیرد و در دیتابیس نگه می‌دارد؛ پنجره با همین اتصالِ موفق
بسته می‌شود. POSTِ همان آدرس تأییدِ بسته می‌شود. POSTِ همان آدرس تأییدِ
خریدِ اپِ اندروید است. شکلِ لینکِ authorize و آدرسِ خریدِ بازار با سندِ فعلیِ بازار خریدِ اپِ اندروید است. آدرس‌ها با سندِ بازار مقایسه شده‌اند (۲۰۲۶-۰۹-۲۶)؛ هنوز هیچ خریدِ واقعی‌ای
مقایسه نشده؛ اولین اتصالِ واقعی محکش است. از آن‌ها رد نشده. سند سایتی JS است ولی متنش از
`developers.cafebazaar.ir/wp-json/wp/v2/document?search=...` خوانا است.
**پرداخت‌های تأییدنشده:** اپ هر بار که وضعیتِ اشتراک را می‌خواند، سفارش‌های **پرداخت‌های تأییدنشده:** اپ هر بار که وضعیتِ اشتراک را می‌خواند، سفارش‌های
pendingِ همان کاربر را دوباره با زرین‌پال می‌سنجد. برای دیدنِ همه یک‌جا: pendingِ همان کاربر را دوباره با زرین‌پال می‌سنجد. برای دیدنِ همه یک‌جا:

View File

@@ -26,7 +26,7 @@ import logging
import secrets import secrets
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from urllib.parse import urlencode from urllib.parse import quote, urlencode
import requests import requests
from django.conf import settings from django.conf import settings
@@ -220,49 +220,85 @@ def _get_access_token() -> str:
return token 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: def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: bool = False) -> BazaarPurchaseResult:
""" """
صحت‌سنجی purchaseToken که کلاینت اندروید بعد از خرید از SDK بازار گرفته. صحت‌سنجی 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(): if not is_configured():
# 🔒 fail-closed: بدون BAZAAR_REFRESH_TOKEN (کلید سرور-به-سرورِ # 🔒 fail-closed: بدونِ کلیدِ بازار هیچ‌وقت به گفته‌ی کلاینت اعتماد
# بازار)، امکان صحت‌سنجیِ واقعیِ purchaseToken با سرور بازار وجود # نمی‌کنیم (قبلاً هر purchaseTokenِ غیرخالی valid فرض می‌شد).
# نداره. قبلاً اینجا هر purchaseToken غیرخالیِ ارسالی از کلاینت
# مستقیماً valid فرض می‌شد (باگ fail-open) که یعنی یک کلاینت
# می‌تونست با فرستادن هر مقدار غیرخالی، بدون خرید واقعی، سفارش رو
# paid کنه. حالا در نبودِ کلیدهای بازار، هیچ‌وقت به گفته‌ی کلاینت
# اعتماد نمی‌کنیم و صریحاً invalid برمی‌گردونیم.
return BazaarPurchaseResult( return BazaarPurchaseResult(
is_valid=False, is_valid=False,
raw={'error': 'bazaar_credentials_not_configured'}, raw={'error': 'bazaar_credentials_not_configured'},
) )
access_token = _get_access_token() # token از کلاینت می‌آید: quote با safe='' تا `/` یا `..` در آن مسیرِ
kind = 'subscriptions' if is_subscription else 'purchases' # دیگری از API را با کلیدِ ما صدا نزند.
url = ( pkg = quote(settings.BAZAAR_PACKAGE_NAME, safe='')
f'{settings.BAZAAR_API_BASE}/api/applications/' sku = quote(product_sku, safe='')
f'{settings.BAZAAR_PACKAGE_NAME}/{kind}/{product_sku}/{purchase_token}/' 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: 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: except requests.RequestException:
logger.exception('Bazaar verify-purchase request failed') logger.exception('Bazaar verify-purchase request failed')
raise BazaarAPIError() raise BazaarAPIError()
if resp.status_code != 200:
return BazaarPurchaseResult(is_valid=False, raw={'status_code': resp.status_code})
try: try:
data = resp.json() data = resp.json()
except ValueError: 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() 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( return BazaarPurchaseResult(
is_valid=purchase_state == 0, # ۰ یعنی خریداری‌شده و معتبر is_valid=is_valid,
purchase_state=purchase_state, purchase_state=purchase_state,
consumption_state=data.get('consumptionState'), consumption_state=data.get('consumptionState'),
developer_payload=data.get('developerPayload', ''), developer_payload=data.get('developerPayload', ''),

View File

@@ -122,3 +122,68 @@ class BazaarOAuthCallbackTests(TestCase):
self.assertTrue(bazaar.verify_purchase(product_sku='s', purchase_token='t').is_valid) self.assertTrue(bazaar.verify_purchase(product_sku='s', purchase_token='t').is_valid)
self.assertEqual(mock_post.call_args.kwargs['data']['refresh_token'], 'db-ref') self.assertEqual(mock_post.call_args.kwargs['data']['refresh_token'], 'db-ref')
@override_settings(
BAZAAR_PISHKHAN_API_SECRET='pishkhan-secret', BAZAAR_REFRESH_TOKEN='',
BAZAAR_PACKAGE_NAME='ir.example.app',
BAZAAR_API_BASE='https://pardakht.cafebazaar.ir/devapi/v2',
)
class BazaarValidateAgainstDocsTests(TestCase):
"""آدرس‌ها و معنای پاسخ‌ها طبقِ سندِ بازار (خوانده‌شده ۲۰۲۶-۰۹-۲۶)."""
def setUp(self):
cache.clear()
def _resp(self, mock_get, status, body):
mock_get.return_value.status_code = status
mock_get.return_value.json.return_value = body
@patch('payment.bazaar.requests.post')
@patch('payment.bazaar.requests.get')
def test_inapp_url_and_new_method_header(self, mock_get, mock_post):
self._resp(mock_get, 200, {'purchaseState': 0, 'consumptionState': 1})
self.assertTrue(bazaar.verify_purchase(product_sku='monthly', purchase_token='tok').is_valid)
self.assertEqual(
mock_get.call_args.args[0],
'https://pardakht.cafebazaar.ir/devapi/v2/api/validate/ir.example.app/inapp/monthly/purchases/tok/',
)
self.assertEqual(mock_get.call_args.kwargs['headers'],
{'CAFEBAZAAR-PISHKHAN-API-SECRET': 'pishkhan-secret'})
mock_post.assert_not_called() # روشِ جدید OAuth نمی‌خواهد
@patch('payment.bazaar.requests.get')
def test_refunded_is_invalid(self, mock_get):
self._resp(mock_get, 200, {'purchaseState': 1})
self.assertFalse(bazaar.verify_purchase(product_sku='s', purchase_token='t').is_valid)
@patch('payment.bazaar.requests.get')
def test_not_found_is_invalid(self, mock_get):
self._resp(mock_get, 404, {'error': 'not_found'})
self.assertFalse(bazaar.verify_purchase(product_sku='s', purchase_token='forged').is_valid)
@patch('payment.bazaar.requests.get')
def test_our_credential_error_is_503_not_invalid(self, mock_get):
# کلیدِ غلط یا سقفِ روزانه نباید خریدِ واقعیِ کاربر را failed کند.
for status in (401, 403, 500):
self._resp(mock_get, status, {'error': 'invalid_credentials'})
with self.assertRaises(bazaar.BazaarAPIError):
bazaar.verify_purchase(product_sku='s', purchase_token='t')
@patch('payment.bazaar.requests.get')
def test_client_token_cannot_escape_the_path(self, mock_get):
self._resp(mock_get, 404, {'error': 'not_found'})
bazaar.verify_purchase(product_sku='s', purchase_token='../../applications/x')
self.assertIn('/purchases/..%2F..%2Fapplications%2Fx/', mock_get.call_args.args[0])
@patch('payment.bazaar.requests.get')
def test_subscription_url_and_expiry(self, mock_get):
now_ms = timezone.now().timestamp() * 1000
self._resp(mock_get, 200, {'validUntilTimestampMsec': now_ms + 86_400_000})
self.assertTrue(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid)
self.assertEqual(
mock_get.call_args.args[0],
'https://pardakht.cafebazaar.ir/devapi/v2/api/applications/ir.example.app/subscriptions/sub/purchases/t',
)
self._resp(mock_get, 200, {'validUntilTimestampMsec': now_ms - 1000})
self.assertFalse(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid)