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 خریدِ اپ. لینکِ «اجازه» ۲۶ سپتامبر ۲۱:۳۳ ساخته شد و ۷۲ ساعت باز است؛ تا
صاحبِ حسابِ بازار بازش نکند refresh token نداریم. **هنوز نداریم:** اسمِ پکیجِ اپ (کد حدسِ
`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', '')),
'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')),
'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')),
'bazaar': _bazaar_configured(),
'aiProvider': _ai_provider_state(),
'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''),
'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:
try:
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_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
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')
# Redirect URI که در پنل بازار ثبت شده؛ باید با آن *عیناً* یکی باشد. به
# خواستِ مدیرِ حسابِ بازار (۲۰۲۶-۰۹-۲۶) همان آدرسِ verify است: GETِ آن codeِ

View File

@@ -108,21 +108,27 @@ curl https://api.math.tadrisino2.ir/api/health
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed می‌ماند (`503`) |
| `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ِ قدیمی |
| `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 را می‌دهد |
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |
| `HEARTS_ENFORCED` | خاموش: با صفر قلب هم می‌شود جواب داد. `1` وقتی فرانت برای ۴۰۳ِ `no_hearts` صفحه‌ی «قلب‌ها تمام شد» دارد |
| `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`
باید عیناً همان باشد). در ادمین، «اتصال به API بازار» → actionِ «ساختِ لینکِ
اتصال» (یا `python manage.py bazaar_authorize_link`). صاحبِ حسابِ بازار آن لینک را
ظرفِ سه روز (`BAZAAR_AUTHORIZE_WINDOW_HOURS`، پیش‌فرض ۷۲) باز می‌کند و «اجازه» می‌زند؛ بازار با `?code=` به همان آدرس برمی‌گردد
و GETش refresh token را می‌گیرد و در دیتابیس نگه می‌دارد؛ پنجره با همین اتصالِ موفق
بسته می‌شود. POSTِ همان آدرس تأییدِ
خریدِ اپِ اندروید است. شکلِ لینکِ authorize و آدرسِ خریدِ بازار با سندِ فعلیِ بازار
مقایسه نشده؛ اولین اتصالِ واقعی محکش است.
خریدِ اپِ اندروید است. آدرس‌ها با سندِ بازار مقایسه شده‌اند (۲۰۲۶-۰۹-۲۶)؛ هنوز هیچ خریدِ واقعی‌ای
از آن‌ها رد نشده. سند سایتی JS است ولی متنش از
`developers.cafebazaar.ir/wp-json/wp/v2/document?search=...` خوانا است.
**پرداخت‌های تأییدنشده:** اپ هر بار که وضعیتِ اشتراک را می‌خواند، سفارش‌های
pendingِ همان کاربر را دوباره با زرین‌پال می‌سنجد. برای دیدنِ همه یک‌جا:

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

View File

@@ -122,3 +122,68 @@ class BazaarOAuthCallbackTests(TestCase):
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')
@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)