Files
MathApp/subscription/views.py

218 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)