from django.db import transaction from django.db.models import F 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 payment.models import Order from payment.services import create_order from .models import Plan, Subscription from .serializers import ( PlansListResponseSerializer, PurchaseRequestSerializer, PurchaseResponseSerializer, SubscriptionStatusResponseSerializer, ) class GuestAwarePurchaseThrottle(ScopedRateThrottle): """ ⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)، GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle (discount/views.py) اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان (تا سقفِ guest_session=20/hour)، برای هر حساب یک bucket تازه از throttle_scope 'purchase' بگیره — یعنی سقفِ ۱۰/ساعتِ درخواستِ purchase عملاً با چرخشِ حساب مهمان چند برابر می‌شد. این endpoint حساسِ مالیه: هر درخواستِ پذیرفته‌شده وارد جریانِ purchase می‌شه، یک Subscription می‌سازه/آپدیت می‌کنه و create_order() رو صدا می‌زنه (که در پروداکشن با درگاهِ واقعی جایگزینِ stub فعلی می‌شه). دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان (ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ purchase/subscription/order/Idempotency-Key/plan-validation دست‌نخورده‌ست. """ 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 PlansListView(APIView): """GET /api/subscription/plans — list available plans.""" @extend_schema(responses=PlansListResponseSerializer, tags=['Subscription (subscription)']) def get(self, request): plans = Plan.objects.all() data = { 'plans': [ { 'id': str(p.id), 'title': p.title, 'price': p.price, 'currency': p.currency, 'popular': p.popular, 'discountPercent': p.discount_percent, 'durationDays': p.duration_days, } for p in plans ] } return Response(PlansListResponseSerializer(data).data) class PurchaseView(APIView): """POST /api/subscription/purchase — starts a purchase (Idempotency-Key header supported). Creates/updates a pending Subscription, then calls the payment app's service directly (in-process, not a separate HTTP call) to create an Order.""" permission_classes = [IsAuthenticated] # scope کیدهای cache رو با pk کاربرِ احرازهویت‌شده می‌سازه (نه فقط IP)؛ # چون Idempotency-Key به‌تنهایی جلوی حجمِ بالای درخواست‌های *متفاوت* # (با کلیدهای idempotency متفاوت) رو نمی‌گیره، این throttle مستقل و # مکملِ اون محافظته. ScopedRateThrottle خودش وقتی request.user # احرازهویت‌شده باشه (is_authenticated=True) به‌جای IP از # request.user.pk به‌عنوانِ ident استفاده می‌کنه، پس تغییرِ IP از # همون اکانت این محدودیت رو دور نمی‌زنه. # ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId # جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ # user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از # GuestAwarePurchaseThrottle استفاده می‌شه که برای کاربرهای مهمان # کلید رو بر اساسِ IP می‌سازه (دقیقاً همون الگویِ GuestAwareAIThrottle، # GuestAwareFamilyThrottle و GuestAwareDiscountThrottle) و برای # کاربرهای غیرمهمان رفتارِ per-user قبلی رو دست‌نخورده نگه می‌داره. throttle_scope = 'purchase' throttle_classes = [GuestAwarePurchaseThrottle] @extend_schema( request=PurchaseRequestSerializer, responses=PurchaseResponseSerializer, parameters=[ OpenApiParameter( 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, description=( 'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. ' 'در صورت عدم ارسال، یک کلید تصادفی ساخته می‌شود.' ), ), ], tags=['Subscription (subscription)'], ) def post(self, request): serializer = PurchaseRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) v = serializer.validated_data user = request.user plan = get_object_or_404(Plan, pk=v['planId']) idempotency_key = request.headers.get('Idempotency-Key') # Three-phase flow so create_order (which may call an external payment # gateway) never runs inside a DB transaction/lock: phase 1 creates/updates # the pending Subscription, phase 2 calls create_order and restores the # prior state on failure, phase 3 attaches order_id atomically. existing_order = None if idempotency_key: existing_order = Order.objects.filter(idempotency_key=idempotency_key).first() prior_subscription = Subscription.objects.filter(user_id=user.id).first() prior_snapshot = None if prior_subscription is not None: prior_snapshot = { 'status': prior_subscription.status, 'plan_id': prior_subscription.plan_id, 'order_id': prior_subscription.order_id, } if existing_order is not None: # Retry path: leave an existing subscription untouched, only # create one if it's genuinely missing. if prior_subscription is None: # Fresh row: version starts at 1 to give it a CAS token. subscription = Subscription.objects.create( user_id=user.id, plan=plan, status='pending', version=1, ) subscription_is_new = True else: subscription = prior_subscription subscription_is_new = False else: subscription, subscription_is_new = Subscription.objects.update_or_create( user_id=user.id, defaults={'plan': plan, 'status': 'pending'}, ) # Two concurrent requests can reach an identical intermediate state, # so version is bumped atomically here to give this write a unique # CAS token the cleanup below can match against. Subscription.objects.filter(pk=subscription.pk).update(version=F('version') + 1) subscription.refresh_from_db(fields=['version']) try: order, payment_url = create_order( user_id=user.id, amount=plan.price, currency=plan.currency, subscription_id=subscription.id, idempotency_key=idempotency_key, ) except Exception: # Guard by pk+status+plan_id+order_id+version so this cleanup only # matches the exact row state phase 1 wrote; any concurrent writer # (another request, a payment webhook, etc.) changes the match and # the delete/update below becomes a no-op instead of clobbering it. guard = { 'pk': subscription.pk, 'status': subscription.status, 'plan_id': subscription.plan_id, 'order_id': subscription.order_id, 'version': subscription.version, } with transaction.atomic(): if subscription_is_new: Subscription.objects.filter(**guard).delete() elif prior_snapshot is not None: Subscription.objects.filter(**guard).update(**prior_snapshot) raise if subscription.order_id != order.id: with transaction.atomic(): subscription.order_id = order.id subscription.save(update_fields=['order_id']) data = {'orderId': str(order.id), 'paymentUrl': payment_url} return Response(PurchaseResponseSerializer(data).data) class SubscriptionStatusView(APIView): """GET /api/subscription/status — current subscription status.""" permission_classes = [IsAuthenticated] @extend_schema(responses=SubscriptionStatusResponseSerializer, tags=['Subscription (subscription)']) def get(self, request): subscription = Subscription.objects.filter(user_id=request.user.id).first() data = { 'active': bool(subscription and subscription.status == 'active'), 'planId': str(subscription.plan_id) if subscription and subscription.plan_id else None, 'expiresAt': subscription.expires_at if subscription else None, } return Response(SubscriptionStatusResponseSerializer(data).data)