from django.core.cache import cache 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/GuestAwareFamilyThrottle/etc: چرخشِ حساب مهمان نباید bucket تازه‌ی purchase بده — این endpoint حساسِ مالیه (Subscription می‌سازه/آپدیت می‌کنه، create_order رو صدا می‌زنه). برای مهمان‌ها کلیدِ cache بر اساسِ IP هست، نه pk.""" 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} # پلن‌ها تقریباً هیچ‌وقت عوض نمی‌شوند و روی صفحه‌ی اشتراک هر بار خوانده می‌شوند. # با هر save/delete روی Plan کش باطل می‌شود (subscription/receivers.py). PLANS_CACHE_KEY = 'subscription:plans:v1' PLANS_CACHE_TTL = 3600 class PlansListView(APIView): """GET /api/subscription/plans — list available plans.""" @extend_schema(responses=PlansListResponseSerializer, tags=['Subscription (subscription)']) def get(self, request): def build(): return { '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, 'features': p.features or [], 'badge': p.badge, } for p in Plan.objects.all() ] } data = cache.get_or_set(PLANS_CACHE_KEY, build, PLANS_CACHE_TTL) 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] # این throttle مستقلِ Idempotency-Key است چون اون فقط جلوی تکرارِ *همون* # کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت. کاربرهای مهمان با هر # deviceId یک pk تازه می‌گیرن، پس کلیدِ per-user ساده قابل‌دورزدنه؛ # GuestAwarePurchaseThrottle برای مهمان‌ها بر اساسِ IP کلید می‌سازه # (مثلِ GuestAwareAIThrottle/FamilyThrottle/DiscountThrottle) و برای # کاربرهای غیرمهمان رفتارِ 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)