throttling, abuse-protection & logging/monitoring pass

This commit is contained in:
2026-08-23 23:44:00 +03:30
parent cb18c93a3e
commit 80298b47b1
33 changed files with 4202 additions and 23 deletions

View File

@@ -1,10 +1,14 @@
from unittest.mock import patch
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
from accounts.authentication import issue_access_token
from accounts.models import User
from progress.models import UserHearts, UserStats
from .models import Grade, Lesson, Node, Question, Unit
from .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion
class LessonCompleteImpossibleCountTests(TestCase):
@@ -107,3 +111,419 @@ class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
response = self._complete(correct_count=1, total_questions=1)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['stars'], 1)
class QuestionAnswerThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ per-user روی QuestionAnswerView
(throttle_scope='question_answer'): Idempotency-Key فقط جلوی retry با
همون کلید رو می‌گیره، نه حجمِ بالای درخواست‌های *متفاوت* که هرکدوم
یک UserAnswer جدید می‌سازن و منطقِ هارت/NodeProgress رو اجرا می‌کنن.
ScopedRateThrottle با pk کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP.
"""
def setUp(self):
from django.core.cache import cache
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-answer-throttle-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)
self.node = Node.objects.create(unit=unit, title='گره ۱', order=1)
self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...')
self.question = Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt='سوال ۱',
)
self.correct_choice = Choice.objects.create(
question=self.question, value='درست', is_correct=True,
)
def _answer(self, idempotency_key=None, remote_addr=None):
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
if idempotency_key:
kwargs['HTTP_IDEMPOTENCY_KEY'] = idempotency_key
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': str(self.correct_choice.id)},
format='json',
**kwargs,
)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}):
for _ in range(3):
response = self._answer()
self.assertEqual(response.status_code, 200)
def test_excessive_requests_from_same_user_return_429(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}):
for _ in range(3):
response = self._answer()
self.assertEqual(response.status_code, 200)
blocked_response = self._answer()
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است)، چون طبقِ فیکسِ GuestAwareContentThrottle حالا کلیدِ
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
# QuestionAnswerGuestThrottleBypassTests پایین‌تر) — این تست فقط
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین
# می‌کنه که باید دقیقاً مثلِ قبل دست‌نخورده بمونه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000701')
registered_token = issue_access_token(registered_user.id)
def _answer_as(remote_addr):
return self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': str(self.correct_choice.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
REMOTE_ADDR=remote_addr,
)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '3/min'}):
for i in range(3):
response = _answer_as(f'10.2.0.{i + 1}')
self.assertEqual(response.status_code, 200)
blocked_response = _answer_as('10.2.0.99')
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_creates_no_answer_and_no_hearts_or_progress_change(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}):
first_response = self._answer()
self.assertEqual(first_response.status_code, 200)
answer_count_before = UserAnswer.objects.filter(user_id=self.user.id).count()
hearts_before = UserHearts.objects.filter(user_id=self.user.id).first()
hearts_snapshot = hearts_before.current_hearts if hearts_before else None
progress_before = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
progress_snapshot = (
{'status': progress_before.status, 'last_activity_at': progress_before.last_activity_at}
if progress_before else None
)
blocked_response = self._answer()
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(
UserAnswer.objects.filter(user_id=self.user.id).count(), answer_count_before,
)
hearts_after = UserHearts.objects.filter(user_id=self.user.id).first()
self.assertEqual(
hearts_after.current_hearts if hearts_after else None, hearts_snapshot,
)
progress_after = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
if progress_snapshot is None:
self.assertIsNone(progress_after)
else:
self.assertEqual(progress_after.status, progress_snapshot['status'])
self.assertEqual(progress_after.last_activity_at, progress_snapshot['last_activity_at'])
def test_idempotency_key_retry_still_works_within_the_limit(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '10/min'}):
first_response = self._answer(idempotency_key='retry-key-1')
self.assertEqual(first_response.status_code, 200)
second_response = self._answer(idempotency_key='retry-key-1')
self.assertEqual(second_response.status_code, 200)
self.assertEqual(
UserAnswer.objects.filter(
user_id=self.user.id, question=self.question, idempotency_key='retry-key-1',
).count(),
1,
)
class LessonCompleteThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ per-user روی LessonCompleteView
(throttle_scope='lesson_complete'): Idempotency-Key فقط جلوی retry با
همون کلید رو می‌گیره، نه حجمِ بالای درخواست‌های *متفاوت* که هرکدوم
یک UserLessonCompletion جدید می‌سازن و سیگنالِ post_save
(NodeProgress) + به‌روزرسانیِ UserStats رو اجرا می‌کنن.
ScopedRateThrottle با pk کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP.
"""
def setUp(self):
from django.core.cache import cache
cache.clear()
self.client = APIClient()
self.user = User.objects.create(device_id='device-complete-throttle-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)
self.node = Node.objects.create(unit=unit, title='گره ۱', order=1)
self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...')
for i in range(3):
Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
)
def _complete(self, idempotency_key=None, remote_addr=None):
kwargs = {'HTTP_AUTHORIZATION': f'Bearer {self.token}'}
if idempotency_key:
kwargs['HTTP_IDEMPOTENCY_KEY'] = idempotency_key
if remote_addr:
kwargs['REMOTE_ADDR'] = remote_addr
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': 1, 'totalQuestions': 3},
format='json',
**kwargs,
)
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}):
for _ in range(3):
response = self._complete()
self.assertEqual(response.status_code, 200)
def test_excessive_requests_from_same_user_return_429(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}):
for _ in range(3):
response = self._complete()
self.assertEqual(response.status_code, 200)
blocked_response = self._complete()
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است)، چون طبقِ فیکسِ GuestAwareContentThrottle حالا کلیدِ
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
# LessonCompleteGuestThrottleBypassTests پایین‌تر) — این تست فقط
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین
# می‌کنه که باید دقیقاً مثلِ قبل دست‌نخورده بمونه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000702')
registered_token = issue_access_token(registered_user.id)
def _complete_as(remote_addr):
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': 1, 'totalQuestions': 3},
format='json',
HTTP_AUTHORIZATION=f'Bearer {registered_token}',
REMOTE_ADDR=remote_addr,
)
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '3/min'}):
for i in range(3):
response = _complete_as(f'10.3.0.{i + 1}')
self.assertEqual(response.status_code, 200)
blocked_response = _complete_as('10.3.0.99')
self.assertEqual(blocked_response.status_code, 429)
def test_throttled_request_creates_no_completion_or_other_side_effects(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/min'}):
first_response = self._complete()
self.assertEqual(first_response.status_code, 200)
completion_count_before = UserLessonCompletion.objects.filter(user_id=self.user.id).count()
stats_before = UserStats.objects.filter(user_id=self.user.id).first()
stats_snapshot = (
{'xp': stats_before.xp, 'coins': stats_before.coins, 'progress_percent': stats_before.progress_percent}
if stats_before else None
)
progress_before = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
progress_snapshot = (
{'status': progress_before.status, 'last_activity_at': progress_before.last_activity_at}
if progress_before else None
)
blocked_response = self._complete()
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(
UserLessonCompletion.objects.filter(user_id=self.user.id).count(), completion_count_before,
)
stats_after = UserStats.objects.filter(user_id=self.user.id).first()
if stats_snapshot is None:
self.assertIsNone(stats_after)
else:
self.assertEqual(stats_after.xp, stats_snapshot['xp'])
self.assertEqual(stats_after.coins, stats_snapshot['coins'])
self.assertEqual(stats_after.progress_percent, stats_snapshot['progress_percent'])
progress_after = NodeProgress.objects.filter(user_id=self.user.id, node=self.node).first()
if progress_snapshot is None:
self.assertIsNone(progress_after)
else:
self.assertEqual(progress_after.status, progress_snapshot['status'])
self.assertEqual(progress_after.last_activity_at, progress_snapshot['last_activity_at'])
class QuestionAnswerGuestThrottleBypassTests(TestCase):
"""
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='question_answer' برای
هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم
می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle
تازه بگیره و منطقِ هارت/NodeProgress رو بی‌محدودیت اجرا کنه. حالا
GuestAwareContentThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP
(همون get_ident که guest_session هم استفاده می‌کنه) می‌سازه، پس چرخشِ
حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرِ
غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده.
"""
def setUp(self):
from django.core.cache import cache
cache.clear()
self.client = APIClient()
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='...')
self.question = Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt='سوال ۱',
)
self.correct_choice = Choice.objects.create(
question=self.question, value='درست', is_correct=True,
)
def _answer(self, token):
return self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': str(self.correct_choice.id)},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
# سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)،
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '2/min'}):
guest1 = User.objects.create(device_id='device-answer-guest-rot-1', is_guest=True, name='')
response1 = self._answer(issue_access_token(guest1.id))
self.assertEqual(response1.status_code, 200)
guest2 = User.objects.create(device_id='device-answer-guest-rot-2', is_guest=True, name='')
response2 = self._answer(issue_access_token(guest2.id))
self.assertEqual(response2.status_code, 200)
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
# bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk.
guest3 = User.objects.create(device_id='device-answer-guest-rot-3', is_guest=True, name='')
response3 = self._answer(issue_access_token(guest3.id))
self.assertEqual(response3.status_code, 429)
def test_registered_users_keep_independent_per_user_buckets(self):
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ
# قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}):
user_a = User.objects.create(is_guest=False, name='', phone='+989120000711')
response_a = self._answer(issue_access_token(user_a.id))
self.assertEqual(response_a.status_code, 200)
user_b = User.objects.create(is_guest=False, name='', phone='+989120000712')
response_b = self._answer(issue_access_token(user_b.id))
self.assertEqual(response_b.status_code, 200)
def test_blocked_guest_request_creates_no_answer_or_hearts_change(self):
# درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید UserAnswer بسازه
# یا هارت/progress رو تغییر بده.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'question_answer': '1/min'}):
guest1 = User.objects.create(device_id='device-answer-guest-rot-4', is_guest=True, name='')
first_response = self._answer(issue_access_token(guest1.id))
self.assertEqual(first_response.status_code, 200)
guest2 = User.objects.create(device_id='device-answer-guest-rot-5', is_guest=True, name='')
blocked_response = self._answer(issue_access_token(guest2.id))
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(UserAnswer.objects.filter(user_id=guest2.id).count(), 0)
self.assertFalse(UserHearts.objects.filter(user_id=guest2.id).exists())
class LessonCompleteGuestThrottleBypassTests(TestCase):
"""
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='lesson_complete' برای
هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم
می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle
تازه بگیره و UserLessonCompletion/xp/coins/progress رو بی‌محدودیت
تولید کنه. حالا GuestAwareContentThrottle برای کاربرهای مهمان کلید رو
بر اساسِ IP می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده.
رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده
مونده.
"""
def setUp(self):
from django.core.cache import cache
cache.clear()
self.client = APIClient()
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
self.node = Node.objects.create(unit=unit, title='گره ۱', order=1)
self.lesson = Lesson.objects.create(node=self.node, title='درس ۱', intro_text='...')
for i in range(3):
Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
)
def _complete(self, token):
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': 1, 'totalQuestions': 3},
format='json',
HTTP_AUTHORIZATION=f'Bearer {token}',
)
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
# سه حساب مهمانِ متفاوت (شبیه‌سازیِ چرخشِ حساب برای دورزدنِ throttle)،
# همه از همون کلاینت/IP — باید همه یک bucket مشترک رو مصرف کنند.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '2/hour'}):
guest1 = User.objects.create(device_id='device-complete-guest-rot-1', is_guest=True, name='')
response1 = self._complete(issue_access_token(guest1.id))
self.assertEqual(response1.status_code, 200)
guest2 = User.objects.create(device_id='device-complete-guest-rot-2', is_guest=True, name='')
response2 = self._complete(issue_access_token(guest2.id))
self.assertEqual(response2.status_code, 200)
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
# bucket بر اساسِ IP به‌اشتراک گذاشته شده، نه user.pk.
guest3 = User.objects.create(device_id='device-complete-guest-rot-3', is_guest=True, name='')
response3 = self._complete(issue_access_token(guest3.id))
self.assertEqual(response3.status_code, 429)
def test_registered_users_keep_independent_per_user_buckets(self):
# عدمِ رگرسیون: کاربرهای غیرمهمان (ثبت‌نام‌شده با شماره) باید مثلِ
# قبل هرکدوم bucket جداگانه‌ی خودشون رو داشته باشن.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/hour'}):
user_a = User.objects.create(is_guest=False, name='', phone='+989120000721')
response_a = self._complete(issue_access_token(user_a.id))
self.assertEqual(response_a.status_code, 200)
user_b = User.objects.create(is_guest=False, name='', phone='+989120000722')
response_b = self._complete(issue_access_token(user_b.id))
self.assertEqual(response_b.status_code, 200)
def test_blocked_guest_request_creates_no_completion_or_reward_side_effects(self):
# درخواستِ بلاک‌شده (از حسابِ مهمانِ چرخشی) نباید UserLessonCompletion
# بسازه یا UserStats/NodeProgress رو تغییر بده.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'lesson_complete': '1/hour'}):
guest1 = User.objects.create(device_id='device-complete-guest-rot-4', is_guest=True, name='')
first_response = self._complete(issue_access_token(guest1.id))
self.assertEqual(first_response.status_code, 200)
guest2 = User.objects.create(device_id='device-complete-guest-rot-5', is_guest=True, name='')
blocked_response = self._complete(issue_access_token(guest2.id))
self.assertEqual(blocked_response.status_code, 429)
self.assertEqual(UserLessonCompletion.objects.filter(user_id=guest2.id).count(), 0)
self.assertFalse(UserStats.objects.filter(user_id=guest2.id).exists())
self.assertFalse(NodeProgress.objects.filter(user_id=guest2.id, node=self.node).exists())

