let the Bazaar verify URL double as the OAuth redirect URI
The Bazaar account owner registered /api/payment/bazaar/verify as the redirect URI. POST stays the app's purchase check; GET now takes Bazaar's ?code=, exchanges it for a refresh token and stores it in the new BazaarCredential row, so connecting needs no SSH or .env edit. The GET is unauthenticated, so it only acts inside a window opened from the admin (or bazaar_authorize_link): 72 h by default, closed by the first successful exchange, state checked when Bazaar echoes it. Authorize/token URL shapes follow the known devapi v2 flow; not checked against Bazaar's current docs, which did not load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
'این لینک را صاحبِ حسابِ بازار باز کند و «اجازه» بزند: <a href="{0}">{0}</a>', url,
|
||||
), messages.SUCCESS)
|
||||
|
||||
@@ -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 غیرخالیِ ارسالی از کلاینت
|
||||
|
||||
10
payment/management/commands/bazaar_authorize_link.py
Normal file
10
payment/management/commands/bazaar_authorize_link.py
Normal file
@@ -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())
|
||||
27
payment/migrations/0008_bazaar_credential.py
Normal file
27
payment/migrations/0008_bazaar_credential.py
Normal file
@@ -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 بازار',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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 'متصل نیست'
|
||||
|
||||
124
payment/tests_bazaar_oauth.py
Normal file
124
payment/tests_bazaar_oauth.py
Normal file
@@ -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')
|
||||
@@ -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(
|
||||
'<!doctype html><meta charset="utf-8"><title>اتصال به بازار</title>'
|
||||
f'<p dir="rtl" style="font:16px sans-serif;margin:2em">{escape(message)}</p>',
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user