Harden auth, AI tutor, payments, and subscriptions
This commit is contained in:
@@ -29,29 +29,9 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
count = 0
|
||||
# ⚛️ 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).
|
||||
# هر رکورد جدا اتمیک است (نه کل حلقه) تا خطای یک رکورد بقیه را rollback نکند.
|
||||
# id ها جدا گرفته میشوند و هر ردیف زیر select_for_update دوباره خوانده و
|
||||
# شرط abandoned بعد از قفل دوباره چک میشود، تا فعالیت همزمان کاربر overwrite نشود.
|
||||
stale_ids = list(stale_qs.values_list('id', flat=True))
|
||||
for progress_id in stale_ids:
|
||||
with transaction.atomic():
|
||||
|
||||
@@ -57,19 +57,9 @@ class Question(models.Model):
|
||||
|
||||
|
||||
class Choice(models.Model):
|
||||
"""
|
||||
⚠️ اصلاح نسبت به سند اولیه: دیاگرام معماری فقط فیلد `value` رو برای
|
||||
Choice مشخص کرده بود و فیلد تشخیص گزینهی درست (is_correct) رو نداشت
|
||||
(این نکته توی سند models هم صراحتاً بهعنوان یک ایراد/نقص علامتگذاری
|
||||
شده بود: "یا این فیلد سهواً جا افتاده یا تشخیص جای دیگهای انجام میشه").
|
||||
چون بدون این فیلد امکان پیادهسازی POST /api/questions/{id}/answer
|
||||
(که باید correct را برگردونه) وجود نداره، این فیلد اینجا اضافه شده.
|
||||
اگه منطق تشخیص پاسخ درست قراره جای دیگهای (سرویس content-authoring)
|
||||
انجام بشه، این فیلد رو میشه بعداً حذف/جابهجا کرد.
|
||||
"""
|
||||
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='choices')
|
||||
value = models.CharField(max_length=100)
|
||||
is_correct = models.BooleanField(default=False) # <-- اصلاح/افزوده شده
|
||||
is_correct = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
@@ -82,15 +72,9 @@ 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 میشن.
|
||||
# تکرار درس مجاز است (فقط بار اول xp/coins واقعی میگیرد)، پس قید یکتایی
|
||||
# سخت روی (user_id, lesson) درست نیست. idempotency_key فقط جلوی رکورد
|
||||
# تکراریِ ناشی از دابلکلیک/رتری روی همان submit را میگیرد.
|
||||
idempotency_key = models.CharField(max_length=100, null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -177,26 +161,13 @@ 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: رتری روی همان submit رکورد تکراری/کسر دوبارهی هارت نمیسازد.
|
||||
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 واقعاً پر شده اعمال بشه، نه وقتی خالی/ناله
|
||||
# (وگرنه پاسخهای بدونِ کلید هم به اشتباه یکتا میشدن).
|
||||
# قید فقط وقتی idempotency_key پر باشد اعمال میشود، تا پاسخهای بدون کلید یکتا نشوند.
|
||||
models.UniqueConstraint(
|
||||
fields=['user_id', 'question', 'idempotency_key'],
|
||||
condition=models.Q(idempotency_key__isnull=False) & ~models.Q(idempotency_key=''),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Choice, Grade, Lesson, Question
|
||||
from .models import Grade
|
||||
|
||||
|
||||
# ---- Grades ----
|
||||
@@ -84,8 +84,13 @@ class AnswerResponseSerializer(serializers.Serializer):
|
||||
# ---- Lesson completion ----
|
||||
|
||||
class LessonCompleteRequestSerializer(serializers.Serializer):
|
||||
correctCount = serializers.IntegerField()
|
||||
totalQuestions = serializers.IntegerField()
|
||||
# 🔒 اصلاح امنیتی: قبلاً هیچ محدودیتی روی این دو فیلد نبود؛ کلاینت
|
||||
# میتونست correctCount منفی یا totalQuestions صفر/منفی بفرسته.
|
||||
# min_value ها فقط شرطِ سادهی «غیرمنفی/مثبت» رو تضمین میکنن؛ چکِ
|
||||
# اصلیِ «correctCount نباید از تعدادِ واقعیِ سوالهای درس بیشتر باشه»
|
||||
# در LessonCompleteView (جایی که به Lesson دسترسی داریم) انجام میشه.
|
||||
correctCount = serializers.IntegerField(min_value=0)
|
||||
totalQuestions = serializers.IntegerField(min_value=1)
|
||||
|
||||
|
||||
class LessonCompleteResponseSerializer(serializers.Serializer):
|
||||
|
||||
@@ -10,29 +10,12 @@ from django.utils import timezone
|
||||
|
||||
from statuslog.signals import status_changed
|
||||
|
||||
from .models import Node, NodeProgress, UserLessonCompletion
|
||||
from .models import NodeProgress, UserLessonCompletion
|
||||
|
||||
|
||||
def _get_or_create_progress(user_id, 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 صدا بزنه (هر دو نقطهی
|
||||
فراخوانی در همین فایل همینطورن).
|
||||
"""
|
||||
"""get_or_create ایجاد میکند، سپس ردیف با select_for_update دوباره خوانده و قفل میشود
|
||||
تا خواندن/تغییر/ذخیره در برابر concurrent update ایمن باشد. باید داخل 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
|
||||
|
||||
108
content/tests.py
108
content/tests.py
@@ -1,3 +1,109 @@
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
# Create your tests here.
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
|
||||
from .models import Grade, Lesson, Node, Question, Unit
|
||||
|
||||
|
||||
class LessonCompleteImpossibleCountTests(TestCase):
|
||||
"""
|
||||
🔒 تستِ رگرسیونِ فیکسِ امنیتی: LessonCompleteView قبلاً correctCount رو
|
||||
بدونِ مقایسه با تعدادِ واقعیِ سوالهای درس میپذیرفت، پس یک کلاینتِ
|
||||
مخرب میتونست با correctCount بزرگتر از تعدادِ واقعیِ سوالها،
|
||||
xp/coins متورم بگیره. این تست هم ردِ درخواستهای غیرممکن رو، هم
|
||||
رفتارِ صحیحِ تکمیل با مقادیرِ معتبر رو تضمین میکنه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-complete-1', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
|
||||
node = Node.objects.create(unit=unit, title='گره ۱', order=1)
|
||||
self.lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...')
|
||||
for i in range(3):
|
||||
Question.objects.create(
|
||||
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
|
||||
)
|
||||
|
||||
def _complete(self, correct_count, total_questions):
|
||||
return self.client.post(
|
||||
f'/api/lessons/{self.lesson.id}/complete',
|
||||
{'correctCount': correct_count, 'totalQuestions': total_questions},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||
)
|
||||
|
||||
def test_correct_count_exceeding_actual_questions_is_rejected(self):
|
||||
# فقط ۳ سوال واقعی در این درس هست؛ کلاینت ادعای ۱۰۰ تای درست از
|
||||
# ۱۰ تای کل رو میکنه.
|
||||
response = self._complete(correct_count=100, total_questions=10)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('correctCount', response.data)
|
||||
|
||||
def test_negative_correct_count_is_rejected(self):
|
||||
response = self._complete(correct_count=-1, total_questions=3)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_zero_total_questions_is_rejected(self):
|
||||
response = self._complete(correct_count=1, total_questions=0)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_valid_completion_still_grants_expected_reward(self):
|
||||
# ۳ از ۳ سوالِ واقعیِ همین درس، درست — باید ۳ ستاره و xp کامل بده.
|
||||
response = self._complete(correct_count=3, total_questions=3)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['stars'], 3)
|
||||
self.assertEqual(response.data['xpEarned'], 30)
|
||||
self.assertEqual(response.data['coinsEarned'], 15)
|
||||
|
||||
|
||||
class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
|
||||
"""
|
||||
🔒 تستِ رگرسیونِ فیکسِ دوم: قبلاً ratio از totalQuestions ارسالیی
|
||||
خودِ کلاینت محاسبه میشد، نه از تعدادِ واقعیِ سوالهای درس. یعنی حتی
|
||||
بعد از سقفزدنِ correctCount، کلاینت میتونست با totalQuestions=1
|
||||
(بهجای ۳ سوالِ واقعی)، ratio رو مصنوعاً ۱۰۰٪ کنه و ۳ ستاره/coins
|
||||
کامل بگیره درحالیکه فقط ۱ از ۳ سوال رو درست جواب داده. حالا ratio
|
||||
همیشه بر اساسِ actual_question_count محاسبه میشه.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create(device_id='device-complete-2', is_guest=True, name='')
|
||||
self.token = issue_access_token(self.user.id)
|
||||
|
||||
grade = Grade.objects.create(title='پایه دوم', icon='icon', color='#fff')
|
||||
unit = Unit.objects.create(grade=grade, title='فصل ۲', order=1)
|
||||
node = Node.objects.create(unit=unit, title='گره ۲', order=1)
|
||||
self.lesson = Lesson.objects.create(node=node, title='درس ۲', intro_text='...')
|
||||
for i in range(3):
|
||||
Question.objects.create(
|
||||
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
|
||||
)
|
||||
|
||||
def _complete(self, correct_count, total_questions):
|
||||
return self.client.post(
|
||||
f'/api/lessons/{self.lesson.id}/complete',
|
||||
{'correctCount': correct_count, 'totalQuestions': total_questions},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'Bearer {self.token}',
|
||||
)
|
||||
|
||||
def test_manipulated_total_questions_does_not_yield_full_stars(self):
|
||||
# درسِ ۳-سوالی؛ کلاینت فقط ۱ درست داشته ولی totalQuestions=1
|
||||
# میفرسته تا وانمود کنه ۱۰۰٪ جواب داده.
|
||||
response = self._complete(correct_count=1, total_questions=1)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotEqual(response.data['stars'], 3)
|
||||
|
||||
def test_ratio_is_based_on_actual_lesson_question_count(self):
|
||||
# ۱ از ۳ سوالِ واقعی = ratio نزدیکِ ۰.۳۳ → طبقِ آستانههای موجود
|
||||
# (>=0.9 → 3, >=0.6 → 2, >0 → 1)، باید دقیقاً ۱ ستاره بده.
|
||||
response = self._complete(correct_count=1, total_questions=1)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['stars'], 1)
|
||||
|
||||
118
content/views.py
118
content/views.py
@@ -1,9 +1,8 @@
|
||||
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
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
@@ -51,22 +50,21 @@ def _node_status(node, user_id, is_first_unlocked_candidate):
|
||||
|
||||
|
||||
class HomeView(APIView):
|
||||
"""GET /api/home?userId&grade — نقشهی مهارتها + آمار (ادغامشده)"""
|
||||
"""GET /api/home?grade — نقشهی مهارتها + آمار (ادغامشده)"""
|
||||
|
||||
# userId همیشه از request.user.id (توکن معتبر) گرفته میشود، نه ورودی کلاینت (IDOR).
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter('userId', OpenApiTypes.INT, required=True),
|
||||
OpenApiParameter('grade', OpenApiTypes.INT, required=False),
|
||||
],
|
||||
responses=HomeResponseSerializer,
|
||||
tags=['Curriculum (content)'],
|
||||
)
|
||||
def get(self, request):
|
||||
user_id = request.query_params.get('userId')
|
||||
grade_id = request.query_params.get('grade')
|
||||
if not user_id:
|
||||
return Response({'detail': 'پارامتر userId الزامی است.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user_id = int(user_id)
|
||||
user_id = request.user.id
|
||||
|
||||
units_qs = Grade.objects.get(pk=grade_id).units.all() if grade_id else []
|
||||
|
||||
@@ -130,10 +128,7 @@ class LessonQuestionsView(APIView):
|
||||
class QuestionAnswerView(APIView):
|
||||
"""POST /api/questions/{id}/answer — ثبت پاسخ"""
|
||||
|
||||
# ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده میشد که به
|
||||
# کلاینت اجازه میداد بهجای هر کاربر دیگری پاسخ ثبت کنه (heart/xp کاربر
|
||||
# دلخواه رو تغییر بده). حالا auth اجباریه و همیشه از request.user.id
|
||||
# (که از توکن معتبر استخراج شده) استفاده میشه.
|
||||
# userId همیشه از request.user.id گرفته میشود، نه ورودی کلاینت.
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
@@ -165,12 +160,7 @@ class QuestionAnswerView(APIView):
|
||||
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,
|
||||
@@ -185,26 +175,9 @@ class QuestionAnswerView(APIView):
|
||||
}
|
||||
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 برمیگردونیم.
|
||||
# اتمیک: ثبت پاسخ + کسر هارت باید یک واحد باشند. UserHearts زیر قفل
|
||||
# خوانده/کم میشود تا lost update در پاسخهای همزمان رخ ندهد. قید یکتاییِ
|
||||
# سطح DB (uniq_user_answer_idempotency_key) جلوی ثبت دوباره با همان کلید را میگیرد.
|
||||
try:
|
||||
with transaction.atomic():
|
||||
UserAnswer.objects.create(
|
||||
@@ -235,16 +208,7 @@ class QuestionAnswerView(APIView):
|
||||
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 کنه.
|
||||
# قفل ردیف UserHearts هم اینجا رعایت میشود تا این خواندنِ 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
|
||||
@@ -286,9 +250,20 @@ class LessonCompleteView(APIView):
|
||||
serializer = LessonCompleteRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
correct_count = serializer.validated_data['correctCount']
|
||||
total_questions = serializer.validated_data['totalQuestions'] or 1
|
||||
# totalQuestions در بدنهی درخواست پذیرفته میشود (سازگاری با schema) اما در محاسبه استفاده نمیشود.
|
||||
|
||||
ratio = correct_count / total_questions
|
||||
# correctCount نباید از تعداد واقعی سوالهای این درس بیشتر باشد، تا xp/coins متورم نگیرد.
|
||||
actual_question_count = lesson.questions.count()
|
||||
if correct_count > actual_question_count:
|
||||
raise ValidationError({
|
||||
'correctCount': (
|
||||
'correctCount نمیتواند از تعداد واقعی سوالهای این درس '
|
||||
f'({actual_question_count}) بیشتر باشد.'
|
||||
)
|
||||
})
|
||||
|
||||
# ratio همیشه بر اساس actual_question_count محاسبه میشود، نه totalQuestions ارسالی کلاینت.
|
||||
ratio = correct_count / (actual_question_count or 1)
|
||||
stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0
|
||||
potential_xp = correct_count * 10
|
||||
potential_coins = stars * 5
|
||||
@@ -296,11 +271,7 @@ class LessonCompleteView(APIView):
|
||||
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* رو.
|
||||
# درخواست تکراری با همین کلید: نتیجهی قبلی idempotent برگردانده میشود.
|
||||
if idempotency_key:
|
||||
existing = UserLessonCompletion.objects.filter(
|
||||
user_id=user_id, lesson=lesson, idempotency_key=idempotency_key,
|
||||
@@ -366,12 +337,8 @@ class LessonCompleteView(APIView):
|
||||
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 برمیگردونیم.
|
||||
# قید یکتاییِ سطح DB جلوی درخواستهای همزمان با همان کلید را میگیرد؛
|
||||
# نتیجهی completion رقیب idempotent برگردانده میشود.
|
||||
if not idempotency_key:
|
||||
raise
|
||||
existing = UserLessonCompletion.objects.filter(
|
||||
@@ -401,26 +368,35 @@ class LessonCompleteView(APIView):
|
||||
|
||||
class NodeProgressListView(APIView):
|
||||
"""
|
||||
GET /api/progress/nodes?userId=&unit= — لیست وضعیتِ کاربر روی هر Node
|
||||
(تکمیلشده/نصفهرها/در حال انجام). userId مثل HomeView بهصورت query
|
||||
param گرفته میشه چون این endpoint قراره از سمت والدین (family app،
|
||||
گزارش هفتگی) و پشتیبانی هم برای کاربر دیگه (نه فقط خودِ request.user)
|
||||
صدا زده بشه، نه فقط خودِ دانشآموز.
|
||||
GET /api/progress/nodes?userId=&unit= — لیست وضعیتِ کاربر روی هر Node.
|
||||
|
||||
توکن اجباری است. userId قابل قبول است (والد میتواند پیشرفت فرزندش را ببیند)،
|
||||
اما فقط اگر برابر خودِ request.user باشد یا درخواستدهنده والدِ تأییدشدهی
|
||||
همان کاربر باشد (FamilyLink با status='accepted')؛ در غیر این صورت 403.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter('userId', OpenApiTypes.INT, required=True),
|
||||
OpenApiParameter('userId', OpenApiTypes.INT, required=False),
|
||||
OpenApiParameter('unit', OpenApiTypes.INT, required=False),
|
||||
],
|
||||
responses=NodeProgressListResponseSerializer,
|
||||
tags=['Curriculum (content)'],
|
||||
)
|
||||
def get(self, request):
|
||||
user_id = request.query_params.get('userId')
|
||||
if not user_id:
|
||||
return Response({'detail': 'پارامتر userId الزامی است.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user_id = int(user_id)
|
||||
requested_user_id = request.query_params.get('userId')
|
||||
user_id = int(requested_user_id) if requested_user_id else request.user.id
|
||||
|
||||
if user_id != request.user.id:
|
||||
from family.models import FamilyLink
|
||||
|
||||
is_verified_parent = FamilyLink.objects.filter(
|
||||
parent_user_id=request.user.id, child_user_id=user_id, status='accepted',
|
||||
).exists()
|
||||
if not is_verified_parent:
|
||||
raise PermissionDenied('اجازهی مشاهدهی پیشرفتِ این کاربر را ندارید.')
|
||||
|
||||
qs = NodeProgress.objects.filter(user_id=user_id).select_related('node')
|
||||
unit_id = request.query_params.get('unit')
|
||||
|
||||
Reference in New Issue
Block a user