Update project

This commit is contained in:
2026-08-19 17:15:55 +03:30
parent 02d40e1238
commit f945f960c9
25 changed files with 1092 additions and 287 deletions

View File

@@ -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

View File

@@ -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: