from django.shortcuts import get_object_or_404 from drf_spectacular.utils import extend_schema from rest_framework.exceptions import PermissionDenied, 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 و بقیه؛ چرخشِ حسابِ مهمان (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} def _reject_guest(user): """ مهمان نه کدِ معرف می‌گیرد نه معرفی‌شده حساب می‌شود: حسابِ مهمان با هر deviceId تازه ساخته می‌شود، پس شمردنش فقط آمار را باد می‌کرد. بعد از ورود با شماره، همان کاربر (با همان پیشرفت) می‌تواند کد را ثبت کند. """ if getattr(user, 'is_guest', False): raise PermissionDenied('برنامه‌ی معرفی بعد از ورود با شماره‌ی موبایل فعال می‌شود.') class AffiliateMeView(APIView): """GET /api/affiliate/me — حساب و کد ارجاع کاربر""" permission_classes = [IsAuthenticated] @extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)']) def get(self, request): _reject_guest(request.user) account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id) paid = account.conversions.filter(status='paid') data = { 'referralCode': account.referral_code, 'commissionRate': account.commission_rate, 'referredCount': account.conversions.count(), 'paidCount': paid.count(), 'totalCommission': sum(paid.values_list('commission_amount', flat=True)), } return Response(AffiliateMeResponseSerializer(data).data) class TrackConversionView(APIView): """ POST /api/affiliate/track — ثبت کلیک/ارجاع طبق سند، این اپ فقط با referralCode به AffiliateAccount وصل می‌شه. referredUserId دیگه ورودیِ کلاینت نیست: چون این درخواست باید توسطِ خودِ کاربرِ تازه‌ارجاع‌شده (بعد از لاگین/ثبت‌نام) زده بشه، هویتش رو از توکن احرازشده (request.user) می‌گیریم، نه از بدنه‌ی درخواست. # هویت از request.user گرفته می‌شه (نه بدنه‌ی درخواست) چون یک # referredUserIdِ کلاینت‌دار یعنی هر کلاینتِ ناشناس می‌تونست برای هر # کاربرِ دیگه‌ای Conversion بسازه یا با اشغالِ همه‌ی userId ها (قیدِ # unique رو دورزدن) مسیرِ ارجاعِ واقعی رو مسدود کنه. """ permission_classes = [IsAuthenticated] # referralCode نامعتبر ۴۰۴ می‌گیره (یک oracle قابل‌probe)، پس throttle # مجزا هم probe رو کند می‌کنه هم (با GuestAwareAffiliateThrottle) جلویِ # 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 _reject_guest(request.user) 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)