Initial commit
This commit is contained in:
0
discount/__init__.py
Normal file
0
discount/__init__.py
Normal file
19
discount/admin.py
Normal file
19
discount/admin.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import DiscountCode, Redemption
|
||||
|
||||
|
||||
class RedemptionInline(admin.TabularInline):
|
||||
model = Redemption
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(DiscountCode)
|
||||
class DiscountCodeAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'code', 'type', 'value', 'max_uses', 'expires_at')
|
||||
inlines = [RedemptionInline]
|
||||
|
||||
|
||||
@admin.register(Redemption)
|
||||
class RedemptionAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'discount_code', 'user_id', 'order_id', 'redeemed_at')
|
||||
6
discount/apps.py
Normal file
6
discount/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DiscountConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'discount'
|
||||
36
discount/migrations/0001_initial.py
Normal file
36
discount/migrations/0001_initial.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-10 12:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DiscountCode',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=30, unique=True)),
|
||||
('type', models.CharField(choices=[('percent', 'درصدی'), ('fixed', 'مبلغ ثابت')], max_length=10)),
|
||||
('value', models.PositiveIntegerField()),
|
||||
('max_uses', models.PositiveIntegerField(default=1)),
|
||||
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Redemption',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('user_id', models.IntegerField(db_index=True)),
|
||||
('order_id', models.IntegerField()),
|
||||
('redeemed_at', models.DateTimeField(auto_now_add=True)),
|
||||
('discount_code', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='redemptions', to='discount.discountcode')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.17 on 2026-08-10 17:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('discount', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='redemption',
|
||||
constraint=models.UniqueConstraint(fields=('discount_code', 'user_id'), name='unique_redemption_per_user_per_code'),
|
||||
),
|
||||
]
|
||||
0
discount/migrations/__init__.py
Normal file
0
discount/migrations/__init__.py
Normal file
34
discount/models.py
Normal file
34
discount/models.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class DiscountCode(models.Model):
|
||||
TYPE_CHOICES = [('percent', 'درصدی'), ('fixed', 'مبلغ ثابت')]
|
||||
|
||||
code = models.CharField(max_length=30, unique=True)
|
||||
type = models.CharField(max_length=10, choices=TYPE_CHOICES)
|
||||
value = models.PositiveIntegerField()
|
||||
max_uses = models.PositiveIntegerField(default=1)
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.code
|
||||
|
||||
|
||||
class Redemption(models.Model):
|
||||
discount_code = models.ForeignKey(DiscountCode, on_delete=models.CASCADE, related_name='redemptions')
|
||||
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
|
||||
order_id = models.IntegerField() # ارجاع سبک به payment.Order
|
||||
redeemed_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
# هر کاربر برای یک کد تخفیف حداکثر یکبار میتونه redeem کنه؛
|
||||
# قبلاً هیچ محدودیتی در سطح دیتابیس نبود و همراه با نبود چک max_uses
|
||||
# در RedeemDiscountView، امکان استفادهی نامحدود از یک کد وجود داشت.
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['discount_code', 'user_id'], name='unique_redemption_per_user_per_code'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"redemption-{self.discount_code_id}-order{self.order_id}"
|
||||
18
discount/serializers.py
Normal file
18
discount/serializers.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class ValidateResponseSerializer(serializers.Serializer):
|
||||
valid = serializers.BooleanField()
|
||||
code = serializers.CharField(required=False)
|
||||
type = serializers.CharField(required=False)
|
||||
value = serializers.IntegerField(required=False)
|
||||
|
||||
|
||||
class RedeemRequestSerializer(serializers.Serializer):
|
||||
code = serializers.CharField()
|
||||
orderId = serializers.CharField()
|
||||
|
||||
|
||||
class RedeemResponseSerializer(serializers.Serializer):
|
||||
success = serializers.BooleanField()
|
||||
discountAmount = serializers.IntegerField()
|
||||
3
discount/tests.py
Normal file
3
discount/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
8
discount/urls.py
Normal file
8
discount/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import RedeemDiscountView, ValidateDiscountView
|
||||
|
||||
urlpatterns = [
|
||||
path('discount/validate', ValidateDiscountView.as_view(), name='discount-validate'),
|
||||
path('discount/redeem', RedeemDiscountView.as_view(), name='discount-redeem'),
|
||||
]
|
||||
129
discount/views.py
Normal file
129
discount/views.py
Normal file
@@ -0,0 +1,129 @@
|
||||
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 (
|
||||
RedeemRequestSerializer,
|
||||
RedeemResponseSerializer,
|
||||
ValidateResponseSerializer,
|
||||
)
|
||||
|
||||
|
||||
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': 'شما قبلاً این کد را استفاده کردهاید.'})
|
||||
|
||||
# فقط سفارشهای در انتظار پرداخت میتونن تخفیف بگیرن؛ سفارش
|
||||
# پرداختشده/ناموفق نباید عقبگرد تغییر کنه.
|
||||
if order.status != 'pending':
|
||||
raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'})
|
||||
|
||||
order_locked = Order.objects.select_for_update().get(pk=order.id)
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user