diff --git a/CLAUDE.md b/CLAUDE.md index 96814fe..2dab0ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. ## وضعیت ۲۰۲۶-۰۹-۲۵ (عصر) — پیامکِ واقعی روشن شد diff --git a/config/health.py b/config/health.py index f5dd20e..411049a 100644 --- a/config/health.py +++ b/config/health.py @@ -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 diff --git a/config/settings.py b/config/settings.py index dab94b5..adba986 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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ِ diff --git a/docs/operations.md b/docs/operations.md index 23e1bf0..f501447 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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ِ همان کاربر را دوباره با زرین‌پال می‌سنجد. برای دیدنِ همه یک‌جا: diff --git a/payment/bazaar.py b/payment/bazaar.py index c352f52..b295815 100644 --- a/payment/bazaar.py +++ b/payment/bazaar.py @@ -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//inapp//purchases// + اشتراک: api/applications//subscriptions//purchases/ + به گفته‌ی سند «تنها اگر 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', ''), diff --git a/payment/tests_bazaar_oauth.py b/payment/tests_bazaar_oauth.py index effc6cd..33f4812 100644 --- a/payment/tests_bazaar_oauth.py +++ b/payment/tests_bazaar_oauth.py @@ -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)