Update project
This commit is contained in:
17
accounts/migrations/0002_user_uniq_guest_device_id.py
Normal file
17
accounts/migrations/0002_user_uniq_guest_device_id.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,14 +100,22 @@ class LoginView(APIView):
|
||||
phone = serializer.validated_data['phone']
|
||||
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
|
||||
if otp == settings.DEV_MASTER_OTP:
|
||||
valid = True
|
||||
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()
|
||||
).order_by('-id')
|
||||
otp_obj = otp_qs.first()
|
||||
).order_by('-id').first()
|
||||
if otp_obj:
|
||||
otp_obj.is_used = True
|
||||
otp_obj.save(update_fields=['is_used'])
|
||||
@@ -115,6 +124,12 @@ class LoginView(APIView):
|
||||
if not valid:
|
||||
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(
|
||||
phone=phone,
|
||||
defaults={'is_guest': False, 'name': ''},
|
||||
|
||||
29
ai_tutor/migrations/0002_askidempotency.py
Normal file
29
ai_tutor/migrations/0002_askidempotency.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,6 +90,37 @@ class AskAIView(APIView):
|
||||
# (select_for_update) در یک تراکنش اتمیک، چک و مصرفِ کوتا برای هر
|
||||
# کاربر 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():
|
||||
AccountUser.objects.select_for_update().get(pk=user.id)
|
||||
|
||||
@@ -75,7 +142,7 @@ class AskAIView(APIView):
|
||||
grade_id=v.get('gradeId'),
|
||||
)
|
||||
|
||||
Message.objects.create(
|
||||
user_message = Message.objects.create(
|
||||
conversation=conversation,
|
||||
role='user',
|
||||
text=v['userQuestion'],
|
||||
@@ -92,6 +159,24 @@ class AskAIView(APIView):
|
||||
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 — تاریخچهی مکالمه"""
|
||||
|
||||
@@ -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,7 +29,38 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
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
|
||||
progress.status = 'abandoned'
|
||||
progress.save(update_fields=['status'])
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,6 +54,7 @@ def touch_node_progress_on_answer(user_id, question):
|
||||
به in_progress برمیگرده.
|
||||
"""
|
||||
node = question.lesson.node
|
||||
with transaction.atomic():
|
||||
progress, _created = _get_or_create_progress(user_id, node)
|
||||
old_status = progress.status
|
||||
now = timezone.now()
|
||||
@@ -54,6 +77,7 @@ def update_node_progress_on_lesson_completion(user_id, lesson):
|
||||
میره روی completed، وگرنه (یا اگه از abandoned برگشته) in_progress.
|
||||
"""
|
||||
node = lesson.node
|
||||
with transaction.atomic():
|
||||
progress, _created = _get_or_create_progress(user_id, node)
|
||||
old_status = progress.status
|
||||
now = timezone.now()
|
||||
|
||||
167
content/views.py
167
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,16 +163,61 @@ 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
|
||||
|
||||
# ⚛️🔒 اصلاح 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:
|
||||
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:
|
||||
hearts.current_hearts -= 1
|
||||
hearts.save(update_fields=['current_hearts'])
|
||||
@@ -168,6 +225,29 @@ class QuestionAnswerView(APIView):
|
||||
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 = {
|
||||
'correct': choice.is_correct,
|
||||
@@ -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,10 +294,52 @@ class LessonCompleteView(APIView):
|
||||
potential_coins = stars * 5
|
||||
|
||||
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(
|
||||
user_id=user_id, lesson=lesson
|
||||
).exists()
|
||||
@@ -221,8 +353,9 @@ class LessonCompleteView(APIView):
|
||||
stars=stars,
|
||||
xp_earned=xp_earned,
|
||||
coins_earned=coins_earned,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
stats, _ = UserStats.objects.get_or_create(user_id=user_id)
|
||||
|
||||
stats.xp += xp_earned
|
||||
stats.coins += coins_earned
|
||||
|
||||
@@ -232,11 +365,29 @@ class LessonCompleteView(APIView):
|
||||
).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 = {
|
||||
|
||||
@@ -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,11 +17,31 @@ 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
|
||||
|
||||
# ⚠️ اصلاح: قبلاً هر 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, # پایینتر بعد از فعالسازی اشتراک ست میشه
|
||||
@@ -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.save(update_fields=['expires_at'])
|
||||
|
||||
return user_offer
|
||||
|
||||
|
||||
|
||||
@@ -98,13 +98,24 @@ class RedeemDiscountView(APIView):
|
||||
if discount.redemptions.filter(user_id=request.user.id).exists():
|
||||
raise ValidationError({'code': 'شما قبلاً این کد را استفاده کردهاید.'})
|
||||
|
||||
# فقط سفارشهای در انتظار پرداخت میتونن تخفیف بگیرن؛ سفارش
|
||||
# پرداختشده/ناموفق نباید عقبگرد تغییر کنه.
|
||||
if order.status != 'pending':
|
||||
raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'})
|
||||
|
||||
# ⚛️🔒 اصلاح race condition: قبلاً چکِ «order.status != 'pending'»
|
||||
# روی همون آبجکتِ order ای انجام میشد که *قبل* از ورود به این
|
||||
# atomic block و *بدون* قفل خونده شده بود (چند خط بالاتر، برای
|
||||
# چکِ مالکیت). یعنی اگه بینِ اون خواندنِ اولیه و این چک، یک
|
||||
# webhookِ پرداختِ همزمان روی همون سفارش paid میشد (که خودش با
|
||||
# select_for_update قفل میگیره)، این ویو همچنان مقدارِ
|
||||
# cached/قدیمیِ status='pending' رو میدید و اجازه میداد مبلغِ
|
||||
# یک سفارشِ *پرداختشده* بعد از پرداخت واقعی کم بشه — دقیقاً
|
||||
# همون کلاسِ باگی که apply_order_payment_result در
|
||||
# payment/services.py با قفلگرفتن *قبل* از چکِ already_paid
|
||||
# حلش کرده. حالا سفارش اول قفل میشه (select_for_update) و
|
||||
# چکِ pending بودن *بعد* از قفل، روی خودِ ردیفِ قفلشده
|
||||
# (order_locked) انجام میشه.
|
||||
order_locked = Order.objects.select_for_update().get(pk=order.id)
|
||||
|
||||
if order_locked.status != 'pending':
|
||||
raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'})
|
||||
|
||||
if discount.type == 'percent':
|
||||
discount_amount = order_locked.amount * discount.value // 100
|
||||
else:
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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 '?'}"
|
||||
|
||||
@@ -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:
|
||||
# ⚛️🔒 اصلاح 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')
|
||||
while FamilyLink.objects.filter(invite_code=code).exists():
|
||||
code = get_random_string(6, allowed_chars='0123456789')
|
||||
|
||||
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,7 +106,18 @@ 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 کنه، حتی اگه اون دعوت اصلاً برای
|
||||
@@ -90,6 +127,9 @@ class FamilyLinkAcceptView(APIView):
|
||||
if link.child_phone and link.child_phone != request.user.phone:
|
||||
raise PermissionDenied('این دعوت متعلق به شمارهی دیگری است.')
|
||||
|
||||
# ⚛️ atomic: تغییر وضعیت لینک + ساختِ Notification یک واحدند —
|
||||
# وگرنه ممکنه اتصال بهعنوان accepted ثبت بشه ولی هیچ اعلانی برای
|
||||
# والد ساخته نشه.
|
||||
link.child_user_id = request.user.id
|
||||
link.status = 'accepted'
|
||||
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')
|
||||
|
||||
week_start = _week_start(timezone.now())
|
||||
with transaction.atomic():
|
||||
log, created = WeeklyReportLog.objects.get_or_create(
|
||||
family_link=link, week_start=week_start, defaults={'sent_at': timezone.now()}
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
@@ -123,7 +123,22 @@ 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 نمیده) هم محافظت شده.
|
||||
"""
|
||||
with db_transaction.atomic():
|
||||
order = Order.objects.select_for_update().get(pk=order.pk)
|
||||
|
||||
already_paid = order.status == 'paid'
|
||||
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,
|
||||
).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:
|
||||
order.status = new_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(
|
||||
order=order,
|
||||
gateway=gateway,
|
||||
@@ -142,7 +197,22 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
|
||||
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,
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from statuslog.signals import status_changed
|
||||
@@ -12,9 +13,18 @@ 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 نتیجه میشه، باید یک واحد بمونن.
|
||||
"""
|
||||
with transaction.atomic():
|
||||
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:
|
||||
return None
|
||||
|
||||
@@ -41,8 +51,21 @@ 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)
|
||||
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'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from django.db import transaction
|
||||
|
||||
from .models import BugReport, Ticket, TicketMessage
|
||||
|
||||
@@ -38,6 +39,8 @@ class TicketMessageAdmin(admin.ModelAdmin):
|
||||
پرداخت موفق (status=paid) صدا زده میشه.
|
||||
"""
|
||||
is_new = obj.pk is None
|
||||
# ⚛️ atomic: ذخیرهی پیام پشتیبانی + ساختِ Notification یک واحدند.
|
||||
with transaction.atomic():
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
if is_new and obj.sender == 'support':
|
||||
|
||||
Reference in New Issue
Block a user