Files
MathApp/discount/views.py
2026-08-19 17:15:55 +03:30

198 lines
10 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.views import APIView
from .models import DiscountCode, Redemption
from .serializers import (
RedeemOfferCodeRequestSerializer,
RedeemRequestSerializer,
RedeemResponseSerializer,
UserOfferResponseSerializer,
ValidateResponseSerializer,
)
from .services import redeem_offer_code
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= — اعتبارسنجی کد پیش از خرید"""
@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 — ثبت مصرف کد هنگام خرید
قبلاً فقط ValidateDiscountView چک max_uses/expires_at رو انجام می‌داد؛
خود Redeem هیچ‌کدوم از این محدودیت‌ها رو دوباره چک نمی‌کرد و بین
validate و redeem هم race condition ممکن بود (دو درخواست هم‌زمان هر دو
از سد شمارش رد بشن). اینجا:
- همون شرط‌های usable بودن (max_uses/expires_at) دوباره در لحظه‌ی
redeem چک می‌شه، نه فقط در validate.
- با select_for_update روی DiscountCode، شمارش و ساخت Redemption در
یک تراکنش اتمیک و با قفل ردیف انجام می‌شه تا از over-redemption
هم‌زمان جلوگیری بشه.
- هر کاربر برای یک کد، حداکثر یک‌بار می‌تونه redeem کنه (unique
constraint روی discount_code+user_id در سطح مدل/migration).
طبق سند، این اپ هیچ محاسبه‌ی مبلغی رو دوباره خودش «ذخیره» نمی‌کنه؛ اما
برای پر کردن discountAmount در پاسخ، باید مبلغ سفارش رو از یک جایی
بخونه. چون هیچ اپ دیگه‌ای به این اپ ارجاع نمی‌ده (نه برعکس)، اینجا فقط
یک خواندن با id از payment.models.Order انجام می‌شه — دقیقاً همون
الگویی که progress/views.py برای خوندن نام کاربر از accounts.models.User
استفاده کرده (import مستقیم + فیلتر با id، نه FK واقعی و نه join).
"""
permission_classes = [IsAuthenticated]
@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
# ⚠️ اصلاح امنیتی: قبلاً مالکیتِ سفارش چک نمی‌شد، پس هر کاربر
# لاگین‌کرده‌ای می‌تونست با حدس‌زدن یک orderId، کد تخفیف رو روی
# سفارشِ کاربر دیگری اعمال کنه. حالا سفارش باید متعلق به همون
# کاربر احرازهویت‌شده باشه.
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': 'شما قبلاً این کد را استفاده کرده‌اید.'})
# ⚛️🔒 اصلاح race condition: قبلاً چکِ «order.status != 'pending'»
# روی همون آبجکتِ order ای انجام می‌شد که *قبل* از ورود به این
# atomic block و *بدون* قفل خونده شده بود (چند خط بالاتر، برای
# چکِ مالکیت). یعنی اگه بینِ اون خواندنِ اولیه و این چک، یک
# webhookِ پرداختِ هم‌زمان روی همون سفارش paid می‌شد (که خودش با
# select_for_update قفل می‌گیره)، این ویو همچنان مقدارِ
# cached/قدیمیِ status='pending' رو می‌دید و اجازه می‌داد مبلغِ
# یک سفارشِ *پرداخت‌شده* بعد از پرداخت واقعی کم بشه — دقیقاً
# همون کلاسِ باگی که apply_order_payment_result در
# payment/services.py با قفل‌گرفتن *قبل* از چکِ already_paid
# حلش کرده. حالا سفارش اول قفل می‌شه (select_for_update) و
# چکِ pending بودن *بعد* از قفل، روی خودِ ردیفِ قفل‌شده
# (order_locked) انجام می‌شه.
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,
)
# ⚠️ اصلاح: قبلاً discountAmount فقط محاسبه و در پاسخ
# برگردونده می‌شد ولی هیچ‌وقت روی خودِ Order اعمال نمی‌شد،
# یعنی جریان واقعی پرداخت مبلغ تخفیف‌نخورده رو دریافت می‌کرد.
# حالا مبلغ سفارش واقعاً کم می‌شه تا مرحله‌ی پرداخت همین
# مبلغ تخفیف‌خورده رو ببینه.
order_locked.amount = max(order_locked.amount - discount_amount, 0)
order_locked.save(update_fields=['amount'])
except IntegrityError:
# fallback در برابر race condition روی unique constraint، حتی
# اگه به هر دلیلی قفل 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]
@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)