Update project
This commit is contained in:
@@ -10,6 +10,7 @@ commandه، نه receiver. باید periodic اجرا بشه (cron / celery beat
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from content.models import NodeProgress
|
||||
@@ -28,15 +29,46 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
count = 0
|
||||
for progress in stale_qs:
|
||||
old_status = progress.status
|
||||
progress.status = 'abandoned'
|
||||
progress.save(update_fields=['status'])
|
||||
status_changed.send(
|
||||
sender='content.NodeProgress', object_id=progress.id,
|
||||
old_status=old_status, new_status='abandoned',
|
||||
event='inactivity_timeout', user_id=progress.user_id,
|
||||
)
|
||||
# ⚛️ atomic (بهازای هر رکورد، نه کل حلقه): این یک job دورهای رو
|
||||
# روی هزاران رکورد میتونه اجرا کنه. اگه کل حلقه یک atomic واحد
|
||||
# باشه، یک خطا روی رکورد هزارم، ابطالِ ۹۹۹ رکورد درستشدهی قبلی رو
|
||||
# هم rollback میکنه (و کل job باید از اول اجرا بشه). بهجاش هر
|
||||
# رکورد (save + signal که StatusChangeLog میسازه) جدا اتمیکه: یک
|
||||
# خطای مقطعی فقط همون رکورد رو نصفهکاره نگه نمیداره، رکوردهای
|
||||
# قبلی/بعدی مستقل میمونن.
|
||||
#
|
||||
# ⚛️🔒 اصلاح race condition (lost update): قبلاً همین آبجکتِ
|
||||
# progressِ خوندهشده از stale_qs (بدونِ قفل) مستقیماً save میشد.
|
||||
# اگه همزمان یک درخواستِ کاربرِ واقعی (touch_node_progress_on_answer
|
||||
# یا update_node_progress_on_lesson_completion در content/services.py،
|
||||
# هر دو با select_for_update) دقیقاً همون ردیف رو بهروزرسانی و
|
||||
# last_activity_at رو تازه میکرد، این کامند میتونست بدونِ اطلاع از
|
||||
# اون تغییرِ تازه، status رو با مقدارِ قدیمیِ در-حافظه به 'abandoned'
|
||||
# برگردونه — یعنی فعالیتِ تازهی کاربر توسط این جاب rollback/overwrite
|
||||
# میشد. حالا برای هر رکورد، فقط id از stale_qs گرفته میشه؛ ردیفِ
|
||||
# واقعی داخل transaction.atomic با select_for_update دوباره خونده و
|
||||
# قفل میشه (دقیقاً همون الگویی که مسیرِ کاربر استفاده میکنه)، و
|
||||
# شرطِ نصفهرها بودن (status='in_progress' و last_activity_at قدیمیتر
|
||||
# از threshold) بعد از گرفتنِ قفل دوباره چک میشه؛ اگه بین queryset
|
||||
# اولیه و گرفتنِ قفل، یک فعالیتِ کاربر همین ردیف رو تازه کرده باشه،
|
||||
# دیگه شرط برقرار نیست و این ردیف نادیده گرفته میشه (نه abandoned).
|
||||
stale_ids = list(stale_qs.values_list('id', flat=True))
|
||||
for progress_id in stale_ids:
|
||||
with transaction.atomic():
|
||||
progress = NodeProgress.objects.select_for_update().get(pk=progress_id)
|
||||
|
||||
if progress.status != 'in_progress' or progress.last_activity_at is None \
|
||||
or progress.last_activity_at >= threshold:
|
||||
continue
|
||||
|
||||
old_status = progress.status
|
||||
progress.status = 'abandoned'
|
||||
progress.save(update_fields=['status'])
|
||||
status_changed.send(
|
||||
sender='content.NodeProgress', object_id=progress.id,
|
||||
old_status=old_status, new_status='abandoned',
|
||||
event='inactivity_timeout', user_id=progress.user_id,
|
||||
)
|
||||
count += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
|
||||
@@ -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,18 +54,19 @@ def touch_node_progress_on_answer(user_id, question):
|
||||
به in_progress برمیگرده.
|
||||
"""
|
||||
node = question.lesson.node
|
||||
progress, _created = _get_or_create_progress(user_id, node)
|
||||
old_status = progress.status
|
||||
now = timezone.now()
|
||||
with transaction.atomic():
|
||||
progress, _created = _get_or_create_progress(user_id, node)
|
||||
old_status = progress.status
|
||||
now = timezone.now()
|
||||
|
||||
if progress.status in ('not_started', 'abandoned'):
|
||||
progress.status = 'in_progress'
|
||||
if progress.started_at is None:
|
||||
progress.started_at = now
|
||||
progress.last_activity_at = now
|
||||
progress.save(update_fields=['status', 'started_at', 'last_activity_at'])
|
||||
if progress.status in ('not_started', 'abandoned'):
|
||||
progress.status = 'in_progress'
|
||||
if progress.started_at is None:
|
||||
progress.started_at = now
|
||||
progress.last_activity_at = now
|
||||
progress.save(update_fields=['status', 'started_at', 'last_activity_at'])
|
||||
|
||||
_emit_if_changed(progress, old_status, event='answer_activity', user_id=user_id)
|
||||
_emit_if_changed(progress, old_status, event='answer_activity', user_id=user_id)
|
||||
return progress
|
||||
|
||||
|
||||
@@ -54,31 +77,32 @@ def update_node_progress_on_lesson_completion(user_id, lesson):
|
||||
میره روی completed، وگرنه (یا اگه از abandoned برگشته) in_progress.
|
||||
"""
|
||||
node = lesson.node
|
||||
progress, _created = _get_or_create_progress(user_id, node)
|
||||
old_status = progress.status
|
||||
now = timezone.now()
|
||||
with transaction.atomic():
|
||||
progress, _created = _get_or_create_progress(user_id, node)
|
||||
old_status = progress.status
|
||||
now = timezone.now()
|
||||
|
||||
lesson_ids = list(node.lessons.values_list('id', flat=True))
|
||||
completed_count = UserLessonCompletion.objects.filter(
|
||||
user_id=user_id, lesson_id__in=lesson_ids
|
||||
).values('lesson_id').distinct().count()
|
||||
lesson_ids = list(node.lessons.values_list('id', flat=True))
|
||||
completed_count = UserLessonCompletion.objects.filter(
|
||||
user_id=user_id, lesson_id__in=lesson_ids
|
||||
).values('lesson_id').distinct().count()
|
||||
|
||||
progress.lessons_completed_count = completed_count
|
||||
if progress.started_at is None:
|
||||
progress.started_at = now
|
||||
progress.last_activity_at = now
|
||||
progress.lessons_completed_count = completed_count
|
||||
if progress.started_at is None:
|
||||
progress.started_at = now
|
||||
progress.last_activity_at = now
|
||||
|
||||
if lesson_ids and completed_count >= len(lesson_ids):
|
||||
progress.status = 'completed'
|
||||
progress.completed_at = now
|
||||
else:
|
||||
progress.status = 'in_progress'
|
||||
if lesson_ids and completed_count >= len(lesson_ids):
|
||||
progress.status = 'completed'
|
||||
progress.completed_at = now
|
||||
else:
|
||||
progress.status = 'in_progress'
|
||||
|
||||
progress.save(update_fields=[
|
||||
'lessons_completed_count', 'status', 'started_at', 'completed_at', 'last_activity_at',
|
||||
])
|
||||
progress.save(update_fields=[
|
||||
'lessons_completed_count', 'status', 'started_at', 'completed_at', 'last_activity_at',
|
||||
])
|
||||
|
||||
_emit_if_changed(progress, old_status, event='lesson_completed', user_id=user_id)
|
||||
_emit_if_changed(progress, old_status, event='lesson_completed', user_id=user_id)
|
||||
return progress
|
||||
|
||||
|
||||
|
||||
237
content/views.py
237
content/views.py
@@ -1,3 +1,4 @@
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Sum
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@@ -138,6 +139,17 @@ class QuestionAnswerView(APIView):
|
||||
@extend_schema(
|
||||
request=AnswerRequestSerializer,
|
||||
responses=AnswerResponseSerializer,
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
|
||||
description=(
|
||||
'کلید اختیاری idempotency (مثل payment و lesson/complete). اگه ست '
|
||||
'بشه، رتری/دابلکلیکِ همون submit با همون کلید، دوباره ردیفِ پاسخ/'
|
||||
'کسرِ هارتِ تکراری نمیسازه و نتیجهی همون پاسخِ قبلاً ثبتشده '
|
||||
'برگردونده میشه.'
|
||||
),
|
||||
),
|
||||
],
|
||||
tags=['Curriculum (content)'],
|
||||
)
|
||||
def post(self, request, question_id):
|
||||
@@ -151,22 +163,90 @@ class QuestionAnswerView(APIView):
|
||||
|
||||
user_id = request.user.id
|
||||
xp_earned = 10 if choice.is_correct else 0
|
||||
idempotency_key = request.headers.get('Idempotency-Key') or None
|
||||
|
||||
UserAnswer.objects.create(
|
||||
user_id=user_id,
|
||||
question=question,
|
||||
choice=choice,
|
||||
correct=choice.is_correct,
|
||||
xp_earned=xp_earned,
|
||||
)
|
||||
if not choice.is_correct:
|
||||
hearts, _ = UserHearts.objects.get_or_create(user_id=user_id)
|
||||
if hearts.current_hearts > 0:
|
||||
hearts.current_hearts -= 1
|
||||
hearts.save(update_fields=['current_hearts'])
|
||||
hearts_remaining = hearts.current_hearts
|
||||
else:
|
||||
hearts = UserHearts.objects.filter(user_id=user_id).first()
|
||||
# ⚛️🔒 اصلاح idempotency (duplicate answer row + دوبار کسرِ هارت):
|
||||
# اگه کلاینت کلید idempotency فرستاده و قبلاً همین submit با همین
|
||||
# کلید ثبت شده، بهجای ساختِ دوبارهی ردیف/کسرِ دوبارهی هارت، همون
|
||||
# نتیجهی قبلی (و وضعیتِ فعلیِ هارت، بدونِ تغییرِ دوبارهاش) برگردونده
|
||||
# میشه — دقیقاً همون الگویی که LessonCompleteView برای
|
||||
# UserLessonCompletion.idempotency_key داره.
|
||||
if idempotency_key:
|
||||
existing = UserAnswer.objects.filter(
|
||||
user_id=user_id, question=question, idempotency_key=idempotency_key,
|
||||
).first()
|
||||
if existing is not None:
|
||||
hearts = UserHearts.objects.filter(user_id=user_id).first()
|
||||
data = {
|
||||
'correct': existing.correct,
|
||||
'correctOptionId': str(correct_choice.id) if correct_choice else '',
|
||||
'xpEarned': existing.xp_earned,
|
||||
'heartsRemaining': hearts.current_hearts if hearts else 5,
|
||||
}
|
||||
return Response(AnswerResponseSerializer(data).data)
|
||||
|
||||
# ⚛️ atomic: UserAnswer.create پایینتر یک سیگنال post_save رو تریگر
|
||||
# میکنه (content/receivers.py) که خودش NodeProgress رو آپدیت و
|
||||
# احتمالاً یک StatusChangeLog میسازه؛ بهعلاوهی get_or_create/save
|
||||
# روی UserHearts، همهشون باید یک واحد اتمیک باشن — وگرنه مثلاً اگه
|
||||
# save روی hearts fail کنه، پاسخ کاربر ثبت شده ولی هارتش کم نشده.
|
||||
#
|
||||
# ⚛️🔒 race condition: خواندنِ hearts.current_hearts و کمکردنش
|
||||
# («lost update») بدون قفل بود — اگه کاربر دو تا پاسخِ غلط رو تقریباً
|
||||
# همزمان بفرسته (یا کلاینت retry کنه)، دو درخواست میتونستن هر دو
|
||||
# current_hearts=5 رو بخونن، هر دو 4 رو ذخیره کنن؛ یعنی بهجای کمشدنِ
|
||||
# ۲ تا هارت، فقط ۱ تا کم بشه. حالا ردیفِ UserHearts قفل میشه
|
||||
# (select_for_update) قبل از خوندن/کمکردنش.
|
||||
#
|
||||
# ⚛️🔒 دفاع در عمق برای idempotency: اگه دو submit همزمان دقیقاً با
|
||||
# همون Idempotency-Key وارد بشن (رتریِ واقعیِ شبکهایِ کلاینت)،
|
||||
# UniqueConstraintِ سطح DB (uniq_user_answer_idempotency_key) جلوی
|
||||
# ردیفِ دومِ UserAnswer رو میگیره و کلِ این atomic block (شاملِ
|
||||
# کسرِ هارت) rollback میشه — پس هیچ اثرِ تکراریای اعمال نشده. در
|
||||
# این حالت، بهجای خطا، نتیجهی همون پاسخی که رقیب ثبت کرده رو
|
||||
# idempotent برمیگردونیم.
|
||||
try:
|
||||
with transaction.atomic():
|
||||
UserAnswer.objects.create(
|
||||
user_id=user_id,
|
||||
question=question,
|
||||
choice=choice,
|
||||
correct=choice.is_correct,
|
||||
xp_earned=xp_earned,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if not choice.is_correct:
|
||||
UserHearts.objects.get_or_create(user_id=user_id)
|
||||
hearts = UserHearts.objects.select_for_update().get(user_id=user_id)
|
||||
if hearts.current_hearts > 0:
|
||||
hearts.current_hearts -= 1
|
||||
hearts.save(update_fields=['current_hearts'])
|
||||
hearts_remaining = hearts.current_hearts
|
||||
else:
|
||||
hearts = UserHearts.objects.filter(user_id=user_id).first()
|
||||
hearts_remaining = hearts.current_hearts if hearts else 5
|
||||
except IntegrityError:
|
||||
if not idempotency_key:
|
||||
raise
|
||||
existing = UserAnswer.objects.filter(
|
||||
user_id=user_id, question=question, idempotency_key=idempotency_key,
|
||||
).first()
|
||||
if existing is None:
|
||||
raise
|
||||
choice = existing.choice
|
||||
xp_earned = existing.xp_earned
|
||||
# 🔒 اصلاح: خواندنِ UserHearts اینجا قبلاً بدون قفل و بیرونِ هر
|
||||
# transaction.atomic انجام میشد (چون بعد از rollback شدنِ
|
||||
# atomic بالا اجرا میشه). یعنی اگه همزمان یک تغییرِ دیگر روی
|
||||
# همون ردیفِ UserHearts در حالِ commit شدن بود (مثلاً پاسخِ غلطِ
|
||||
# دیگری از همین کاربر که select_for_update گرفته)، این خواندنِ
|
||||
# بدون قفل میتونست با اون race کنه و یک مقدارِ ناسازگار/گذرا
|
||||
# برگردونه. حالا دقیقاً همون الگوی قفلِ ردیف که مسیرِ معمولیِ
|
||||
# این ویو استفاده میکنه (select_for_update داخلِ
|
||||
# transaction.atomic) اینجا هم برای همین خواندن بهکار رفته تا
|
||||
# پاسخِ fallback نتونه با هیچ جهشِ دیگری روی هارت race کنه.
|
||||
with transaction.atomic():
|
||||
hearts = UserHearts.objects.select_for_update().filter(user_id=user_id).first()
|
||||
hearts_remaining = hearts.current_hearts if hearts else 5
|
||||
|
||||
data = {
|
||||
@@ -189,6 +269,16 @@ class LessonCompleteView(APIView):
|
||||
@extend_schema(
|
||||
request=LessonCompleteRequestSerializer,
|
||||
responses=LessonCompleteResponseSerializer,
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
|
||||
description=(
|
||||
'کلید اختیاری idempotency (مثل payment). اگه ست بشه، رتری/'
|
||||
'دابلکلیکِ همون submit با همون کلید، دوباره ردیف/جایزهی '
|
||||
'تکراری نمیسازه و نتیجهی همون completion موجود برگردونده میشه.'
|
||||
),
|
||||
),
|
||||
],
|
||||
tags=['Curriculum (content)'],
|
||||
)
|
||||
def post(self, request, lesson_id):
|
||||
@@ -204,39 +294,100 @@ class LessonCompleteView(APIView):
|
||||
potential_coins = stars * 5
|
||||
|
||||
user_id = request.user.id
|
||||
idempotency_key = request.headers.get('Idempotency-Key') or None
|
||||
|
||||
# ⚠️ اصلاح: تکمیل هر درس یکبارمصرفه — دفعات بعدیِ تکمیل همون درس
|
||||
# نباید دوباره xp/coins به آمار کاربر اضافه کنه (قبلاً هر بار
|
||||
# تکمیل، xp_earned/coins_earned رو بدون قید به stats اضافه میکرد).
|
||||
is_first_completion = not UserLessonCompletion.objects.filter(
|
||||
user_id=user_id, lesson=lesson
|
||||
).exists()
|
||||
xp_earned = potential_xp if is_first_completion else 0
|
||||
coins_earned = potential_coins if is_first_completion else 0
|
||||
# ⚛️🔒 اصلاح race condition (duplicate completion row): اگه کلاینت
|
||||
# کلید idempotency فرستاده و قبلاً همین submit با همین کلید ثبت
|
||||
# شده، بهجای ساختِ دوبارهی ردیف/جایزه، همون نتیجهی قبلی برگردونده
|
||||
# میشه (idempotent). این جدا از قفلِ UserStats پایینه که فقط جلوی
|
||||
# duplicate *reward* رو میگرفت، نه duplicate *row* رو.
|
||||
if idempotency_key:
|
||||
existing = UserLessonCompletion.objects.filter(
|
||||
user_id=user_id, lesson=lesson, idempotency_key=idempotency_key,
|
||||
).first()
|
||||
if existing is not None:
|
||||
stats = UserStats.objects.filter(user_id=user_id).first()
|
||||
stats_payload = {
|
||||
'progressPercent': stats.progress_percent if stats else 0,
|
||||
'xp': stats.xp if stats else 0,
|
||||
'coins': stats.coins if stats else 0,
|
||||
}
|
||||
data = {
|
||||
'stars': existing.stars,
|
||||
'xpEarned': existing.xp_earned,
|
||||
'coinsEarned': existing.coins_earned,
|
||||
'stats': stats_payload,
|
||||
}
|
||||
return Response(LessonCompleteResponseSerializer(data).data)
|
||||
|
||||
UserLessonCompletion.objects.create(
|
||||
user_id=user_id,
|
||||
lesson=lesson,
|
||||
correct_count=correct_count,
|
||||
stars=stars,
|
||||
xp_earned=xp_earned,
|
||||
coins_earned=coins_earned,
|
||||
)
|
||||
stats, _ = UserStats.objects.get_or_create(user_id=user_id)
|
||||
stats.xp += xp_earned
|
||||
stats.coins += coins_earned
|
||||
# ⚛️ atomic: UserLessonCompletion.create پایینتر یک سیگنال post_save
|
||||
# رو تریگر میکنه (content/receivers.py → NodeProgress + احتمالاً
|
||||
# StatusChangeLog)؛ بهعلاوهی get_or_create/save روی UserStats.
|
||||
# بدون atomic، اگه save روی stats fail کنه، تکمیلِ درس ثبت شده ولی
|
||||
# xp/coins/progress_percent بهروز نشده — یعنی چون تکمیل هر درس
|
||||
# یکبارمصرفه، کاربر دیگه هیچوقت اون xp رو نمیگیره.
|
||||
#
|
||||
# ⚛️🔒 race condition: چکِ «is_first_completion» قبلاً قبل از atomic
|
||||
# و بدون قفل انجام میشد — دو درخواستِ همزمان (دابلکلیک/رتریِ
|
||||
# کلاینت) برای تکمیلِ همون درس میتونستن هر دو ببینن که هنوز کامل
|
||||
# نشده و هر دو xp/coins کامل بگیرن (دوبرابر شدنِ جایزه). حالا ردیفِ
|
||||
# UserStats کاربر قفل میشه (select_for_update) و چکِ
|
||||
# is_first_completion داخلِ همون قفل، بعد از لاکشدن، دوباره انجام
|
||||
# میشه — یعنی درخواستِ دومِ همزمان، حالا که اولی commit شده، بهدرستی
|
||||
# میبینه که دیگه first completion نیست.
|
||||
try:
|
||||
with transaction.atomic():
|
||||
UserStats.objects.get_or_create(user_id=user_id)
|
||||
stats = UserStats.objects.select_for_update().get(user_id=user_id)
|
||||
|
||||
total_lessons = Lesson.objects.count() or 1
|
||||
completed_lessons = UserLessonCompletion.objects.filter(
|
||||
user_id=user_id
|
||||
).values('lesson_id').distinct().count()
|
||||
stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2)
|
||||
stats.save(update_fields=['xp', 'coins', 'progress_percent'])
|
||||
is_first_completion = not UserLessonCompletion.objects.filter(
|
||||
user_id=user_id, lesson=lesson
|
||||
).exists()
|
||||
xp_earned = potential_xp if is_first_completion else 0
|
||||
coins_earned = potential_coins if is_first_completion else 0
|
||||
|
||||
UserLessonCompletion.objects.create(
|
||||
user_id=user_id,
|
||||
lesson=lesson,
|
||||
correct_count=correct_count,
|
||||
stars=stars,
|
||||
xp_earned=xp_earned,
|
||||
coins_earned=coins_earned,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
stats.xp += xp_earned
|
||||
stats.coins += coins_earned
|
||||
|
||||
total_lessons = Lesson.objects.count() or 1
|
||||
completed_lessons = UserLessonCompletion.objects.filter(
|
||||
user_id=user_id
|
||||
).values('lesson_id').distinct().count()
|
||||
stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2)
|
||||
stats.save(update_fields=['xp', 'coins', 'progress_percent'])
|
||||
except IntegrityError:
|
||||
# ⚛️🔒 دفاع در عمق: اگه دو submit همزمان دقیقاً با همون
|
||||
# Idempotency-Key وارد بشن (رتریِ واقعیِ شبکهایِ کلاینت)،
|
||||
# UniqueConstraint سطح DB جلوی ردیفِ دوم رو میگیره و کل این
|
||||
# atomic block (شاملِ آپدیتِ stats) rollback میشه — پس هیچ
|
||||
# جایزهی تکراریای اعمال نشده. در این حالت، بهجای خطا، نتیجهی
|
||||
# همون completion که رقیب ساخته رو idempotent برمیگردونیم.
|
||||
if not idempotency_key:
|
||||
raise
|
||||
existing = UserLessonCompletion.objects.filter(
|
||||
user_id=user_id, lesson=lesson, idempotency_key=idempotency_key,
|
||||
).first()
|
||||
if existing is None:
|
||||
raise
|
||||
stats = UserStats.objects.filter(user_id=user_id).first()
|
||||
stars = existing.stars
|
||||
xp_earned = existing.xp_earned
|
||||
coins_earned = existing.coins_earned
|
||||
|
||||
stats_payload = {
|
||||
'progressPercent': stats.progress_percent,
|
||||
'xp': stats.xp,
|
||||
'coins': stats.coins,
|
||||
'progressPercent': stats.progress_percent if stats else 0,
|
||||
'xp': stats.xp if stats else 0,
|
||||
'coins': stats.coins if stats else 0,
|
||||
}
|
||||
|
||||
data = {
|
||||
|
||||
Reference in New Issue
Block a user