harden OTP for real SMS and close the second audit round

Kavenegar is being switched on, so every OTP request now costs credit:
- phone numbers are normalised to 09XXXXXXXXX everywhere (login, OTP,
  family invite, TEST_LOGIN_PHONES keys) and existing rows are migrated;
  non-Iranian and landline numbers are rejected before any SMS
- cooldown and a daily per-phone cap are counted in the database, so the
  three gunicorn workers no longer triple them
- a global daily OTP SMS cap (default 300) stops SMS pumping
- per-IP rates widened for CGNAT / school Wi-Fi (env-overridable)
- provider message id is logged; SMS text warns not to share the code

Second audit round: an offer no longer shortens a longer paid plan,
Zarinpal error bodies no longer 500, island XP reaches the parent report,
a non-numeric questionId no longer 500s after the answer is generated.

596 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 12:28:06 +03:30
parent 74aa9aa8ca
commit 0580aec7b0
24 changed files with 685 additions and 119 deletions

View File

@@ -514,8 +514,9 @@ class LessonCompleteView(APIView):
# می‌تونست به‌جای هر کاربرِ دیگری xp/coins ثبت کنه.
permission_classes = [IsAuthenticated]
# Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره؛ برای همینه که
# GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ ۱۰/hour
# (نه ۶۰/min مثلِ question_answer) چون این endpoint رکوردِ ماندگار می‌سازه.
# GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ در
# settings (`lesson_complete`، الان ۱۲۰/hour) کمتر از question_answer است
# چون این endpoint رکوردِ ماندگار می‌سازه.
throttle_scope = 'lesson_complete'
throttle_classes = [GuestAwareContentThrottle]
@@ -554,6 +555,9 @@ class LessonCompleteView(APIView):
# امتیاز فقط برای جواب‌هایی که واقعاً ثبت شده‌اند. پیش‌تر عددِ کلاینت
# (فقط با سقفِ تعدادِ سوال‌ها) پذیرفته می‌شد، پس صدا زدنِ مستقیمِ همین
# endpoint برای هر درس، بی‌جواب دادن، امتیاز و جدولِ رتبه‌بندی را پر می‌کرد.
# ⚠️ جلوی «غلط بزن، کلید را ببین، درست بزن» را نمی‌گیرد
# (verified_correct_count را ببینید)، و همین جواب‌ها در پایانِ آزمونِ
# جزیره دوباره امتیاز می‌گیرند. هر دو در بازبینیِ ۲۰۲۶-۰۹-۲۵ بازتولید شد.
correct_count = min(correct_count, verified_correct_count(request.user.id, lesson_id=lesson.id))
# ratio همیشه بر اساس actual_question_count محاسبه می‌شود، نه totalQuestions ارسالی کلاینت.
@@ -883,9 +887,12 @@ class NodeQuizCompleteView(APIView):
correct_count = serializer.validated_data['correctCount']
total_questions = serializer.validated_data['totalQuestions']
# سقف‌ها از روی خودِ مبحث، نه از روی حرفِ کلاینت، تا امتیازِ متورم
# ثبت نشود: آزمون بیشتر از استخرِ سوال‌های مبحث (و سقفِ NodeQuizView)
# سوال ندارد، و پاسخِ درست بیشتر از سوال‌ها نمی‌شود.
# سقف‌ها از روی خودِ مبحث، نه از روی حرفِ کلاینت: آزمون بیشتر از
# استخرِ سوال‌های مبحث (و سقفِ NodeQuizView) سوال ندارد، و پاسخِ درست
# بیشتر از سوال‌ها نمی‌شود.
# ⚠️ سقف «کلِ استخر» است (تا ۲۰)، نه آزمونِ چهارسوالی‌ای که واقعاً داده
# شد؛ جواب‌های درستِ مسیرِ درس‌ها هم اینجا دوباره شمرده می‌شوند. قفل بودنِ
# جزیره هم بررسی نمی‌شود: هر جزیره‌ای با correctCount=0 «تمام» می‌شود.
pool = min(
Question.objects.filter(lesson__node_id=node_id).count(),
NodeQuizView.MAX_SIZE,