218 lines
11 KiB
Python
218 lines
11 KiB
Python
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)
|