Files
MathApp/affiliate/views.py

131 lines
6.4 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.shortcuts import get_object_or_404
from drf_spectacular.utils import 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 AffiliateAccount, Conversion
from .serializers import (
AffiliateMeResponseSerializer,
ConversionListResponseSerializer,
TrackRequestSerializer,
TrackResponseSerializer,
)
class GuestAwareAffiliateThrottle(ScopedRateThrottle):
"""
⚠️ اضافه شده: همون الگویِ GuestAwareDiscountThrottle/GuestAwareFamilyThrottle
و بقیه؛ چرخشِ حسابِ مهمان (deviceId جدید → pk جدید) یک ScopedRateThrottleِ
سادهٔ per-user رو دور می‌زد. اینجا حساس‌تره چون referralCode یک
شناسه‌ی حدس‌زدنی‌ست (404 برای کدِ نامعتبر = oracle) و یک حدسِ درست
یک Conversion واقعی می‌سازه که به کمیسیونِ مالیِ affiliate صاحبِ کد
منجر می‌شه؛ یعنی بدونِ این throttle، چرخشِ حساب مهمان به مهاجم
guessهای عملاً نامحدودی برای enumerate/fraud کردنِ کدهای رفرال
می‌داد. برای کاربرهای مهمان کلیدِ cache از pk به IP کلاینت تغییر
می‌کنه؛ رفتارِ throttle برای کاربرهای غیرمهمان (per-user، مثلِ
قبل) دست‌نخورده می‌مونه.
"""
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 AffiliateMeView(APIView):
"""GET /api/affiliate/me — حساب و کد ارجاع کاربر"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)'])
def get(self, request):
account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id)
data = {'referralCode': account.referral_code, 'commissionRate': account.commission_rate}
return Response(AffiliateMeResponseSerializer(data).data)
class TrackConversionView(APIView):
"""
POST /api/affiliate/track — ثبت کلیک/ارجاع
طبق سند، این اپ فقط با referralCode به AffiliateAccount وصل می‌شه.
referredUserId دیگه ورودیِ کلاینت نیست: چون این درخواست باید توسطِ
خودِ کاربرِ تازه‌ارجاع‌شده (بعد از لاگین/ثبت‌نام) زده بشه، هویتش رو
از توکن احرازشده (request.user) می‌گیریم، نه از بدنه‌ی درخواست.
# ⚠️ اصلاح امنیتی: قبلاً این endpoint با AllowAny و یک
# referredUserId دلخواه از سمت کلاینت کار می‌کرد؛ یعنی هر کلاینتِ
# ناشناس می‌تونست برای هر کاربرِ واقعیِ دیگه‌ای (بدون اطلاع یا
# مشارکت خودش) به‌جای او Conversion بسازه، یا حتی برای فرار از
# قید unique روی referred_user_id، از قبل تمام userId های موجود
# رو برای یک کد افیلیتِ جعلی «اشغال» کنه و مسیر ارجاع واقعی رو
# برای همیشه مسدود کنه. با گرفتن هویت از request.user (نیازمند
# احراز هویت)، دیگه امکان جعل/انتساب ارجاع به کاربر دیگری وجود
# نداره؛ چک self-referral هم حفظ شده.
"""
permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: قبلاً این endpoint هیچ throttleای نداشت؛ referralCode
# نامعتبر ۴۰۴ می‌گیره (یک oracle قابل‌probe) و حدسِ درست یک
# Conversion واقعیِ متصل به کمیسیونِ affiliate می‌سازه. طبقِ همون
# الگویِ بقیه‌ی اپ‌ها، throttle_scope مجزا با GuestAwareAffiliateThrottle
# اضافه شد تا هم probe کردنِ کد رو کند کنه، هم چرخشِ حساب مهمان
# دیگه bucket تازه نده.
throttle_scope = 'affiliate_track'
throttle_classes = [GuestAwareAffiliateThrottle]
@extend_schema(
request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)']
)
def post(self, request):
serializer = TrackRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode'])
referred_user_id = request.user.id
if referred_user_id == account.user_id:
raise ValidationError({'referredUserId': 'امکان ثبت ارجاع برای خودِ صاحب کد وجود ندارد.'})
conversion, _ = Conversion.objects.get_or_create(
referred_user_id=referred_user_id,
defaults={'affiliate_account': account},
)
data = {'success': True, 'conversionId': str(conversion.pk), 'status': conversion.status}
return Response(TrackResponseSerializer(data).data)
class ConversionsListView(APIView):
"""GET /api/affiliate/conversions — لیست تبدیل‌ها و کمیسیون"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=ConversionListResponseSerializer, tags=['Affiliate (affiliate)'])
def get(self, request):
account = AffiliateAccount.objects.filter(user_id=request.user.id).first()
conversions = account.conversions.all() if account else Conversion.objects.none()
items = [
{
'id': str(c.pk),
'referredUserId': str(c.referred_user_id),
'status': c.status,
'commissionAmount': c.commission_amount,
}
for c in conversions
]
data = {'conversions': items}
return Response(ConversionListResponseSerializer(data).data)