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:
@@ -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`.
|
||||
|
||||
## وضعیت ۲۰۲۶-۰۹-۲۵ (عصر) — پیامکِ واقعی روشن شد
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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ِ
|
||||
|
||||
@@ -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ِ همان کاربر را دوباره با زرینپال میسنجد. برای دیدنِ همه یکجا:
|
||||
|
||||
@@ -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', ''),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user