Update project
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
آفرها (مثلاً بجای اشتراک، دسترسی به یک اپ خاص) فقط همین فایل رو لمس میکنه،
|
||||
نه apps.py/receivers.py و نه هیچ اپ دیگهای.
|
||||
"""
|
||||
from django.db import IntegrityError
|
||||
from django.db import IntegrityError, transaction
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from .models import Offer, UserOffer
|
||||
@@ -17,27 +17,48 @@ def apply_offer(*, user_id: int, offer: Offer, source: str) -> UserOffer | None:
|
||||
یک آفر رو به یک کاربر اختصاص میده و اثرش رو اعمال میکنه (فعلاً: اشتراک
|
||||
رایگان/محدود). اگه کاربر قبلاً همین آفر رو گرفته باشه (unique constraint)
|
||||
None برمیگردونه، نه خطا — چون auto-assign هنگام ثبتنام نباید کرش کنه.
|
||||
|
||||
⚛️ atomic: ساخت UserOffer، فعالسازی Subscription (grant_free_subscription)
|
||||
و آپدیت expires_at روی همون UserOffer، هر سه یک واحد اتمیکاند. بدون این،
|
||||
اگه grant_free_subscription (یا هر چیزی که داخلش صدا زده میشه، مثل
|
||||
سیگنال status_changed) وسط راه fail کنه، یک UserOffer «یتیم» توی
|
||||
دیتابیس میمونه که هیچ اشتراکی واقعاً براش فعال نشده.
|
||||
"""
|
||||
if not offer.is_active:
|
||||
return None
|
||||
|
||||
try:
|
||||
user_offer = UserOffer.objects.create(
|
||||
user_id=user_id, offer=offer, source=source,
|
||||
expires_at=None, # پایینتر بعد از فعالسازی اشتراک ست میشه
|
||||
# ⚠️ اصلاح: قبلاً هر IntegrityError داخل کل بلوکِ atomic (که شاملِ
|
||||
# grant_free_subscription هم میشد) بهعنوان «duplicate UserOffer»
|
||||
# تفسیر میشد و None برمیگردوند — یعنی اگه یک IntegrityError واقعیِ
|
||||
# دیگه (مثلاً داخل grant_free_subscription) اتفاق میافتاد، خطا قورت
|
||||
# داده میشد و caller فکر میکرد «کاربر قبلاً این آفر رو گرفته»، در
|
||||
# حالی که اصلاً چنین چیزی نبود. حالا فقط IntegrityError ناشی از
|
||||
# UniqueConstraintِ خودِ UserOffer.create (duplicate واقعی) گرفته
|
||||
# میشه — با یک savepoint جداگانه (atomic تودرتو) که فقط همون create
|
||||
# رو rollback میکنه، نه کل تراکنش رو — و خطاهای دیگه (مثلاً از داخل
|
||||
# grant_free_subscription) عادی propagate میشن. atomic بودنِ کلِ
|
||||
# عملیات (UserOffer + Subscription + expires_at) هم دستنخورده باقی
|
||||
# مونده، دقیقاً طبق همون دلیلی که در docstring بالا توضیح داده شده.
|
||||
with transaction.atomic():
|
||||
try:
|
||||
with transaction.atomic(): # savepoint مجزا فقط برای create
|
||||
user_offer = UserOffer.objects.create(
|
||||
user_id=user_id, offer=offer, source=source,
|
||||
expires_at=None, # پایینتر بعد از فعالسازی اشتراک ست میشه
|
||||
)
|
||||
except IntegrityError:
|
||||
return None
|
||||
|
||||
from subscription.services import grant_free_subscription
|
||||
|
||||
duration_days = offer.duration_days if offer.kind == 'trial' else None
|
||||
subscription = grant_free_subscription(
|
||||
user_id, duration_days=duration_days, event=f'offer_{offer.kind}',
|
||||
)
|
||||
except IntegrityError:
|
||||
return None
|
||||
|
||||
from subscription.services import grant_free_subscription
|
||||
user_offer.expires_at = subscription.expires_at
|
||||
user_offer.save(update_fields=['expires_at'])
|
||||
|
||||
duration_days = offer.duration_days if offer.kind == 'trial' else None
|
||||
subscription = grant_free_subscription(
|
||||
user_id, duration_days=duration_days, event=f'offer_{offer.kind}',
|
||||
)
|
||||
|
||||
user_offer.expires_at = subscription.expires_at
|
||||
user_offer.save(update_fields=['expires_at'])
|
||||
return user_offer
|
||||
|
||||
|
||||
|
||||
@@ -98,13 +98,24 @@ class RedeemDiscountView(APIView):
|
||||
if discount.redemptions.filter(user_id=request.user.id).exists():
|
||||
raise ValidationError({'code': 'شما قبلاً این کد را استفاده کردهاید.'})
|
||||
|
||||
# فقط سفارشهای در انتظار پرداخت میتونن تخفیف بگیرن؛ سفارش
|
||||
# پرداختشده/ناموفق نباید عقبگرد تغییر کنه.
|
||||
if order.status != 'pending':
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user