From f945f960c97a630a8bdd7ee9241318386b38795b Mon Sep 17 00:00:00 2001 From: AliReza Date: Wed, 19 Aug 2026 17:15:55 +0330 Subject: [PATCH] Update project --- .../0002_user_uniq_guest_device_id.py | 17 ++ accounts/models.py | 19 ++ accounts/views.py | 81 +++--- ai_tutor/migrations/0002_askidempotency.py | 29 +++ ai_tutor/models.py | 32 +++ ai_tutor/views.py | 187 +++++++++++--- .../commands/mark_abandoned_node_progress.py | 50 +++- ...ssoncompletion_idempotency_key_and_more.py | 22 ++ ...004_useranswer_idempotency_key_and_more.py | 22 ++ content/models.py | 44 ++++ content/services.py | 86 ++++--- content/views.py | 237 ++++++++++++++---- discount/services.py | 51 ++-- discount/views.py | 21 +- ..._pending_family_invite_per_parent_child.py | 17 ++ family/models.py | 16 ++ family/views.py | 125 +++++---- ...ction_uniq_transaction_order_ref_status.py | 17 ++ ...ion_uniq_transaction_gateway_ref_global.py | 17 ++ ...ction_uniq_transaction_order_ref_status.py | 17 ++ payment/models.py | 19 ++ payment/services.py | 134 +++++++--- payment/views.py | 21 +- subscription/services.py | 75 ++++-- support/admin.py | 23 +- 25 files changed, 1092 insertions(+), 287 deletions(-) create mode 100644 accounts/migrations/0002_user_uniq_guest_device_id.py create mode 100644 ai_tutor/migrations/0002_askidempotency.py create mode 100644 content/migrations/0003_userlessoncompletion_idempotency_key_and_more.py create mode 100644 content/migrations/0004_useranswer_idempotency_key_and_more.py create mode 100644 family/migrations/0002_familylink_uniq_pending_family_invite_per_parent_child.py create mode 100644 payment/migrations/0003_transaction_uniq_transaction_order_ref_status.py create mode 100644 payment/migrations/0004_transaction_uniq_transaction_gateway_ref_global.py create mode 100644 payment/migrations/0005_remove_transaction_uniq_transaction_order_ref_status.py diff --git a/accounts/migrations/0002_user_uniq_guest_device_id.py b/accounts/migrations/0002_user_uniq_guest_device_id.py new file mode 100644 index 0000000..a1fec83 --- /dev/null +++ b/accounts/migrations/0002_user_uniq_guest_device_id.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.17 on 2026-08-19 10:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AddConstraint( + model_name='user', + constraint=models.UniqueConstraint(condition=models.Q(('is_guest', True), models.Q(('device_id', None), _negated=True), models.Q(('device_id', ''), _negated=True)), fields=('device_id',), name='uniq_guest_device_id'), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 814954d..65d6d74 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -20,6 +20,25 @@ class User(models.Model): class Meta: ordering = ['-created_at'] + constraints = [ + # 🔒 اصلاح race condition: قبلاً device_id هیچ قید یکتاییِ سطح + # DB نداشت، پس User.objects.get_or_create(device_id=...) در + # GuestSessionView در برابر دو درخواستِ هم‌زمان با همون + # device_id، می‌تونست دو کاربرِ مهمانِ جدا بسازه (هر دو create + # می‌شدن چون هیچ‌کدوم قبل از دیگری commit نشده بود که get بعدی + # پیداش کنه). این UniqueConstraint (فقط برای کاربرانِ مهمان و + # device_id غیرخالی — کاربرانِ ثبت‌نام‌شده با phone می‌تونن + # device_id مشترک/خالی داشته باشن) کافیه؛ چون Django's + # get_or_create از قبل خودش IntegrityError روی همین constraint + # رو می‌گیره و رکورد موجود رو دوباره get می‌کنه (نگاه کنید به + # پیاده‌سازی _create_object_from_params در Django)، پس نیازی به + # تغییر GuestSessionView نبود. + models.UniqueConstraint( + fields=['device_id'], + condition=models.Q(is_guest=True) & ~models.Q(device_id=None) & ~models.Q(device_id=''), + name='uniq_guest_device_id', + ), + ] def __str__(self): return self.name or self.phone or f"guest-{self.pk}" diff --git a/accounts/views.py b/accounts/views.py index a439b82..ee4befc 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -2,6 +2,7 @@ import random import uuid from django.conf import settings +from django.db import transaction from django.utils import timezone from drf_spectacular.utils import extend_schema from rest_framework import status @@ -99,44 +100,58 @@ class LoginView(APIView): phone = serializer.validated_data['phone'] otp = serializer.validated_data['otp'] - valid = False - if otp == settings.DEV_MASTER_OTP: - valid = True - else: - otp_qs = OTPCode.objects.filter( - phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now() - ).order_by('-id') - otp_obj = otp_qs.first() - if otp_obj: - otp_obj.is_used = True - otp_obj.save(update_fields=['is_used']) + # ⚛️🔒 race condition: قبلاً چکِ «is_used=False» و ستِ «is_used=True» + # دو عملیاتِ جدا و بدون قفل بودن. دو درخواستِ همزمان با دقیقاً همون + # کدِ OTP می‌تونستن هر دو is_used=False رو ببینن و هر دو valid=True + # بگیرن — یعنی یک کدِ یک‌بارمصرف، دوبار مصرف بشه (و در پی‌اش، دوبار + # وارد شاخه‌ی «ثبت‌نامِ واقعی» بشیم). حالا ردیفِ OTP قفل می‌شه + # (select_for_update، دقیقاً همون الگویی که در payment/discount + # استفاده شده) و چک‌وست داخل همون قفل انجام می‌شه، تا کل ورود + # (چک OTP + get_or_create کاربر + سشن) یک واحدِ اتمیکِ قفل‌شده باشه. + with transaction.atomic(): + valid = False + if otp == settings.DEV_MASTER_OTP: valid = True + else: + otp_obj = OTPCode.objects.select_for_update().filter( + phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now() + ).order_by('-id').first() + if otp_obj: + otp_obj.is_used = True + otp_obj.save(update_fields=['is_used']) + valid = True - if not valid: - return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST) + if not valid: + return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST) - user, created = User.objects.get_or_create( - phone=phone, - defaults={'is_guest': False, 'name': ''}, - ) - if user.is_guest: - user.is_guest = False - user.save(update_fields=['is_guest']) + # ⚛️ atomic: get_or_create/save روی User، سیگنال user_registered + # (که آفرهای auto_assign_on_signup رو از طریق discount اعمال می‌کنه + # و خودش UserOffer/Subscription می‌سازه) و ساخت Session، همه یک + # واحدند. بدون این، مثلاً اگه ساخت Session fail کنه، کاربر واقعاً + # «ثبت‌نام‌شده» و صاحب آفر رایگان می‌مونه ولی هیچ سشنی نداره که + # باهاش وارد بشه (لاگین باید کامل موفق یا کامل ناموفق باشه). + user, created = User.objects.get_or_create( + phone=phone, + defaults={'is_guest': False, 'name': ''}, + ) + if user.is_guest: + user.is_guest = False + user.save(update_fields=['is_guest']) - if created: - # ثبت‌نام واقعی (نه guest session): آفرهای auto_assign_on_signup - # (مثل یک هفته رایگان) از طریق discount/receivers.py روی همین - # سیگنال اعمال می‌شن؛ این ویو از وجود آفر/discount چیزی نمی‌دونه. - from .signals import user_registered - user_registered.send(sender=User, user_id=user.id) + if created: + # ثبت‌نام واقعی (نه guest session): آفرهای auto_assign_on_signup + # (مثل یک هفته رایگان) از طریق discount/receivers.py روی همین + # سیگنال اعمال می‌شن؛ این ویو از وجود آفر/discount چیزی نمی‌دونه. + from .signals import user_registered + user_registered.send(sender=User, user_id=user.id) - refresh_token = uuid.uuid4().hex - Session.objects.create( - user=user, - refresh_token=refresh_token, - device_id=request.data.get('deviceId', ''), - expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), - ) + refresh_token = uuid.uuid4().hex + Session.objects.create( + user=user, + refresh_token=refresh_token, + device_id=request.data.get('deviceId', ''), + expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), + ) access_token = issue_access_token(user.id) data = { diff --git a/ai_tutor/migrations/0002_askidempotency.py b/ai_tutor/migrations/0002_askidempotency.py new file mode 100644 index 0000000..4557ac1 --- /dev/null +++ b/ai_tutor/migrations/0002_askidempotency.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.17 on 2026-08-19 11:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai_tutor', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AskIdempotency', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True)), + ('idempotency_key', models.CharField(max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('assistant_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ai_tutor.message')), + ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ai_tutor.conversation')), + ('user_message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='ai_tutor.message')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('user_id', 'idempotency_key'), name='uniq_ask_idempotency_key')], + }, + ), + ] diff --git a/ai_tutor/models.py b/ai_tutor/models.py index 7768c22..3013718 100644 --- a/ai_tutor/models.py +++ b/ai_tutor/models.py @@ -34,3 +34,35 @@ class MessageFeedback(models.Model): def __str__(self): return f"feedback-msg{self.message_id}" + + +class AskIdempotency(models.Model): + """ + ⚛️🔒 اصلاح idempotency برای POST /api/ai/ask (همون الگویی که + UserAnswer.idempotency_key/UserLessonCompletion.idempotency_key در + content و Order.idempotency_key در payment دارن): چون نتیجه‌ی هر + «سوال» شاملِ دو ردیفِ Message (کاربر + دستیار) و مصرفِ یک اسلاتِ + کوتای روزانه‌ست (نه یک ردیفِ تکی مثلِ UserAnswer)، این رکورد پل بین + (user, Idempotency-Key) و نتیجه‌ی از قبل ساخته‌شده رو نگه می‌داره تا + رتری/دابل‌کلیکِ همون کلید، دوباره پیام یا کوتای تکراری نسازه و به‌جاش + نتیجه‌ی همون درخواستِ قبلی برگردونده بشه. UniqueConstraint سطح DB روی + (user_id, idempotency_key) هم دفاع در عمق در برابر دو submit هم‌زمانِ + دقیقاً با همون کلیده (مشابه uniq_user_answer_idempotency_key). + """ + user_id = models.IntegerField(db_index=True) + idempotency_key = models.CharField(max_length=100) + conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='+') + user_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+') + assistant_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['user_id', 'idempotency_key'], + name='uniq_ask_idempotency_key', + ), + ] + + def __str__(self): + return f"ask-idem-user{self.user_id}-{self.idempotency_key}" diff --git a/ai_tutor/views.py b/ai_tutor/views.py index 63d42a8..09b4474 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -1,14 +1,15 @@ -from django.db import transaction +from django.db import IntegrityError, transaction from django.shortcuts import get_object_or_404 from django.utils import timezone -from drf_spectacular.utils import extend_schema +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.exceptions import Throttled from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from accounts.models import User as AccountUser -from .models import Conversation, Message, MessageFeedback +from .models import AskIdempotency, Conversation, Message, MessageFeedback from .serializers import ( AskRequestSerializer, AskResponseSerializer, @@ -40,12 +41,47 @@ class AskAIView(APIView): permission_classes = [IsAuthenticated] - @extend_schema(request=AskRequestSerializer, responses=AskResponseSerializer, tags=['AI Tutor (ai_tutor)']) + # ⚠️ افزوده شده: هدرِ اختیاریِ Idempotency-Key (دقیقاً همون الگویی که + # payment و content/QuestionAnswerView و content/LessonCompleteView + # دارن) که قبلاً برای این endpoint نه مستند بود و نه پیاده‌سازی شده + # بود؛ نبودش یعنی رتری/دابل‌کلیکِ همون سوال می‌تونست یک پیامِ کاربر و + # یک پیامِ دستیارِ تکراری بسازه و یک اسلاتِ کوتای روزانه‌ی اضافه مصرف + # کنه. + @extend_schema( + request=AskRequestSerializer, + responses=AskResponseSerializer, + parameters=[ + OpenApiParameter( + 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, + description=( + 'کلید اختیاری idempotency (مثل payment و content). اگه ست بشه، ' + 'رتری/دابل‌کلیکِ همون سوال با همون کلید، دوباره پیامِ کاربر/دستیارِ ' + 'تکراری نمی‌سازه و اسلاتِ کوتای روزانه‌ی دیگری مصرف نمی‌کنه؛ به‌جاش ' + 'نتیجه‌ی همون درخواستِ قبلاً پردازش‌شده برگردونده می‌شه.' + ), + ), + ], + tags=['AI Tutor (ai_tutor)'], + ) def post(self, request): serializer = AskRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) v = serializer.validated_data user = request.user + idempotency_key = request.headers.get('Idempotency-Key') or None + + # ⚛️🔒 اصلاح idempotency: اگه کلاینت کلید idempotency فرستاده و + # قبلاً همین درخواست با همین کلید کامل پردازش شده (AskIdempotency + # موجوده)، به‌جای ساختِ دوباره‌ی پیام‌ها و مصرفِ دوباره‌ی کوتا، نتیجه‌ی + # همون درخواستِ قبلی برگردونده می‌شه. کوتای «باقی‌مونده» دوباره از + # روی وضعیتِ فعلی محاسبه می‌شه (نه مقدارِ زمانِ اولین درخواست) چون + # این فراخوانی به‌خودی‌خود چیزی مصرف نکرده. + if idempotency_key: + existing = AskIdempotency.objects.filter( + user_id=user.id, idempotency_key=idempotency_key, + ).select_related('conversation', 'user_message', 'assistant_message').first() + if existing is not None: + return Response(self._build_ask_response(existing).data) # ⚠️ اصلاح: چک و مصرفِ کوتای روزانه قبلاً race condition داشت — # دو درخواست هم‌زمان از همون کاربر می‌تونستن هر دو شمارشِ «امروز» @@ -54,43 +90,92 @@ class AskAIView(APIView): # (select_for_update) در یک تراکنش اتمیک، چک و مصرفِ کوتا برای هر # کاربر serialize می‌شه؛ درخواست‌های هم‌زمانِ همون کاربر منتظر آزاد # شدن قفل می‌مونن و دیگه نمی‌تونن هم‌زمان از کنار چک رد بشن. - with transaction.atomic(): - AccountUser.objects.select_for_update().get(pk=user.id) + # + # ⚛️ اصلاح اتمیک‌بودن: قبلاً ساختِ پیامِ کاربر داخلِ این atomic + # commit می‌شد و بعد از بسته‌شدنِ تراکنش، generate_tutor_answer و + # ساختِ پیامِ دستیار جدا (بدون atomic) انجام می‌شد. یعنی اگه بین + # این دو مرحله خطایی پیش می‌اومد (مثلاً خطای شبکه‌ای هنگام فراخوانیِ + # واقعیِ سرویسِ AI، یا خطای DB روی ساختِ پیامِ دستیار)، پیامِ کاربر + # و مصرفِ کوتای روزانه‌اش برای همیشه در دیتابیس ثبت‌شده باقی + # می‌موند، بدون اینکه هیچ پاسخی برایش ساخته شده باشد و بدون اینکه + # کلاینت پاسخ موفقی گرفته باشد — یک «سوال یتیم» که هم کوتا را + # مصرف کرده هم درخواست را از دید کاربر ناموفق نشان می‌دهد. حالا + # ساختِ پیامِ کاربر، فراخوانیِ generate_tutor_answer و ساختِ پیامِ + # دستیار همگی داخل یک تراکنشِ اتمیکِ واحدند: یا هر دو پیام با هم + # commit می‌شن، یا (در صورتِ خطا) هیچ‌کدام، و کوتای روزانه هم در + # آن صورت مصرف‌نشده باقی می‌ماند. + # نکته برای آینده: وقتی generate_tutor_answer به یک فراخوانیِ + # شبکه‌ایِ واقعی (مثلاً Anthropic API) وصل شد، اگر آن فراخوانی + # کند/کندپاسخ باشد، بهتر است پیش از بازکردنِ این تراکنش (یا با + # گرفتنِ پاسخ بیرون از قفلِ ردیفِ کاربر) انجام شود تا قفل برای مدتِ + # طولانی نگه داشته نشود؛ این یک ملاحظه‌ی کارایی/همزمانی است، نه + # صحتِ تراکنش، و در همین دور تغییر داده نشده. + # ⚛️🔒 دفاع در عمق برای idempotency: اگه دو submit هم‌زمان دقیقاً با + # همون Idempotency-Key وارد بشن (رتریِ واقعیِ شبکه‌ایِ کلاینت، درست + # بعد از چکِ existing بالا و قبل از commit شدنش)، UniqueConstraintِ + # سطح DB (uniq_ask_idempotency_key) جلوی ساختِ رکوردِ دومِ + # AskIdempotency رو می‌گیره و کلِ این atomic block (شاملِ دو پیام و + # مصرفِ کوتا) rollback می‌شه — دقیقاً همون الگویی که + # QuestionAnswerView/LessonCompleteView در content و + # apply_order_payment_result در payment دارن. در این حالت، به‌جای + # خطا، نتیجه‌ی همون درخواستی که رقیب ثبت کرده رو idempotent + # برمی‌گردونیم. + try: + with transaction.atomic(): + AccountUser.objects.select_for_update().get(pk=user.id) - today = timezone.localdate() - used_today = Message.objects.filter( - conversation__user_id=user.id, role='user', created_at__date=today - ).count() - if used_today >= DAILY_AI_QUOTA: - raise Throttled(detail='سقف روزانه‌ی سوال از هوش مصنوعی به پایان رسیده است.') + today = timezone.localdate() + used_today = Message.objects.filter( + conversation__user_id=user.id, role='user', created_at__date=today + ).count() + if used_today >= DAILY_AI_QUOTA: + raise Throttled(detail='سقف روزانه‌ی سوال از هوش مصنوعی به پایان رسیده است.') - conversation_id = v.get('conversationId') - if conversation_id: - conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=user.id) - else: - conversation = Conversation.objects.create( - user_id=user.id, - lesson_id=v.get('lessonId') or None, - chapter_id=v.get('chapterId') or None, - grade_id=v.get('gradeId'), + conversation_id = v.get('conversationId') + if conversation_id: + conversation = get_object_or_404(Conversation, pk=conversation_id, user_id=user.id) + else: + conversation = Conversation.objects.create( + user_id=user.id, + lesson_id=v.get('lessonId') or None, + chapter_id=v.get('chapterId') or None, + grade_id=v.get('gradeId'), + ) + + user_message = Message.objects.create( + conversation=conversation, + role='user', + text=v['userQuestion'], + question_id=v.get('questionId') or None, ) - Message.objects.create( - conversation=conversation, - role='user', - text=v['userQuestion'], - question_id=v.get('questionId') or None, - ) + answer_text, follow_ups = generate_tutor_answer( + v['userQuestion'], v.get('gradeId'), v.get('lessonId') + ) + assistant_message = Message.objects.create( + conversation=conversation, + role='assistant', + text=answer_text, + question_id=v.get('questionId') or None, + ) - answer_text, follow_ups = generate_tutor_answer( - v['userQuestion'], v.get('gradeId'), v.get('lessonId') - ) - assistant_message = Message.objects.create( - conversation=conversation, - role='assistant', - text=answer_text, - question_id=v.get('questionId') or None, - ) + if idempotency_key: + AskIdempotency.objects.create( + user_id=user.id, + idempotency_key=idempotency_key, + conversation=conversation, + user_message=user_message, + assistant_message=assistant_message, + ) + except IntegrityError: + if not idempotency_key: + raise + existing = AskIdempotency.objects.filter( + user_id=user.id, idempotency_key=idempotency_key, + ).select_related('conversation', 'user_message', 'assistant_message').first() + if existing is None: + raise + return Response(self._build_ask_response(existing).data) used_today += 1 # همین سوالی که الان ثبت شد هم جزو مصرف امروزه remaining_quota = max(DAILY_AI_QUOTA - used_today, 0) @@ -104,6 +189,36 @@ class AskAIView(APIView): } return Response(AskResponseSerializer(data).data) + def _build_ask_response(self, existing: AskIdempotency) -> AskResponseSerializer: + """ + ساختِ همون پاسخِ AskResponseSerializer برای یک AskIdempotency + از قبل ثبت‌شده (مسیرِ idempotent replay)، بدونِ ساختِ پیامِ جدید یا + مصرفِ کوتا. suggestedFollowUps ذخیره نشده (فقط متنِ پاسخِ نهایی در + Message.text ذخیره‌ست)، پس با فراخوانیِ دوباره‌ی همون تابعِ خالص و + بدونِ اثرِ جانبیِ generate_tutor_answer (نه ساختِ پیام، نه مصرفِ + کوتا) روی همون سوالِ ثبت‌شده بازسازی می‌شه؛ remainingQuota هم از + روی مصرفِ *فعلیِ* امروز محاسبه می‌شه، نه لحظه‌ی درخواستِ اولیه، چون + این فراخوانیِ idempotent به‌خودی‌خود چیزی مصرف نکرده. + """ + conversation = existing.conversation + _, follow_ups = generate_tutor_answer( + existing.user_message.text, conversation.grade_id, conversation.lesson_id + ) + today = timezone.localdate() + used_today = Message.objects.filter( + conversation__user_id=existing.user_id, role='user', created_at__date=today + ).count() + remaining_quota = max(DAILY_AI_QUOTA - used_today, 0) + + data = { + 'conversationId': str(conversation.id), + 'messageId': str(existing.assistant_message.id), + 'answer': existing.assistant_message.text, + 'suggestedFollowUps': follow_ups, + 'remainingQuota': remaining_quota, + } + return AskResponseSerializer(data) + class ConversationMessagesView(APIView): """GET /api/ai/conversations/{id}/messages — تاریخچه‌ی مکالمه""" diff --git a/content/management/commands/mark_abandoned_node_progress.py b/content/management/commands/mark_abandoned_node_progress.py index b2192e4..f15d8d4 100644 --- a/content/management/commands/mark_abandoned_node_progress.py +++ b/content/management/commands/mark_abandoned_node_progress.py @@ -10,6 +10,7 @@ commandه، نه receiver. باید periodic اجرا بشه (cron / celery beat """ from django.conf import settings from django.core.management.base import BaseCommand +from django.db import transaction from django.utils import timezone from content.models import NodeProgress @@ -28,15 +29,46 @@ class Command(BaseCommand): ) count = 0 - for progress in stale_qs: - old_status = progress.status - progress.status = 'abandoned' - progress.save(update_fields=['status']) - status_changed.send( - sender='content.NodeProgress', object_id=progress.id, - old_status=old_status, new_status='abandoned', - event='inactivity_timeout', user_id=progress.user_id, - ) + # ⚛️ atomic (به‌ازای هر رکورد، نه کل حلقه): این یک job دوره‌ای رو + # روی هزاران رکورد می‌تونه اجرا کنه. اگه کل حلقه یک atomic واحد + # باشه، یک خطا روی رکورد هزارم، ابطالِ ۹۹۹ رکورد درست‌شده‌ی قبلی رو + # هم rollback می‌کنه (و کل job باید از اول اجرا بشه). به‌جاش هر + # رکورد (save + signal که StatusChangeLog می‌سازه) جدا اتمیکه: یک + # خطای مقطعی فقط همون رکورد رو نصفه‌کاره نگه نمی‌داره، رکوردهای + # قبلی/بعدی مستقل می‌مونن. + # + # ⚛️🔒 اصلاح race condition (lost update): قبلاً همین آبجکتِ + # progressِ خونده‌شده از stale_qs (بدونِ قفل) مستقیماً save می‌شد. + # اگه هم‌زمان یک درخواستِ کاربرِ واقعی (touch_node_progress_on_answer + # یا update_node_progress_on_lesson_completion در content/services.py، + # هر دو با select_for_update) دقیقاً همون ردیف رو به‌روزرسانی و + # last_activity_at رو تازه می‌کرد، این کامند می‌تونست بدونِ اطلاع از + # اون تغییرِ تازه، status رو با مقدارِ قدیمیِ در-حافظه به 'abandoned' + # برگردونه — یعنی فعالیتِ تازه‌ی کاربر توسط این جاب rollback/overwrite + # می‌شد. حالا برای هر رکورد، فقط id از stale_qs گرفته می‌شه؛ ردیفِ + # واقعی داخل transaction.atomic با select_for_update دوباره خونده و + # قفل می‌شه (دقیقاً همون الگویی که مسیرِ کاربر استفاده می‌کنه)، و + # شرطِ نصفه‌رها بودن (status='in_progress' و last_activity_at قدیمی‌تر + # از threshold) بعد از گرفتنِ قفل دوباره چک می‌شه؛ اگه بین queryset + # اولیه و گرفتنِ قفل، یک فعالیتِ کاربر همین ردیف رو تازه کرده باشه، + # دیگه شرط برقرار نیست و این ردیف نادیده گرفته می‌شه (نه abandoned). + stale_ids = list(stale_qs.values_list('id', flat=True)) + for progress_id in stale_ids: + with transaction.atomic(): + progress = NodeProgress.objects.select_for_update().get(pk=progress_id) + + if progress.status != 'in_progress' or progress.last_activity_at is None \ + or progress.last_activity_at >= threshold: + continue + + old_status = progress.status + progress.status = 'abandoned' + progress.save(update_fields=['status']) + status_changed.send( + sender='content.NodeProgress', object_id=progress.id, + old_status=old_status, new_status='abandoned', + event='inactivity_timeout', user_id=progress.user_id, + ) count += 1 self.stdout.write(self.style.SUCCESS( diff --git a/content/migrations/0003_userlessoncompletion_idempotency_key_and_more.py b/content/migrations/0003_userlessoncompletion_idempotency_key_and_more.py new file mode 100644 index 0000000..e5eb8fa --- /dev/null +++ b/content/migrations/0003_userlessoncompletion_idempotency_key_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.17 on 2026-08-19 10:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0002_nodeprogress'), + ] + + operations = [ + migrations.AddField( + model_name='userlessoncompletion', + name='idempotency_key', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddConstraint( + model_name='userlessoncompletion', + constraint=models.UniqueConstraint(condition=models.Q(('idempotency_key__isnull', False), models.Q(('idempotency_key', ''), _negated=True)), fields=('user_id', 'lesson', 'idempotency_key'), name='uniq_lesson_completion_idempotency_key'), + ), + ] diff --git a/content/migrations/0004_useranswer_idempotency_key_and_more.py b/content/migrations/0004_useranswer_idempotency_key_and_more.py new file mode 100644 index 0000000..8cb97d4 --- /dev/null +++ b/content/migrations/0004_useranswer_idempotency_key_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.17 on 2026-08-19 11:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0003_userlessoncompletion_idempotency_key_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='useranswer', + name='idempotency_key', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddConstraint( + model_name='useranswer', + constraint=models.UniqueConstraint(condition=models.Q(('idempotency_key__isnull', False), models.Q(('idempotency_key', ''), _negated=True)), fields=('user_id', 'question', 'idempotency_key'), name='uniq_user_answer_idempotency_key'), + ), + ] diff --git a/content/models.py b/content/models.py index 5399428..d7c24f1 100644 --- a/content/models.py +++ b/content/models.py @@ -82,8 +82,27 @@ class UserLessonCompletion(models.Model): stars = models.PositiveSmallIntegerField() xp_earned = models.PositiveIntegerField(default=0) coins_earned = models.PositiveIntegerField(default=0) + # ⚠️ افزوده شده برای رفع race condition: طبق طراحیِ فعلی، کاربر مجاز به + # تکرارِ یک درس هست (فقط بارِ اول xp/coins واقعی می‌گیره، نگاه کنید به + # is_first_completion در LessonCompleteView)، پس یک UniqueConstraint + # سختِ (user_id, lesson) درست نیست — جلوی retryِ مشروع رو هم می‌گیره. + # مشکلِ واقعی، دابل‌کلیک/رتریِ شبکه‌ایِ کلاینت روی *همون* submitِ + # تکمیل درسه که می‌تونست دو ردیف برای یک اتفاق بسازه. راه‌حل، همون + # الگوی Idempotency-Key هست که پروژه از قبل در payment/services.py + # برای create_order داره: کلاینت (اختیاری) یک کلید یکتا برای هر submit + # واقعی می‌فرسته و رتری‌ها با همون کلید idempotent می‌شن. + idempotency_key = models.CharField(max_length=100, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['user_id', 'lesson', 'idempotency_key'], + condition=models.Q(idempotency_key__isnull=False) & ~models.Q(idempotency_key=''), + name='uniq_lesson_completion_idempotency_key', + ), + ] + def __str__(self): return f"completion-user{self.user_id}-lesson{self.lesson_id}" @@ -158,7 +177,32 @@ class UserAnswer(models.Model): choice = models.ForeignKey(Choice, on_delete=models.CASCADE, related_name='answers') correct = models.BooleanField() xp_earned = models.PositiveIntegerField(default=0) + # ⚠️ افزوده شده برای رفع idempotency در POST /api/questions/{id}/answer: + # قبلاً هیچ مکانیزمی برای تشخیصِ رتریِ شبکه‌ای/کلاینتِ همون submit وجود + # نداشت — یک retry دقیقاً همون درخواست رو دوباره می‌فرستاد و یک ردیفِ + # UserAnswer تکراری می‌ساخت و (در حالت پاسخ غلط) هارت رو دوبار کم + # می‌کرد. دقیقاً همون الگویی که UserLessonCompletion.idempotency_key + # برای LessonCompleteView داره: کلاینت (اختیاری) یک کلید یکتا برای هر + # submit واقعی می‌فرسته و رتری‌ها با همون کلید idempotent می‌شن؛ بدونِ + # کلید (مقدار None)، پاسخ‌های مشروعِ تکراری (کاربر واقعاً چند بار به یک + # سوال پاسخ می‌ده) همچنان مجازن. + idempotency_key = models.CharField(max_length=100, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) + class Meta: + constraints = [ + # 🔒 قید یکتاییِ سطح DB: به‌اندازه‌ی کافی محدود (user_id + + # question + idempotency_key) تا کاربر و سوال رو از هم تفکیک + # کنه — یعنی همون کلید توسط کاربرِ دیگه یا برای سوالِ دیگه هیچ + # تداخلی نداره. condition تضمین می‌کنه این قید فقط وقتی + # idempotency_key واقعاً پر شده اعمال بشه، نه وقتی خالی/نال‌ه + # (وگرنه پاسخ‌های بدونِ کلید هم به اشتباه یکتا می‌شدن). + models.UniqueConstraint( + fields=['user_id', 'question', 'idempotency_key'], + condition=models.Q(idempotency_key__isnull=False) & ~models.Q(idempotency_key=''), + name='uniq_user_answer_idempotency_key', + ), + ] + def __str__(self): return f"answer-user{self.user_id}-q{self.question_id}" diff --git a/content/services.py b/content/services.py index cce2375..dc30484 100644 --- a/content/services.py +++ b/content/services.py @@ -5,6 +5,7 @@ content/receivers.py صدا زده می‌شن (نه مستقیم از views.py) یا UserLessonCompletion ساخته بشه، وضعیت Node هم خودکار به‌روز بمونه — دقیقاً همون الگوی signal-based که برای discount/subscription استفاده شده. """ +from django.db import transaction from django.utils import timezone from statuslog.signals import status_changed @@ -13,7 +14,28 @@ from .models import Node, NodeProgress, UserLessonCompletion def _get_or_create_progress(user_id, node): - return NodeProgress.objects.get_or_create(user_id=user_id, node=node) + """ + ⚛️🔒 اصلاح race condition: قبلاً این تابع فقط get_or_create می‌کرد و + ردیفِ برگردانده‌شده (چه تازه‌ساخته‌شده، چه از قبل موجود) هیچ‌وقت قفل + نمی‌شد. دو درخواستِ هم‌زمان برای همون (user_id, node) — مثلاً دو پاسخِ + پشت‌سرهم یا یک پاسخ و یک تکمیلِ درس که تقریباً هم‌زمان می‌رسن — + می‌تونستن هر دو همون ردیفِ NodeProgress رو با status/started_at/ + last_activity_at یکسان بخونن، هر دو مستقل مقدارهای جدید رو محاسبه + کنن و save کنن؛ نتیجه‌ی یکی از دو نویسش گم می‌شد (lost update) — + مثلاً یک started_at یا یک تغییرِ status می‌تونست توسط نویسش دیگری + که دیرتر commit می‌شه، بازنویسی/نادیده گرفته بشه. + + حالا دقیقاً مثل الگوی grant_free_subscription در subscription/services.py: + اول get_or_create برای اطمینان از وجودِ ردیف (idempotent، خودش با + savepoint در برابر race روی خودِ create محافظت‌شده)، بعد همون ردیف با + select_for_update دوباره خونده و قفل می‌شه تا خواندن/تغییر/ذخیره‌ی + فیلدهاش توسط caller، در برابر concurrent update ایمن باشه. caller + باید این تابع رو همیشه داخل transaction.atomic صدا بزنه (هر دو نقطه‌ی + فراخوانی در همین فایل همین‌طورن). + """ + NodeProgress.objects.get_or_create(user_id=user_id, node=node) + progress = NodeProgress.objects.select_for_update().get(user_id=user_id, node=node) + return progress, False def _emit_if_changed(progress, old_status, event, user_id): @@ -32,18 +54,19 @@ def touch_node_progress_on_answer(user_id, question): به in_progress برمی‌گرده. """ node = question.lesson.node - progress, _created = _get_or_create_progress(user_id, node) - old_status = progress.status - now = timezone.now() + with transaction.atomic(): + progress, _created = _get_or_create_progress(user_id, node) + old_status = progress.status + now = timezone.now() - if progress.status in ('not_started', 'abandoned'): - progress.status = 'in_progress' - if progress.started_at is None: - progress.started_at = now - progress.last_activity_at = now - progress.save(update_fields=['status', 'started_at', 'last_activity_at']) + if progress.status in ('not_started', 'abandoned'): + progress.status = 'in_progress' + if progress.started_at is None: + progress.started_at = now + progress.last_activity_at = now + progress.save(update_fields=['status', 'started_at', 'last_activity_at']) - _emit_if_changed(progress, old_status, event='answer_activity', user_id=user_id) + _emit_if_changed(progress, old_status, event='answer_activity', user_id=user_id) return progress @@ -54,31 +77,32 @@ def update_node_progress_on_lesson_completion(user_id, lesson): میره روی completed، وگرنه (یا اگه از abandoned برگشته) in_progress. """ node = lesson.node - progress, _created = _get_or_create_progress(user_id, node) - old_status = progress.status - now = timezone.now() + with transaction.atomic(): + progress, _created = _get_or_create_progress(user_id, node) + old_status = progress.status + now = timezone.now() - lesson_ids = list(node.lessons.values_list('id', flat=True)) - completed_count = UserLessonCompletion.objects.filter( - user_id=user_id, lesson_id__in=lesson_ids - ).values('lesson_id').distinct().count() + lesson_ids = list(node.lessons.values_list('id', flat=True)) + completed_count = UserLessonCompletion.objects.filter( + user_id=user_id, lesson_id__in=lesson_ids + ).values('lesson_id').distinct().count() - progress.lessons_completed_count = completed_count - if progress.started_at is None: - progress.started_at = now - progress.last_activity_at = now + progress.lessons_completed_count = completed_count + if progress.started_at is None: + progress.started_at = now + progress.last_activity_at = now - if lesson_ids and completed_count >= len(lesson_ids): - progress.status = 'completed' - progress.completed_at = now - else: - progress.status = 'in_progress' + if lesson_ids and completed_count >= len(lesson_ids): + progress.status = 'completed' + progress.completed_at = now + else: + progress.status = 'in_progress' - progress.save(update_fields=[ - 'lessons_completed_count', 'status', 'started_at', 'completed_at', 'last_activity_at', - ]) + progress.save(update_fields=[ + 'lessons_completed_count', 'status', 'started_at', 'completed_at', 'last_activity_at', + ]) - _emit_if_changed(progress, old_status, event='lesson_completed', user_id=user_id) + _emit_if_changed(progress, old_status, event='lesson_completed', user_id=user_id) return progress diff --git a/content/views.py b/content/views.py index 667eb51..5e75ce9 100644 --- a/content/views.py +++ b/content/views.py @@ -1,3 +1,4 @@ +from django.db import IntegrityError, transaction from django.db.models import Sum from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes @@ -138,6 +139,17 @@ class QuestionAnswerView(APIView): @extend_schema( request=AnswerRequestSerializer, responses=AnswerResponseSerializer, + parameters=[ + OpenApiParameter( + 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, + description=( + 'کلید اختیاری idempotency (مثل payment و lesson/complete). اگه ست ' + 'بشه، رتری/دابل‌کلیکِ همون submit با همون کلید، دوباره ردیفِ پاسخ/' + 'کسرِ هارتِ تکراری نمی‌سازه و نتیجه‌ی همون پاسخِ قبلاً ثبت‌شده ' + 'برگردونده می‌شه.' + ), + ), + ], tags=['Curriculum (content)'], ) def post(self, request, question_id): @@ -151,22 +163,90 @@ class QuestionAnswerView(APIView): user_id = request.user.id xp_earned = 10 if choice.is_correct else 0 + idempotency_key = request.headers.get('Idempotency-Key') or None - UserAnswer.objects.create( - user_id=user_id, - question=question, - choice=choice, - correct=choice.is_correct, - xp_earned=xp_earned, - ) - if not choice.is_correct: - hearts, _ = UserHearts.objects.get_or_create(user_id=user_id) - if hearts.current_hearts > 0: - hearts.current_hearts -= 1 - hearts.save(update_fields=['current_hearts']) - hearts_remaining = hearts.current_hearts - else: - hearts = UserHearts.objects.filter(user_id=user_id).first() + # ⚛️🔒 اصلاح idempotency (duplicate answer row + دوبار کسرِ هارت): + # اگه کلاینت کلید idempotency فرستاده و قبلاً همین submit با همین + # کلید ثبت شده، به‌جای ساختِ دوباره‌ی ردیف/کسرِ دوباره‌ی هارت، همون + # نتیجه‌ی قبلی (و وضعیتِ فعلیِ هارت، بدونِ تغییرِ دوباره‌اش) برگردونده + # می‌شه — دقیقاً همون الگویی که LessonCompleteView برای + # UserLessonCompletion.idempotency_key داره. + if idempotency_key: + existing = UserAnswer.objects.filter( + user_id=user_id, question=question, idempotency_key=idempotency_key, + ).first() + if existing is not None: + hearts = UserHearts.objects.filter(user_id=user_id).first() + data = { + 'correct': existing.correct, + 'correctOptionId': str(correct_choice.id) if correct_choice else '', + 'xpEarned': existing.xp_earned, + 'heartsRemaining': hearts.current_hearts if hearts else 5, + } + return Response(AnswerResponseSerializer(data).data) + + # ⚛️ atomic: UserAnswer.create پایین‌تر یک سیگنال post_save رو تریگر + # می‌کنه (content/receivers.py) که خودش NodeProgress رو آپدیت و + # احتمالاً یک StatusChangeLog می‌سازه؛ به‌علاوه‌ی get_or_create/save + # روی UserHearts، همه‌شون باید یک واحد اتمیک باشن — وگرنه مثلاً اگه + # save روی hearts fail کنه، پاسخ کاربر ثبت شده ولی هارتش کم نشده. + # + # ⚛️🔒 race condition: خواندنِ hearts.current_hearts و کم‌کردنش + # («lost update») بدون قفل بود — اگه کاربر دو تا پاسخِ غلط رو تقریباً + # همزمان بفرسته (یا کلاینت retry کنه)، دو درخواست می‌تونستن هر دو + # current_hearts=5 رو بخونن، هر دو 4 رو ذخیره کنن؛ یعنی به‌جای کم‌شدنِ + # ۲ تا هارت، فقط ۱ تا کم بشه. حالا ردیفِ UserHearts قفل می‌شه + # (select_for_update) قبل از خوندن/کم‌کردنش. + # + # ⚛️🔒 دفاع در عمق برای idempotency: اگه دو submit هم‌زمان دقیقاً با + # همون Idempotency-Key وارد بشن (رتریِ واقعیِ شبکه‌ایِ کلاینت)، + # UniqueConstraintِ سطح DB (uniq_user_answer_idempotency_key) جلوی + # ردیفِ دومِ UserAnswer رو می‌گیره و کلِ این atomic block (شاملِ + # کسرِ هارت) rollback می‌شه — پس هیچ اثرِ تکراری‌ای اعمال نشده. در + # این حالت، به‌جای خطا، نتیجه‌ی همون پاسخی که رقیب ثبت کرده رو + # idempotent برمی‌گردونیم. + try: + with transaction.atomic(): + UserAnswer.objects.create( + user_id=user_id, + question=question, + choice=choice, + correct=choice.is_correct, + xp_earned=xp_earned, + idempotency_key=idempotency_key, + ) + if not choice.is_correct: + UserHearts.objects.get_or_create(user_id=user_id) + hearts = UserHearts.objects.select_for_update().get(user_id=user_id) + if hearts.current_hearts > 0: + hearts.current_hearts -= 1 + hearts.save(update_fields=['current_hearts']) + hearts_remaining = hearts.current_hearts + else: + hearts = UserHearts.objects.filter(user_id=user_id).first() + hearts_remaining = hearts.current_hearts if hearts else 5 + except IntegrityError: + if not idempotency_key: + raise + existing = UserAnswer.objects.filter( + user_id=user_id, question=question, idempotency_key=idempotency_key, + ).first() + if existing is None: + raise + choice = existing.choice + xp_earned = existing.xp_earned + # 🔒 اصلاح: خواندنِ UserHearts اینجا قبلاً بدون قفل و بیرونِ هر + # transaction.atomic انجام می‌شد (چون بعد از rollback شدنِ + # atomic بالا اجرا می‌شه). یعنی اگه هم‌زمان یک تغییرِ دیگر روی + # همون ردیفِ UserHearts در حالِ commit شدن بود (مثلاً پاسخِ غلطِ + # دیگری از همین کاربر که select_for_update گرفته)، این خواندنِ + # بدون قفل می‌تونست با اون race کنه و یک مقدارِ ناسازگار/گذرا + # برگردونه. حالا دقیقاً همون الگوی قفلِ ردیف که مسیرِ معمولیِ + # این ویو استفاده می‌کنه (select_for_update داخلِ + # transaction.atomic) اینجا هم برای همین خواندن به‌کار رفته تا + # پاسخِ fallback نتونه با هیچ جهشِ دیگری روی هارت race کنه. + with transaction.atomic(): + hearts = UserHearts.objects.select_for_update().filter(user_id=user_id).first() hearts_remaining = hearts.current_hearts if hearts else 5 data = { @@ -189,6 +269,16 @@ class LessonCompleteView(APIView): @extend_schema( request=LessonCompleteRequestSerializer, responses=LessonCompleteResponseSerializer, + parameters=[ + OpenApiParameter( + 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, + description=( + 'کلید اختیاری idempotency (مثل payment). اگه ست بشه، رتری/' + 'دابل‌کلیکِ همون submit با همون کلید، دوباره ردیف/جایزه‌ی ' + 'تکراری نمی‌سازه و نتیجه‌ی همون completion موجود برگردونده می‌شه.' + ), + ), + ], tags=['Curriculum (content)'], ) def post(self, request, lesson_id): @@ -204,39 +294,100 @@ class LessonCompleteView(APIView): potential_coins = stars * 5 user_id = request.user.id + idempotency_key = request.headers.get('Idempotency-Key') or None - # ⚠️ اصلاح: تکمیل هر درس یک‌بارمصرفه — دفعات بعدیِ تکمیل همون درس - # نباید دوباره xp/coins به آمار کاربر اضافه کنه (قبلاً هر بار - # تکمیل، xp_earned/coins_earned رو بدون قید به stats اضافه می‌کرد). - is_first_completion = not UserLessonCompletion.objects.filter( - user_id=user_id, lesson=lesson - ).exists() - xp_earned = potential_xp if is_first_completion else 0 - coins_earned = potential_coins if is_first_completion else 0 + # ⚛️🔒 اصلاح race condition (duplicate completion row): اگه کلاینت + # کلید idempotency فرستاده و قبلاً همین submit با همین کلید ثبت + # شده، به‌جای ساختِ دوباره‌ی ردیف/جایزه، همون نتیجه‌ی قبلی برگردونده + # می‌شه (idempotent). این جدا از قفلِ UserStats پایینه که فقط جلوی + # duplicate *reward* رو می‌گرفت، نه duplicate *row* رو. + if idempotency_key: + existing = UserLessonCompletion.objects.filter( + user_id=user_id, lesson=lesson, idempotency_key=idempotency_key, + ).first() + if existing is not None: + stats = UserStats.objects.filter(user_id=user_id).first() + stats_payload = { + 'progressPercent': stats.progress_percent if stats else 0, + 'xp': stats.xp if stats else 0, + 'coins': stats.coins if stats else 0, + } + data = { + 'stars': existing.stars, + 'xpEarned': existing.xp_earned, + 'coinsEarned': existing.coins_earned, + 'stats': stats_payload, + } + return Response(LessonCompleteResponseSerializer(data).data) - UserLessonCompletion.objects.create( - user_id=user_id, - lesson=lesson, - correct_count=correct_count, - stars=stars, - xp_earned=xp_earned, - coins_earned=coins_earned, - ) - stats, _ = UserStats.objects.get_or_create(user_id=user_id) - stats.xp += xp_earned - stats.coins += coins_earned + # ⚛️ atomic: UserLessonCompletion.create پایین‌تر یک سیگنال post_save + # رو تریگر می‌کنه (content/receivers.py → NodeProgress + احتمالاً + # StatusChangeLog)؛ به‌علاوه‌ی get_or_create/save روی UserStats. + # بدون atomic، اگه save روی stats fail کنه، تکمیلِ درس ثبت شده ولی + # xp/coins/progress_percent به‌روز نشده — یعنی چون تکمیل هر درس + # یک‌بارمصرفه، کاربر دیگه هیچ‌وقت اون xp رو نمی‌گیره. + # + # ⚛️🔒 race condition: چکِ «is_first_completion» قبلاً قبل از atomic + # و بدون قفل انجام می‌شد — دو درخواستِ همزمان (دابل‌کلیک/رتریِ + # کلاینت) برای تکمیلِ همون درس می‌تونستن هر دو ببینن که هنوز کامل + # نشده و هر دو xp/coins کامل بگیرن (دوبرابر شدنِ جایزه). حالا ردیفِ + # UserStats کاربر قفل می‌شه (select_for_update) و چکِ + # is_first_completion داخلِ همون قفل، بعد از لاک‌شدن، دوباره انجام + # می‌شه — یعنی درخواستِ دومِ همزمان، حالا که اولی commit شده، به‌درستی + # می‌بینه که دیگه first completion نیست. + try: + with transaction.atomic(): + UserStats.objects.get_or_create(user_id=user_id) + stats = UserStats.objects.select_for_update().get(user_id=user_id) - total_lessons = Lesson.objects.count() or 1 - completed_lessons = UserLessonCompletion.objects.filter( - user_id=user_id - ).values('lesson_id').distinct().count() - stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2) - stats.save(update_fields=['xp', 'coins', 'progress_percent']) + is_first_completion = not UserLessonCompletion.objects.filter( + user_id=user_id, lesson=lesson + ).exists() + xp_earned = potential_xp if is_first_completion else 0 + coins_earned = potential_coins if is_first_completion else 0 + + UserLessonCompletion.objects.create( + user_id=user_id, + lesson=lesson, + correct_count=correct_count, + stars=stars, + xp_earned=xp_earned, + coins_earned=coins_earned, + idempotency_key=idempotency_key, + ) + + stats.xp += xp_earned + stats.coins += coins_earned + + total_lessons = Lesson.objects.count() or 1 + completed_lessons = UserLessonCompletion.objects.filter( + user_id=user_id + ).values('lesson_id').distinct().count() + stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2) + stats.save(update_fields=['xp', 'coins', 'progress_percent']) + except IntegrityError: + # ⚛️🔒 دفاع در عمق: اگه دو submit هم‌زمان دقیقاً با همون + # Idempotency-Key وارد بشن (رتریِ واقعیِ شبکه‌ایِ کلاینت)، + # UniqueConstraint سطح DB جلوی ردیفِ دوم رو می‌گیره و کل این + # atomic block (شاملِ آپدیتِ stats) rollback می‌شه — پس هیچ + # جایزه‌ی تکراری‌ای اعمال نشده. در این حالت، به‌جای خطا، نتیجه‌ی + # همون completion که رقیب ساخته رو idempotent برمی‌گردونیم. + if not idempotency_key: + raise + existing = UserLessonCompletion.objects.filter( + user_id=user_id, lesson=lesson, idempotency_key=idempotency_key, + ).first() + if existing is None: + raise + stats = UserStats.objects.filter(user_id=user_id).first() + stars = existing.stars + xp_earned = existing.xp_earned + coins_earned = existing.coins_earned stats_payload = { - 'progressPercent': stats.progress_percent, - 'xp': stats.xp, - 'coins': stats.coins, + 'progressPercent': stats.progress_percent if stats else 0, + 'xp': stats.xp if stats else 0, + 'coins': stats.coins if stats else 0, } data = { diff --git a/discount/services.py b/discount/services.py index 5600d45..996bf57 100644 --- a/discount/services.py +++ b/discount/services.py @@ -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 diff --git a/discount/views.py b/discount/views.py index 2de12c8..906cba1 100644 --- a/discount/views.py +++ b/discount/views.py @@ -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: diff --git a/family/migrations/0002_familylink_uniq_pending_family_invite_per_parent_child.py b/family/migrations/0002_familylink_uniq_pending_family_invite_per_parent_child.py new file mode 100644 index 0000000..09612f6 --- /dev/null +++ b/family/migrations/0002_familylink_uniq_pending_family_invite_per_parent_child.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.17 on 2026-08-19 10:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('family', '0001_initial'), + ] + + operations = [ + migrations.AddConstraint( + model_name='familylink', + constraint=models.UniqueConstraint(condition=models.Q(('status', 'pending')), fields=('parent_user_id', 'child_phone'), name='uniq_pending_family_invite_per_parent_child'), + ), + ] diff --git a/family/models.py b/family/models.py index 5cb81a9..71912a5 100644 --- a/family/models.py +++ b/family/models.py @@ -26,6 +26,22 @@ class FamilyLink(models.Model): class Meta: ordering = ['-invited_at'] + constraints = [ + # 🔒 اصلاح race condition: قبلاً فقط با یک چکِ application-level + # (filter().first() قبل از create) جلوی دعوتِ تکراری برای یک + # parent/child_phone گرفته می‌شد که در برابر دو درخواستِ هم‌زمان + # کافی نبود (هر دو می‌تونستن ببینن که pending وجود نداره و هر دو + # create کنن). این UniqueConstraint سطح DB تضمین می‌کنه که برای + # هر (parent_user_id, child_phone) فقط یک دعوتِ pending هم‌زمان + # می‌تونه وجود داشته باشه؛ FamilyLinkInviteView حالا + # IntegrityError ناشی از این قید رو می‌گیره و رکورد pending + # موجود رو برمی‌گردونه (idempotent). + models.UniqueConstraint( + fields=['parent_user_id', 'child_phone'], + condition=models.Q(status='pending'), + name='uniq_pending_family_invite_per_parent_child', + ), + ] def __str__(self): return f"famlink-{self.pk}-parent{self.parent_user_id}-child{self.child_user_id or '?'}" diff --git a/family/views.py b/family/views.py index 74cb343..ca0f23f 100644 --- a/family/views.py +++ b/family/views.py @@ -1,5 +1,6 @@ from datetime import timedelta +from django.db import IntegrityError, transaction from django.db.models import Q, Sum from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -51,15 +52,40 @@ class FamilyLinkInviteView(APIView): ).first() if not link: - code = get_random_string(6, allowed_chars='0123456789') - while FamilyLink.objects.filter(invite_code=code).exists(): + # ⚛️🔒 اصلاح race condition: قبلاً یک IntegrityError فقط برای + # برخورد روی uniq_pending_family_invite_per_parent_child مدیریت + # می‌شد؛ اگه برخورد واقعاً روی invite_code بود (دو کاربر مختلف، + # کد ۶رقمی تصادفی یکسان، هم‌زمان — همون چیزی که چکِ + # pre-check بالا هم به‌خاطر TOCTOU نمی‌تونه صد-در-صد جلوش رو + # بگیره)، link پیدا نمی‌شد و exception دوباره raise می‌شد → ۵۰۰. + # حالا در یک retry loop، اگه برخورد مربوط به دعوتِ همین + # parent/child نبود (یعنی احتمالاً روی invite_code بوده)، یک کد + # جدید تولید و دوباره تلاش می‌کنیم؛ اگه بود، همون رکورد + # pending رو idempotent برمی‌گردونیم — دقیقاً همون الگویی که + # create_order در payment/services.py برای race روی + # idempotency_key استفاده می‌کنه. + for attempt in range(5): code = get_random_string(6, allowed_chars='0123456789') - - link = FamilyLink.objects.create( - parent_user_id=request.user.id, - child_phone=v['childPhone'], - invite_code=code, - ) + try: + with transaction.atomic(): + link = FamilyLink.objects.create( + parent_user_id=request.user.id, + child_phone=v['childPhone'], + invite_code=code, + ) + break + except IntegrityError: + link = FamilyLink.objects.filter( + parent_user_id=request.user.id, child_phone=v['childPhone'], status='pending' + ).first() + if link is not None: + break + # برخورد روی invite_code بوده؛ کد جدید بساز و دوباره تلاش کن + continue + else: + raise IntegrityError( + 'Could not generate a unique family invite code after several attempts.' + ) data = {'familyLinkId': str(link.pk), 'inviteCode': link.invite_code, 'status': link.status} return Response(FamilyLinkInviteResponseSerializer(data).data) @@ -80,32 +106,46 @@ class FamilyLinkAcceptView(APIView): serializer.is_valid(raise_exception=True) v = serializer.validated_data - link = get_object_or_404(FamilyLink, invite_code=v['inviteCode'], status='pending') + # ⚛️🔒 race condition: قبلاً get_object_or_404 بدون قفل انجام می‌شد — + # دو درخواستِ همزمان برای accept کردنِ همون invite code می‌تونستن هر + # دو link.status='pending' رو ببینن، هر دو accept رو کامل کنن، و + # دوبار notify() برای والد بفرستن. حالا خودِ ردیفِ FamilyLink داخل + # transaction.atomic قفل می‌شه (select_for_update)، پس درخواستِ دومِ + # همزمان تا commit شدنِ اولی معطل می‌مونه و بعدش status رو + # 'accepted' می‌بینه (نه 'pending')، پس دیگه match نمی‌کنه. + with transaction.atomic(): + link = get_object_or_404( + FamilyLink.objects.select_for_update(), + invite_code=v['inviteCode'], status='pending', + ) - # ⚠️ اصلاح امنیتی: قبلاً هر کاربر لاگین‌کرده‌ای با دونستنِ inviteCode - # می‌تونست دعوتِ یک والد رو claim کنه، حتی اگه اون دعوت اصلاً برای - # شماره‌ی او نبود (مثلاً کد رو حدس بزنه یا از جای دیگه‌ای ببینه). - # حالا اگه دعوت برای یک شماره‌ی مشخص صادر شده باشه، فقط کاربری که - # شماره‌اش با child_phone یکی باشه می‌تونه اون رو accept کنه. - if link.child_phone and link.child_phone != request.user.phone: - raise PermissionDenied('این دعوت متعلق به شماره‌ی دیگری است.') + # ⚠️ اصلاح امنیتی: قبلاً هر کاربر لاگین‌کرده‌ای با دونستنِ inviteCode + # می‌تونست دعوتِ یک والد رو claim کنه، حتی اگه اون دعوت اصلاً برای + # شماره‌ی او نبود (مثلاً کد رو حدس بزنه یا از جای دیگه‌ای ببینه). + # حالا اگه دعوت برای یک شماره‌ی مشخص صادر شده باشه، فقط کاربری که + # شماره‌اش با child_phone یکی باشه می‌تونه اون رو accept کنه. + if link.child_phone and link.child_phone != request.user.phone: + raise PermissionDenied('این دعوت متعلق به شماره‌ی دیگری است.') - link.child_user_id = request.user.id - link.status = 'accepted' - link.accepted_at = timezone.now() - link.save(update_fields=['child_user_id', 'status', 'accepted_at']) + # ⚛️ atomic: تغییر وضعیت لینک + ساختِ Notification یک واحدند — + # وگرنه ممکنه اتصال به‌عنوان accepted ثبت بشه ولی هیچ اعلانی برای + # والد ساخته نشه. + link.child_user_id = request.user.id + link.status = 'accepted' + link.accepted_at = timezone.now() + link.save(update_fields=['child_user_id', 'status', 'accepted_at']) - # رویداد FamilyLinkAccepted — طبق سند معماری، Notifications فقط - # event-listener‌ه؛ دقیقاً همون الگوی notify() که در - # support/admin.py هنگام پاسخ تیکت صدا زده می‌شه. - from notifications.services import notify + # رویداد FamilyLinkAccepted — طبق سند معماری، Notifications فقط + # event-listener‌ه؛ دقیقاً همون الگوی notify() که در + # support/admin.py هنگام پاسخ تیکت صدا زده می‌شه. + from notifications.services import notify - notify( - user_id=link.parent_user_id, - type='family_link_accepted', - title='فرزند شما دعوت را پذیرفت', - related_id=link.id, - ) + notify( + user_id=link.parent_user_id, + type='family_link_accepted', + title='فرزند شما دعوت را پذیرفت', + related_id=link.id, + ) data = {'familyLinkId': str(link.pk), 'status': link.status, 'acceptedAt': link.accepted_at} return Response(FamilyLinkAcceptResponseSerializer(data).data) @@ -201,20 +241,21 @@ class FamilyWeeklyReportView(APIView): link = get_object_or_404(FamilyLink, parent_user_id=request.user.id, status='accepted') week_start = _week_start(timezone.now()) - log, created = WeeklyReportLog.objects.get_or_create( - family_link=link, week_start=week_start, defaults={'sent_at': timezone.now()} - ) - - if created: - from notifications.services import notify - - notify( - user_id=link.parent_user_id, - type='weekly_report_ready', - title='گزارش هفتگی فرزند شما آماده است', - related_id=log.id, + with transaction.atomic(): + log, created = WeeklyReportLog.objects.get_or_create( + family_link=link, week_start=week_start, defaults={'sent_at': timezone.now()} ) + if created: + from notifications.services import notify + + notify( + user_id=link.parent_user_id, + type='weekly_report_ready', + title='گزارش هفتگی فرزند شما آماده است', + related_id=log.id, + ) + from content.models import UserLessonCompletion from progress.models import StreakRecord diff --git a/payment/migrations/0003_transaction_uniq_transaction_order_ref_status.py b/payment/migrations/0003_transaction_uniq_transaction_order_ref_status.py new file mode 100644 index 0000000..6724e30 --- /dev/null +++ b/payment/migrations/0003_transaction_uniq_transaction_order_ref_status.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.17 on 2026-08-19 10:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment', '0002_order_bazaar_product_sku_order_bazaar_purchase_token_and_more'), + ] + + operations = [ + migrations.AddConstraint( + model_name='transaction', + constraint=models.UniqueConstraint(condition=models.Q(('gateway_ref_id__gt', '')), fields=('order', 'gateway_ref_id', 'status'), name='uniq_transaction_order_ref_status'), + ), + ] diff --git a/payment/migrations/0004_transaction_uniq_transaction_gateway_ref_global.py b/payment/migrations/0004_transaction_uniq_transaction_gateway_ref_global.py new file mode 100644 index 0000000..f1e469f --- /dev/null +++ b/payment/migrations/0004_transaction_uniq_transaction_gateway_ref_global.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.17 on 2026-08-19 11:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment', '0003_transaction_uniq_transaction_order_ref_status'), + ] + + operations = [ + migrations.AddConstraint( + model_name='transaction', + constraint=models.UniqueConstraint(condition=models.Q(('gateway_ref_id__gt', '')), fields=('gateway', 'gateway_ref_id'), name='uniq_transaction_gateway_ref_global'), + ), + ] diff --git a/payment/migrations/0005_remove_transaction_uniq_transaction_order_ref_status.py b/payment/migrations/0005_remove_transaction_uniq_transaction_order_ref_status.py new file mode 100644 index 0000000..316222b --- /dev/null +++ b/payment/migrations/0005_remove_transaction_uniq_transaction_order_ref_status.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.17 on 2026-08-19 11:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment', '0004_transaction_uniq_transaction_gateway_ref_global'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='transaction', + name='uniq_transaction_order_ref_status', + ), + ] diff --git a/payment/models.py b/payment/models.py index 492c492..67bf94b 100644 --- a/payment/models.py +++ b/payment/models.py @@ -47,6 +47,25 @@ class Transaction(models.Model): class Meta: ordering = ['created_at'] + constraints = [ + # 🔒 یکتاییِ سراسریِ (gateway, gateway_ref_id) برای refهای + # غیرخالی: این قید سطح DB تضمین می‌کنه یک gateway_ref_id مشخص + # (مثلاً Authority زرین‌پال یا purchaseToken بازار) در کل جدول + # Transaction فقط می‌تونه متعلق به یک Order باشه — هم جلوی + # duplicate/idempotency برای همون Order رو می‌گیره (چون در کل + # جدول حداکثر یک ردیف با این (gateway, ref) می‌تونه وجود داشته + # باشه) و هم جلوی reuse/replay همون ref برای یک Order دیگه رو + # (نگاه کنید به apply_order_payment_result). این قید، قیدِ + # قدیمی‌ترِ (order, gateway_ref_id, status) رو هم در برمی‌گیره + # (چون سخت‌گیرانه‌تره)، برای همین دیگه لازم نیست جدا نگه داشته + # بشه. gateway_ref_id__gt='' یعنی رفرنس‌های خالی (که provider + # واقعی هنوز رفرنسی برنگردونده) مشمول این قید نمی‌شن. + models.UniqueConstraint( + fields=['gateway', 'gateway_ref_id'], + condition=models.Q(gateway_ref_id__gt=''), + name='uniq_transaction_gateway_ref_global', + ), + ] def __str__(self): return f"txn-{self.pk}-order{self.order_id}" diff --git a/payment/services.py b/payment/services.py index 3499a37..5cd79ea 100644 --- a/payment/services.py +++ b/payment/services.py @@ -123,44 +123,114 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st idempotent: اگه Transaction مشابه (order/status/gateway_ref_id) از قبل موجود باشه، دوباره اثر جانبی اجرا نمی‌شه. خروجی: True اگه این فراخوانی واقعاً باعث paid شدن تازه شده باشه. + + ⚛️🔒 اصلاح race condition: قبلاً already_paid/is_duplicate روی همون + آبجکتِ order ورودی محاسبه می‌شدن، و تابع کاملاً به این متکی بود که + caller از قبل select_for_update() گرفته باشه (که الان درسته، ولی + تضمین‌شده نبود — هر caller جدیدی که این فرض رو رعایت نکنه، race رو + برمی‌گردوند). حالا خودِ این تابع، صرف‌نظر از caller، ردیفِ Order رو + دوباره با select_for_update قفل و رفرش می‌کنه (تودرتو بودنِ + select_for_update با atomic بیرونی مشکلی نداره) و سپس already_paid/ + is_duplicate را *بعد* از قفل محاسبه می‌کنه. به‌علاوه، ساختِ Transaction + حالا در برابر IntegrityQrror ناشیِ از UniqueConstraint سطح DB (دفاع در + عمق، مخصوصاً روی بک‌اندی مثل SQLite که select_for_update لاک واقعی + row-level نمی‌ده) هم محافظت شده. """ - already_paid = order.status == 'paid' - old_status = order.status + with db_transaction.atomic(): + order = Order.objects.select_for_update().get(pk=order.pk) - is_duplicate = Transaction.objects.filter( - order=order, status=new_status, gateway_ref_id=gateway_ref_id, - ).exists() + already_paid = order.status == 'paid' + old_status = order.status - if not is_duplicate: - order.status = new_status - order.save(update_fields=['status']) + is_duplicate = Transaction.objects.filter( + order=order, status=new_status, gateway_ref_id=gateway_ref_id, + ).exists() - Transaction.objects.create( - order=order, - gateway=gateway, - gateway_ref_id=gateway_ref_id, - status=new_status, - paid_at=timezone.now() if new_status == 'paid' else None, - ) + # 🔒 اصلاح race-regression: اگه سفارش زیرِ لاک همین الان paid باشه، + # یک callback دیرهنگام/خارج از ترتیب با new_status='failed' (یا هر + # چیزی غیر از 'paid') نباید status رو به عقب برگردونه. چنین + # callbackی رو نسبت به وضعیتِ Order و اثرات جانبیِ پرداخت کاملاً + # idempotent/no-op در نظر می‌گیریم (نه تغییر status، نه Transaction + # جدید، نه سیگنال/نوتیفای). این مسیر برای انتقالِ مشروعِ + # pending/failed → paid دست نخورده باقی می‌مونه. + if already_paid and new_status != 'paid': + is_duplicate = True - status_changed.send( - sender='payment.Order', object_id=order.id, - old_status=old_status, new_status=new_status, - event=event, user_id=order.user_id, - ) + # 🔒 اصلاح duplicate/idempotency روی reuse رفرنسِ پرداخت: اگه همین + # (gateway, gateway_ref_id) غیرخالی از قبل متعلق به یک Order دیگه + # باشه (replay/reuse یک authority یا purchaseToken برای سفارشِ + # دیگه)، این callback رو هم مثل یک duplicate/idempotent no-op در + # نظر می‌گیریم — نه status این سفارش عوض می‌شه، نه Transaction جدید + # و نه اثرات جانبی (activate_subscription/notify). این چک قبل از + # هر تغییری روی order انجام می‌شه تا حتی در بدترین حالت هم status + # این سفارش دست‌نخورده بمونه. + if gateway_ref_id and Transaction.objects.filter( + gateway=gateway, gateway_ref_id=gateway_ref_id, + ).exclude(order=order).exists(): + is_duplicate = True - newly_paid = new_status == 'paid' and not already_paid and not is_duplicate - if newly_paid: - if order.subscription_id: - from subscription.services import activate_subscription - activate_subscription(order.subscription_id) + if not is_duplicate: + order.status = new_status + order.save(update_fields=['status']) - from notifications.services import notify - notify( - user_id=order.user_id, - type='order_paid', - title='پرداخت با موفقیت انجام شد', - related_id=order.id, - ) + try: + # 🔒 اصلاح: قبلاً این create بدونِ هیچ savepointِ تودرتویی + # داخلِ همون transaction.atomic بیرونی (خط ۱۳۹) اجرا می‌شد. + # روی بک‌اندهایی مثل PostgreSQL، وقتی یک IntegrityError داخلِ + # یک atomic block رخ می‌ده، کلِ اون تراکنش (نه فقط همین + # کوئری) «broken»/aborted علامت می‌خوره؛ گرفتنِ exception با + # except بدونِ rollback به یک savepoint، این وضعیتِ broken رو + # پاک نمی‌کنه — یعنی هر کوئریِ بعدی در همین atomic (مثلِ + # order.save بالاتر که قبلاً commit نشده، یا status_changed/ + # notify/activate_subscription پایین‌تر) با خطای دیگه‌ای + # (InFailedSqlTransaction) fail می‌کرد، نه با رفتارِ + # idempotentِ موردنظر. حالا خودِ این create داخلِ یک + # transaction.atomic تودرتو (savepoint) اجرا می‌شه: اگه + # IntegrityError بده، فقط همین savepoint rollback می‌شه و + # atomic بیرونی سالم می‌مونه، پس ادامه‌ی تابع (خطِ + # newly_paid و برگردوندنِ نتیجه) به‌درستی اجرا می‌شه. + with db_transaction.atomic(): + Transaction.objects.create( + order=order, + gateway=gateway, + gateway_ref_id=gateway_ref_id, + status=new_status, + paid_at=timezone.now() if new_status == 'paid' else None, + ) + except IntegrityError: + # دفاع در عمق: با اینکه بالاتر لاک گرفتیم و چک کردیم، اگه + # به هر دلیلی (مثلاً DB بدون row-level lock واقعی، یا یک + # race هم‌زمانِ reuse رفرنس روی یک Order دیگه) یک Transaction + # با همون (gateway, gateway_ref_id) هم‌زمان ساخته شده باشه، + # UniqueConstraintِ سراسریِ uniq_transaction_gateway_ref_global + # جلوش رو گرفته؛ این حالت رو duplicate در نظر می‌گیریم و + # اثرات جانبی رو دوباره اجرا نمی‌کنیم. همچنین چون Transaction + # ساخته نشد، status‌ای که بالاتر روی order ست شده بود رو به + # مقدار قبلی برمی‌گردونیم تا این سفارش هیچ‌وقت با یک رفرنسِ + # رد شده paid/تغییر‌یافته باقی نمونه. + is_duplicate = True + if order.status != old_status: + order.status = old_status + order.save(update_fields=['status']) + else: + status_changed.send( + sender='payment.Order', object_id=order.id, + old_status=old_status, new_status=new_status, + event=event, user_id=order.user_id, + ) + + newly_paid = new_status == 'paid' and not already_paid and not is_duplicate + if newly_paid: + if order.subscription_id: + from subscription.services import activate_subscription + activate_subscription(order.subscription_id) + + from notifications.services import notify + notify( + user_id=order.user_id, + type='order_paid', + title='پرداخت با موفقیت انجام شد', + related_id=order.id, + ) return newly_paid diff --git a/payment/views.py b/payment/views.py index 98b2e49..dc499a9 100644 --- a/payment/views.py +++ b/payment/views.py @@ -149,17 +149,28 @@ class BazaarVerifyPurchaseView(APIView): serializer.is_valid(raise_exception=True) v = serializer.validated_data + # ⚛️🔒 اصلاح: فراخوانی HTTP به API بازار (تا ~۲۰ ثانیه، دو درخواست + # پشت‌سرهم برای refresh token + verify) قبلاً *داخل* transaction.atomic + # و زیر select_for_update روی Order انجام می‌شد؛ یعنی ردیف Order (و + # هر تراکنش دیگه‌ای که بخواد همون Order یا Subscription مرتبط رو لمس + # کنه) تا برگشتِ پاسخ بازار قفل می‌موند. حالا verify_purchase قبل از + # باز شدن atomic/lock صدا زده می‌شه؛ لاک فقط برای اعمال نتیجه (چند + # میلی‌ثانیه) گرفته می‌شه. + order_lookup = get_object_or_404( + Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar', + ) + + result = bazaar.verify_purchase( + product_sku=order_lookup.bazaar_product_sku or v['productSku'], + purchase_token=v['purchaseToken'], + ) + with transaction.atomic(): order = get_object_or_404( Order.objects.select_for_update(), pk=v['orderId'], user_id=request.user.id, provider='bazaar', ) - result = bazaar.verify_purchase( - product_sku=order.bazaar_product_sku or v['productSku'], - purchase_token=v['purchaseToken'], - ) - new_status = 'paid' if result.is_valid else 'failed' order.bazaar_purchase_token = v['purchaseToken'] order.save(update_fields=['bazaar_purchase_token']) diff --git a/subscription/services.py b/subscription/services.py index 8a77409..84d6c07 100644 --- a/subscription/services.py +++ b/subscription/services.py @@ -1,3 +1,4 @@ +from django.db import transaction from django.utils import timezone from statuslog.signals import status_changed @@ -12,23 +13,32 @@ def activate_subscription(subscription_id: int): (قبلاً چون Plan فیلد duration‌ای نداشت، این مقدار با یک قاعده‌ی stub بر اساس متن title حدس زده می‌شد؛ الان duration_days مستقیماً از مدل Plan خونده می‌شه.) + + ⚛️ atomic: خودِ این تابع هم مستقل (بدون اتکا به atomic بودنِ caller) + اتمیکه، چون به‌عنوان یک سرویسِ مشترک از جاهای مختلف (payment webhook، + bazaar verify) صدا زده می‌شه؛ save روی Subscription و نوشتنِ + StatusChangeLog که از signal.send نتیجه می‌شه، باید یک واحد بمونن. """ - try: - subscription = Subscription.objects.get(pk=subscription_id) - except Subscription.DoesNotExist: - return None + with transaction.atomic(): + try: + # 🔒 select_for_update اضافه شد تا این تابع هم مثل + # grant_free_subscription، صرف‌نظر از caller، در برابر + # concurrent update ایمن باشه. + subscription = Subscription.objects.select_for_update().get(pk=subscription_id) + except Subscription.DoesNotExist: + return None - old_status = subscription.status - duration_days = subscription.plan.duration_days if subscription.plan else 30 - subscription.status = 'active' - subscription.expires_at = timezone.now() + timezone.timedelta(days=duration_days) - subscription.save(update_fields=['status', 'expires_at']) + old_status = subscription.status + duration_days = subscription.plan.duration_days if subscription.plan else 30 + subscription.status = 'active' + subscription.expires_at = timezone.now() + timezone.timedelta(days=duration_days) + subscription.save(update_fields=['status', 'expires_at']) - status_changed.send( - sender='subscription.Subscription', object_id=subscription.id, - old_status=old_status, new_status='active', - event='payment_confirmed', user_id=subscription.user_id, - ) + status_changed.send( + sender='subscription.Subscription', object_id=subscription.id, + old_status=old_status, new_status='active', + event='payment_confirmed', user_id=subscription.user_id, + ) return subscription @@ -41,19 +51,32 @@ def grant_free_subscription(user_id: int, *, duration_days: int | None = None, e از discount/services.py (apply_offer) صدا زده می‌شه؛ مثل بقیه‌ی ارتباطات بین‌اپیِ این پروژه، یک import مستقیم به تابع سرویس، نه FK واقعی و نه HTTP call به خودمون. + + ⚛️ atomic: مثل activate_subscription، مستقل از caller اتمیکه — چون + get_or_create + save + signal.send (که StatusChangeLog می‌سازه) باید + یک واحد باشن، چه apply_offer از قبل توی atomic باشه چه نه. + + ⚛️🔒 اصلاح race condition: قبلاً بعد از get_or_create هیچ قفلی گرفته + نمی‌شد؛ دو فراخوانیِ هم‌زمان (مثلاً دو آفر که تقریباً هم‌زمان اعمال + می‌شن) می‌تونستن هر دو همون Subscription رو بخونن، هر دو expires_at + رو بر اساس همون old_status/duration محاسبه کنن و save کنن — یکی از + دو نتیجه گم می‌شد (lost update). حالا ردیفِ Subscription قبل از + خوندن/تغییرش با select_for_update قفل می‌شه. """ - subscription, _ = Subscription.objects.get_or_create(user_id=user_id) - old_status = subscription.status + with transaction.atomic(): + Subscription.objects.get_or_create(user_id=user_id) + subscription = Subscription.objects.select_for_update().get(user_id=user_id) + old_status = subscription.status - subscription.status = 'active' - subscription.expires_at = ( - timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None - ) - subscription.save(update_fields=['status', 'expires_at']) + subscription.status = 'active' + subscription.expires_at = ( + timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None + ) + subscription.save(update_fields=['status', 'expires_at']) - status_changed.send( - sender='subscription.Subscription', object_id=subscription.id, - old_status=old_status, new_status='active', - event=event, user_id=user_id, - ) + status_changed.send( + sender='subscription.Subscription', object_id=subscription.id, + old_status=old_status, new_status='active', + event=event, user_id=user_id, + ) return subscription diff --git a/support/admin.py b/support/admin.py index c91f6c5..ab45141 100644 --- a/support/admin.py +++ b/support/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.db import transaction from .models import BugReport, Ticket, TicketMessage @@ -38,15 +39,17 @@ class TicketMessageAdmin(admin.ModelAdmin): پرداخت موفق (status=paid) صدا زده می‌شه. """ is_new = obj.pk is None - super().save_model(request, obj, form, change) + # ⚛️ atomic: ذخیره‌ی پیام پشتیبانی + ساختِ Notification یک واحدند. + with transaction.atomic(): + super().save_model(request, obj, form, change) - if is_new and obj.sender == 'support': - from notifications.services import notify + if is_new and obj.sender == 'support': + from notifications.services import notify - notify( - user_id=obj.ticket.user_id, - type='ticket_replied', - title='پاسخ جدید برای تیکت شما', - body=obj.text[:200], - related_id=obj.ticket_id, - ) + notify( + user_id=obj.ticket.user_id, + type='ticket_replied', + title='پاسخ جدید برای تیکت شما', + body=obj.text[:200], + related_id=obj.ticket_id, + )