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

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