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:
@@ -430,6 +430,7 @@ REST_FRAMEWORK = {
|
|||||||
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '30/hour'),
|
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '30/hour'),
|
||||||
'guest_session': os.environ.get('GUEST_SESSION_IP_RATE', '60/hour'),
|
'guest_session': os.environ.get('GUEST_SESSION_IP_RATE', '60/hour'),
|
||||||
'bazaar_verify': '10/hour',
|
'bazaar_verify': '10/hour',
|
||||||
|
'bazaar_oauth': '20/hour',
|
||||||
'ai_ask': '20/hour',
|
'ai_ask': '20/hour',
|
||||||
'rag_ingest': '30/hour',
|
'rag_ingest': '30/hour',
|
||||||
# سقفِ ارزانِ خواندن روی endpointهای پرترافیکِ GET (home/leaderboard/
|
# سقفِ ارزانِ خواندن روی 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_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
|
||||||
BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
|
BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
|
||||||
BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2')
|
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)
|
# Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4)
|
||||||
|
|||||||
@@ -108,11 +108,22 @@ curl https://api.math.tadrisino2.ir/api/health
|
|||||||
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed میماند (`503`) |
|
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed میماند (`503`) |
|
||||||
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشهی کاری میخواندش: workerهای `gthread` با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ همزمانِ دستیار کلِ API را قفل میکرد |
|
| `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ِ قدیمی |
|
| `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 را میدهد |
|
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را میدهد |
|
||||||
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |
|
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |
|
||||||
| `HEARTS_ENFORCED` | خاموش: با صفر قلب هم میشود جواب داد. `1` وقتی فرانت برای ۴۰۳ِ `no_hearts` صفحهی «قلبها تمام شد» دارد |
|
| `HEARTS_ENFORCED` | خاموش: با صفر قلب هم میشود جواب داد. `1` وقتی فرانت برای ۴۰۳ِ `no_hearts` صفحهی «قلبها تمام شد» دارد |
|
||||||
| `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بستهاند |
|
| `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ِ همان کاربر را دوباره با زرینپال میسنجد. برای دیدنِ همه یکجا:
|
pendingِ همان کاربر را دوباره با زرینپال میسنجد. برای دیدنِ همه یکجا:
|
||||||
`python manage.py payment_reconcile --days 3`.
|
`python manage.py payment_reconcile --days 3`.
|
||||||
|
|||||||
@@ -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):
|
class TransactionInline(admin.TabularInline):
|
||||||
@@ -19,3 +21,35 @@ class OrderAdmin(admin.ModelAdmin):
|
|||||||
class TransactionAdmin(admin.ModelAdmin):
|
class TransactionAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'order', 'gateway', 'status', 'paid_at')
|
list_display = ('id', 'order', 'gateway', 'status', 'paid_at')
|
||||||
list_filter = ('gateway', 'status')
|
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
|
رسمی: developers.cafebazaar.ir/fa/guidelines/in-app-billing
|
||||||
|
|
||||||
جریان رسمی:
|
جریان رسمی:
|
||||||
۱) یکبار (دستی، از پنل devapi توسعهدهنده) با CLIENT_ID/CLIENT_SECRET یک
|
۱) یکبار: صاحبِ حسابِ بازار لینکِ start_authorization را باز میکند و
|
||||||
authorization code گرفته میشه و با اون REFRESH_TOKEN ساخته میشه —
|
«اجازه» میزند؛ بازار با `?code=` به Redirect URI (همان
|
||||||
این مرحله در این فایل نیست چون یکبار و دستیست، نه بخشی از API عمومی.
|
`/api/payment/bazaar/verify`، GET) برمیگردد و complete_authorization
|
||||||
|
آن را با REFRESH_TOKEN عوض میکند و در BazaarCredential نگه میدارد.
|
||||||
۲) بکاند با همون REFRESH_TOKEN، هر بار (یا هر بار که access_token منقضی
|
۲) بکاند با همون REFRESH_TOKEN، هر بار (یا هر بار که access_token منقضی
|
||||||
شده) یک ACCESS_TOKEN تازه میگیره (POST /auth/token با
|
شده) یک ACCESS_TOKEN تازه میگیره (POST /auth/token با
|
||||||
grant_type=refresh_token).
|
grant_type=refresh_token).
|
||||||
@@ -22,11 +23,15 @@ fail-closed عمل میکنه و invalid برمیگردونه؛ هیچ
|
|||||||
ارسالیِ کلاینت، بدون صحتسنجیِ واقعی با سرور بازار، اعتماد نمیشه.
|
ارسالیِ کلاینت، بدون صحتسنجیِ واقعی با سرور بازار، اعتماد نمیشه.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework.exceptions import APIException
|
from rest_framework.exceptions import APIException
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -53,6 +58,119 @@ class BazaarPurchaseResult:
|
|||||||
raw: dict | None = None
|
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:
|
def _get_access_token() -> str:
|
||||||
"""
|
"""
|
||||||
ACCESS_TOKEN رو کش میکنه (توی cache framework جنگو) تا هر verify یک
|
ACCESS_TOKEN رو کش میکنه (توی cache framework جنگو) تا هر verify یک
|
||||||
@@ -63,7 +181,8 @@ def _get_access_token() -> str:
|
|||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
if not settings.BAZAAR_REFRESH_TOKEN:
|
refresh_token = get_refresh_token()
|
||||||
|
if not refresh_token:
|
||||||
# بدون REFRESH_TOKEN، امکان گرفتن access_token واقعی از بازار نیست؛
|
# بدون REFRESH_TOKEN، امکان گرفتن access_token واقعی از بازار نیست؛
|
||||||
# قبلاً اینجا یک 'stub-access-token' برمیگشت که باعث میشد
|
# قبلاً اینجا یک 'stub-access-token' برمیگشت که باعث میشد
|
||||||
# verify_purchase هر purchaseToken غیرخالی رو valid فرض کنه
|
# verify_purchase هر purchaseToken غیرخالی رو valid فرض کنه
|
||||||
@@ -79,7 +198,7 @@ def _get_access_token() -> str:
|
|||||||
'grant_type': 'refresh_token',
|
'grant_type': 'refresh_token',
|
||||||
'client_id': settings.BAZAAR_CLIENT_ID,
|
'client_id': settings.BAZAAR_CLIENT_ID,
|
||||||
'client_secret': settings.BAZAAR_CLIENT_SECRET,
|
'client_secret': settings.BAZAAR_CLIENT_SECRET,
|
||||||
'refresh_token': settings.BAZAAR_REFRESH_TOKEN,
|
'refresh_token': refresh_token,
|
||||||
},
|
},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
@@ -107,7 +226,7 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b
|
|||||||
is_subscription=True برای اشتراکهای دورهای بازار (endpoint جدا از خرید
|
is_subscription=True برای اشتراکهای دورهای بازار (endpoint جدا از خرید
|
||||||
تکمحصولی)، طبق سند رسمی.
|
تکمحصولی)، طبق سند رسمی.
|
||||||
"""
|
"""
|
||||||
if not settings.BAZAAR_REFRESH_TOKEN:
|
if not get_refresh_token():
|
||||||
# 🔒 fail-closed: بدون BAZAAR_REFRESH_TOKEN (کلید سرور-به-سرورِ
|
# 🔒 fail-closed: بدون BAZAAR_REFRESH_TOKEN (کلید سرور-به-سرورِ
|
||||||
# بازار)، امکان صحتسنجیِ واقعیِ purchaseToken با سرور بازار وجود
|
# بازار)، امکان صحتسنجیِ واقعیِ purchaseToken با سرور بازار وجود
|
||||||
# نداره. قبلاً اینجا هر 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):
|
def __str__(self):
|
||||||
return f"txn-{self.pk}-order{self.order_id}"
|
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.conf import settings
|
||||||
from django.db import transaction
|
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 django.shortcuts import get_object_or_404
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
@@ -219,9 +220,24 @@ class ZarinpalCallbackView(APIView):
|
|||||||
return Response(ZarinpalCallbackResponseSerializer(data).data)
|
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):
|
class BazaarVerifyPurchaseView(APIView):
|
||||||
"""
|
"""
|
||||||
POST /api/payment/bazaar/verify — تایید پرداخت درونبرنامهای بازار
|
POST /api/payment/bazaar/verify — تایید پرداخت درونبرنامهای بازار
|
||||||
|
GET /api/payment/bazaar/verify?code=… — Redirect URIِ OAuthِ بازار
|
||||||
|
|
||||||
|
همین آدرس در پنلِ بازار Redirect URI ثبت شده (خواستِ مدیرِ حساب،
|
||||||
|
۲۰۲۶-۰۹-۲۶)، پس دو کار دارد که با متد از هم جدا میشوند: GET را مرورگرِ
|
||||||
|
صاحبِ حساب بعد از «اجازه» میزند و بیاحراز است (فقط داخلِ پنجرهای که
|
||||||
|
start_authorization باز کرده اثر دارد)؛ POST را اپِ اندروید بعد از خرید.
|
||||||
|
|
||||||
برخلاف درگاه معمولی که با webhook خودِ درگاه تایید میشه، در پرداخت
|
برخلاف درگاه معمولی که با webhook خودِ درگاه تایید میشه، در پرداخت
|
||||||
درونبرنامهای بازار هیچ webhookی نیست: کلاینت اندروید بعد از خرید از
|
درونبرنامهای بازار هیچ webhookی نیست: کلاینت اندروید بعد از خرید از
|
||||||
@@ -241,6 +257,33 @@ class BazaarVerifyPurchaseView(APIView):
|
|||||||
throttle_scope = 'bazaar_verify'
|
throttle_scope = 'bazaar_verify'
|
||||||
throttle_classes = [GuestAwareBazaarThrottle]
|
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(
|
@extend_schema(
|
||||||
request=BazaarVerifyRequestSerializer,
|
request=BazaarVerifyRequestSerializer,
|
||||||
responses=BazaarVerifyResponseSerializer,
|
responses=BazaarVerifyResponseSerializer,
|
||||||
|
|||||||
Reference in New Issue
Block a user