View File

@@ -5,6 +5,7 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from progress.models import UserHearts, UserStats
@@ -23,6 +24,40 @@ from .serializers import (
)
class GuestAwareContentThrottle(ScopedRateThrottle):
"""
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py)
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده
از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، برای هر
حساب یک bucket تازه از throttle_scope 'question_answer'/'lesson_complete'
بگیره و محدودیتِ واقعی رو دور بزنه — درحالی‌که هر دو endpoint اثرِ
جانبیِ ماندگار دارن (UserAnswer + کسرِ هارت، یا UserLessonCompletion +
xp/coins/progress).
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی
حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب
دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان
(ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ
answer/heart/progress/completion/idempotency دست‌نخورده‌ست.
"""
def get_cache_key(self, request, view):
user = request.user
if user and user.is_authenticated and getattr(user, 'is_guest', False):
ident = f'guest-ip:{self.get_ident(request)}'
elif user and user.is_authenticated:
ident = user.pk
else:
ident = self.get_ident(request)
return self.cache_format % {'scope': self.scope, 'ident': ident}
class GradesListView(APIView):
"""GET /api/grades — لیست پایه‌ها"""
@@ -130,6 +165,20 @@ class QuestionAnswerView(APIView):
# userId همیشه از request.user.id گرفته می‌شود، نه ورودی کلاینت.
permission_classes = [IsAuthenticated]
# Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ بالای
# درخواست‌های *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک
# UserAnswer جدید می‌سازن و منطقِ هارت/NodeProgress رو اجرا می‌کنن —
# حتی وقتی هارتِ کاربر صفر شده. ScopedRateThrottle وقتی request.user
# احرازهویت‌شده باشه با pk کاربر کلید می‌خوره (نه فقط IP)، پس تغییرِ
# IP این محدودیت رو دور نمی‌زنه.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از
# GuestAwareContentThrottleِ مشترک استفاده می‌شه که برای کاربرهای
# مهمان کلید رو بر اساسِ IP می‌سازه و برای کاربرهای غیرمهمان رفتارِ
# per-user قبلی رو دست‌نخورده نگه می‌داره.
throttle_scope = 'question_answer'
throttle_classes = [GuestAwareContentThrottle]
@extend_schema(
request=AnswerRequestSerializer,
@@ -229,6 +278,18 @@ class LessonCompleteView(APIView):
# کلاینت اجازه می‌داد به‌جای هر کاربر دیگری xp/coins ثبت کنه. حالا auth
# اجباریه و همیشه از request.user.id استفاده می‌شه.
permission_classes = [IsAuthenticated]
# Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ بالای
# درخواست‌های *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک
# UserLessonCompletion جدید می‌سازن و سیگنالِ post_save (NodeProgress) +
# به‌روزرسانیِ UserStats رو اجرا می‌کنن. ScopedRateThrottle با pk
# کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP.
# ⚠️ اصلاح شده: همون مشکلِ چرخشِ حسابِ مهمان (نگاه کنید به
# GuestAwareContentThrottle بالا) اینجا هم بود؛ حالا از همون کلاسِ
# مشترک استفاده می‌شه. نرخ هم از ۶۰/min به ۱۰/hour تغییر کرد چون این
# endpoint رکوردِ completion ماندگار می‌سازه و xp/coins/progress رو
# واقعاً تغییر می‌ده (برخلافِ question_answer که فرکانسِ طبیعی‌اش بالاتره).
throttle_scope = 'lesson_complete'
throttle_classes = [GuestAwareContentThrottle]
@extend_schema(
request=LessonCompleteRequestSerializer,