266 lines
15 KiB
Python
266 lines
15 KiB
Python
from django.db import IntegrityError, transaction
|
||
from django.shortcuts import get_object_or_404
|
||
from django.utils import timezone
|
||
from drf_spectacular.types import OpenApiTypes
|
||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||
from rest_framework.exceptions import ValidationError
|
||
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 .models import DiscountCode, Redemption
|
||
from .serializers import (
|
||
RedeemOfferCodeRequestSerializer,
|
||
RedeemRequestSerializer,
|
||
RedeemResponseSerializer,
|
||
UserOfferResponseSerializer,
|
||
ValidateResponseSerializer,
|
||
)
|
||
from .services import redeem_offer_code
|
||
|
||
|
||
class GuestAwareDiscountThrottle(ScopedRateThrottle):
|
||
"""
|
||
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) و
|
||
GuestAwareFamilyThrottle (family/views.py) اینجا هم هست.
|
||
ScopedRateThrottle استاندارد برای درخواستِ احرازهویتشده از
|
||
request.user.pk بهعنوانِ کلیدِ throttle استفاده میکنه؛ چون
|
||
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان (تا سقفِ
|
||
guest_session=20/hour)، برای هر حساب یک bucket تازه از throttle_scope
|
||
'discount_redeem' بگیره. ریسکِ این endpoint از بقیه هم بیشتره، چون
|
||
DiscountCode یک رکوردِ سراسری با max_uses مشترکِ بینِ همهی کاربرهاست؛
|
||
یک حدسِ درست صرفاً اطلاعات لو نمیده، بلکه یک Redemption واقعی
|
||
میسازه و مبلغِ سفارش رو کم میکنه — یعنی throttle دورزدهشده به
|
||
مهاجم guessهای واقعاً بیشتری روی یک کدِ سراسری میده.
|
||
|
||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||
lookup/redemption/order دستنخوردهست.
|
||
"""
|
||
|
||
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}
|
||
|
||
|
||
def _is_usable(discount: DiscountCode) -> bool:
|
||
return bool(
|
||
discount.redemptions.count() < discount.max_uses
|
||
and (discount.expires_at is None or discount.expires_at >= timezone.now())
|
||
)
|
||
|
||
|
||
class ValidateDiscountView(APIView):
|
||
"""GET /api/discount/validate?code= — اعتبارسنجی کد پیش از خرید"""
|
||
|
||
# این ویو مثل RedeemDiscountView به IsAuthenticated نیاز دارد؛ در غیر این
|
||
# صورت کلاینت بدون احراز هویت میتواند کدهای تخفیف را probe کند.
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
# ⚠️ اضافه شده: این endpoint خودِ type/value کد رو هم برمیگردونه (نه
|
||
# فقط valid/invalid)، پس بدونِ throttle یک کاربرِ احرازهویتشده میتونست
|
||
# بینهایت کدهای تخفیف رو probe کنه و جزئیاتشون رو یاد بگیره. طبقِ
|
||
# همون الگویِ ScopedRateThrottle که برای RedeemOfferCodeView
|
||
# (offer_redeem) اضافه شد: چون کاربر احرازهویتشده، کلید بر اساسِ pk
|
||
# کاربره (نه IP). نرخ کمی بالاتر از offer_redeem انتخاب شده چون این
|
||
# یک چکِ read-only پیش از خریده (ممکنه کاربر چند کد رو قبل از تصمیمِ
|
||
# نهایی امتحان کنه)، نه خودِ مصرف کد.
|
||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از همون
|
||
# GuestAwareDiscountThrottleِ مشترک (که RedeemDiscountView/
|
||
# RedeemOfferCodeView هم استفاده میکنن) استفاده میشه که برای
|
||
# کاربرهای مهمان کلید رو بر اساسِ IP میسازه و برای کاربرهای
|
||
# غیرمهمان رفتارِ per-user قبلی رو دستنخورده نگه میداره.
|
||
throttle_scope = 'discount_validate'
|
||
throttle_classes = [GuestAwareDiscountThrottle]
|
||
|
||
@extend_schema(
|
||
parameters=[OpenApiParameter('code', OpenApiTypes.STR, required=True)],
|
||
responses=ValidateResponseSerializer,
|
||
tags=['Discount (discount)'],
|
||
)
|
||
def get(self, request):
|
||
code = request.query_params.get('code', '')
|
||
discount = DiscountCode.objects.filter(code=code).first()
|
||
|
||
if not discount or not _is_usable(discount):
|
||
return Response(ValidateResponseSerializer({'valid': False}).data)
|
||
|
||
data = {'valid': True, 'code': discount.code, 'type': discount.type, 'value': discount.value}
|
||
return Response(ValidateResponseSerializer(data).data)
|
||
|
||
|
||
class RedeemDiscountView(APIView):
|
||
"""
|
||
POST /api/discount/redeem — ثبت مصرف کد هنگام خرید
|
||
|
||
شرطهای usable بودن (max_uses/expires_at) دوباره در لحظهی redeem زیر
|
||
select_for_update چک میشود تا از over-redemption همزمان جلوگیری شود.
|
||
هر کاربر برای یک کد حداکثر یکبار میتواند redeem کند (قید یکتایی مدل).
|
||
|
||
discountAmount با یک خواندنِ مستقیم از payment.models.Order پر میشود
|
||
(بدون FK واقعی، مطابق قرارداد پروژه برای ارجاع بین اپها).
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
# ⚠️ اضافه شده: این endpoint هم دقیقاً همون oracle ای که باعث شد
|
||
# ValidateDiscountView و RedeemOfferCodeView throttle بگیرن رو داره —
|
||
# کدِ ناموجود ۴۰۴ میگیره، کدِ موجود-ولی-نامعتبر ۴۰۰ میگیره، پس بدونِ
|
||
# throttle یک کاربرِ احرازهویتشده میتونست کدهای تخفیف رو روی سفارشِ
|
||
# خودش probe/brute-force کنه. اینجا ریسک از اون دوتا هم بیشتره، چون
|
||
# حدسِ درست صرفاً اطلاعات لو نمیده؛ یک کدِ واقعی رو مصرف میکنه
|
||
# (سهمِ max_uses مشترک بین همهی کاربرها رو میخوره) و باعثِ افتِ
|
||
# واقعیِ مبلغِ سفارش میشه؛ ضمناً هر تلاش دو تا select_for_update و یک
|
||
# تراکنشِ دیتابیسی اجرا میکنه که گرونتر از GET سبکِ validate است.
|
||
# طبقِ همون الگو، ScopedRateThrottle با کلیدِ per-user (نه IP) اضافه شد.
|
||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از
|
||
# GuestAwareDiscountThrottle استفاده میشه که برای کاربرهای مهمان
|
||
# کلید رو بر اساسِ IP میسازه (دقیقاً همون الگویِ GuestAwareAIThrottle
|
||
# و GuestAwareFamilyThrottle) و برای کاربرهای غیرمهمان رفتارِ per-user
|
||
# قبلی رو دستنخورده نگه میداره.
|
||
throttle_scope = 'discount_redeem'
|
||
throttle_classes = [GuestAwareDiscountThrottle]
|
||
|
||
@extend_schema(request=RedeemRequestSerializer, responses=RedeemResponseSerializer,
|
||
tags=['Discount (discount)'])
|
||
def post(self, request):
|
||
serializer = RedeemRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
order_id = int(v['orderId'])
|
||
|
||
from payment.models import Order
|
||
# سفارش باید متعلق به همان کاربر احرازهویتشده باشد (جلوگیری از اعمال تخفیف روی سفارش دیگران).
|
||
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
|
||
|
||
try:
|
||
with transaction.atomic():
|
||
discount = get_object_or_404(
|
||
DiscountCode.objects.select_for_update(), code=v['code']
|
||
)
|
||
|
||
if not _is_usable(discount):
|
||
raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفادهاش پر شده.'})
|
||
|
||
if discount.redemptions.filter(user_id=request.user.id).exists():
|
||
raise ValidationError({'code': 'شما قبلاً این کد را استفاده کردهاید.'})
|
||
|
||
# سفارش زیر قفل خوانده میشود و چک pending بودن *بعد* از قفل روی همان
|
||
# ردیف انجام میشود، تا با یک webhook همزمان که سفارش را paid کرده race نکند.
|
||
order_locked = Order.objects.select_for_update().get(pk=order.id)
|
||
|
||
if order_locked.status != 'pending':
|
||
raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'})
|
||
|
||
if discount.type == 'percent':
|
||
discount_amount = order_locked.amount * discount.value // 100
|
||
else:
|
||
discount_amount = min(discount.value, order_locked.amount)
|
||
|
||
Redemption.objects.create(
|
||
discount_code=discount,
|
||
user_id=request.user.id,
|
||
order_id=order_id,
|
||
)
|
||
|
||
# مبلغ سفارش واقعاً کم میشود تا مرحلهی پرداخت مبلغ تخفیفخورده را ببیند.
|
||
order_locked.amount = max(order_locked.amount - discount_amount, 0)
|
||
order_locked.save(update_fields=['amount'])
|
||
except IntegrityError:
|
||
# fallback برای رقابت همزمان روی قید یکتایی، حتی اگر قفل select_for_update رد شود.
|
||
raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفادهاش پر شده.'})
|
||
|
||
data = {'success': True, 'discountAmount': discount_amount}
|
||
return Response(RedeemResponseSerializer(data).data)
|
||
|
||
|
||
class RedeemOfferCodeView(APIView):
|
||
"""
|
||
POST /api/discount/offers/redeem — اعمال آفر (اشتراک رایگان/محدود) با کد،
|
||
برخلاف auto_assign_on_signup که خودکارِ هنگام ثبتنامه، این برای زمانیه
|
||
که سینیور بخواد یک آفر رو بعداً (نه فقط هنگام ثبتنام) با یک کد به کاربر
|
||
بده — مثلاً یک کمپین تبلیغاتی.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
# ⚠️ اضافه شده: کدهای نامعتبر فقط یک ValidationError ساده (۴۰۰) برمیگردونن
|
||
# و هیچ throttleای اینجا نبود؛ یعنی یک کاربرِ احرازهویتشده میتونست
|
||
# بدونِ هیچ محدودیتی کدهای آفر رو حدس بزنه (offer-code probing). طبقِ
|
||
# همون الگویِ ScopedRateThrottle که در بقیهی اکشنهایِ کمتکرار/حساسِ
|
||
# پروژه استفاده شده (مثلِ purchase در subscription، support_create در
|
||
# support)، اینجا هم یک throttle_scope مجزا اضافه شد.
|
||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود — بهخصوص اینجا
|
||
# حساستره چون Offer.code سراسری/یکتاست و redeem_offer_code() فقط
|
||
# جلوی تکرارِ *همون* کاربر رو میگیره، نه کاربرهای مهمانِ جدید (یک
|
||
# حسابِ مهمانِ چرخشی میتونست همون آفرِ سراسری رو دوباره از
|
||
# apply_offer() بگیره). حالا از همون GuestAwareDiscountThrottle که
|
||
# برای RedeemDiscountView اضافه شد استفاده میشه (دقیقاً همون الگویِ
|
||
# GuestAwareAIThrottle، GuestAwareFamilyThrottle و
|
||
# GuestAwarePurchaseThrottle) که برای کاربرهای مهمان کلید رو بر
|
||
# اساسِ IP میسازه و برای کاربرهای غیرمهمان رفتارِ per-user قبلی رو
|
||
# دستنخورده نگه میداره.
|
||
throttle_scope = 'offer_redeem'
|
||
throttle_classes = [GuestAwareDiscountThrottle]
|
||
|
||
@extend_schema(
|
||
request=RedeemOfferCodeRequestSerializer, responses=UserOfferResponseSerializer,
|
||
tags=['Discount (discount)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = RedeemOfferCodeRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
|
||
user_offer = redeem_offer_code(user_id=request.user.id, code=serializer.validated_data['code'])
|
||
data = {
|
||
'id': user_offer.id,
|
||
'offerName': user_offer.offer.name,
|
||
'kind': user_offer.offer.kind,
|
||
'status': user_offer.status,
|
||
'grantedAt': user_offer.granted_at,
|
||
'expiresAt': user_offer.expires_at,
|
||
}
|
||
return Response(UserOfferResponseSerializer(data).data)
|
||
|
||
|
||
class MyOffersView(APIView):
|
||
"""GET /api/discount/offers/mine — لیست آفرهایی که کاربر گرفته (خودکار یا با کد)"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(responses=UserOfferResponseSerializer(many=True), tags=['Discount (discount)'])
|
||
def get(self, request):
|
||
from .models import UserOffer
|
||
|
||
user_offers = UserOffer.objects.filter(user_id=request.user.id).select_related('offer')
|
||
data = [
|
||
{
|
||
'id': uo.id,
|
||
'offerName': uo.offer.name,
|
||
'kind': uo.offer.kind,
|
||
'status': uo.status,
|
||
'grantedAt': uo.granted_at,
|
||
'expiresAt': uo.expires_at,
|
||
}
|
||
for uo in user_offers
|
||
]
|
||
return Response(UserOfferResponseSerializer(data, many=True).data)
|