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:
40
accounts/migrations/0005_normalize_user_phones.py
Normal file
40
accounts/migrations/0005_normalize_user_phones.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
شمارههای موجود را به همان شکلِ 09XXXXXXXXX میبرد که serializerها حالا میسازند
|
||||
(accounts/phone.py)؛ وگرنه کسی که با +98… ثبتنام کرده بود دیگر به حسابِ خودش
|
||||
نمیرسید و حسابِ تازه میگرفت.
|
||||
|
||||
ردیفی که شکلِ نرمالش از قبل مالِ کاربرِ دیگری است دست نمیخورد (unique است و
|
||||
ادغامِ دو حساب تصمیمِ انسانی است)؛ ردیفی هم که موبایلِ ایران نیست همانطور میماند.
|
||||
هر دو فقط شمرده و چاپ میشوند. برگشتناپذیر است چون شکلِ قبلی ذخیره نمیشود.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def normalize(apps, schema_editor):
|
||||
from accounts.phone import normalize_phone
|
||||
|
||||
User = apps.get_model('accounts', 'User')
|
||||
changed = skipped_taken = skipped_invalid = 0
|
||||
for user in User.objects.exclude(phone__isnull=True).exclude(phone=''):
|
||||
target = normalize_phone(user.phone)
|
||||
if target is None:
|
||||
skipped_invalid += 1
|
||||
elif target != user.phone:
|
||||
if User.objects.filter(phone=target).exists():
|
||||
skipped_taken += 1
|
||||
else:
|
||||
User.objects.filter(pk=user.pk).update(phone=target)
|
||||
changed += 1
|
||||
if changed or skipped_taken or skipped_invalid:
|
||||
print(f'\n normalize phones: changed={changed} taken={skipped_taken} invalid={skipped_invalid}')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0004_otpcode_failed_attempts_user_tokens_valid_after'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(normalize, migrations.RunPython.noop),
|
||||
]
|
||||
31
accounts/phone.py
Normal file
31
accounts/phone.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
نرمالسازیِ شمارهی موبایلِ ایران به شکلِ یکتای 09XXXXXXXXX.
|
||||
|
||||
چرا: پیش از این `phone` رشتهی خام بود و 0912…، +98912…، 98912… و همان
|
||||
شماره با ارقامِ فارسی چهار «شماره»ی جدا بودند — هر کدام cooldown و سقفِ پیامکِ
|
||||
خودش را داشت و حسابِ جدا میساخت (security-before-production.md، بندِ ۱۷).
|
||||
هرچه شکلِ 09\\d{9} نگیرد رد میشود، پس شمارهی خارجی یا خطِ ثابت هم به
|
||||
کاوهنگار نمیرسد (پیامکِ بینالمللی گران است و هدفِ SMS pumping).
|
||||
|
||||
عمداً بیوابستگی به DRF است تا config/settings.py هم بتواند واردش کند
|
||||
(کلیدهای TEST_LOGIN_PHONES)؛ فیلدِ serializer در accounts/serializers.py است.
|
||||
"""
|
||||
import re
|
||||
|
||||
_DIGITS = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
|
||||
_SEPARATORS = re.compile(r'[\s\-().\u200c\u200e\u200f]')
|
||||
_MOBILE = re.compile(r'09\d{9}')
|
||||
|
||||
|
||||
def normalize_phone(value) -> str | None:
|
||||
"""شکلِ یکتای 09XXXXXXXXX، یا None اگر موبایلِ ایران نباشد."""
|
||||
s = _SEPARATORS.sub('', str(value or '').translate(_DIGITS))
|
||||
if s.startswith('+98'):
|
||||
s = '0' + s[3:]
|
||||
elif s.startswith('0098'):
|
||||
s = '0' + s[4:]
|
||||
elif s.startswith('98') and len(s) == 12:
|
||||
s = '0' + s[2:]
|
||||
elif s.startswith('9') and len(s) == 10:
|
||||
s = '0' + s
|
||||
return s if _MOBILE.fullmatch(s) else None
|
||||
@@ -2,6 +2,8 @@ import re
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from .phone import normalize_phone
|
||||
|
||||
# لینک، آیدیِ شبکهی اجتماعی، و هر رشتهی ۵ رقمی یا بیشتر (شمارهی تلفن با
|
||||
# فاصله/خطتیره هم) — ارقامِ فارسی و عربی هم.
|
||||
_CONTACT = re.compile(
|
||||
@@ -15,6 +17,20 @@ def clean_display_name(value: str) -> str:
|
||||
return ' '.join(_CONTACT.sub(' ', value or '').split())[:30]
|
||||
|
||||
|
||||
class IranMobileField(serializers.CharField):
|
||||
"""CharField که خروجیاش همیشه 09XXXXXXXXX است."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
kwargs.setdefault('max_length', 20)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def to_internal_value(self, data):
|
||||
phone = normalize_phone(super().to_internal_value(data))
|
||||
if phone is None:
|
||||
raise serializers.ValidationError('شمارهی موبایل معتبر نیست؛ مثلاً 09121234567.')
|
||||
return phone
|
||||
|
||||
|
||||
# ---- Request serializers ----
|
||||
|
||||
class GuestSessionRequestSerializer(serializers.Serializer):
|
||||
@@ -23,7 +39,7 @@ class GuestSessionRequestSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class LoginRequestSerializer(serializers.Serializer):
|
||||
phone = serializers.CharField(max_length=15)
|
||||
phone = IranMobileField()
|
||||
otp = serializers.CharField(max_length=6)
|
||||
|
||||
|
||||
@@ -91,7 +107,7 @@ class UpdateProfileRequestSerializer(serializers.Serializer):
|
||||
|
||||
class RequestOTPRequestSerializer(serializers.Serializer):
|
||||
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تستپذیری Swagger اضافه شد)."""
|
||||
phone = serializers.CharField(max_length=15)
|
||||
phone = IranMobileField()
|
||||
|
||||
|
||||
# ---- Response serializers ----
|
||||
|
||||
@@ -125,6 +125,16 @@ def send_sms(*, phone: str, message: str) -> None:
|
||||
)
|
||||
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
|
||||
|
||||
# پذیرفتهشدن ≠ رسیدن. شناسه و وضعیتِ پیام لاگ میشود تا اگر کسی گفت «کد
|
||||
# نیامد»، همین شناسه در پنلِ کاوهنگار (گزارشِ ارسال) دنبال شود — مثلاً
|
||||
# شمارهای که پیامکِ تبلیغاتی را مسدود کرده. متنِ پیام لاگ نمیشود.
|
||||
entries = body.get('entries') if isinstance(body, dict) else None
|
||||
first = entries[0] if isinstance(entries, list) and entries and isinstance(entries[0], dict) else {}
|
||||
logger.info(
|
||||
'SMS accepted by provider: phone_suffix=%s messageid=%s message_status=%s',
|
||||
_phone_suffix(phone), first.get('messageid'), first.get('status'),
|
||||
)
|
||||
|
||||
|
||||
def send_otp_sms(*, phone: str, code: str) -> None:
|
||||
"""
|
||||
|
||||
@@ -27,14 +27,14 @@ class SendOtpSmsTests(TestCase):
|
||||
@override_settings(DEBUG=True)
|
||||
def test_dev_mode_does_not_call_provider(self):
|
||||
with patch('accounts.services.requests.post') as mock_post:
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
mock_post.assert_not_called()
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='')
|
||||
def test_production_without_api_key_raises_instead_of_silent_success(self):
|
||||
with patch('accounts.services.requests.post') as mock_post:
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
mock_post.assert_not_called()
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key', KAVENEGAR_SENDER='')
|
||||
@@ -49,11 +49,11 @@ class SendOtpSmsTests(TestCase):
|
||||
'entries': [{'messageid': 1, 'status': 1}],
|
||||
}
|
||||
with patch('accounts.services.requests.post', return_value=mock_response) as mock_post:
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
mock_post.assert_called_once()
|
||||
args, kwargs = mock_post.call_args
|
||||
self.assertIn('test-key', args[0])
|
||||
self.assertEqual(kwargs['data']['receptor'], '+989120000000')
|
||||
self.assertEqual(kwargs['data']['receptor'], '09120000000')
|
||||
self.assertIn('1234', kwargs['data']['message'])
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||
@@ -61,7 +61,7 @@ class SendOtpSmsTests(TestCase):
|
||||
mock_response = Mock(status_code=400, text='bad request')
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||
def test_production_http_200_with_application_level_rejection_raises(self):
|
||||
@@ -77,7 +77,7 @@ class SendOtpSmsTests(TestCase):
|
||||
}
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||
def test_production_malformed_provider_body_raises(self):
|
||||
@@ -87,20 +87,20 @@ class SendOtpSmsTests(TestCase):
|
||||
mock_response.json.side_effect = ValueError('No JSON object could be decoded')
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='test-key')
|
||||
def test_production_network_failure_raises(self):
|
||||
import requests as requests_module
|
||||
with patch('accounts.services.requests.post', side_effect=requests_module.ConnectionError('boom')):
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_dev_mode_log_never_contains_full_phone_only_safe_suffix(self):
|
||||
# 🔒 اثبات فیکسِ این دور: لاگِ OTP نباید هیچوقت شمارهتلفنِ کامل رو
|
||||
# نشون بده؛ فقط ۴ رقمِ آخر (شناسهی امن) باید در پیامِ لاگ باشه.
|
||||
phone = '+989120000000'
|
||||
phone = '09120000000'
|
||||
with self.assertLogs('accounts.services', level='INFO') as captured:
|
||||
send_otp_sms(phone=phone, code='1234')
|
||||
|
||||
@@ -124,7 +124,7 @@ class SendOtpSmsTests(TestCase):
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
message = captured.records[0].getMessage()
|
||||
self.assertNotIn('bad request body secret-ish', message)
|
||||
self.assertIn('phone_suffix=0000', message)
|
||||
@@ -136,7 +136,7 @@ class SendOtpSmsTests(TestCase):
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
message = captured.records[0].getMessage()
|
||||
self.assertNotIn('not json body secret-ish', message)
|
||||
self.assertIn('phone_suffix=0000', message)
|
||||
@@ -154,7 +154,7 @@ class SendOtpSmsTests(TestCase):
|
||||
with patch('accounts.services.requests.post', return_value=mock_response):
|
||||
with self.assertLogs('accounts.services', level='ERROR') as captured:
|
||||
with self.assertRaises(OTPDeliveryError):
|
||||
send_otp_sms(phone='+989120000000', code='1234')
|
||||
send_otp_sms(phone='09120000000', code='1234')
|
||||
message = captured.records[0].getMessage()
|
||||
self.assertNotIn('secret-ish detail', message)
|
||||
self.assertIn('phone_suffix=0000', message)
|
||||
@@ -176,22 +176,23 @@ class RequestOTPViewTests(TestCase):
|
||||
def test_dev_mode_returns_otp_in_response_without_provider_call(self):
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '+989120000001'}, format='json',
|
||||
'/api/auth/otp/request', {'phone': '09120000001'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.data['success'])
|
||||
self.assertTrue(response.data['devOtp'])
|
||||
mock_send.assert_called_once()
|
||||
self.assertTrue(OTPCode.objects.filter(phone='+989120000001').exists())
|
||||
self.assertTrue(OTPCode.objects.filter(phone='09120000001').exists())
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='')
|
||||
def test_production_delivery_failure_does_not_report_success(self):
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '+989120000002'}, format='json',
|
||||
'/api/auth/otp/request', {'phone': '09120000002'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 503)
|
||||
# OTP row is created before delivery is attempted; only the SMS leg fails.
|
||||
self.assertTrue(OTPCode.objects.filter(phone='+989120000002').exists())
|
||||
# ردیفِ کدی که هرگز نرسید حذف میشود تا در cooldown و سقفِ روزانهی
|
||||
# دیتابیسی شمرده نشود (۲۰۲۶-۰۹-۲۵).
|
||||
self.assertFalse(OTPCode.objects.filter(phone='09120000002').exists())
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_generated_otp_is_always_a_6_digit_string_with_leading_zeros(self):
|
||||
@@ -199,11 +200,11 @@ class RequestOTPViewTests(TestCase):
|
||||
with patch('accounts.views.secrets.randbelow', return_value=7):
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '+989120000003'}, format='json',
|
||||
'/api/auth/otp/request', {'phone': '09120000003'}, format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['devOtp'], '000007')
|
||||
otp = OTPCode.objects.get(phone='+989120000003')
|
||||
otp = OTPCode.objects.get(phone='09120000003')
|
||||
self.assertEqual(otp.code, '000007')
|
||||
self.assertEqual(len(otp.code), 6)
|
||||
self.assertTrue(otp.code.isdigit())
|
||||
@@ -242,7 +243,7 @@ class RequestOTPStaleInvalidationTests(TestCase):
|
||||
# (RequestOTPPhoneCooldownTests).
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_second_request_invalidates_first_otp(self):
|
||||
phone = '+989121110001'
|
||||
phone = '09121110001'
|
||||
first_code = self._request_otp(phone, '111111')
|
||||
first_otp = OTPCode.objects.get(phone=phone, code=first_code)
|
||||
self.assertFalse(first_otp.is_used)
|
||||
@@ -254,7 +255,7 @@ class RequestOTPStaleInvalidationTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_newest_otp_still_works_for_login(self):
|
||||
phone = '+989121110002'
|
||||
phone = '09121110002'
|
||||
self._request_otp(phone, '111111')
|
||||
second_code = self._request_otp(phone, '222222')
|
||||
|
||||
@@ -265,7 +266,7 @@ class RequestOTPStaleInvalidationTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_older_otp_is_rejected_after_new_request(self):
|
||||
phone = '+989121110003'
|
||||
phone = '09121110003'
|
||||
first_code = self._request_otp(phone, '111111')
|
||||
self._request_otp(phone, '222222')
|
||||
|
||||
@@ -342,7 +343,7 @@ class LoginPhoneRateLimitTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_LOGIN_PHONE_MAX_ATTEMPTS=3)
|
||||
def test_repeated_attempts_for_same_phone_are_blocked(self):
|
||||
phone = '+989123334444'
|
||||
phone = '09123334444'
|
||||
|
||||
for _ in range(3):
|
||||
response = self.client.post(
|
||||
@@ -374,7 +375,7 @@ class RequestOTPPhoneRateLimitTests(TestCase):
|
||||
# (RequestOTPPhoneCooldownTests).
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_repeated_requests_for_same_phone_are_eventually_blocked(self):
|
||||
phone = '+989127778888'
|
||||
phone = '09127778888'
|
||||
|
||||
# otp_request (IP-محور) پیشفرض 5/hour است؛ برای اینکه فقط
|
||||
# محدودیتِ شمارهتلفن رو بسنجیم، throttle IP-محور رو موقتاً باز
|
||||
@@ -394,7 +395,7 @@ class RequestOTPPhoneRateLimitTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=3, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_changing_client_ip_does_not_bypass_the_phone_limit(self):
|
||||
phone = '+989127778889'
|
||||
phone = '09127778889'
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
@@ -416,7 +417,7 @@ class RequestOTPPhoneRateLimitTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_MAX_ATTEMPTS=1, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_throttled_request_creates_no_new_otp_and_sends_no_sms(self):
|
||||
phone = '+989127778890'
|
||||
phone = '09127778890'
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'otp_request': '1000/hour'}):
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
@@ -458,7 +459,7 @@ class RequestOTPPhoneRateLimitConcurrencyTests(TransactionTestCase):
|
||||
def test_two_simultaneous_requests_do_not_both_pass_the_phone_limit(self):
|
||||
import threading
|
||||
|
||||
phone = '+989127778891'
|
||||
phone = '09127778891'
|
||||
barrier = threading.Barrier(2)
|
||||
results = []
|
||||
results_lock = threading.Lock()
|
||||
@@ -502,7 +503,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
|
||||
def test_immediate_resend_for_same_phone_is_blocked_by_cooldown(self):
|
||||
phone = '+989127779001'
|
||||
phone = '09127779001'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
@@ -521,7 +522,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
# موفق بشه، بقیه باید با cooldown (429) بلاک بشن — و چون
|
||||
# درخواستهایِ بلاکشده توسطِ cooldown هیچوقت به شمارندهی ساعتی
|
||||
# نمیرسن، سهمیهی ساعتی مصرف نمیشه.
|
||||
phone = '+989127779002'
|
||||
phone = '09127779002'
|
||||
|
||||
# throttle IP-محورِ otp_request (پیشفرض 5/hour) رو موقتاً باز
|
||||
# میکنیم تا اینجا فقط cooldown/سهمیهی ساعتیِ per-phone سنجیده بشه،
|
||||
@@ -541,7 +542,11 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
|
||||
# سهمیهی ساعتی هنوز دستنخورده است: بعد از پایانِ cooldown،
|
||||
# همچنان میشه تا سقفِ مجاز درخواست داد.
|
||||
# گذشتِ زمان: هم کلیدِ کش و هم cooldownِ دیتابیسی (از expires_at).
|
||||
cache.delete(f'otp_request_phone_cooldown:{phone}')
|
||||
OTPCode.objects.filter(phone=phone).update(
|
||||
expires_at=timezone.now() + timezone.timedelta(seconds=settings.OTP_TTL_SECONDS - 61),
|
||||
)
|
||||
with patch('accounts.views.send_otp_sms') as mock_send_again:
|
||||
allowed_again = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': phone}, format='json',
|
||||
@@ -551,7 +556,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=60)
|
||||
def test_cooldown_blocked_request_creates_no_otp_and_sends_no_sms(self):
|
||||
phone = '+989127779003'
|
||||
phone = '09127779003'
|
||||
|
||||
with patch('accounts.views.send_otp_sms') as mock_send:
|
||||
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||
@@ -569,7 +574,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
def test_cooldown_expires_after_configured_window(self):
|
||||
import time
|
||||
|
||||
phone = '+989127779004'
|
||||
phone = '09127779004'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
@@ -588,10 +593,10 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
def test_cooldown_is_per_phone_not_global(self):
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '+989127779005'}, format='json',
|
||||
'/api/auth/otp/request', {'phone': '09127779005'}, format='json',
|
||||
)
|
||||
second_response = self.client.post(
|
||||
'/api/auth/otp/request', {'phone': '+989127779006'}, format='json',
|
||||
'/api/auth/otp/request', {'phone': '09127779006'}, format='json',
|
||||
)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
self.assertEqual(second_response.status_code, 200)
|
||||
@@ -604,7 +609,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
|
||||
def test_cooldown_response_reports_retry_after_header_and_body(self):
|
||||
phone = '+989127779008'
|
||||
phone = '09127779008'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
@@ -633,7 +638,7 @@ class RequestOTPPhoneCooldownTests(TestCase):
|
||||
def test_retry_after_counts_down_as_time_passes(self):
|
||||
import time
|
||||
|
||||
phone = '+989127779009'
|
||||
phone = '09127779009'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
self.client.post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||
@@ -673,7 +678,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
|
||||
def test_two_simultaneous_requests_for_same_phone_only_one_passes_cooldown(self):
|
||||
import threading
|
||||
|
||||
phone = '+989127779007'
|
||||
phone = '09127779007'
|
||||
barrier = threading.Barrier(2)
|
||||
results = []
|
||||
results_lock = threading.Lock()
|
||||
@@ -706,7 +711,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
|
||||
# هیچ کدی دریافت نکرده بود ولی همچنان تا ۱۲۰ ثانیه بلاک میموند.
|
||||
# این تست تضمین میکنه که با شکستِ ارسال، cooldown پاک میشه و
|
||||
# کاربر میتونه فوراً دوباره تلاش کنه.
|
||||
phone = '+989127779010'
|
||||
phone = '09127779010'
|
||||
|
||||
with patch('accounts.views.send_otp_sms', side_effect=OTPDeliveryError('boom')):
|
||||
first_response = self.client.post(
|
||||
@@ -727,7 +732,7 @@ class RequestOTPPhoneCooldownConcurrencyTests(TransactionTestCase):
|
||||
def test_cooldown_still_active_after_successful_delivery(self):
|
||||
# اثباتِ عدمِ رگرسیون: وقتی ارسال موفق باشه، cooldown همچنان طبقِ
|
||||
# رفتارِ قبلی فعال میمونه (فقط شکستِ تحویل باید cooldown رو پاک کنه).
|
||||
phone = '+989127779011'
|
||||
phone = '09127779011'
|
||||
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
first_response = self.client.post(
|
||||
@@ -895,22 +900,22 @@ class UserProfileViewTests(TestCase):
|
||||
|
||||
def test_returns_existing_profile_fields_for_registered_user(self):
|
||||
user = User.objects.create(
|
||||
phone='+989120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2,
|
||||
phone='09120000001', is_guest=False, name='علی', avatar='avatar.png', grade_id=2,
|
||||
)
|
||||
Profile.objects.create(user=user, birth_year=2005, parent_phone='+989120000002', bio='سلام')
|
||||
Profile.objects.create(user=user, birth_year=2005, parent_phone='09120000002', bio='سلام')
|
||||
token = issue_access_token(user.id)
|
||||
|
||||
response = self._get_profile(token)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data['phone'], '+989120000001')
|
||||
self.assertEqual(data['phone'], '09120000001')
|
||||
self.assertEqual(data['name'], 'علی')
|
||||
self.assertEqual(data['avatar'], 'avatar.png')
|
||||
self.assertEqual(data['isGuest'], False)
|
||||
self.assertEqual(data['gradeId'], 2)
|
||||
self.assertEqual(data['birthYear'], 2005)
|
||||
self.assertEqual(data['parentPhone'], '+989120000002')
|
||||
self.assertEqual(data['parentPhone'], '09120000002')
|
||||
self.assertEqual(data['bio'], 'سلام')
|
||||
|
||||
def test_does_not_leak_other_users_profile(self):
|
||||
@@ -1653,7 +1658,7 @@ class TestLoginPhoneParsingTests(TestCase):
|
||||
def test_valid_pairs_are_parsed(self):
|
||||
self.assertEqual(
|
||||
self._parse(' 09120000001:834512 , +989120000002:110947 '),
|
||||
{'09120000001': '834512', '+989120000002': '110947'},
|
||||
{'09120000001': '834512', '09120000002': '110947'},
|
||||
)
|
||||
|
||||
def test_empty_value_yields_no_test_phones(self):
|
||||
|
||||
@@ -58,7 +58,7 @@ class OtpTests(TestCase):
|
||||
APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||
|
||||
def test_a_code_dies_after_five_wrong_guesses(self):
|
||||
phone = '+989121234001'
|
||||
phone = '09121234001'
|
||||
self._request(phone)
|
||||
for guess in ('000001', '000002', '000003', '000004', '000005'):
|
||||
APIClient().post('/api/auth/login', {'phone': phone, 'otp': guess}, format='json')
|
||||
@@ -68,7 +68,7 @@ class OtpTests(TestCase):
|
||||
|
||||
|
||||
@override_settings(DEBUG=True, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0,
|
||||
TEST_LOGIN_PHONES={'+989120000101': '4321'})
|
||||
TEST_LOGIN_PHONES={'09120000101': '4321'})
|
||||
class GuestSignupKeepsProgressTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
@@ -85,25 +85,25 @@ class GuestSignupKeepsProgressTests(TestCase):
|
||||
def test_the_guest_becomes_the_registered_user(self):
|
||||
with patch('accounts.views.secrets.randbelow', return_value=654321), \
|
||||
patch('accounts.views.send_otp_sms'):
|
||||
self.client.post('/api/auth/otp/request', {'phone': '+989121234002'}, format='json')
|
||||
body = self._login('+989121234002', '654321').json()
|
||||
self.client.post('/api/auth/otp/request', {'phone': '09121234002'}, format='json')
|
||||
body = self._login('09121234002', '654321').json()
|
||||
|
||||
self.assertEqual(int(body['userId']), self.guest_id)
|
||||
user = User.objects.get(pk=self.guest_id)
|
||||
self.assertEqual((user.phone, user.is_guest), ('+989121234002', False))
|
||||
self.assertEqual((user.phone, user.is_guest), ('09121234002', False))
|
||||
self.assertEqual(UserStats.objects.get(user_id=self.guest_id).xp, 120)
|
||||
|
||||
def test_an_existing_account_is_not_merged(self):
|
||||
existing = User.objects.create(phone='+989121234003', is_guest=False)
|
||||
existing = User.objects.create(phone='09121234003', is_guest=False)
|
||||
with patch('accounts.views.secrets.randbelow', return_value=111222), \
|
||||
patch('accounts.views.send_otp_sms'):
|
||||
self.client.post('/api/auth/otp/request', {'phone': '+989121234003'}, format='json')
|
||||
body = self._login('+989121234003', '111222').json()
|
||||
self.client.post('/api/auth/otp/request', {'phone': '09121234003'}, format='json')
|
||||
body = self._login('09121234003', '111222').json()
|
||||
self.assertEqual(int(body['userId']), existing.id)
|
||||
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
|
||||
|
||||
def test_demo_test_phones_never_absorb_a_guest(self):
|
||||
body = self._login('+989120000101', '4321').json()
|
||||
body = self._login('09120000101', '4321').json()
|
||||
self.assertNotEqual(int(body['userId']), self.guest_id)
|
||||
self.assertTrue(User.objects.get(pk=self.guest_id).is_guest)
|
||||
|
||||
|
||||
130
accounts/tests_otp_limits.py
Normal file
130
accounts/tests_otp_limits.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
نرمالسازیِ شماره و سقفهای دیتابیسیِ پیامکِ OTP (۲۰۲۶-۰۹-۲۵، همان روزی که
|
||||
کلیدِ کاوهنگار وصل شد). security-before-production.md بندِ ۱۷.
|
||||
|
||||
کش در هر تست پاک میشود تا رفتارِ «workerِ دیگر» (کشِ خالی، دیتابیسِ مشترک)
|
||||
شبیهسازی شود. همزمانیِ واقعیِ دو پروسهی gunicorn تست نشده است.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
import requests
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.models import OTPCode, User
|
||||
from accounts.services import send_sms
|
||||
|
||||
|
||||
def _request(phone):
|
||||
return APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
|
||||
|
||||
|
||||
@override_settings(DEBUG=True, TEST_LOGIN_PHONES={})
|
||||
class PhoneNormalizationTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_other_spellings_of_the_same_number_share_one_cooldown(self):
|
||||
# همان سنجهای که security-before-production.md برای بستنِ بندِ ۱۷ نوشته.
|
||||
with patch('accounts.views.send_otp_sms') as send:
|
||||
self.assertEqual(_request('09121112233').status_code, 200)
|
||||
for spelling in ('+989121112233', '989121112233', '۰۹۱۲۱۱۱۲۲۳۳', '0912 111 2233'):
|
||||
self.assertEqual(_request(spelling).status_code, 429, spelling)
|
||||
send.assert_called_once()
|
||||
self.assertEqual(set(OTPCode.objects.values_list('phone', flat=True)), {'09121112233'})
|
||||
|
||||
def test_login_with_another_spelling_reaches_the_same_account(self):
|
||||
User.objects.create(phone='09121112244', is_guest=False)
|
||||
with patch('accounts.views.secrets.randbelow', return_value=246810), \
|
||||
patch('accounts.views.send_otp_sms'):
|
||||
_request('+989121112244')
|
||||
body = APIClient().post('/api/auth/login', {'phone': '۰۹۱۲۱۱۱۲۲۴۴', 'otp': '246810'},
|
||||
format='json').json()
|
||||
self.assertEqual(User.objects.filter(phone__endswith='9121112244').count(), 1)
|
||||
self.assertEqual(body['userId'], str(User.objects.get(phone='09121112244').id))
|
||||
|
||||
def test_non_mobile_numbers_are_rejected_before_any_sms(self):
|
||||
with patch('accounts.views.send_otp_sms') as send:
|
||||
for bad in ('+14155552671', '02188888888', '0912123', 'abc'):
|
||||
self.assertEqual(_request(bad).status_code, 400, bad)
|
||||
send.assert_not_called()
|
||||
self.assertFalse(OTPCode.objects.exists())
|
||||
|
||||
@override_settings(TEST_LOGIN_PHONES={'09120000101': '4321'})
|
||||
def test_demo_phone_matches_in_any_spelling(self):
|
||||
response = APIClient().post('/api/auth/login', {'phone': '+989120000101', 'otp': '4321'},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
@override_settings(DEBUG=True, TEST_LOGIN_PHONES={}, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=120)
|
||||
class DatabaseBackedLimitTests(TestCase):
|
||||
"""کشِ LocMem مالِ هر worker است؛ این سقفها باید با کشِ خالی هم بمانند."""
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
|
||||
def test_cooldown_holds_on_a_worker_with_an_empty_cache(self):
|
||||
with patch('accounts.views.send_otp_sms') as send:
|
||||
self.assertEqual(_request('09121112255').status_code, 200)
|
||||
cache.clear() # درخواستِ بعدی به workerِ دیگری میرسد
|
||||
response = _request('09121112255')
|
||||
self.assertEqual(response.status_code, 429)
|
||||
self.assertTrue(1 <= response.json()['retryAfter'] <= 120)
|
||||
send.assert_called_once()
|
||||
|
||||
@override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0, OTP_REQUEST_PHONE_DAILY_MAX=3)
|
||||
def test_daily_cap_per_phone(self):
|
||||
with patch('accounts.views.send_otp_sms') as send:
|
||||
statuses = []
|
||||
for _ in range(5):
|
||||
cache.clear() # سقفِ ساعتیِ کش را کنار میگذارد؛ فقط سقفِ روزانه بماند
|
||||
statuses.append(_request('09121112266').status_code)
|
||||
self.assertEqual(statuses, [200, 200, 200, 429, 429])
|
||||
self.assertEqual(send.call_count, 3)
|
||||
|
||||
@override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
|
||||
def test_codes_older_than_a_day_do_not_count(self):
|
||||
OTPCode.objects.bulk_create([
|
||||
OTPCode(phone='09121112277', code='000000',
|
||||
expires_at=timezone.now() - timezone.timedelta(days=2))
|
||||
for _ in range(20)
|
||||
])
|
||||
with patch('accounts.views.send_otp_sms'):
|
||||
self.assertEqual(_request('09121112277').status_code, 200)
|
||||
|
||||
@override_settings(OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0, OTP_SMS_GLOBAL_DAILY_MAX=2)
|
||||
def test_global_daily_cap_stops_sms_pumping(self):
|
||||
with patch('accounts.views.send_otp_sms') as send:
|
||||
self.assertEqual(_request('09121110001').status_code, 200)
|
||||
self.assertEqual(_request('09121110002').status_code, 200)
|
||||
with self.assertLogs('accounts.views', level='ERROR'):
|
||||
response = _request('09121110003')
|
||||
self.assertEqual(response.status_code, 503)
|
||||
self.assertEqual(send.call_count, 2)
|
||||
self.assertFalse(OTPCode.objects.filter(phone='09121110003').exists())
|
||||
|
||||
@override_settings(DEBUG=False, KAVENEGAR_API_KEY='k')
|
||||
def test_failed_delivery_frees_the_cooldown_and_quota(self):
|
||||
with patch('accounts.services.requests.post', side_effect=requests.ConnectionError('down')):
|
||||
self.assertEqual(_request('09121112288').status_code, 503)
|
||||
cache.clear()
|
||||
ok = type('R', (), {'status_code': 200, 'json': lambda self: {
|
||||
'return': {'status': 200}, 'entries': [{'messageid': 1, 'status': 1}]}})()
|
||||
with patch('accounts.services.requests.post', return_value=ok):
|
||||
self.assertEqual(_request('09121112288').status_code, 200)
|
||||
|
||||
|
||||
class ProviderLoggingTests(TestCase):
|
||||
@override_settings(KAVENEGAR_API_KEY='k')
|
||||
def test_accepted_message_logs_its_id_not_its_text(self):
|
||||
ok = type('R', (), {'status_code': 200, 'json': lambda self: {
|
||||
'return': {'status': 200}, 'entries': [{'messageid': 8792343, 'status': 1}]}})()
|
||||
with patch('accounts.services.requests.post', return_value=ok), \
|
||||
self.assertLogs('accounts.services', level='INFO') as logs:
|
||||
send_sms(phone='09121112299', message='کد ورود شما: 123456')
|
||||
joined = '\n'.join(logs.output)
|
||||
self.assertIn('messageid=8792343', joined)
|
||||
self.assertNotIn('123456', joined)
|
||||
@@ -186,6 +186,8 @@ class RequestOTPView(APIView):
|
||||
# محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
|
||||
# (وگرنه یه مهاجم میتونه بینِ چند IP پخش کنه). cache.add فقط وقتی
|
||||
# کلید نیست مقدار میذاره، پس اتمیکه.
|
||||
# شماره پیش از اینجا به 09XXXXXXXXX نرمال شده (accounts/phone.py)، پس
|
||||
# املای دیگرِ همان شماره سهمِ جدا نمیگیرد.
|
||||
phone_requests_key = f'otp_request_phone_attempts:{phone}'
|
||||
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
|
||||
attempt_count = 1
|
||||
@@ -207,25 +209,62 @@ class RequestOTPView(APIView):
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
|
||||
# فقط آخرین کد صادرشده معتبر است.
|
||||
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
|
||||
|
||||
# ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص
|
||||
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را میپذیرد.
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
OTPCode.objects.create(
|
||||
phone=phone,
|
||||
code=code,
|
||||
expires_at=timezone.now() + timezone.timedelta(minutes=5),
|
||||
)
|
||||
|
||||
# سقفهای دیتابیسی: cooldown و سقفِ ساعتیِ بالا در کشِ LocMem و مالِ هر
|
||||
# workerاند (روی سرور ×۳). اینها از ردیفهای OTPCode شمرده میشوند و بینِ
|
||||
# workerها مشترکاند. زمانِ صدور = expires_at − OTP_TTL_SECONDS (مدل
|
||||
# created_at ندارد). بررسی و ساختِ ردیف در یک تراکنشاند؛ با
|
||||
# transaction_mode=IMMEDIATE دو درخواستِ همزمان پشتِ هم اجرا میشوند
|
||||
# (همزمانیِ بینِ دو workerِ واقعی با تست پوشش داده نشده).
|
||||
# فراخوانیِ کاوهنگار عمداً بیرونِ تراکنش است تا قفلِ نوشتن حینِ شبکه نماند.
|
||||
now = timezone.now()
|
||||
ttl = timezone.timedelta(seconds=settings.OTP_TTL_SECONDS)
|
||||
day_window_expiry = now - timezone.timedelta(days=1) + ttl
|
||||
with transaction.atomic():
|
||||
phone_codes = OTPCode.objects.filter(phone=phone)
|
||||
last_expiry = phone_codes.order_by('-expires_at').values_list('expires_at', flat=True).first()
|
||||
if cooldown_seconds and last_expiry is not None:
|
||||
elapsed = (now - (last_expiry - ttl)).total_seconds()
|
||||
if elapsed < cooldown_seconds:
|
||||
retry_after = max(1, math.ceil(cooldown_seconds - elapsed))
|
||||
return Response(
|
||||
{
|
||||
'detail': f'لطفاً {retry_after} ثانیه صبر کنید و دوباره درخواست کد بدهید.',
|
||||
'retryAfter': retry_after,
|
||||
},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
headers={'Retry-After': str(retry_after)},
|
||||
)
|
||||
if phone_codes.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_REQUEST_PHONE_DAILY_MAX:
|
||||
logger.warning('OTP request phone daily limit exceeded: phone_suffix=%s', phone[-4:])
|
||||
return Response(
|
||||
{'detail': 'سقفِ روزانهی درخواستِ کد برای این شماره پر شده است؛ فردا دوباره تلاش کنید.'},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
if OTPCode.objects.filter(expires_at__gt=day_window_expiry).count() >= settings.OTP_SMS_GLOBAL_DAILY_MAX:
|
||||
# یا حملهی SMS pumping است یا رشدِ واقعی؛ هر دو یعنی کسی باید نگاه کند.
|
||||
logger.error(
|
||||
'OTP global daily SMS cap reached: cap=%s phone_suffix=%s',
|
||||
settings.OTP_SMS_GLOBAL_DAILY_MAX, phone[-4:],
|
||||
)
|
||||
return Response(
|
||||
{'detail': 'ارسالِ پیامک موقتاً ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.'},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
# فقط آخرین کد صادرشده معتبر است.
|
||||
OTPCode.objects.filter(phone=phone, is_used=False).update(is_used=True)
|
||||
# ۶ رقم: با ۴ رقم و محدودیتِ تلاشِ per-worker، گرفتنِ حسابِ یک نفرِ مشخص
|
||||
# روزی چند درصد شانس داشت. فرانت تا ۶ رقم را میپذیرد.
|
||||
code = f'{secrets.randbelow(1_000_000):06d}'
|
||||
otp_row = OTPCode.objects.create(phone=phone, code=code, expires_at=now + ttl)
|
||||
try:
|
||||
send_otp_sms(phone=phone, code=code)
|
||||
except OTPDeliveryError:
|
||||
# ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک میکنیم
|
||||
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه. سقفِ ساعتی/
|
||||
# throttleِ IP دستنخورده میمونن (محدودیتِ حجم، نه این cooldown).
|
||||
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه، و ردیفِ کد رو هم
|
||||
# حذف میکنیم تا در cooldown و سقفِ روزانهی دیتابیسی شمرده نشه.
|
||||
# سقفِ ساعتیِ کش و throttleِ IP دستنخورده میمونن (محدودیتِ حجم).
|
||||
cache.delete(phone_cooldown_key)
|
||||
otp_row.delete()
|
||||
raise
|
||||
|
||||
# devOtp فقط در محیط dev برمیگردد؛ در پروداکشن کد فقط از طریق پیامک تحویل داده میشود.
|
||||
@@ -380,9 +419,13 @@ class LogoutView(APIView):
|
||||
"""
|
||||
POST /api/auth/logout — خروج از حساب.
|
||||
|
||||
refreshTokenِ این دستگاه (اگر فرستاده شود) باطل میشود و همهی access
|
||||
tokenهای صادرشده تا این لحظه رد میشوند. دستگاههای دیگر با refreshِ
|
||||
refreshTokenِ این دستگاه (اگر فرستاده شود) باطل میشود و access tokenهای
|
||||
صادرشده *پیش از ثانیهی خروج* رد میشوند. دستگاههای دیگر با refreshِ
|
||||
خودشان بیصدا توکنِ تازه میگیرند؛ فقط همین دستگاه بیرون میماند.
|
||||
|
||||
⚠️ مقایسه ثانیهای است: توکنی که در همان ثانیهی خروج صادر شده معتبر
|
||||
میماند (بازتولید شد). با AUTH_USER_CACHE_TTL>0 و کشِ per-worker، کشِ
|
||||
کاربر فقط در همین worker پاک میشود؛ پیشفرض ۰ است و آن حالت تست نشده.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
@@ -3,8 +3,12 @@
|
||||
|
||||
تا ۲۰۲۶-۰۹-۲۵ هیچ کدی Conversion را از «pending» جلو نمیبرد و پورسانتی
|
||||
حساب نمیشد؛ برنامهی معرفی فقط یک جدولِ ثبت بود. حالا اولین سفارشِ
|
||||
پرداختشدهی کاربرِ معرفیشده Conversion را `paid` میکند و
|
||||
پرداختشدهی کاربرِ معرفیشده *بعد از ثبتِ کد* Conversion را `paid` میکند و
|
||||
`commission_amount = مبلغِ پرداختی × commission_rate` را ثبت میکند.
|
||||
|
||||
⚠️ «کاربرِ تازه» بررسی نمیشود: مشتریِ قدیمی هم هر وقت بخواهد کد ثبت میکند
|
||||
و تمدیدِ بعدیاش پورسانت میسازد (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد). جلوی
|
||||
معرفیِ خود با شمارهی دوم هم چیزی نیست.
|
||||
پرداختِ خودِ پورسانت به معرف (تسویه) هنوز دستی و از پنلِ ادمین است.
|
||||
|
||||
ثبتنام یا ورودِ مهمان هیچ پورسانتی نمیسازد؛ فقط پول جابهجا شدنِ واقعی.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -422,9 +422,13 @@ REST_FRAMEWORK = {
|
||||
# `/api/health` در `clientIp` نشان میدهد سرور چه IPای میبیند.
|
||||
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '2')),
|
||||
'DEFAULT_THROTTLE_RATES': {
|
||||
'otp_request': '5/hour',
|
||||
'otp_login': '10/hour',
|
||||
'guest_session': '20/hour',
|
||||
# سقفهای per-IP عمداً گشادهاند: پشتِ CGNATِ اپراتورهای موبایل، وایفایِ
|
||||
# مدرسه یا شبکهی یک دفتر، دهها نفر یک IP دارند و با ۵ در ساعت فقط ۵ نفر
|
||||
# میتوانستند وارد شوند. حفاظِ اصلی per-phone و per-code است (پایینتر و
|
||||
# در دیتابیس)؛ اینها فقط جلوی یک ماشینِ تنها را میگیرند.
|
||||
'otp_request': os.environ.get('OTP_REQUEST_IP_RATE', '20/hour'),
|
||||
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '30/hour'),
|
||||
'guest_session': os.environ.get('GUEST_SESSION_IP_RATE', '60/hour'),
|
||||
'bazaar_verify': '10/hour',
|
||||
'ai_ask': '20/hour',
|
||||
'rag_ingest': '30/hour',
|
||||
@@ -600,6 +604,20 @@ OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
|
||||
# (که جلویِ burst رو نمیگیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار
|
||||
# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
|
||||
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
|
||||
|
||||
# عمرِ هر کدِ ورود. سقفهای دیتابیسیِ پایین زمانِ صدور را از expires_at منهای
|
||||
# همین مقدار میخوانند، پس عوض کردنش آن سقفها را هم (برای کدهای قدیمی) جابهجا میکند.
|
||||
OTP_TTL_SECONDS = 300
|
||||
|
||||
# سقفهایی که در دیتابیس شمرده میشوند، نه در کش. کشِ پیشفرض LocMem و مالِ
|
||||
# هر worker است، پس cooldown و سقفِ ساعتیِ بالا روی سرور (۳ worker) عملاً سه
|
||||
# برابرند؛ این دو بینِ همهی workerها مشترکاند و بدونِ Redis هم درست کار میکنند.
|
||||
# - روزانهی هر شماره: جلوی بمبارانِ پیامکیِ یک گوشی در طولِ روز.
|
||||
# - روزانهی کلِ سامانه: سقفِ خرجِ اعتبارِ کاوهنگار اگر کسی با شمارههای تصادفی
|
||||
# و IPهای زیاد پیامک بکشد (SMS pumping). رسیدن به آن ۵۰۳ و لاگِ ERROR میدهد؛
|
||||
# با رشدِ کاربرِ واقعی از .env بالا ببرید.
|
||||
OTP_REQUEST_PHONE_DAILY_MAX = int(os.environ.get('OTP_REQUEST_PHONE_DAILY_MAX', '10'))
|
||||
OTP_SMS_GLOBAL_DAILY_MAX = int(os.environ.get('OTP_SMS_GLOBAL_DAILY_MAX', '300'))
|
||||
# هر کدِ ورود بعد از این تعداد حدسِ غلط باطل میشود (شمارش در دیتابیس).
|
||||
# با کدِ ۶رقمی یعنی شانسِ حدس زدنِ هر کد ۵ در یک میلیون.
|
||||
OTP_MAX_FAILED_ATTEMPTS = 5
|
||||
@@ -617,6 +635,8 @@ DEV_MASTER_OTP = '0000' if DEBUG else None
|
||||
# را normalize نمیکند (accounts/serializers.py فقط CharField است)، پس
|
||||
# «09121111111» و «+989121111111» دو کاربرِ جدا هستند.
|
||||
def _parse_test_login_phones(raw: str) -> dict[str, str]:
|
||||
from accounts.phone import normalize_phone
|
||||
|
||||
pairs = {}
|
||||
for item in raw.split(','):
|
||||
item = item.strip()
|
||||
@@ -633,7 +653,14 @@ def _parse_test_login_phones(raw: str) -> dict[str, str]:
|
||||
'قالبِ درست «شماره:کد» است؛ شماره ۱۰ تا ۱۵ رقم (با + اختیاری) '
|
||||
'و کد ۴ تا ۶ رقم. مثال: TEST_LOGIN_PHONES=09120000001:834512'
|
||||
)
|
||||
pairs[phone] = code
|
||||
# همان نرمالسازیِ ورودیِ API (accounts/phone.py)، وگرنه شمارهای که
|
||||
# اینجا +98… نوشته شده با ورودیِ نرمالشدهی 09… هرگز جور نمیشد.
|
||||
normalized = normalize_phone(phone)
|
||||
if normalized is None:
|
||||
raise ImproperlyConfigured(
|
||||
f'TEST_LOGIN_PHONES شمارهی موبایلِ ایران نیست: «{item}». مثال: 09120000001:834512'
|
||||
)
|
||||
pairs[normalized] = code
|
||||
return pairs
|
||||
|
||||
|
||||
@@ -646,7 +673,11 @@ TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES',
|
||||
# بدون تنظیم این env، ارسال باید صراحتاً خطا بدهد، نه silently موفق باشد.
|
||||
KAVENEGAR_API_KEY = os.environ.get('KAVENEGAR_API_KEY', '')
|
||||
KAVENEGAR_SENDER = os.environ.get('KAVENEGAR_SENDER', '')
|
||||
OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}')
|
||||
# جملهی دوم هشدارِ مهندسیِ اجتماعی است (رایج در پیامکهای بانکی)؛ کلِ متن زیرِ
|
||||
# ۷۰ نویسه میماند تا یک صفحه پیامکِ فارسی حساب شود، نه دو صفحه.
|
||||
OTP_SMS_TEMPLATE = os.environ.get(
|
||||
'OTP_SMS_TEMPLATE', 'کد ورود شما به تدریسینو: {code}\nاین کد را به هیچکس ندهید.',
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Content progress (NodeProgress status)
|
||||
|
||||
140
config/tests_audit_round2.py
Normal file
140
config/tests_audit_round2.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵: ایرادهایی که بازتولید و بسته شدند.
|
||||
|
||||
هر تست همان سناریویی را اجرا میکند که پیشتر خراب بود. ایرادهایی که عمداً
|
||||
باز ماندند (تصمیمِ محصولی یا خطرِ شکستنِ فرانت) در docs/security-before-production.md
|
||||
و کامنتِ همان کدها ثبتاند.
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
||||
from discount.models import Offer
|
||||
from family.models import FamilyLink
|
||||
from payment.models import Order
|
||||
from payment.services import apply_order_payment_result
|
||||
from subscription.models import Plan, Subscription
|
||||
|
||||
|
||||
def _client(user):
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
return client
|
||||
|
||||
|
||||
class _Base(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = User.objects.create(phone='09120008001', is_guest=False)
|
||||
self.client = _client(self.user)
|
||||
self.year = Plan.objects.create(title='سالانه', price=299000, duration_days=365)
|
||||
|
||||
def tearDown(self):
|
||||
cache.clear()
|
||||
|
||||
def buy(self, client=None):
|
||||
response = (client or self.client).post(
|
||||
'/api/subscription/purchase', {'planId': str(self.year.id)}, format='json')
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
return Order.objects.get(pk=response.json()['orderId'])
|
||||
|
||||
def pay(self, order, ref):
|
||||
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id=ref,
|
||||
new_status='paid', event='test')
|
||||
|
||||
|
||||
class OfferNeverShortensSubscription(_Base):
|
||||
def test_trial_code_after_a_paid_year_keeps_the_year(self):
|
||||
self.pay(self.buy(), ref='R1')
|
||||
before = Subscription.objects.get(user_id=self.user.id).expires_at
|
||||
Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7, code='WEEK')
|
||||
response = self.client.post('/api/discount/offers/redeem', {'code': 'WEEK'}, format='json')
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
self.assertEqual(Subscription.objects.get(user_id=self.user.id).expires_at, before)
|
||||
|
||||
@override_settings(DEV_MASTER_OTP='000000')
|
||||
def test_guest_who_paid_keeps_the_year_after_registering(self):
|
||||
Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7,
|
||||
auto_assign_on_signup=True)
|
||||
guest = User.objects.create(device_id='audit-2-device', is_guest=True)
|
||||
guest_client = _client(guest)
|
||||
self.pay(self.buy(guest_client), ref='R2')
|
||||
response = guest_client.post('/api/auth/login', {'phone': '09125550001', 'otp': '000000'},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
days = (Subscription.objects.get(user_id=guest.id).expires_at - timezone.now()).days
|
||||
self.assertGreater(days, 360)
|
||||
|
||||
def test_trial_still_works_for_a_user_without_subscription(self):
|
||||
Offer.objects.create(name='هفته رایگان', kind='trial', duration_days=7, code='WEEK')
|
||||
self.client.post('/api/discount/offers/redeem', {'code': 'WEEK'}, format='json')
|
||||
sub = Subscription.objects.get(user_id=self.user.id)
|
||||
self.assertEqual(sub.status, 'active')
|
||||
self.assertIn((sub.expires_at - timezone.now()).days, (6, 7))
|
||||
|
||||
def test_free_full_still_upgrades_a_paid_year_to_unlimited(self):
|
||||
self.pay(self.buy(), ref='R3')
|
||||
Offer.objects.create(name='رایگانِ کامل', kind='free_full', code='FULL')
|
||||
self.client.post('/api/discount/offers/redeem', {'code': 'FULL'}, format='json')
|
||||
self.assertIsNone(Subscription.objects.get(user_id=self.user.id).expires_at)
|
||||
|
||||
|
||||
@override_settings(ZARINPAL_MERCHANT_ID='x' * 36, ZARINPAL_REDIRECT_URL='https://app.example/')
|
||||
class ZarinpalErrorBodyReturnsToApp(_Base):
|
||||
def test_documented_error_body_redirects_instead_of_500(self):
|
||||
order = self.buy()
|
||||
Order.objects.filter(pk=order.pk).update(gateway_authority='A' * 36)
|
||||
fake = MagicMock(status_code=200)
|
||||
fake.json.return_value = {'data': [], 'errors': {'code': -51, 'message': 'failed'}}
|
||||
with patch('payment.zarinpal.requests.post', return_value=fake):
|
||||
response = APIClient().get('/api/payment/zarinpal/callback',
|
||||
{'Authority': 'A' * 36, 'Status': 'OK'})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn('status=failed', response['Location'])
|
||||
|
||||
|
||||
class WeeklyReportCountsIslands(TestCase):
|
||||
def test_island_quiz_xp_reaches_the_parent(self):
|
||||
cache.clear()
|
||||
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||
node = Node.objects.create(unit=Unit.objects.create(grade=grade, title='فصل', order=1),
|
||||
title='جزیره', order=1)
|
||||
child = User.objects.create(phone='09120008002', grade_id=grade.id)
|
||||
child_client = _client(child)
|
||||
for i in range(4):
|
||||
question = Question.objects.create(
|
||||
lesson=Lesson.objects.create(node=node, title=f'درس {i}'),
|
||||
type='multiple_choice', prompt=f'سوال {i}')
|
||||
right = Choice.objects.create(question=question, value='الف', is_correct=True)
|
||||
Choice.objects.create(question=question, value='ب', is_correct=False)
|
||||
child_client.post(f'/api/questions/{question.id}/answer', {'selectedOptionId': right.id},
|
||||
format='json')
|
||||
child_client.post(f'/api/nodes/{node.id}/quiz/complete',
|
||||
{'correctCount': 4, 'totalQuestions': 4}, format='json')
|
||||
|
||||
parent = User.objects.create(phone='09120008003')
|
||||
FamilyLink.objects.create(parent_user_id=parent.id, child_user_id=child.id,
|
||||
child_phone=child.phone, invite_code='654321',
|
||||
status='accepted', accepted_at=timezone.now())
|
||||
report = _client(parent).get('/api/family/weekly-report').json()
|
||||
self.assertEqual(report['xpEarned'], 40)
|
||||
self.assertEqual(report['islandsCompleted'], 1)
|
||||
self.assertEqual(report['lessonsCompleted'], 0)
|
||||
|
||||
|
||||
class AskWithNonNumericQuestionId(TestCase):
|
||||
def test_answer_is_saved_not_500(self):
|
||||
cache.clear()
|
||||
user = User.objects.create(phone='09120008004')
|
||||
response = _client(user).post('/api/ai/ask',
|
||||
{'userQuestion': 'کسر چیست؟', 'questionId': 'q-12'},
|
||||
format='json')
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
from ai_tutor.models import Message
|
||||
self.assertIsNone(Message.objects.get(pk=response.json()['messageId']).question_id)
|
||||
@@ -76,7 +76,13 @@ def node_playable_lesson_ids(node) -> list[int]:
|
||||
|
||||
|
||||
def verified_correct_count(user_id, *, lesson_id=None, node_id=None) -> int:
|
||||
"""تعدادِ سوالهای متمایزی از این درس/مبحث که کاربر درست جواب داده و ثبت شده."""
|
||||
"""
|
||||
تعدادِ سوالهای متمایزی از این درس/مبحث که کاربر درست جواب داده و ثبت شده.
|
||||
|
||||
«درست» یعنی *هر* جوابِ درست، حتی بعد از جوابِ غلط روی همان سوال. چون
|
||||
جوابِ غلط `correctOptionId` را برمیگرداند و تکرارِ جواب آزاد است، این عدد
|
||||
جلوی «جواب نداده امتیاز گرفتن» را میگیرد، نه «غلط زدن و بعد درست زدن».
|
||||
"""
|
||||
from .models import UserAnswer
|
||||
|
||||
qs = UserAnswer.objects.filter(user_id=user_id, correct=True)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -56,6 +56,9 @@ class GuestAwareDiscountThrottle(ScopedRateThrottle):
|
||||
|
||||
|
||||
def _is_usable(discount: DiscountCode) -> bool:
|
||||
# ⚠️ هر Redemption شمرده میشود، حتی روی سفارشی که هرگز پرداخت نشد؛ پس
|
||||
# سبدِ رهاشده سهمِ max_uses را برای همیشه میسوزاند و خودِ همان کاربر هم
|
||||
# روی سفارشِ تازهاش دیگر نمیتواند کد بزند (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد).
|
||||
return bool(
|
||||
discount.redemptions.count() < discount.max_uses
|
||||
and (discount.expires_at is None or discount.expires_at >= timezone.now())
|
||||
|
||||
@@ -100,7 +100,9 @@ PaymentRequest را میزند، `Order.gateway_authority` را ذخیره م
|
||||
|
||||
کاربرِ واردشده با شماره کدِ معرف میگیرد (`GET /api/affiliate/me`) و کاربرِ
|
||||
تازه کدِ دیگری را ثبت میکند (`POST /api/affiliate/track`). **پورسانت فقط روی
|
||||
اولین پرداختِ واقعیِ کاربرِ معرفیشده** حساب میشود
|
||||
اولین پرداختِ واقعیِ کاربرِ معرفیشده پس از ثبتِ کد** حساب میشود. تازه بودنِ
|
||||
کاربر سنجیده نمیشود: مشتریِ قدیمی هم میتواند بعداً کد ثبت کند و تمدیدش پورسانت
|
||||
بسازد (تصمیمِ محصولیِ باز، بازبینیِ ۲۰۲۶-۰۹-۲۵)
|
||||
(`affiliate.services.record_paid_order`، از داخلِ تأییدِ پرداخت):
|
||||
`commission_amount = مبلغِ پرداختی × commission_rate` و Conversion `paid` میشود.
|
||||
مهمان نه کد میگیرد نه معرفیشده حساب میشود (۴۰۳). تسویهی پورسانت با معرف هنوز
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
| ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت |
|
||||
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
|
||||
| ۱۶ | فرانت توکن را در `localStorage` نگه میدارد | کم | فرانت |
|
||||
| ۱۷ | ~~شمارهی موبایل نرمال نمیشود؛ سقفهای per-phone با املای دیگر دور زده میشوند~~ **بسته شد ۲۰۲۶-۰۹-۲۵** | متوسط | کد |
|
||||
| ۱۸ | `/api/payment/orders/{id}/pay` بدونِ ورود و بدونِ سنجشِ مالکیت | کم | کد |
|
||||
|
||||
---
|
||||
|
||||
@@ -206,6 +208,43 @@
|
||||
- **خطر:** هر XSS در سایت هر دو را میخواند.
|
||||
- **رفع:** CSPِ سختگیرانه روی `math.tadrisino2.ir`؛ در بلندمدت refresh در کوکیِ `HttpOnly`.
|
||||
|
||||
## ۱۷. شمارهی موبایل نرمال نمیشود (افزوده در بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵)
|
||||
|
||||
- **چیست:** `phone` در ورود، درخواستِ OTP و دعوتِ خانواده رشتهی خام است
|
||||
(`accounts/serializers.py`، `max_length=15` بدونِ اعتبارسنجی). `09121234567`،
|
||||
`+989121234567`، `989121234567` و همان شماره با ارقامِ فارسی چهار «شماره»ی جدااند.
|
||||
- **خطر:** cooldownِ ۲ دقیقهای و سقفِ ۵ در ساعتِ هر شماره بهازای هر املا جداست، پس با
|
||||
چند IP میشود یک گوشی را با پیامک بمباران کرد (وقتی کاوهنگار وصل شود، به هزینهی ما)؛
|
||||
یک آدم با دو املا دو حسابِ جدا میسازد؛ دعوتِ والد با املای دیگر ۴۰۳ میگیرد.
|
||||
(بازتولید شد با تستِ موقت؛ پیامکِ واقعی فرستاده نشد.)
|
||||
- **رفع:** یک تابعِ `normalize_phone` (ارقامِ فارسی/عربی → لاتین، `+98`/`0098`/`98` → `0`،
|
||||
فقط `09\d{9}`) در هر سه serializer، و migrationِ داده برای ردیفهای موجود.
|
||||
- **چطور بسنجید:** دو درخواستِ OTP پشتِسرِهم با `0912…` و `+98912…` باید دومی ۴۲۹ بگیرد.
|
||||
- **✅ بسته شد ۲۰۲۶-۰۹-۲۵** (همان روزی که کلیدِ کاوهنگار روی سرور رفت؛ کامیت: _پس از کامیت
|
||||
اینجا بنویسید_). `accounts/phone.py::normalize_phone` در ورود، درخواستِ OTP، دعوتِ
|
||||
خانواده و کلیدهای `TEST_LOGIN_PHONES`؛ migrationِ `accounts.0005` شمارههای موجود را
|
||||
نرمال میکند (ردیفی که شکلِ نرمالش مالِ کاربرِ دیگری است دست نمیخورد و فقط شمرده میشود).
|
||||
`parentPhone` در پروفایل هنوز نرمال **نمیشود**. همراهش، چون از این تاریخ پیامکِ واقعی
|
||||
خرجِ اعتبار است:
|
||||
- cooldownِ ۲ دقیقهای و سقفِ روزانهی هر شماره (`OTP_REQUEST_PHONE_DAILY_MAX=10`)
|
||||
حالا در **دیتابیس** هم شمرده میشوند، پس روی ۳ workerِ سرور سهبرابر نمیشوند (بندِ ۹
|
||||
برای این دو دیگر صدق نمیکند؛ برای throttleهای IP هنوز میکند).
|
||||
- سقفِ روزانهی کلِ پیامکِ OTP (`OTP_SMS_GLOBAL_DAILY_MAX=300`) جلوی SMS pumping؛
|
||||
رسیدن به آن ۵۰۳ و لاگِ ERROR میدهد. با رشدِ کاربرِ واقعی از `.env` بالا ببرید.
|
||||
- سقفهای per-IP گشادهتر شدند (کد ۲۰، ورود ۳۰، مهمان ۶۰ در ساعت، همه از `.env` قابلِ
|
||||
تغییر) چون پشتِ CGNAT و وایفایِ مدرسه دهها نفر یک IP دارند.
|
||||
- تستها: `accounts/tests_otp_limits.py`. همزمانیِ واقعیِ دو پروسهی gunicorn تست نشده.
|
||||
|
||||
## ۱۸. صفحهی پرداخت مالکیت را نمیسنجد (افزوده در بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵)
|
||||
|
||||
- **چیست:** `PaymentRedirectView` (`AllowAny`) برای هر شناسهی سفارشِ ترتیبی از زرینپال
|
||||
authority میگیرد.
|
||||
- **خطر:** غریبه با پیمایشِ `/orders/1/pay`، `/orders/2/pay`… روی سفارشهای باز authority
|
||||
مینشاند؛ از آن به بعد صاحبِ سفارش «کدِ تخفیف را پیش از درگاه وارد کنید» میگیرد
|
||||
(بازتولید شد، با زرینپالِ mock). وضعیتِ paid/failedِ سفارشِ دیگران هم از ریدایرکت پیداست.
|
||||
- **رفع:** در `paymentUrl` یک توکنِ امضاشدهی کوتاهعمر (`signing`) با `order_id` بگذارید و
|
||||
همانجا بسنجید؛ یا شناسهی غیرترتیبی.
|
||||
|
||||
---
|
||||
|
||||
## چیزهایی که بسته شدهاند (برای اینکه دوباره باز نشوند)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from accounts.serializers import IranMobileField
|
||||
|
||||
|
||||
class FamilyLinkInviteRequestSerializer(serializers.Serializer):
|
||||
childPhone = serializers.CharField(max_length=15)
|
||||
childPhone = IranMobileField()
|
||||
|
||||
|
||||
class FamilyLinkInviteResponseSerializer(serializers.Serializer):
|
||||
@@ -47,5 +49,8 @@ class FamilyWeeklyReportResponseSerializer(serializers.Serializer):
|
||||
weekStart = serializers.DateField()
|
||||
xpEarned = serializers.IntegerField()
|
||||
lessonsCompleted = serializers.IntegerField()
|
||||
# افزوده ۲۰۲۶-۰۹-۲۵: جزیرههایی که آزمونشان در این هفته تمام شده. xpEarned
|
||||
# از همین تاریخ امتیازِ آزمونِ جزیره را هم دارد.
|
||||
islandsCompleted = serializers.IntegerField()
|
||||
currentStreak = serializers.IntegerField()
|
||||
openedAt = serializers.DateTimeField(allow_null=True)
|
||||
|
||||
@@ -24,10 +24,10 @@ class FamilyLinkAcceptThrottleTests(TestCase):
|
||||
self.client = APIClient()
|
||||
self.parent = User.objects.create(device_id='device-family-parent-1', is_guest=True, name='')
|
||||
self.child = User.objects.create(
|
||||
device_id='device-family-child-1', is_guest=True, name='', phone='+989120000001',
|
||||
device_id='device-family-child-1', is_guest=True, name='', phone='09120000001',
|
||||
)
|
||||
self.child_token = issue_access_token(self.child.id)
|
||||
self.registered_user = User.objects.create(is_guest=False, name='', phone='+989120000098')
|
||||
self.registered_user = User.objects.create(is_guest=False, name='', phone='09120000098')
|
||||
self.registered_token = issue_access_token(self.registered_user.id)
|
||||
self.link = FamilyLink.objects.create(
|
||||
parent_user_id=self.parent.id, child_phone=self.child.phone, invite_code='123456',
|
||||
@@ -60,7 +60,7 @@ class FamilyLinkAcceptThrottleTests(TestCase):
|
||||
def test_throttle_is_scoped_per_user_not_shared_globally(self):
|
||||
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
|
||||
other_user = User.objects.create(
|
||||
is_guest=False, name='', phone='+989120000099',
|
||||
is_guest=False, name='', phone='09120000099',
|
||||
)
|
||||
other_token = issue_access_token(other_user.id)
|
||||
|
||||
@@ -153,11 +153,11 @@ class FamilyLinkAcceptGuestThrottleBypassTests(TestCase):
|
||||
# قبل هرکدوم bucket جداگانهی خودشون رو داشته باشن — throttle
|
||||
# برای اونا هنوز بر اساسِ user.pk است، نه IP.
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_accept': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120000201')
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='09120000201')
|
||||
response_a = self._accept('000001', issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 404)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120000202')
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='09120000202')
|
||||
response_b = self._accept('000002', issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 404)
|
||||
@@ -168,7 +168,7 @@ class FamilyLinkAcceptGuestThrottleBypassTests(TestCase):
|
||||
# برسه — throttle باید *قبل* از منطقِ accept رد کنه.
|
||||
parent = User.objects.create(device_id='device-family-guest-rot-parent', is_guest=True, name='')
|
||||
child = User.objects.create(
|
||||
device_id='device-family-guest-rot-child', is_guest=True, name='', phone='+989120000203',
|
||||
device_id='device-family-guest-rot-child', is_guest=True, name='', phone='09120000203',
|
||||
)
|
||||
link = FamilyLink.objects.create(
|
||||
parent_user_id=parent.id, child_phone=child.phone, invite_code='654321',
|
||||
@@ -217,55 +217,55 @@ class FamilyLinkInviteThrottleTests(TestCase):
|
||||
)
|
||||
|
||||
def test_unauthenticated_request_is_rejected(self):
|
||||
response = self._invite('+989120001001')
|
||||
response = self._invite('09120001001')
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_requests_within_the_limit_still_succeed(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._invite(f'+98912000110{i}', token=self.token)
|
||||
response = self._invite(f'0912000110{i}', token=self.token)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('inviteCode', response.data)
|
||||
|
||||
def test_excessive_invite_requests_from_same_user_are_blocked(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._invite(f'+98912000120{i}', token=self.token)
|
||||
response = self._invite(f'0912000120{i}', token=self.token)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._invite('+989120001299', token=self.token)
|
||||
blocked_response = self._invite('09120001299', token=self.token)
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
|
||||
# کاربرِ غیرمهمان (نه self.parent که مهمانه)، چون کلیدِ throttleِ
|
||||
# مهمانها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو میسنجه.
|
||||
registered_parent = User.objects.create(is_guest=False, name='', phone='+989120001300')
|
||||
registered_parent = User.objects.create(is_guest=False, name='', phone='09120001300')
|
||||
registered_token = issue_access_token(registered_parent.id)
|
||||
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '3/hour'}):
|
||||
for i in range(3):
|
||||
response = self._invite(
|
||||
f'+98912000131{i}', token=registered_token, remote_addr=f'10.5.0.{i + 1}',
|
||||
f'0912000131{i}', token=registered_token, remote_addr=f'10.5.0.{i + 1}',
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
blocked_response = self._invite('+989120001399', token=registered_token, remote_addr='10.5.0.99')
|
||||
blocked_response = self._invite('09120001399', token=registered_token, remote_addr='10.5.0.99')
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
|
||||
def test_throttled_request_does_not_create_family_link(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}):
|
||||
first_response = self._invite('+989120001401', token=self.token)
|
||||
first_response = self._invite('09120001401', token=self.token)
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
link_count_before = FamilyLink.objects.filter(parent_user_id=self.parent.id).count()
|
||||
blocked_response = self._invite('+989120001402', token=self.token)
|
||||
blocked_response = self._invite('09120001402', token=self.token)
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertEqual(
|
||||
FamilyLink.objects.filter(parent_user_id=self.parent.id).count(), link_count_before,
|
||||
)
|
||||
self.assertFalse(
|
||||
FamilyLink.objects.filter(parent_user_id=self.parent.id, child_phone='+989120001402').exists(),
|
||||
FamilyLink.objects.filter(parent_user_id=self.parent.id, child_phone='09120001402').exists(),
|
||||
)
|
||||
|
||||
|
||||
@@ -295,39 +295,39 @@ class FamilyLinkInviteGuestThrottleBypassTests(TestCase):
|
||||
def test_rotating_guest_accounts_share_the_same_throttle_bucket(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '2/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-family-invite-guest-rot-1', is_guest=True, name='')
|
||||
response1 = self._invite('+989120001501', issue_access_token(guest1.id))
|
||||
response1 = self._invite('09120001501', issue_access_token(guest1.id))
|
||||
self.assertEqual(response1.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-family-invite-guest-rot-2', is_guest=True, name='')
|
||||
response2 = self._invite('+989120001502', issue_access_token(guest2.id))
|
||||
response2 = self._invite('09120001502', issue_access_token(guest2.id))
|
||||
self.assertEqual(response2.status_code, 200)
|
||||
|
||||
# سومین حسابِ مهمانِ تازه (pk جدید) باید throttle بشه، چون
|
||||
# bucket بر اساسِ IP بهاشتراک گذاشته شده، نه user.pk.
|
||||
guest3 = User.objects.create(device_id='device-family-invite-guest-rot-3', is_guest=True, name='')
|
||||
response3 = self._invite('+989120001503', issue_access_token(guest3.id))
|
||||
response3 = self._invite('09120001503', issue_access_token(guest3.id))
|
||||
|
||||
self.assertEqual(response3.status_code, 429)
|
||||
|
||||
def test_registered_users_keep_independent_per_user_buckets(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}):
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='+989120001601')
|
||||
response_a = self._invite('+989120001701', issue_access_token(user_a.id))
|
||||
user_a = User.objects.create(is_guest=False, name='', phone='09120001601')
|
||||
response_a = self._invite('09120001701', issue_access_token(user_a.id))
|
||||
self.assertEqual(response_a.status_code, 200)
|
||||
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='+989120001602')
|
||||
response_b = self._invite('+989120001702', issue_access_token(user_b.id))
|
||||
user_b = User.objects.create(is_guest=False, name='', phone='09120001602')
|
||||
response_b = self._invite('09120001702', issue_access_token(user_b.id))
|
||||
|
||||
self.assertEqual(response_b.status_code, 200)
|
||||
|
||||
def test_blocked_guest_request_does_not_create_family_link(self):
|
||||
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'family_invite_create': '1/hour'}):
|
||||
guest1 = User.objects.create(device_id='device-family-invite-guest-rot-4', is_guest=True, name='')
|
||||
first_response = self._invite('+989120001801', issue_access_token(guest1.id))
|
||||
first_response = self._invite('09120001801', issue_access_token(guest1.id))
|
||||
self.assertEqual(first_response.status_code, 200)
|
||||
|
||||
guest2 = User.objects.create(device_id='device-family-invite-guest-rot-5', is_guest=True, name='')
|
||||
blocked_response = self._invite('+989120001802', issue_access_token(guest2.id))
|
||||
blocked_response = self._invite('09120001802', issue_access_token(guest2.id))
|
||||
|
||||
self.assertEqual(blocked_response.status_code, 429)
|
||||
self.assertFalse(FamilyLink.objects.filter(parent_user_id=guest2.id).exists())
|
||||
|
||||
@@ -151,6 +151,8 @@ class FamilyLinkAcceptView(APIView):
|
||||
)
|
||||
|
||||
# اگر دعوت برای شمارهی مشخصی صادر شده باشد، فقط همان شماره میتواند accept کند.
|
||||
# ⚠️ مقایسهی رشتهی خام است: دعوت با +98912… را صاحبِ 0912… نمیتواند
|
||||
# بپذیرد (۴۰۳، بازتولید شد). مهمان (phone=None) هم هیچ دعوتی را نمیپذیرد.
|
||||
if link.child_phone and link.child_phone != request.user.phone:
|
||||
raise PermissionDenied('این دعوت متعلق به شمارهی دیگری است.')
|
||||
|
||||
@@ -284,6 +286,11 @@ class FamilyWeeklyReportView(APIView):
|
||||
جنگوییه، همون «خوندن» با import مستقیم مدلهای آن دو اپ پیادهسازی
|
||||
شده — دقیقاً همون الگویی که progress/views.py (LeaderboardView) برای
|
||||
خوندن نام کاربر از accounts.User استفاده میکنه.
|
||||
|
||||
امتیاز هم از درسها و هم از آزمونِ جزیره (UserNodeCompletion) جمع میشود.
|
||||
پیش از ۲۰۲۶-۰۹-۲۵ فقط درسها شمرده میشدند و فرزندی که با آزمونِ جزیره (مسیرِ
|
||||
اصلیِ بازیِ فرانت) پیش میرفت برای والد «۰ امتیاز» دیده میشد.
|
||||
⚠️ ParentalSettings فقط ذخیره میشود؛ هیچجای کد اعمالش نمیکند.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
@@ -308,14 +315,21 @@ class FamilyWeeklyReportView(APIView):
|
||||
related_id=log.id,
|
||||
)
|
||||
|
||||
from content.models import UserLessonCompletion
|
||||
from content.models import UserLessonCompletion, UserNodeCompletion
|
||||
from progress.models import StreakRecord
|
||||
|
||||
completions = UserLessonCompletion.objects.filter(
|
||||
user_id=link.child_user_id, created_at__date__gte=week_start
|
||||
)
|
||||
xp_earned = completions.aggregate(total=Sum('xp_earned'))['total'] or 0
|
||||
islands = UserNodeCompletion.objects.filter(
|
||||
user_id=link.child_user_id, created_at__date__gte=week_start
|
||||
)
|
||||
xp_earned = (
|
||||
(completions.aggregate(total=Sum('xp_earned'))['total'] or 0)
|
||||
+ (islands.aggregate(total=Sum('xp_earned'))['total'] or 0)
|
||||
)
|
||||
lessons_completed = completions.count()
|
||||
islands_completed = islands.values('node_id').distinct().count()
|
||||
|
||||
streak = StreakRecord.objects.filter(user_id=link.child_user_id).first()
|
||||
current_streak = streak.current_streak if streak else 0
|
||||
@@ -328,6 +342,7 @@ class FamilyWeeklyReportView(APIView):
|
||||
'weekStart': week_start,
|
||||
'xpEarned': xp_earned,
|
||||
'lessonsCompleted': lessons_completed,
|
||||
'islandsCompleted': islands_completed,
|
||||
'currentStreak': current_streak,
|
||||
'openedAt': log.opened_at,
|
||||
}
|
||||
|
||||
@@ -125,6 +125,11 @@ class PaymentRedirectView(APIView):
|
||||
URL باز شود authority ساخته و روی سفارش ذخیره میشود؛ بازکردنِ دوباره
|
||||
(مثلاً دکمهی back مرورگر) همان authority قبلی را دوباره استفاده
|
||||
میکند، نه یک PaymentRequest تازه.
|
||||
|
||||
⚠️ AllowAny است و مالکیتِ سفارش را نمیسنجد (مرورگر هدرِ Bearer نمیفرستد)؛
|
||||
شناسهی سفارش ترتیبی است. هر کسی با باز کردنِ `/orders/<n>/pay` برای سفارشِ
|
||||
دیگران authority میسازد و از آن به بعد صاحبِ سفارش دیگر نمیتواند کدِ
|
||||
تخفیف بزند (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد).
|
||||
"""
|
||||
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@@ -120,12 +120,23 @@ def verify_payment(*, authority: str, amount: int, currency: str) -> ZarinpalVer
|
||||
raise ZarinpalAPIError()
|
||||
|
||||
try:
|
||||
result_data = resp.json()['data']
|
||||
code = result_data.get('code')
|
||||
body = resp.json()
|
||||
result_data = body['data']
|
||||
except (ValueError, KeyError, TypeError):
|
||||
logger.error('Zarinpal payment-verify response malformed: status=%s', resp.status_code)
|
||||
raise ZarinpalAPIError()
|
||||
|
||||
# زرینپال روی خطا (مثلاً -51 پرداختِ ناموفق) `"data": []` و کدِ خطا را در
|
||||
# `errors` برمیگرداند. این یک «رد»ِ صریح است، نه خرابیِ شبکه، پس مثلِ هر کدِ
|
||||
# غیرِ ۱۰۰/۱۰۱ نامعتبر حساب میشود. پیش از ۲۰۲۶-۰۹-۲۵ اینجا `[].get` کرش
|
||||
# میکرد و کاربر بهجای برگشت به اپ صفحهی ۵۰۰ میدید. با بدنهی مستندِ
|
||||
# زرینپال تست شده، نه با درگاهِ واقعی.
|
||||
if not isinstance(result_data, dict):
|
||||
errors = body.get('errors') if isinstance(body, dict) else None
|
||||
logger.warning('Zarinpal payment-verify rejected: errors=%s', errors)
|
||||
return ZarinpalVerifyResult(is_valid=False, raw=body if isinstance(body, dict) else None)
|
||||
code = result_data.get('code')
|
||||
|
||||
# ۱۰۰ = تاییدِ موفقِ تازه، ۱۰۱ = قبلاً تایید شده (idempotent از سمتِ زرینپال) — هر دو معتبرن.
|
||||
if code in (100, 101):
|
||||
return ZarinpalVerifyResult(is_valid=True, ref_id=str(result_data.get('ref_id', '')), raw=result_data)
|
||||
|
||||
@@ -65,16 +65,29 @@ def grant_free_subscription(user_id: int, *, duration_days: int | None = None, e
|
||||
|
||||
مستقل از caller اتمیک است. ردیف Subscription قبل از خواندن/تغییر با
|
||||
select_for_update قفل میشود تا فراخوانیهای همزمان lost update نسازند.
|
||||
|
||||
اشتراکِ فعالی که دیرتر تمام میشود (یا نامحدود است) کوتاه نمیشود؛ آفر فقط
|
||||
وقتی اثر دارد که بیشتر از چیزی که کاربر دارد بدهد. پیش از ۲۰۲۶-۰۹-۲۵ کاربری
|
||||
که سالانه خریده بود و بعد کدِ آفرِ ۷ روزه میزد (یا مهمانی که خریده بود و بعد
|
||||
ثبتنام میکرد و آفرِ خودکارِ ثبتنام میگرفت) با ۷ روز میماند.
|
||||
"""
|
||||
with transaction.atomic():
|
||||
Subscription.objects.get_or_create(user_id=user_id)
|
||||
subscription = Subscription.objects.select_for_update().get(user_id=user_id)
|
||||
old_status = subscription.status
|
||||
|
||||
subscription.status = 'active'
|
||||
subscription.expires_at = (
|
||||
offered_until = (
|
||||
timezone.now() + timezone.timedelta(days=duration_days) if duration_days else None
|
||||
)
|
||||
still_active = old_status == 'active' and (
|
||||
subscription.expires_at is None or subscription.expires_at > timezone.now()
|
||||
)
|
||||
keeps_longer = still_active and offered_until is not None and (
|
||||
subscription.expires_at is None or subscription.expires_at >= offered_until
|
||||
)
|
||||
subscription.status = 'active'
|
||||
if not keeps_longer:
|
||||
subscription.expires_at = offered_until
|
||||
subscription.save(update_fields=['status', 'expires_at'])
|
||||
|
||||
status_changed.send(
|
||||
|
||||
@@ -112,8 +112,9 @@ class PurchaseView(APIView):
|
||||
plan = get_object_or_404(Plan, pk=v['planId'])
|
||||
idempotency_key = request.headers.get('Idempotency-Key')
|
||||
|
||||
# Three-phase flow so create_order (which may call an external payment
|
||||
# gateway) never runs inside a DB transaction/lock: phase 1 creates/updates
|
||||
# Three-phase flow so create_order never runs inside a DB transaction/lock.
|
||||
# (create_order itself no longer calls Zarinpal — PaymentRedirectView
|
||||
# does, on first visit — so this split is now defensive.) Phase 1 creates/updates
|
||||
# the pending Subscription, phase 2 calls create_order and restores the
|
||||
# prior state on failure, phase 3 attaches order_id atomically.
|
||||
existing_order = None
|
||||
|
||||
Reference in New Issue
Block a user