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

@@ -98,12 +98,21 @@ def _is_db_ref_id(value) -> bool:
این تابع فقط تشخیص می‌ده که آیا مقدار برای ذخیره‌سازی در همچین
فیلدی امن/عددی هست یا نه؛ روی مرزِ generate_tutor_answer اثری نداره
(اونجا همیشه مقدارِ خامِ اصلی پاس داده می‌شه).
پیامِ دستیار هم از همین مسیر (`_db_ref_or_none`) رد می‌شود؛ پیش از
۲۰۲۶-۰۹-۲۵ نمی‌شد و questionIdِ غیرعددی *بعد از* تولیدِ جواب ۵۰۰ می‌داد و
سهمِ روزانه مصرف‌شده می‌ماند.
"""
if value in (None, ''):
return False
return str(value).strip().lstrip('-').isdigit()
def _db_ref_or_none(value):
"""مقدارِ عددی برای ستونِ IntegerField، وگرنه None (نه خطای ORM)."""
return value if _is_db_ref_id(value) else None
def _conversation_history(conversation, before_message) -> list[dict]:
"""
چند پیامِ آخرِ همین گفتگو، پیش از سوالِ تازه، به ترتیبِ زمان.
@@ -419,7 +428,7 @@ class AskAIView(APIView):
conversation=conversation,
role='assistant',
text=answer_text,
question_id=v.get('questionId') or None,
question_id=_db_ref_or_none(v.get('questionId')),
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
)
@@ -558,7 +567,7 @@ class AskAIView(APIView):
with transaction.atomic():
assistant_message = Message.objects.create(
conversation=conversation, role='assistant', text=answer_text,
question_id=v.get('questionId') or None,
question_id=_db_ref_or_none(v.get('questionId')),
**_annotation(retrieval_meta, generation_meta, from_cache=answer_from_cache),
)
if idempotency_key: