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

@@ -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'),
),
]

View File

@@ -20,6 +20,25 @@ class User(models.Model):
class Meta: class Meta:
ordering = ['-created_at'] 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): def __str__(self):
return self.name or self.phone or f"guest-{self.pk}" return self.name or self.phone or f"guest-{self.pk}"

View File

@@ -2,6 +2,7 @@ import random
import uuid import uuid
from django.conf import settings from django.conf import settings
from django.db import transaction
from django.utils import timezone from django.utils import timezone
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import status from rest_framework import status
@@ -99,14 +100,22 @@ class LoginView(APIView):
phone = serializer.validated_data['phone'] phone = serializer.validated_data['phone']
otp = serializer.validated_data['otp'] otp = serializer.validated_data['otp']
# ⚛️🔒 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 valid = False
if otp == settings.DEV_MASTER_OTP: if otp == settings.DEV_MASTER_OTP:
valid = True valid = True
else: else:
otp_qs = OTPCode.objects.filter( otp_obj = OTPCode.objects.select_for_update().filter(
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now() phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
).order_by('-id') ).order_by('-id').first()
otp_obj = otp_qs.first()
if otp_obj: if otp_obj:
otp_obj.is_used = True otp_obj.is_used = True
otp_obj.save(update_fields=['is_used']) otp_obj.save(update_fields=['is_used'])
@@ -115,6 +124,12 @@ class LoginView(APIView):
if not valid: if not valid:
return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST)
# ⚛️ 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( user, created = User.objects.get_or_create(
phone=phone, phone=phone,
defaults={'is_guest': False, 'name': ''}, defaults={'is_guest': False, 'name': ''},

View File

@@ -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')],
},
),
]

View File

@@ -34,3 +34,35 @@ class MessageFeedback(models.Model):
def __str__(self): def __str__(self):
return f"feedback-msg{self.message_id}" 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}"

View File

@@ -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.shortcuts import get_object_or_404
from django.utils import timezone 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.exceptions import Throttled
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from accounts.models import User as AccountUser from accounts.models import User as AccountUser
from .models import Conversation, Message, MessageFeedback from .models import AskIdempotency, Conversation, Message, MessageFeedback
from .serializers import ( from .serializers import (
AskRequestSerializer, AskRequestSerializer,
AskResponseSerializer, AskResponseSerializer,
@@ -40,12 +41,47 @@ class AskAIView(APIView):
permission_classes = [IsAuthenticated] 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): def post(self, request):
serializer = AskRequestSerializer(data=request.data) serializer = AskRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
v = serializer.validated_data v = serializer.validated_data
user = request.user 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 داشت — # ⚠️ اصلاح: چک و مصرفِ کوتای روزانه قبلاً race condition داشت —
# دو درخواست هم‌زمان از همون کاربر می‌تونستن هر دو شمارشِ «امروز» # دو درخواست هم‌زمان از همون کاربر می‌تونستن هر دو شمارشِ «امروز»
@@ -54,6 +90,37 @@ class AskAIView(APIView):
# (select_for_update) در یک تراکنش اتمیک، چک و مصرفِ کوتا برای هر # (select_for_update) در یک تراکنش اتمیک، چک و مصرفِ کوتا برای هر
# کاربر serialize می‌شه؛ درخواست‌های هم‌زمانِ همون کاربر منتظر آزاد # کاربر serialize می‌شه؛ درخواست‌های هم‌زمانِ همون کاربر منتظر آزاد
# شدن قفل می‌مونن و دیگه نمی‌تونن هم‌زمان از کنار چک رد بشن. # شدن قفل می‌مونن و دیگه نمی‌تونن هم‌زمان از کنار چک رد بشن.
#
# ⚛️ اصلاح اتمیک‌بودن: قبلاً ساختِ پیامِ کاربر داخلِ این 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(): with transaction.atomic():
AccountUser.objects.select_for_update().get(pk=user.id) AccountUser.objects.select_for_update().get(pk=user.id)
@@ -75,7 +142,7 @@ class AskAIView(APIView):
grade_id=v.get('gradeId'), grade_id=v.get('gradeId'),
) )
Message.objects.create( user_message = Message.objects.create(
conversation=conversation, conversation=conversation,
role='user', role='user',
text=v['userQuestion'], text=v['userQuestion'],
@@ -92,6 +159,24 @@ class AskAIView(APIView):
question_id=v.get('questionId') or None, 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 # همین سوالی که الان ثبت شد هم جزو مصرف امروزه used_today += 1 # همین سوالی که الان ثبت شد هم جزو مصرف امروزه
remaining_quota = max(DAILY_AI_QUOTA - used_today, 0) remaining_quota = max(DAILY_AI_QUOTA - used_today, 0)
@@ -104,6 +189,36 @@ class AskAIView(APIView):
} }
return Response(AskResponseSerializer(data).data) 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): class ConversationMessagesView(APIView):
"""GET /api/ai/conversations/{id}/messages — تاریخچه‌ی مکالمه""" """GET /api/ai/conversations/{id}/messages — تاریخچه‌ی مکالمه"""

View File

@@ -10,6 +10,7 @@ commandه، نه receiver. باید periodic اجرا بشه (cron / celery beat
""" """
from django.conf import settings from django.conf import settings
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone from django.utils import timezone
from content.models import NodeProgress from content.models import NodeProgress
@@ -28,7 +29,38 @@ class Command(BaseCommand):
) )
count = 0 count = 0
for progress in stale_qs: # ⚛️ 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 old_status = progress.status
progress.status = 'abandoned' progress.status = 'abandoned'
progress.save(update_fields=['status']) progress.save(update_fields=['status'])

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -82,8 +82,27 @@ class UserLessonCompletion(models.Model):
stars = models.PositiveSmallIntegerField() stars = models.PositiveSmallIntegerField()
xp_earned = models.PositiveIntegerField(default=0) xp_earned = models.PositiveIntegerField(default=0)
coins_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) 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): def __str__(self):
return f"completion-user{self.user_id}-lesson{self.lesson_id}" 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') choice = models.ForeignKey(Choice, on_delete=models.CASCADE, related_name='answers')
correct = models.BooleanField() correct = models.BooleanField()
xp_earned = models.PositiveIntegerField(default=0) 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) 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): def __str__(self):
return f"answer-user{self.user_id}-q{self.question_id}" return f"answer-user{self.user_id}-q{self.question_id}"

View File

@@ -5,6 +5,7 @@ content/receivers.py صدا زده می‌شن (نه مستقیم از views.py)
یا UserLessonCompletion ساخته بشه، وضعیت Node هم خودکار به‌روز بمونه — یا UserLessonCompletion ساخته بشه، وضعیت Node هم خودکار به‌روز بمونه —
دقیقاً همون الگوی signal-based که برای discount/subscription استفاده شده. دقیقاً همون الگوی signal-based که برای discount/subscription استفاده شده.
""" """
from django.db import transaction
from django.utils import timezone from django.utils import timezone
from statuslog.signals import status_changed from statuslog.signals import status_changed
@@ -13,7 +14,28 @@ from .models import Node, NodeProgress, UserLessonCompletion
def _get_or_create_progress(user_id, node): 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): def _emit_if_changed(progress, old_status, event, user_id):
@@ -32,6 +54,7 @@ def touch_node_progress_on_answer(user_id, question):
به in_progress برمی‌گرده. به in_progress برمی‌گرده.
""" """
node = question.lesson.node node = question.lesson.node
with transaction.atomic():
progress, _created = _get_or_create_progress(user_id, node) progress, _created = _get_or_create_progress(user_id, node)
old_status = progress.status old_status = progress.status
now = timezone.now() now = timezone.now()
@@ -54,6 +77,7 @@ def update_node_progress_on_lesson_completion(user_id, lesson):
میره روی completed، وگرنه (یا اگه از abandoned برگشته) in_progress. میره روی completed، وگرنه (یا اگه از abandoned برگشته) in_progress.
""" """
node = lesson.node node = lesson.node
with transaction.atomic():
progress, _created = _get_or_create_progress(user_id, node) progress, _created = _get_or_create_progress(user_id, node)
old_status = progress.status old_status = progress.status
now = timezone.now() now = timezone.now()

View File

@@ -1,3 +1,4 @@
from django.db import IntegrityError, transaction
from django.db.models import Sum from django.db.models import Sum
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@@ -138,6 +139,17 @@ class QuestionAnswerView(APIView):
@extend_schema( @extend_schema(
request=AnswerRequestSerializer, request=AnswerRequestSerializer,
responses=AnswerResponseSerializer, responses=AnswerResponseSerializer,
parameters=[
OpenApiParameter(
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
description=(
'کلید اختیاری idempotency (مثل payment و lesson/complete). اگه ست '
'بشه، رتری/دابل‌کلیکِ همون submit با همون کلید، دوباره ردیفِ پاسخ/'
'کسرِ هارتِ تکراری نمی‌سازه و نتیجه‌ی همون پاسخِ قبلاً ثبت‌شده '
'برگردونده می‌شه.'
),
),
],
tags=['Curriculum (content)'], tags=['Curriculum (content)'],
) )
def post(self, request, question_id): def post(self, request, question_id):
@@ -151,16 +163,61 @@ class QuestionAnswerView(APIView):
user_id = request.user.id user_id = request.user.id
xp_earned = 10 if choice.is_correct else 0 xp_earned = 10 if choice.is_correct else 0
idempotency_key = request.headers.get('Idempotency-Key') or None
# ⚛️🔒 اصلاح 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( UserAnswer.objects.create(
user_id=user_id, user_id=user_id,
question=question, question=question,
choice=choice, choice=choice,
correct=choice.is_correct, correct=choice.is_correct,
xp_earned=xp_earned, xp_earned=xp_earned,
idempotency_key=idempotency_key,
) )
if not choice.is_correct: if not choice.is_correct:
hearts, _ = UserHearts.objects.get_or_create(user_id=user_id) 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: if hearts.current_hearts > 0:
hearts.current_hearts -= 1 hearts.current_hearts -= 1
hearts.save(update_fields=['current_hearts']) hearts.save(update_fields=['current_hearts'])
@@ -168,6 +225,29 @@ class QuestionAnswerView(APIView):
else: else:
hearts = UserHearts.objects.filter(user_id=user_id).first() hearts = UserHearts.objects.filter(user_id=user_id).first()
hearts_remaining = hearts.current_hearts if hearts else 5 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 = { data = {
'correct': choice.is_correct, 'correct': choice.is_correct,
@@ -189,6 +269,16 @@ class LessonCompleteView(APIView):
@extend_schema( @extend_schema(
request=LessonCompleteRequestSerializer, request=LessonCompleteRequestSerializer,
responses=LessonCompleteResponseSerializer, responses=LessonCompleteResponseSerializer,
parameters=[
OpenApiParameter(
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
description=(
'کلید اختیاری idempotency (مثل payment). اگه ست بشه، رتری/'
'دابل‌کلیکِ همون submit با همون کلید، دوباره ردیف/جایزه‌ی '
'تکراری نمی‌سازه و نتیجه‌ی همون completion موجود برگردونده می‌شه.'
),
),
],
tags=['Curriculum (content)'], tags=['Curriculum (content)'],
) )
def post(self, request, lesson_id): def post(self, request, lesson_id):
@@ -204,10 +294,52 @@ class LessonCompleteView(APIView):
potential_coins = stars * 5 potential_coins = stars * 5
user_id = request.user.id user_id = request.user.id
idempotency_key = request.headers.get('Idempotency-Key') or None
# ⚛️🔒 اصلاح 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)
# ⚛️ 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)
# ⚠️ اصلاح: تکمیل هر درس یک‌بارمصرفه — دفعات بعدیِ تکمیل همون درس
# نباید دوباره xp/coins به آمار کاربر اضافه کنه (قبلاً هر بار
# تکمیل، xp_earned/coins_earned رو بدون قید به stats اضافه می‌کرد).
is_first_completion = not UserLessonCompletion.objects.filter( is_first_completion = not UserLessonCompletion.objects.filter(
user_id=user_id, lesson=lesson user_id=user_id, lesson=lesson
).exists() ).exists()
@@ -221,8 +353,9 @@ class LessonCompleteView(APIView):
stars=stars, stars=stars,
xp_earned=xp_earned, xp_earned=xp_earned,
coins_earned=coins_earned, coins_earned=coins_earned,
idempotency_key=idempotency_key,
) )
stats, _ = UserStats.objects.get_or_create(user_id=user_id)
stats.xp += xp_earned stats.xp += xp_earned
stats.coins += coins_earned stats.coins += coins_earned
@@ -232,11 +365,29 @@ class LessonCompleteView(APIView):
).values('lesson_id').distinct().count() ).values('lesson_id').distinct().count()
stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2) stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2)
stats.save(update_fields=['xp', 'coins', 'progress_percent']) 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 = { stats_payload = {
'progressPercent': stats.progress_percent, 'progressPercent': stats.progress_percent if stats else 0,
'xp': stats.xp, 'xp': stats.xp if stats else 0,
'coins': stats.coins, 'coins': stats.coins if stats else 0,
} }
data = { data = {

View File

@@ -6,7 +6,7 @@
آفرها (مثلاً بجای اشتراک، دسترسی به یک اپ خاص) فقط همین فایل رو لمس می‌کنه، آفرها (مثلاً بجای اشتراک، دسترسی به یک اپ خاص) فقط همین فایل رو لمس می‌کنه،
نه apps.py/receivers.py و نه هیچ اپ دیگه‌ای. نه apps.py/receivers.py و نه هیچ اپ دیگه‌ای.
""" """
from django.db import IntegrityError from django.db import IntegrityError, transaction
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from .models import Offer, UserOffer from .models import Offer, UserOffer
@@ -17,11 +17,31 @@ def apply_offer(*, user_id: int, offer: Offer, source: str) -> UserOffer | None:
یک آفر رو به یک کاربر اختصاص می‌ده و اثرش رو اعمال می‌کنه (فعلاً: اشتراک یک آفر رو به یک کاربر اختصاص می‌ده و اثرش رو اعمال می‌کنه (فعلاً: اشتراک
رایگان/محدود). اگه کاربر قبلاً همین آفر رو گرفته باشه (unique constraint) رایگان/محدود). اگه کاربر قبلاً همین آفر رو گرفته باشه (unique constraint)
None برمی‌گردونه، نه خطا — چون auto-assign هنگام ثبت‌نام نباید کرش کنه. None برمی‌گردونه، نه خطا — چون auto-assign هنگام ثبت‌نام نباید کرش کنه.
⚛️ atomic: ساخت UserOffer، فعال‌سازی Subscription (grant_free_subscription)
و آپدیت expires_at روی همون UserOffer، هر سه یک واحد اتمیک‌اند. بدون این،
اگه grant_free_subscription (یا هر چیزی که داخلش صدا زده می‌شه، مثل
سیگنال status_changed) وسط راه fail کنه، یک UserOffer «یتیم» توی
دیتابیس می‌مونه که هیچ اشتراکی واقعاً براش فعال نشده.
""" """
if not offer.is_active: if not offer.is_active:
return None return 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: try:
with transaction.atomic(): # savepoint مجزا فقط برای create
user_offer = UserOffer.objects.create( user_offer = UserOffer.objects.create(
user_id=user_id, offer=offer, source=source, user_id=user_id, offer=offer, source=source,
expires_at=None, # پایین‌تر بعد از فعال‌سازی اشتراک ست می‌شه expires_at=None, # پایین‌تر بعد از فعال‌سازی اشتراک ست می‌شه
@@ -38,6 +58,7 @@ def apply_offer(*, user_id: int, offer: Offer, source: str) -> UserOffer | None:
user_offer.expires_at = subscription.expires_at user_offer.expires_at = subscription.expires_at
user_offer.save(update_fields=['expires_at']) user_offer.save(update_fields=['expires_at'])
return user_offer return user_offer

View File

@@ -98,13 +98,24 @@ class RedeemDiscountView(APIView):
if discount.redemptions.filter(user_id=request.user.id).exists(): if discount.redemptions.filter(user_id=request.user.id).exists():
raise ValidationError({'code': 'شما قبلاً این کد را استفاده کرده‌اید.'}) raise ValidationError({'code': 'شما قبلاً این کد را استفاده کرده‌اید.'})
# فقط سفارش‌های در انتظار پرداخت می‌تونن تخفیف بگیرن؛ سفارش # ⚛️🔒 اصلاح race condition: قبلاً چکِ «order.status != 'pending'»
# پرداخت‌شده/ناموفق نباید عقب‌گرد تغییر کنه. # روی همون آبجکتِ order ای انجام می‌شد که *قبل* از ورود به این
if order.status != 'pending': # atomic block و *بدون* قفل خونده شده بود (چند خط بالاتر، برای
raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'}) # چکِ مالکیت). یعنی اگه بینِ اون خواندنِ اولیه و این چک، یک
# 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) order_locked = Order.objects.select_for_update().get(pk=order.id)
if order_locked.status != 'pending':
raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'})
if discount.type == 'percent': if discount.type == 'percent':
discount_amount = order_locked.amount * discount.value // 100 discount_amount = order_locked.amount * discount.value // 100
else: else:

View File

@@ -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'),
),
]

View File

@@ -26,6 +26,22 @@ class FamilyLink(models.Model):
class Meta: class Meta:
ordering = ['-invited_at'] 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): def __str__(self):
return f"famlink-{self.pk}-parent{self.parent_user_id}-child{self.child_user_id or '?'}" return f"famlink-{self.pk}-parent{self.parent_user_id}-child{self.child_user_id or '?'}"

View File

@@ -1,5 +1,6 @@
from datetime import timedelta from datetime import timedelta
from django.db import IntegrityError, transaction
from django.db.models import Q, Sum from django.db.models import Q, Sum
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
@@ -51,15 +52,40 @@ class FamilyLinkInviteView(APIView):
).first() ).first()
if not link: if not link:
# ⚛️🔒 اصلاح 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') code = get_random_string(6, allowed_chars='0123456789')
while FamilyLink.objects.filter(invite_code=code).exists(): try:
code = get_random_string(6, allowed_chars='0123456789') with transaction.atomic():
link = FamilyLink.objects.create( link = FamilyLink.objects.create(
parent_user_id=request.user.id, parent_user_id=request.user.id,
child_phone=v['childPhone'], child_phone=v['childPhone'],
invite_code=code, 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} data = {'familyLinkId': str(link.pk), 'inviteCode': link.invite_code, 'status': link.status}
return Response(FamilyLinkInviteResponseSerializer(data).data) return Response(FamilyLinkInviteResponseSerializer(data).data)
@@ -80,7 +106,18 @@ class FamilyLinkAcceptView(APIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
v = serializer.validated_data 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 # ⚠️ اصلاح امنیتی: قبلاً هر کاربر لاگین‌کرده‌ای با دونستنِ inviteCode
# می‌تونست دعوتِ یک والد رو claim کنه، حتی اگه اون دعوت اصلاً برای # می‌تونست دعوتِ یک والد رو claim کنه، حتی اگه اون دعوت اصلاً برای
@@ -90,6 +127,9 @@ class FamilyLinkAcceptView(APIView):
if link.child_phone and link.child_phone != request.user.phone: if link.child_phone and link.child_phone != request.user.phone:
raise PermissionDenied('این دعوت متعلق به شماره‌ی دیگری است.') raise PermissionDenied('این دعوت متعلق به شماره‌ی دیگری است.')
# ⚛️ atomic: تغییر وضعیت لینک + ساختِ Notification یک واحدند —
# وگرنه ممکنه اتصال به‌عنوان accepted ثبت بشه ولی هیچ اعلانی برای
# والد ساخته نشه.
link.child_user_id = request.user.id link.child_user_id = request.user.id
link.status = 'accepted' link.status = 'accepted'
link.accepted_at = timezone.now() link.accepted_at = timezone.now()
@@ -201,6 +241,7 @@ class FamilyWeeklyReportView(APIView):
link = get_object_or_404(FamilyLink, parent_user_id=request.user.id, status='accepted') link = get_object_or_404(FamilyLink, parent_user_id=request.user.id, status='accepted')
week_start = _week_start(timezone.now()) week_start = _week_start(timezone.now())
with transaction.atomic():
log, created = WeeklyReportLog.objects.get_or_create( log, created = WeeklyReportLog.objects.get_or_create(
family_link=link, week_start=week_start, defaults={'sent_at': timezone.now()} family_link=link, week_start=week_start, defaults={'sent_at': timezone.now()}
) )

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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',
),
]

View File

@@ -47,6 +47,25 @@ class Transaction(models.Model):
class Meta: class Meta:
ordering = ['created_at'] 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): def __str__(self):
return f"txn-{self.pk}-order{self.order_id}" return f"txn-{self.pk}-order{self.order_id}"

View File

@@ -123,7 +123,22 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
idempotent: اگه Transaction مشابه (order/status/gateway_ref_id) از قبل idempotent: اگه Transaction مشابه (order/status/gateway_ref_id) از قبل
موجود باشه، دوباره اثر جانبی اجرا نمی‌شه. خروجی: True اگه این فراخوانی موجود باشه، دوباره اثر جانبی اجرا نمی‌شه. خروجی: True اگه این فراخوانی
واقعاً باعث paid شدن تازه شده باشه. واقعاً باعث 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 نمی‌ده) هم محافظت شده.
""" """
with db_transaction.atomic():
order = Order.objects.select_for_update().get(pk=order.pk)
already_paid = order.status == 'paid' already_paid = order.status == 'paid'
old_status = order.status old_status = order.status
@@ -131,10 +146,50 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
order=order, status=new_status, gateway_ref_id=gateway_ref_id, order=order, status=new_status, gateway_ref_id=gateway_ref_id,
).exists() ).exists()
# 🔒 اصلاح 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
# 🔒 اصلاح 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
if not is_duplicate: if not is_duplicate:
order.status = new_status order.status = new_status
order.save(update_fields=['status']) order.save(update_fields=['status'])
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( Transaction.objects.create(
order=order, order=order,
gateway=gateway, gateway=gateway,
@@ -142,7 +197,22 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
status=new_status, status=new_status,
paid_at=timezone.now() if new_status == 'paid' else None, 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( status_changed.send(
sender='payment.Order', object_id=order.id, sender='payment.Order', object_id=order.id,
old_status=old_status, new_status=new_status, old_status=old_status, new_status=new_status,

View File

@@ -149,17 +149,28 @@ class BazaarVerifyPurchaseView(APIView):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
v = serializer.validated_data 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(): with transaction.atomic():
order = get_object_or_404( order = get_object_or_404(
Order.objects.select_for_update(), Order.objects.select_for_update(),
pk=v['orderId'], user_id=request.user.id, provider='bazaar', 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' new_status = 'paid' if result.is_valid else 'failed'
order.bazaar_purchase_token = v['purchaseToken'] order.bazaar_purchase_token = v['purchaseToken']
order.save(update_fields=['bazaar_purchase_token']) order.save(update_fields=['bazaar_purchase_token'])

View File

@@ -1,3 +1,4 @@
from django.db import transaction
from django.utils import timezone from django.utils import timezone
from statuslog.signals import status_changed from statuslog.signals import status_changed
@@ -12,9 +13,18 @@ def activate_subscription(subscription_id: int):
(قبلاً چون Plan فیلد duration‌ای نداشت، این مقدار با یک قاعده‌ی stub (قبلاً چون Plan فیلد duration‌ای نداشت، این مقدار با یک قاعده‌ی stub
بر اساس متن title حدس زده می‌شد؛ الان duration_days مستقیماً از مدل بر اساس متن title حدس زده می‌شد؛ الان duration_days مستقیماً از مدل
Plan خونده می‌شه.) Plan خونده می‌شه.)
⚛️ atomic: خودِ این تابع هم مستقل (بدون اتکا به atomic بودنِ caller)
اتمیکه، چون به‌عنوان یک سرویسِ مشترک از جاهای مختلف (payment webhook،
bazaar verify) صدا زده می‌شه؛ save روی Subscription و نوشتنِ
StatusChangeLog که از signal.send نتیجه می‌شه، باید یک واحد بمونن.
""" """
with transaction.atomic():
try: try:
subscription = Subscription.objects.get(pk=subscription_id) # 🔒 select_for_update اضافه شد تا این تابع هم مثل
# grant_free_subscription، صرف‌نظر از caller، در برابر
# concurrent update ایمن باشه.
subscription = Subscription.objects.select_for_update().get(pk=subscription_id)
except Subscription.DoesNotExist: except Subscription.DoesNotExist:
return None return None
@@ -41,8 +51,21 @@ def grant_free_subscription(user_id: int, *, duration_days: int | None = None, e
از discount/services.py (apply_offer) صدا زده می‌شه؛ مثل بقیه‌ی از discount/services.py (apply_offer) صدا زده می‌شه؛ مثل بقیه‌ی
ارتباطات بین‌اپیِ این پروژه، یک import مستقیم به تابع سرویس، نه FK ارتباطات بین‌اپیِ این پروژه، یک import مستقیم به تابع سرویس، نه FK
واقعی و نه HTTP call به خودمون. واقعی و نه 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) 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 old_status = subscription.status
subscription.status = 'active' subscription.status = 'active'

View File

@@ -1,4 +1,5 @@
from django.contrib import admin from django.contrib import admin
from django.db import transaction
from .models import BugReport, Ticket, TicketMessage from .models import BugReport, Ticket, TicketMessage
@@ -38,6 +39,8 @@ class TicketMessageAdmin(admin.ModelAdmin):
پرداخت موفق (status=paid) صدا زده می‌شه. پرداخت موفق (status=paid) صدا زده می‌شه.
""" """
is_new = obj.pk is None is_new = obj.pk is None
# ⚛️ atomic: ذخیره‌ی پیام پشتیبانی + ساختِ Notification یک واحدند.
with transaction.atomic():
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
if is_new and obj.sender == 'support': if is_new and obj.sender == 'support':