diff --git a/config/settings.py b/config/settings.py index 5a57ae8..dab94b5 100644 --- a/config/settings.py +++ b/config/settings.py @@ -430,6 +430,7 @@ REST_FRAMEWORK = { 'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '30/hour'), 'guest_session': os.environ.get('GUEST_SESSION_IP_RATE', '60/hour'), 'bazaar_verify': '10/hour', + 'bazaar_oauth': '20/hour', 'ai_ask': '20/hour', 'rag_ingest': '30/hour', # سقفِ ارزانِ خواندن روی endpointهای پرترافیکِ GET (home/leaderboard/ @@ -723,6 +724,16 @@ 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', '') BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2') +# Redirect URI که در پنل بازار ثبت شده؛ باید با آن *عیناً* یکی باشد. به +# خواستِ مدیرِ حسابِ بازار (۲۰۲۶-۰۹-۲۶) همان آدرسِ verify است: GETِ آن codeِ +# بازار را میگیرد و POSTِ آن خریدِ اپ را. PUBLIC_BASE_URL پایینتر تعریف +# شده، پس آدرس اینجا کامل نوشته میشود. +BAZAAR_REDIRECT_URI = ( + os.environ.get('BAZAAR_REDIRECT_URI', '').strip() + or 'https://api.math.tadrisino2.ir/api/payment/bazaar/verify' +) +# چند ساعت لینکِ «اجازه»ی بازار باز میماند؛ بعد از اتصالِ موفق خودش بسته میشود. +BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOURS', '72')) # ----------------------------------------------------------------------- # Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4) diff --git a/docs/operations.md b/docs/operations.md index 01cf963..23e1bf0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -108,11 +108,22 @@ 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` لازم نیست — بخشِ «اتصال به بازار» | | `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ِ ثبتشده در پنلِ بازار همان +`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 و آدرسِ خریدِ بازار با سندِ فعلیِ بازار +مقایسه نشده؛ اولین اتصالِ واقعی محکش است. + **پرداختهای تأییدنشده:** اپ هر بار که وضعیتِ اشتراک را میخواند، سفارشهای pendingِ همان کاربر را دوباره با زرینپال میسنجد. برای دیدنِ همه یکجا: `python manage.py payment_reconcile --days 3`. diff --git a/payment/admin.py b/payment/admin.py index 4bf1c7d..ea1ab00 100644 --- a/payment/admin.py +++ b/payment/admin.py @@ -1,6 +1,8 @@ -from django.contrib import admin +from django.contrib import admin, messages +from django.utils.html import format_html -from .models import Order, Transaction +from . import bazaar +from .models import BazaarCredential, Order, Transaction class TransactionInline(admin.TabularInline): @@ -19,3 +21,35 @@ class OrderAdmin(admin.ModelAdmin): class TransactionAdmin(admin.ModelAdmin): list_display = ('id', 'order', 'gateway', 'status', 'paid_at') list_filter = ('gateway', 'status') + + +@admin.register(BazaarCredential) +class BazaarCredentialAdmin(admin.ModelAdmin): + """ + اتصالِ یکباره به API بازار، بدونِ SSH: action لینکِ «اجازه» را میسازد؛ + صاحبِ حسابِ بازار بازش میکند و بازار به verify برمیگردد. خودِ توکن + هیچجا نمایش داده نمیشود. + """ + + list_display = ('__str__', 'authorized_at', 'pending_since') + fields = ('connected', 'authorized_at', 'pending_since') + readonly_fields = fields + actions = ['make_authorize_link'] + + @admin.display(boolean=True, description='توکن دارد') + def connected(self, obj): + return bool(obj.refresh_token) + + def changelist_view(self, request, extra_context=None): + BazaarCredential.objects.get_or_create(pk=1) + return super().changelist_view(request, extra_context) + + def has_add_permission(self, request): + return False + + @admin.action(description='ساختِ لینکِ اتصال به بازار') + def make_authorize_link(self, request, queryset): + url = bazaar.start_authorization() + self.message_user(request, format_html( + 'این لینک را صاحبِ حسابِ بازار باز کند و «اجازه» بزند: {0}', url, + ), messages.SUCCESS) diff --git a/payment/bazaar.py b/payment/bazaar.py index 33ea37b..c352f52 100644 --- a/payment/bazaar.py +++ b/payment/bazaar.py @@ -3,9 +3,10 @@ رسمی: developers.cafebazaar.ir/fa/guidelines/in-app-billing جریان رسمی: - ۱) یکبار (دستی، از پنل devapi توسعهدهنده) با CLIENT_ID/CLIENT_SECRET یک - authorization code گرفته میشه و با اون REFRESH_TOKEN ساخته میشه — - این مرحله در این فایل نیست چون یکبار و دستیست، نه بخشی از API عمومی. + ۱) یکبار: صاحبِ حسابِ بازار لینکِ start_authorization را باز میکند و + «اجازه» میزند؛ بازار با `?code=` به Redirect URI (همان + `/api/payment/bazaar/verify`، GET) برمیگردد و complete_authorization + آن را با REFRESH_TOKEN عوض میکند و در BazaarCredential نگه میدارد. ۲) بکاند با همون REFRESH_TOKEN، هر بار (یا هر بار که access_token منقضی شده) یک ACCESS_TOKEN تازه میگیره (POST /auth/token با grant_type=refresh_token). @@ -22,11 +23,15 @@ fail-closed عمل میکنه و invalid برمیگردونه؛ هیچ ارسالیِ کلاینت، بدون صحتسنجیِ واقعی با سرور بازار، اعتماد نمیشه. """ import logging +import secrets from dataclasses import dataclass +from datetime import timedelta +from urllib.parse import urlencode import requests from django.conf import settings from django.core.cache import cache +from django.utils import timezone from rest_framework.exceptions import APIException logger = logging.getLogger(__name__) @@ -53,6 +58,119 @@ class BazaarPurchaseResult: raw: dict | None = None +def _authorize_window() -> timedelta: + # پنجرهی اتصال: از ساختِ لینک تا برگشتن از بازار. پیشفرضِ سه روز چون + # صاحبِ حسابِ بازار آدمِ دیگری است و ممکن است یکی دو روز طول بکشد + # (تصمیمِ صاحبِ پروژه، ۲۰۲۶-۰۹-۲۶). بعد از اتصالِ موفق پنجره خودش بسته + # میشود، پس بلندیاش فقط تا همان لحظه در معرض است. + return timedelta(hours=settings.BAZAAR_AUTHORIZE_WINDOW_HOURS) + + +def get_refresh_token() -> str: + """مقدارِ `.env` اگر باشد، وگرنه آنچه بعد از «اجازه» در دیتابیس ذخیره شد.""" + if settings.BAZAAR_REFRESH_TOKEN: + return settings.BAZAAR_REFRESH_TOKEN + from .models import BazaarCredential + cred = BazaarCredential.objects.filter(pk=1).first() + return cred.refresh_token if cred else '' + + +def start_authorization() -> str: + """ + پنجرهی اتصال را باز میکند و لینکی برمیگرداند که صاحبِ حسابِ بازار باید + باز کند و «اجازه» بزند. بازار بعدش به BAZAAR_REDIRECT_URI (همان verify) + با `?code=` برمیگردد. + + ⚠️ شکلِ این آدرس از روندِ شناختهشدهی devapi v2 است؛ سندِ فعلیِ بازار + (صفحهی JS) خوانده نشد. اینکه بازار `state` را برمیگرداند یا نه هم + دیده نشده — برای همین complete_authorization نبودِ state را تحمل میکند. + """ + from .models import BazaarCredential + state = secrets.token_urlsafe(24) + BazaarCredential.objects.update_or_create( + pk=1, defaults={'pending_state': state, 'pending_since': timezone.now()}, + ) + query = urlencode({ + 'response_type': 'code', + 'access_type': 'offline', + 'redirect_uri': settings.BAZAAR_REDIRECT_URI, + 'client_id': settings.BAZAAR_CLIENT_ID, + 'state': state, + }) + return f'{settings.BAZAAR_API_BASE}/auth/authorize/?{query}' + + +class BazaarAuthorizationError(Exception): + """پیامش مستقیم به کسی که در مرورگر است نشان داده میشود؛ رازی در آن نیست.""" + + +def complete_authorization(*, code: str, state: str = '') -> None: + """ + codeی که بازار به Redirect URI فرستاد را با refresh token عوض و ذخیره میکند. + فقط داخلِ پنجرهی باز کار میکند؛ اگر بازار state را برگرداند باید با + همانی که ساختیم یکی باشد. پنجره فقط با اتصالِ موفق بسته میشود، تا یک + درخواستِ خراب (یا عمدی با codeِ بیمعنی) پنجرهی چندروزه را نسوزاند؛ + تلاشها با throttleِ `bazaar_oauth` محدودند. + """ + from django.db import transaction + + from .models import BazaarCredential + + if not (settings.BAZAAR_CLIENT_ID and settings.BAZAAR_CLIENT_SECRET): + raise BazaarAuthorizationError('کلیدهای بازار (CLIENT_ID/SECRET) روی سرور تنظیم نشدهاند.') + + cred = BazaarCredential.objects.filter(pk=1).first() + if ( + not cred or not cred.pending_since + or timezone.now() - cred.pending_since > _authorize_window() + ): + raise BazaarAuthorizationError('پنجرهی اتصال باز نیست؛ لینکِ تازه بسازید.') + if state and not secrets.compare_digest(state, cred.pending_state): + raise BazaarAuthorizationError('state با لینکی که ساخته شد یکی نیست.') + opened_at = cred.pending_since + + try: + resp = requests.post( + f'{settings.BAZAAR_API_BASE}/auth/token/', + data={ + 'grant_type': 'authorization_code', + 'code': code, + 'client_id': settings.BAZAAR_CLIENT_ID, + 'client_secret': settings.BAZAAR_CLIENT_SECRET, + 'redirect_uri': settings.BAZAAR_REDIRECT_URI, + }, + timeout=10, + ) + except requests.RequestException: + logger.exception('Bazaar code exchange failed') + raise BazaarAuthorizationError('بازار در دسترس نبود؛ دوباره لینک بسازید و امتحان کنید.') + + try: + data = resp.json() + except ValueError: + data = {} + refresh_token = data.get('refresh_token') if isinstance(data, dict) else None + if resp.status_code != 200 or not refresh_token: + # بدنه را لاگ نمیکنیم: ممکن است توکن داشته باشد. + logger.error('Bazaar code exchange rejected: status=%s keys=%s', + resp.status_code, sorted(data) if isinstance(data, dict) else '?') + raise BazaarAuthorizationError(f'بازار code را نپذیرفت (HTTP {resp.status_code}).') + + # بستنِ پنجره شرطی است: از دو درخواستِ همزمان فقط اولی ذخیره میکند. + with transaction.atomic(): + stored = BazaarCredential.objects.filter(pk=1, pending_since=opened_at).update( + refresh_token=refresh_token, authorized_at=timezone.now(), + pending_state='', pending_since=None, + ) + if not stored: + raise BazaarAuthorizationError('این لینک همین الان یک بار استفاده شد.') + cache.delete(ACCESS_TOKEN_CACHE_KEY) + if data.get('access_token'): + cache.set(ACCESS_TOKEN_CACHE_KEY, data['access_token'], + timeout=max(int(data.get('expires_in', 3600)) - 60, 60)) + logger.info('Bazaar API authorized; refresh token stored') + + def _get_access_token() -> str: """ ACCESS_TOKEN رو کش میکنه (توی cache framework جنگو) تا هر verify یک @@ -63,7 +181,8 @@ def _get_access_token() -> str: if cached: return cached - if not settings.BAZAAR_REFRESH_TOKEN: + refresh_token = get_refresh_token() + if not refresh_token: # بدون REFRESH_TOKEN، امکان گرفتن access_token واقعی از بازار نیست؛ # قبلاً اینجا یک 'stub-access-token' برمیگشت که باعث میشد # verify_purchase هر purchaseToken غیرخالی رو valid فرض کنه @@ -79,7 +198,7 @@ def _get_access_token() -> str: 'grant_type': 'refresh_token', 'client_id': settings.BAZAAR_CLIENT_ID, 'client_secret': settings.BAZAAR_CLIENT_SECRET, - 'refresh_token': settings.BAZAAR_REFRESH_TOKEN, + 'refresh_token': refresh_token, }, timeout=10, ) @@ -107,7 +226,7 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b is_subscription=True برای اشتراکهای دورهای بازار (endpoint جدا از خرید تکمحصولی)، طبق سند رسمی. """ - if not settings.BAZAAR_REFRESH_TOKEN: + if not get_refresh_token(): # 🔒 fail-closed: بدون BAZAAR_REFRESH_TOKEN (کلید سرور-به-سرورِ # بازار)، امکان صحتسنجیِ واقعیِ purchaseToken با سرور بازار وجود # نداره. قبلاً اینجا هر purchaseToken غیرخالیِ ارسالی از کلاینت diff --git a/payment/management/commands/bazaar_authorize_link.py b/payment/management/commands/bazaar_authorize_link.py new file mode 100644 index 0000000..230d1b1 --- /dev/null +++ b/payment/management/commands/bazaar_authorize_link.py @@ -0,0 +1,10 @@ +from django.core.management.base import BaseCommand + +from payment import bazaar + + +class Command(BaseCommand): + help = 'لینکِ «اجازه»ی بازار را میسازد (همان actionِ ادمینِ «اتصال به API بازار»).' + + def handle(self, *args, **options): + self.stdout.write(bazaar.start_authorization()) diff --git a/payment/migrations/0008_bazaar_credential.py b/payment/migrations/0008_bazaar_credential.py new file mode 100644 index 0000000..de9c067 --- /dev/null +++ b/payment/migrations/0008_bazaar_credential.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.17 on 2026-09-26 17:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment', '0007_order_plan_id'), + ] + + operations = [ + migrations.CreateModel( + name='BazaarCredential', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('refresh_token', models.TextField(blank=True)), + ('authorized_at', models.DateTimeField(blank=True, null=True)), + ('pending_state', models.CharField(blank=True, max_length=64)), + ('pending_since', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'verbose_name': 'اتصال به API بازار', + 'verbose_name_plural': 'اتصال به API بازار', + }, + ), + ] diff --git a/payment/models.py b/payment/models.py index 484b66e..fd71a01 100644 --- a/payment/models.py +++ b/payment/models.py @@ -69,3 +69,29 @@ class Transaction(models.Model): def __str__(self): return f"txn-{self.pk}-order{self.order_id}" + + +class BazaarCredential(models.Model): + """ + refresh tokenِ API بازار، که با اولین «اجازه دادن» در پنل بازار ساخته + میشود. در دیتابیس است نه `.env` تا گرفتنش کسی را مجبور به SSH نکند: + بازار کاربر را با `?code=` به همان `/api/payment/bazaar/verify` + برمیگرداند (Redirect URIای که در پنل ثبت شده) و ویو خودش code را عوض + میکند. یک ردیف بیشتر ندارد (pk=1). + + `pending_state`/`pending_since` پنجرهی اتصال است: GETِ بیپنجره هیچ + کاری نمیکند، تا کسی نتواند با codeِ حسابِ بازارِ خودش توکنِ ما را + عوض کند. `BAZAAR_REFRESH_TOKEN` در `.env`، اگر باشد، بر این ردیف مقدم است. + """ + + refresh_token = models.TextField(blank=True) + authorized_at = models.DateTimeField(null=True, blank=True) + pending_state = models.CharField(max_length=64, blank=True) + pending_since = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = 'اتصال به API بازار' + verbose_name_plural = 'اتصال به API بازار' + + def __str__(self): + return 'متصل' if self.refresh_token else 'متصل نیست' diff --git a/payment/tests_bazaar_oauth.py b/payment/tests_bazaar_oauth.py new file mode 100644 index 0000000..effc6cd --- /dev/null +++ b/payment/tests_bazaar_oauth.py @@ -0,0 +1,124 @@ +from datetime import timedelta +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +from django.core.cache import cache +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from . import bazaar +from .models import BazaarCredential + +URL = '/api/payment/bazaar/verify' + + +@override_settings( + BAZAAR_CLIENT_ID='cid', BAZAAR_CLIENT_SECRET='csecret', BAZAAR_REFRESH_TOKEN='', + BAZAAR_API_BASE='https://pardakht.cafebazaar.ir/devapi/v2', + BAZAAR_REDIRECT_URI='https://api.example/api/payment/bazaar/verify', +) +class BazaarOAuthCallbackTests(TestCase): + """GETِ verify همان Redirect URIِ بازار است؛ POSTش خریدِ اپ.""" + + def setUp(self): + cache.clear() + self.client = APIClient() + + def _token_response(self, mock_post, status=200, body=None): + mock_post.return_value.status_code = status + mock_post.return_value.json.return_value = body if body is not None else { + 'access_token': 'acc', 'refresh_token': 'ref-123', 'expires_in': 3600, + } + + def test_authorize_link_points_back_to_verify(self): + q = parse_qs(urlsplit(bazaar.start_authorization()).query) + self.assertEqual(q['redirect_uri'], ['https://api.example/api/payment/bazaar/verify']) + self.assertEqual(q['client_id'], ['cid']) + self.assertEqual(q['state'], [BazaarCredential.objects.get(pk=1).pending_state]) + + @patch('payment.bazaar.requests.post') + def test_code_is_exchanged_and_stored(self, mock_post): + self._token_response(mock_post) + state = parse_qs(urlsplit(bazaar.start_authorization()).query)['state'][0] + + resp = self.client.get(URL, {'code': 'the-code', 'state': state}) + + self.assertEqual(resp.status_code, 200) + self.assertNotIn('ref-123', resp.content.decode()) + sent = mock_post.call_args.kwargs['data'] + self.assertEqual(sent['grant_type'], 'authorization_code') + self.assertEqual(sent['code'], 'the-code') + self.assertEqual(sent['redirect_uri'], 'https://api.example/api/payment/bazaar/verify') + self.assertEqual(bazaar.get_refresh_token(), 'ref-123') + cred = BazaarCredential.objects.get(pk=1) + self.assertEqual(cred.pending_state, '') + + @patch('payment.bazaar.requests.post') + def test_without_open_window_nothing_is_called(self, mock_post): + resp = self.client.get(URL, {'code': 'attacker-code'}) + self.assertEqual(resp.status_code, 400) + mock_post.assert_not_called() + self.assertEqual(bazaar.get_refresh_token(), '') + + @patch('payment.bazaar.requests.post') + def test_expired_window_is_refused(self, mock_post): + bazaar.start_authorization() + BazaarCredential.objects.filter(pk=1).update( + pending_since=timezone.now() - timedelta(days=4), + ) + self.assertEqual(self.client.get(URL, {'code': 'c'}).status_code, 400) + mock_post.assert_not_called() + + @patch('payment.bazaar.requests.post') + def test_wrong_state_is_refused_and_closes_nothing_new(self, mock_post): + bazaar.start_authorization() + self.assertEqual(self.client.get(URL, {'code': 'c', 'state': 'forged'}).status_code, 400) + mock_post.assert_not_called() + + @patch('payment.bazaar.requests.post') + def test_window_is_single_use(self, mock_post): + self._token_response(mock_post) + bazaar.start_authorization() + self.assertEqual(self.client.get(URL, {'code': 'c1'}).status_code, 200) + self.assertEqual(self.client.get(URL, {'code': 'c2'}).status_code, 400) + self.assertEqual(mock_post.call_count, 1) + + @patch('payment.bazaar.requests.post') + def test_rejected_code_stores_nothing_and_keeps_window_open(self, mock_post): + self._token_response(mock_post, status=400, body={'error': 'invalid_grant'}) + bazaar.start_authorization() + self.assertEqual(self.client.get(URL, {'code': 'bad'}).status_code, 400) + self.assertEqual(bazaar.get_refresh_token(), '') + + self._token_response(mock_post) + self.assertEqual(self.client.get(URL, {'code': 'good'}).status_code, 200) + self.assertEqual(bazaar.get_refresh_token(), 'ref-123') + + @patch('payment.bazaar.requests.post') + def test_two_days_later_the_link_still_works(self, mock_post): + self._token_response(mock_post) + bazaar.start_authorization() + BazaarCredential.objects.filter(pk=1).update( + pending_since=timezone.now() - timedelta(days=2), + ) + self.assertEqual(self.client.get(URL, {'code': 'c'}).status_code, 200) + + def test_bazaar_error_param_is_reported(self): + self.assertEqual(self.client.get(URL, {'error': 'access_denied'}).status_code, 400) + + def test_post_still_requires_auth(self): + resp = self.client.post(URL, {'orderId': '1', 'purchaseToken': 't'}, format='json') + self.assertIn(resp.status_code, (401, 403)) + + @patch('payment.bazaar.requests.get') + @patch('payment.bazaar.requests.post') + def test_stored_token_is_used_for_purchase_verification(self, mock_post, mock_get): + BazaarCredential.objects.create(pk=1, refresh_token='db-ref') + mock_post.return_value.raise_for_status = lambda: None + mock_post.return_value.json.return_value = {'access_token': 'acc', 'expires_in': 3600} + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {'purchaseState': 0} + + 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') diff --git a/payment/views.py b/payment/views.py index e4c055e..19f735a 100644 --- a/payment/views.py +++ b/payment/views.py @@ -2,7 +2,8 @@ from urllib.parse import urlsplit from django.conf import settings from django.db import transaction -from django.http import HttpResponseRedirect +from django.http import HttpResponse, HttpResponseRedirect +from django.utils.html import escape from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema @@ -219,9 +220,24 @@ class ZarinpalCallbackView(APIView): return Response(ZarinpalCallbackResponseSerializer(data).data) +def _bazaar_oauth_page(message: str, *, status: int) -> HttpResponse: + # این را صاحبِ حسابِ بازار در مرورگر میبیند، نه اپ؛ پس HTML ساده. + return HttpResponse( + '
{escape(message)}
', + status=status, + ) + + class BazaarVerifyPurchaseView(APIView): """ POST /api/payment/bazaar/verify — تایید پرداخت درونبرنامهای بازار + GET /api/payment/bazaar/verify?code=… — Redirect URIِ OAuthِ بازار + + همین آدرس در پنلِ بازار Redirect URI ثبت شده (خواستِ مدیرِ حساب، + ۲۰۲۶-۰۹-۲۶)، پس دو کار دارد که با متد از هم جدا میشوند: GET را مرورگرِ + صاحبِ حساب بعد از «اجازه» میزند و بیاحراز است (فقط داخلِ پنجرهای که + start_authorization باز کرده اثر دارد)؛ POST را اپِ اندروید بعد از خرید. برخلاف درگاه معمولی که با webhook خودِ درگاه تایید میشه، در پرداخت درونبرنامهای بازار هیچ webhookی نیست: کلاینت اندروید بعد از خرید از @@ -241,6 +257,33 @@ class BazaarVerifyPurchaseView(APIView): throttle_scope = 'bazaar_verify' throttle_classes = [GuestAwareBazaarThrottle] + def get_permissions(self): + if self.request.method == 'GET': + return [AllowAny()] + return super().get_permissions() + + def get_throttles(self): + if self.request.method == 'GET': + self.throttle_scope = 'bazaar_oauth' + return super().get_throttles() + + @extend_schema(exclude=True) + def get(self, request): + if request.query_params.get('error'): + return _bazaar_oauth_page( + f'بازار اجازه نداد: {request.query_params.get("error")}', status=400, + ) + code = request.query_params.get('code', '') + if not code: + return _bazaar_oauth_page( + 'این آدرس برای برگشت از بازار است و بدون code کاری نمیکند.', status=400, + ) + try: + bazaar.complete_authorization(code=code, state=request.query_params.get('state', '')) + except bazaar.BazaarAuthorizationError as exc: + return _bazaar_oauth_page(str(exc), status=400) + return _bazaar_oauth_page('اتصال به بازار انجام شد. میتوانید این صفحه را ببندید.', status=200) + @extend_schema( request=BazaarVerifyRequestSerializer, responses=BazaarVerifyResponseSerializer,