from django.db import transaction from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from . import bazaar from .models import Order from .permissions import HasInternalServiceKey, HasWebhookSecret from .serializers import ( BazaarVerifyRequestSerializer, BazaarVerifyResponseSerializer, CreateOrderRequestSerializer, CreateOrderResponseSerializer, OrderDetailResponseSerializer, WebhookRequestSerializer, WebhookResponseSerializer, ) from .services import apply_order_payment_result, create_order class GuestAwareBazaarThrottle(ScopedRateThrottle): """ ⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)، GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/ GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py) اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، برای هر حساب یک bucket تازه از throttle_scope 'bazaar_verify' بگیره — درحالی‌که هر درخواستِ پذیرفته‌شده یک فراخوانیِ سرور-به-سرور به API بازار رو تریگر می‌کنه. دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان (ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ verify/order ownership/transaction/apply-payment دست‌نخورده‌ست. """ def get_cache_key(self, request, view): user = request.user if user and user.is_authenticated and getattr(user, 'is_guest', False): ident = f'guest-ip:{self.get_ident(request)}' elif user and user.is_authenticated: ident = user.pk else: ident = self.get_ident(request) return self.cache_format % {'scope': self.scope, 'ident': ident} class CreateOrderView(APIView): """ POST /api/payment/orders — ساخت سفارش (فراخوانی‌شده از اپ Subscription) طبق سند API، این یک فراخوانی سرور-به-سرور از اپ Subscription‌ه، نه از کلاینت. در عمل subscription/views.py مستقیماً تابع create_order رو import و صدا می‌زنه، پس این endpoint برای جریان عادی از بیرون صدا زده نمی‌شه؛ برای همین به‌جای AllowAny یک shared-secret داخلی (HasInternalServiceKey، هدر X-Internal-Key) لازمه، وگرنه هر کلاینتی می‌تونست با یک userId دلخواه سفارش بسازه. """ permission_classes = [HasInternalServiceKey] # ⚠️ افزوده شده: هدرهای موردنیاز (X-Internal-Key, Idempotency-Key) قبلاً # مستند نشده بودن و از Swagger UI اصلاً قابل ست‌کردن/تست‌کردن نبودن. @extend_schema( request=CreateOrderRequestSerializer, responses=CreateOrderResponseSerializer, parameters=[ OpenApiParameter( 'X-Internal-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True, description='کلید مخفیِ فراخوانی داخلی/سرور-به-سرور (settings.INTERNAL_SERVICE_KEY).', ), OpenApiParameter( 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, description=( 'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. ' 'در صورت عدم ارسال، یک کلید تصادفی ساخته می‌شود.' ), ), ], tags=['Payment (payment)'], ) def post(self, request): serializer = CreateOrderRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) v = serializer.validated_data order, payment_url = create_order( user_id=int(v['userId']), amount=v['amount'], currency=v.get('currency') or 'IRT', subscription_id=int(v['subscriptionId']) if v.get('subscriptionId') else None, idempotency_key=request.headers.get('Idempotency-Key'), provider=v.get('provider') or 'gateway', bazaar_product_sku=v.get('bazaarProductSku') or '', ) data = { 'orderId': str(order.id), 'status': order.status, 'paymentUrl': payment_url, 'provider': order.provider, } return Response(CreateOrderResponseSerializer(data).data) class PaymentWebhookView(APIView): """ POST /api/payment/webhook — کال‌بک درگاه پرداخت (از سمت درگاه میاد، نه کلاینت) محافظت‌شده با HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی نتونه با یک orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک فعال کنه. همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره Transaction/notify تکراری نمی‌سازه (مثلاً وقتی درگاه callback رو دوباره می‌فرسته). """ permission_classes = [HasWebhookSecret] # ⚠️ اضافه شده: این endpoint قبلاً هیچ throttleای نداشت، درحالی‌که فقط # با مقایسه‌ی یک هدرِ ثابت (X-Webhook-Secret) محافظت می‌شه؛ بدونِ # throttle، تعدادِ نامحدودی تلاش برای حدسِ این سکرت (یا fuzz کردنِ # orderId بعد از افشای احتمالیِ سکرت) ممکن بود، درحالی‌که یک حدسِ # درست باعثِ paid شدنِ واقعیِ سفارش و فعال‌سازیِ اشتراک می‌شه. چون # این caller یک کاربرِ اپ نیست (خودِ درگاهِ پرداخته)، از یک # ScopedRateThrottleِ سادهٔ IP-محور استفاده شده، نه الگویِ # GuestAware*Throttle (که نیازمندِ request.user است). throttle_scope = 'payment_webhook' throttle_classes = [ScopedRateThrottle] # ⚠️ افزوده شده: مستندسازی هدر X-Webhook-Secret که قبلاً در Swagger UI # قابل تنظیم/تست نبود. @extend_schema( request=WebhookRequestSerializer, responses=WebhookResponseSerializer, parameters=[ OpenApiParameter( 'X-Webhook-Secret', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True, description='کلید مخفیِ وب‌هوک درگاه پرداخت (settings.PAYMENT_WEBHOOK_SECRET).', ), ], tags=['Payment (payment)'], ) def post(self, request): serializer = WebhookRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) v = serializer.validated_data gateway_ref_id = v.get('gatewayRefId', '') with transaction.atomic(): order = get_object_or_404(Order.objects.select_for_update(), pk=v['orderId']) # ⚠️ اصلاح امنیتی/صحت داده (قبلی): هر بار فراخوانی webhook (حتی # تکراری از سمت درگاه) یک Transaction جدید می‌ساخت. حالا این # idempotency + اثرات جانبی (فعال‌سازی اشتراک/notify/statuslog) # داخل apply_order_payment_result مشترکه (هم اینجا هم بازار). apply_order_payment_result( order=order, gateway=v.get('gateway') or 'zarinpal', gateway_ref_id=gateway_ref_id, new_status=v['status'], event='gateway_webhook', ) return Response(WebhookResponseSerializer({'received': True}).data) class BazaarVerifyPurchaseView(APIView): """ POST /api/payment/bazaar/verify — تایید پرداخت درون‌برنامه‌ای بازار برخلاف درگاه معمولی که با webhook خودِ درگاه تایید می‌شه، در پرداخت درون‌برنامه‌ای بازار هیچ webhookی نیست: کلاینت اندروید بعد از خرید از SDK بازار (IInAppBillingService)، یک purchaseToken می‌گیره و باید همون رو برای این endpoint بفرسته تا بک‌اند با فراخوانی سرور-به-سرور به API رسمی بازار (bazaar.verify_purchase)، صحتش رو تایید کنه — مشابه دقیقاً همون چیزی که سند رسمی بازار توصیه می‌کنه: «هیچ‌وقت فقط به گفته‌ی کلاینت اعتماد نکنید، حتماً سمت سرور verify کنید». """ permission_classes = [IsAuthenticated] # ⚠️ افزوده شده: این endpoint یک فراخوانیِ سرور-به-سرور به API بازار # (verify_purchase) رو تریگر می‌کنه؛ بدون throttle مخصوص، یک کاربر # می‌تونست با ارسال مکرر purchaseToken نامعتبر، بار زیادی روی سرویس # بازار و بک‌اند بندازه. throttle_scope مجزا (bazaar_verify) جدا از # نرخ‌های دیگه (مثل otp_*) تنظیم شده. # ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId # جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ # user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از # GuestAwareBazaarThrottle استفاده می‌شه که برای کاربرهای مهمان کلید # رو بر اساسِ IP می‌سازه و برای کاربرهای غیرمهمان رفتارِ per-user # قبلی رو دست‌نخورده نگه می‌داره. نرخ هم از ۲۰/hour به ۱۰/hour # سخت‌گیرانه‌تر شد چون هر درخواستِ پذیرفته‌شده یک تماسِ سرور-به-سرورِ # واقعی با بازار می‌زنه. throttle_scope = 'bazaar_verify' throttle_classes = [GuestAwareBazaarThrottle] @extend_schema( request=BazaarVerifyRequestSerializer, responses=BazaarVerifyResponseSerializer, tags=['Payment (payment)'], ) def post(self, request): serializer = BazaarVerifyRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) v = serializer.validated_data # ⚛️🔒 اصلاح: فراخوانی HTTP به API بازار (تا ~۲۰ ثانیه، دو درخواست # پشت‌سرهم برای refresh token + verify) قبلاً *داخل* transaction.atomic # و زیر select_for_update روی Order انجام می‌شد؛ یعنی ردیف Order (و # هر تراکنش دیگه‌ای که بخواد همون Order یا Subscription مرتبط رو لمس # کنه) تا برگشتِ پاسخ بازار قفل می‌موند. حالا verify_purchase قبل از # باز شدن atomic/lock صدا زده می‌شه؛ لاک فقط برای اعمال نتیجه (چند # میلی‌ثانیه) گرفته می‌شه. order_lookup = get_object_or_404( Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar', ) result = bazaar.verify_purchase( product_sku=order_lookup.bazaar_product_sku or v['productSku'], purchase_token=v['purchaseToken'], ) with transaction.atomic(): order = get_object_or_404( Order.objects.select_for_update(), pk=v['orderId'], user_id=request.user.id, provider='bazaar', ) new_status = 'paid' if result.is_valid else 'failed' order.bazaar_purchase_token = v['purchaseToken'] order.save(update_fields=['bazaar_purchase_token']) apply_order_payment_result( order=order, gateway='bazaar', gateway_ref_id=v['purchaseToken'], new_status=new_status, event='bazaar_verify', ) data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid} return Response(BazaarVerifyResponseSerializer(data).data) class OrderDetailView(APIView): """GET /api/payment/orders/{id} — وضعیت سفارش""" permission_classes = [IsAuthenticated] @extend_schema(responses=OrderDetailResponseSerializer, tags=['Payment (payment)']) def get(self, request, order_id): order = get_object_or_404(Order, pk=order_id, user_id=request.user.id) data = { 'orderId': str(order.id), 'status': order.status, 'amount': order.amount, 'transactions': [ { 'id': str(t.id), 'gateway': t.gateway, 'status': t.status, 'paidAt': t.paid_at, } for t in order.transactions.all() ], } return Response(OrderDetailResponseSerializer(data).data)