Signed payment links, hashed refresh tokens, login-only leaderboard, load test tools

- /api/payment/orders/{id}/pay requires the short-lived signature that paymentUrl carries;
  without it (or with another order's) it is the same 404 as an unknown order
- Session.refresh_token stores the SHA-256 of the token; migration accounts.0007 hashes the
  existing rows so tokens clients already hold keep working
- /api/leaderboard requires a token (guests have one); names of children were scrapable
- RAG mock only for `manage.py test`, not any command with an argument named "test"
- tools/loadtest: user factory, load generator, fake GapGPT+Qdrant, with method notes
- security-before-production.md lists only open items; closed ones become a guards table
  with the tests that hold them. Comments describe current behaviour, not history

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 21:12:28 +03:30
parent b3f624a512
commit 645baf846b
22 changed files with 584 additions and 193 deletions

View File

@@ -74,7 +74,7 @@ signing key is the one committed in this repo, seven fixed-code demo logins exis
users sit in the production DB, and `/api/health`, the API schema, the leaderboard and users sit in the production DB, and `/api/health`, the API schema, the leaderboard and
`/admin/` are all public. Each is listed there with its risk, why it was left, and the `/admin/` are all public. Each is listed there with its risk, why it was left, and the
fix. **Do not treat this deployment as production-ready until every item there is closed**, fix. **Do not treat this deployment as production-ready until every item there is closed**,
and record the date and commit of each fix in that file rather than deleting the item. and when one is closed, move it to that file's «حفاظ‌هایی که هست» table with the test that holds it (the date lives in the commit message; the owner wants docs to describe the current state only).
## Commands ## Commands
@@ -661,7 +661,7 @@ Redirect URIِ ثبت‌شده در پنلِ بازار خودِ `/api/payment/b
- **از این به بعد هر درخواستِ OTP پول است.** برای همین: شماره به `09XXXXXXXXX` نرمال - **از این به بعد هر درخواستِ OTP پول است.** برای همین: شماره به `09XXXXXXXXX` نرمال
می‌شود و غیرِ موبایلِ ایران ۴۰۰ می‌گیرد؛ cooldown و سقفِ روزانه‌ی هر شماره (۱۰) و سقفِ می‌شود و غیرِ موبایلِ ایران ۴۰۰ می‌گیرد؛ cooldown و سقفِ روزانه‌ی هر شماره (۱۰) و سقفِ
روزانه‌ی کلِ سامانه (۳۰۰، ضدِ SMS pumping) در دیتابیس شمرده می‌شوند نه کشِ per-worker. روزانه‌ی کلِ سامانه (۳۰۰، ضدِ SMS pumping) در دیتابیس شمرده می‌شوند نه کشِ per-worker.
جزئیات در بندِ ۱۷ِ `docs/security-before-production.md`، تست‌ها در `accounts/tests_otp_limits.py`. تست‌ها در `accounts/tests_otp_limits.py`.
- مسیرِ کاملِ کاربرِ تازه روی سرورِ زنده سبز شد، **بدونِ پیامکِ واقعی**: کد مستقیم در - مسیرِ کاملِ کاربرِ تازه روی سرورِ زنده سبز شد، **بدونِ پیامکِ واقعی**: کد مستقیم در
دیتابیس ساخته شد. کاربرِ تستیِ آن **کاربرِ ۱۷ با شماره‌ی `09000000001`** (نام «کاربر دیتابیس ساخته شد. کاربرِ تستیِ آن **کاربرِ ۱۷ با شماره‌ی `09000000001`** (نام «کاربر
تست»، یک جزیره تمام‌شده) روی پروداکشن مانده و مثلِ داده‌ی دمو پیش از کاربرِ واقعی باید پاک شود. تست»، یک جزیره تمام‌شده) روی پروداکشن مانده و مثلِ داده‌ی دمو پیش از کاربرِ واقعی باید پاک شود.

View File

@@ -37,7 +37,7 @@ from django.db import transaction
from django.utils import timezone from django.utils import timezone
from accounts.authentication import issue_access_token from accounts.authentication import issue_access_token
from accounts.models import OTPCode, Profile, Session, User from accounts.models import OTPCode, Profile, Session, User, hash_refresh_token
# --- شناسه‌های ثابتِ داده‌ی seed -------------------------------------------- # --- شناسه‌های ثابتِ داده‌ی seed --------------------------------------------
# شماره‌های 0912000000X عمداً خارج از بازه‌ی واقعیِ اپراتورهای ایران نیستن؛ # شماره‌های 0912000000X عمداً خارج از بازه‌ی واقعیِ اپراتورهای ایران نیستن؛
@@ -177,7 +177,7 @@ class Command(BaseCommand):
) )
Session.objects.get_or_create( Session.objects.get_or_create(
user=main_user, user=main_user,
refresh_token=f'seed-refresh-{MAIN_PHONE}', refresh_token=hash_refresh_token(f'seed-refresh-{MAIN_PHONE}'),
defaults=dict( defaults=dict(
device_id='seed-device-main', device_id='seed-device-main',
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
@@ -192,7 +192,7 @@ class Command(BaseCommand):
) )
Session.objects.get_or_create( Session.objects.get_or_create(
user=guest_user, user=guest_user,
refresh_token=f'seed-refresh-{GUEST_DEVICE_ID}', refresh_token=hash_refresh_token(f'seed-refresh-{GUEST_DEVICE_ID}'),
defaults=dict( defaults=dict(
device_id=GUEST_DEVICE_ID, device_id=GUEST_DEVICE_ID,
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),

View File

@@ -0,0 +1,28 @@
"""
Session.refresh_token از این به بعد هشِ SHA-256 است (accounts/models.py، hash_refresh_token).
ردیف‌های موجود هش می‌شوند تا توکن‌هایی که کلاینت‌ها در دست دارند همچنان کار کنند. از هش نمی‌شود
خودِ توکن را ساخت؛ برگرداندنِ این migration هش‌ها را سرِ جایشان می‌گذارد و کدِ قبلی آن‌ها را
نمی‌شناسد، یعنی هر کاربر یک بار دوباره وارد می‌شود.
"""
import hashlib
from django.db import migrations
def hash_existing(apps, schema_editor):
Session = apps.get_model('accounts', 'Session')
# اول مادی کردن، بعد نوشتن: کرسرِ بازِ SQLite وسطِ حلقه‌ی نوشتن قفل می‌سازد (CLAUDE.md).
rows = list(Session.objects.values_list('id', 'refresh_token'))
for pk, raw in rows:
Session.objects.filter(pk=pk).update(refresh_token=hashlib.sha256(raw.encode()).hexdigest())
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_profile_birth_date_drop_parent_phone'),
]
operations = [
migrations.RunPython(hash_existing, migrations.RunPython.noop),
]

View File

@@ -1,3 +1,5 @@
import hashlib
from django.db import models from django.db import models
@@ -60,11 +62,18 @@ class Profile(models.Model):
return f"profile-{self.user_id}" return f"profile-{self.user_id}"
def hash_refresh_token(raw: str) -> str:
"""آنچه در Session.refresh_token ذخیره و با آن جست‌وجو می‌شود؛ خودِ توکن فقط دستِ کلاینت است."""
return hashlib.sha256(raw.encode()).hexdigest()
class Session(models.Model): class Session(models.Model):
""" """
نگهداری refresh_token هر ورود. توکن دسترسی (access token) در دیتابیس یک ورود. توکنِ دسترسی ذخیره نمی‌شود (امضاشده و stateless است، accounts/authentication.py).
ذخیره نمی‌شه؛ به‌صورت stateless امضا (signed) می‌شه — دقیقاً طبق فیلدهای
همین مدل که فقط refresh_token داره، نه access token. `refresh_token` هشِ SHA-256ِ توکنِ تمدید است، نه خودش (hash_refresh_token): هر کس دیتابیس،
یک بکاپ یا پنلِ ادمین را ببیند با آن نمی‌تواند به‌جای کاربر وارد شود. توکن ۶۴ نویسه‌ی تصادفی
است، پس هشِ سریعِ بی‌نمک کافی است.
""" """
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions') user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
refresh_token = models.CharField(max_length=200, unique=True) refresh_token = models.CharField(max_length=200, unique=True)

View File

@@ -13,7 +13,7 @@ from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle from rest_framework.throttling import ScopedRateThrottle
from .authentication import issue_access_token from .authentication import issue_access_token
from .models import OTPCode, Profile, Session, User from .models import OTPCode, Profile, Session, User, hash_refresh_token
from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms
@@ -286,9 +286,10 @@ class RefreshTokenReplayTests(TestCase):
def setUp(self): def setUp(self):
self.client = APIClient() self.client = APIClient()
self.user = User.objects.create(is_guest=True, device_id='device-refresh-test') self.user = User.objects.create(is_guest=True, device_id='device-refresh-test')
self.raw = 'test-refresh-token-abc123'
self.session = Session.objects.create( self.session = Session.objects.create(
user=self.user, user=self.user,
refresh_token='test-refresh-token-abc123', refresh_token=hash_refresh_token(self.raw),
device_id='device-refresh-test', device_id='device-refresh-test',
expires_at=timezone.now() + timezone.timedelta(days=30), expires_at=timezone.now() + timezone.timedelta(days=30),
revoked=False, revoked=False,
@@ -296,19 +297,19 @@ class RefreshTokenReplayTests(TestCase):
def test_valid_refresh_token_succeeds(self): def test_valid_refresh_token_succeeds(self):
response = self.client.post( response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json', '/api/auth/refresh', {'refreshToken': self.raw}, format='json',
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue(response.data['token']) self.assertTrue(response.data['token'])
def test_same_refresh_token_rejected_on_second_use(self): def test_same_refresh_token_rejected_on_second_use(self):
first_response = self.client.post( first_response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json', '/api/auth/refresh', {'refreshToken': self.raw}, format='json',
) )
self.assertEqual(first_response.status_code, 200) self.assertEqual(first_response.status_code, 200)
second_response = self.client.post( second_response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json', '/api/auth/refresh', {'refreshToken': self.raw}, format='json',
) )
self.assertEqual(second_response.status_code, 401) self.assertEqual(second_response.status_code, 401)
@@ -317,15 +318,15 @@ class RefreshTokenReplayTests(TestCase):
def test_successful_refresh_returns_new_refresh_token(self): def test_successful_refresh_returns_new_refresh_token(self):
response = self.client.post( response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json', '/api/auth/refresh', {'refreshToken': self.raw}, format='json',
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
new_refresh_token = response.data['refreshToken'] new_refresh_token = response.data['refreshToken']
self.assertTrue(new_refresh_token) self.assertTrue(new_refresh_token)
self.assertNotEqual(new_refresh_token, self.session.refresh_token) self.assertNotEqual(new_refresh_token, self.raw)
self.assertTrue( self.assertTrue(
Session.objects.filter( Session.objects.filter(
refresh_token=new_refresh_token, user=self.user, revoked=False, refresh_token=hash_refresh_token(new_refresh_token), user=self.user, revoked=False,
).exists() ).exists()
) )
@@ -761,20 +762,23 @@ class RefreshTokenThrottleTests(TestCase):
self.user = User.objects.create(is_guest=True, device_id='device-refresh-throttle-test') self.user = User.objects.create(is_guest=True, device_id='device-refresh-throttle-test')
def _create_session(self, token_suffix): def _create_session(self, token_suffix):
return Session.objects.create( raw = f'test-refresh-token-{token_suffix}'
session = Session.objects.create(
user=self.user, user=self.user,
refresh_token=f'test-refresh-token-{token_suffix}', refresh_token=hash_refresh_token(raw),
device_id='device-refresh-throttle-test', device_id='device-refresh-throttle-test',
expires_at=timezone.now() + timezone.timedelta(days=30), expires_at=timezone.now() + timezone.timedelta(days=30),
revoked=False, revoked=False,
) )
session.raw = raw
return session
def test_requests_within_the_limit_still_succeed(self): def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}): with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}):
for i in range(3): for i in range(3):
session = self._create_session(i) session = self._create_session(i)
response = self.client.post( response = self.client.post(
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json', '/api/auth/refresh', {'refreshToken': session.raw}, format='json',
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@@ -783,13 +787,13 @@ class RefreshTokenThrottleTests(TestCase):
for i in range(3): for i in range(3):
session = self._create_session(i) session = self._create_session(i)
response = self.client.post( response = self.client.post(
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json', '/api/auth/refresh', {'refreshToken': session.raw}, format='json',
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
extra_session = self._create_session('extra') extra_session = self._create_session('extra')
blocked_response = self.client.post( blocked_response = self.client.post(
'/api/auth/refresh', {'refreshToken': extra_session.refresh_token}, format='json', '/api/auth/refresh', {'refreshToken': extra_session.raw}, format='json',
) )
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
@@ -797,7 +801,7 @@ class RefreshTokenThrottleTests(TestCase):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '1/hour'}): with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '1/hour'}):
first_session = self._create_session('first') first_session = self._create_session('first')
first_response = self.client.post( first_response = self.client.post(
'/api/auth/refresh', {'refreshToken': first_session.refresh_token}, format='json', '/api/auth/refresh', {'refreshToken': first_session.raw}, format='json',
) )
self.assertEqual(first_response.status_code, 200) self.assertEqual(first_response.status_code, 200)
@@ -806,7 +810,7 @@ class RefreshTokenThrottleTests(TestCase):
with patch('accounts.views.issue_access_token') as mock_issue_token: with patch('accounts.views.issue_access_token') as mock_issue_token:
blocked_response = self.client.post( blocked_response = self.client.post(
'/api/auth/refresh', {'refreshToken': blocked_session.refresh_token}, format='json', '/api/auth/refresh', {'refreshToken': blocked_session.raw}, format='json',
) )
self.assertEqual(blocked_response.status_code, 429) self.assertEqual(blocked_response.status_code, 429)
mock_issue_token.assert_not_called() mock_issue_token.assert_not_called()
@@ -1669,3 +1673,31 @@ class TestLoginPhoneParsingTests(TestCase):
'09120000001:abcdef', '09120000001:1234567'): '09120000001:abcdef', '09120000001:1234567'):
with self.subTest(raw=raw), self.assertRaises(ImproperlyConfigured): with self.subTest(raw=raw), self.assertRaises(ImproperlyConfigured):
self._parse(raw) self._parse(raw)
class RefreshTokenStoredHashedTests(TestCase):
"""دیتابیس، بکاپ یا پنلِ ادمین نباید خودِ توکنِ تمدید را داشته باشد؛ فقط هشش."""
def test_issued_tokens_are_stored_only_as_hash(self):
client = APIClient()
body = client.post('/api/auth/session', {'type': 'guest', 'deviceId': 'hash-dev'}, format='json').json()
raw = body['refreshToken']
self.assertFalse(Session.objects.filter(refresh_token=raw).exists())
self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(raw)).exists())
renewed = client.post('/api/auth/refresh', {'refreshToken': raw}, format='json').json()['refreshToken']
self.assertFalse(Session.objects.filter(refresh_token=renewed).exists())
self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(renewed), revoked=False).exists())
def test_migration_hashes_existing_rows(self):
from importlib import import_module
from django.apps import apps as global_apps
user = User.objects.create(is_guest=True, device_id='hash-mig')
Session.objects.create(user=user, refresh_token='old-raw-token',
expires_at=timezone.now() + timezone.timedelta(days=1))
import_module('accounts.migrations.0007_hash_refresh_tokens').hash_existing(global_apps, None)
self.assertEqual(Session.objects.get(user=user).refresh_token, hash_refresh_token('old-raw-token'))
response = APIClient().post('/api/auth/refresh', {'refreshToken': 'old-raw-token'}, format='json')
self.assertEqual(response.status_code, 200)

View File

@@ -16,7 +16,7 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
from .authentication import invalidate_user_cache, issue_access_token from .authentication import invalidate_user_cache, issue_access_token
from .models import OTPCode, Profile, Session, User from .models import OTPCode, Profile, Session, User, hash_refresh_token
from .services import OTPDeliveryError, send_otp_sms from .services import OTPDeliveryError, send_otp_sms
from .serializers import ( from .serializers import (
DeleteAccountRequestSerializer, DeleteAccountRequestSerializer,
@@ -101,7 +101,7 @@ class GuestSessionView(APIView):
refresh_token = secrets.token_hex(32) refresh_token = secrets.token_hex(32)
Session.objects.create( Session.objects.create(
user=user, user=user,
refresh_token=refresh_token, refresh_token=hash_refresh_token(refresh_token),
device_id=device_id, device_id=device_id,
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
) )
@@ -385,7 +385,7 @@ class LoginView(APIView):
refresh_token = uuid.uuid4().hex refresh_token = uuid.uuid4().hex
Session.objects.create( Session.objects.create(
user=user, user=user,
refresh_token=refresh_token, refresh_token=hash_refresh_token(refresh_token),
device_id=request.data.get('deviceId', ''), device_id=request.data.get('deviceId', ''),
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
) )
@@ -504,7 +504,7 @@ class LogoutView(APIView):
with transaction.atomic(): with transaction.atomic():
if refresh_token: if refresh_token:
Session.objects.filter( Session.objects.filter(
user_id=request.user.id, refresh_token=refresh_token, user_id=request.user.id, refresh_token=hash_refresh_token(refresh_token),
).update(revoked=True) ).update(revoked=True)
User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now()) User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now())
invalidate_user_cache(request.user.id) invalidate_user_cache(request.user.id)
@@ -536,13 +536,13 @@ class RefreshTokenView(APIView):
# UPDATE اتمیک جلوی استفاده‌ی هم‌زمان از یک refresh token را می‌گیرد (replay protection). # UPDATE اتمیک جلوی استفاده‌ی هم‌زمان از یک refresh token را می‌گیرد (replay protection).
with transaction.atomic(): with transaction.atomic():
updated_count = Session.objects.filter( updated_count = Session.objects.filter(
refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now() refresh_token=hash_refresh_token(refresh_token), revoked=False, expires_at__gte=timezone.now()
).update(revoked=True) ).update(revoked=True)
if not updated_count: if not updated_count:
return Response({'detail': 'رفرش‌توکن نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_401_UNAUTHORIZED) return Response({'detail': 'رفرش‌توکن نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_401_UNAUTHORIZED)
old_session = Session.objects.get(refresh_token=refresh_token) old_session = Session.objects.get(refresh_token=hash_refresh_token(refresh_token))
# ثبتِ فعالیتِ کاربر برای رفرشِ موفقِ توکن. # ثبتِ فعالیتِ کاربر برای رفرشِ موفقِ توکن.
User.objects.filter(pk=old_session.user_id).update(last_activity_at=timezone.now()) User.objects.filter(pk=old_session.user_id).update(last_activity_at=timezone.now())
@@ -550,7 +550,7 @@ class RefreshTokenView(APIView):
new_refresh_token = secrets.token_hex(32) new_refresh_token = secrets.token_hex(32)
new_session = Session.objects.create( new_session = Session.objects.create(
user_id=old_session.user_id, user_id=old_session.user_id,
refresh_token=new_refresh_token, refresh_token=hash_refresh_token(new_refresh_token),
device_id=old_session.device_id, device_id=old_session.device_id,
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
) )

View File

@@ -871,9 +871,10 @@ RAG_ANSWER_CACHE_TTL = int(os.environ.get('RAG_ANSWER_CACHE_TTL', '1800'))
# در اجرای تست‌ها همیشه RAG خاموش است تا هیچ تستی به OpenAI/Qdrant واقعی # در اجرای تست‌ها همیشه RAG خاموش است تا هیچ تستی به OpenAI/Qdrant واقعی
# نزند (سرعت + پایداری). تست‌هایی که مسیرِ RAG را می‌آزمایند خودشان با # نزند (سرعت + پایداری). تست‌هایی که مسیرِ RAG را می‌آزمایند خودشان با
# override_settings + mockِ کلاینت‌ها این را برمی‌گردانند (rag/tests.py). # override_settings + mockِ کلاینت‌ها این را برمی‌گردانند (rag/tests.py).
# فقط خودِ دستورِ `manage.py test`؛ هر آرگومانی به اسمِ «test» (مثلاً نامِ یک collection) نه.
import sys as _sys import sys as _sys
if 'test' in _sys.argv: if _sys.argv[1:2] == ['test']:
RAG_FORCE_MOCK = True RAG_FORCE_MOCK = True
RAG_ANSWER_CACHE_TTL = 0 RAG_ANSWER_CACHE_TTL = 0

View File

@@ -172,12 +172,11 @@ if 'LocMem' in settings.CACHES['default']['BACKEND'] and not settings.DEBUG:
PYCHECK PYCHECK
# ─── ۳.۶ مجوزِ فایل‌های داده ─────────────────────────────────────────────── # ─── ۳.۶ مجوزِ فایل‌های داده ───────────────────────────────────────────────
# این ماشین بینِ چند تیم مشترک است. تا ۲۰۲۶-۱۰-۰۴ دیتابیس و همه‌ی بکاپ‌ها 0644 بودند و # این ماشین بینِ چند تیم مشترک است و دیتابیس شماره‌ها و گفتگوهای کاربران را دارد؛ پس پوشه‌ی
# پوشه‌ها قابلِ پیمایش؛ کاربرِ nobody روی سرور توانست سرِ db.sqlite3 و یک بکاپ را بخواند # پروژه برای others بسته، دیتابیس 600 و بکاپ‌ها 700/600. در هر انتشار دوباره اعمال می‌شود، چون
# (شماره‌ها، refresh tokenهای بی‌هش، گفتگوها). در هر انتشار دوباره بسته می‌شود، چون بکاپِ # بکاپِ دستیِ تازه با umaskِ پیش‌فرض (0644) ساخته می‌شود. nginx فقط به سوکتِ /run وصل می‌شود و
# دستیِ تازه دوباره با umaskِ پیش‌فرض (0644) ساخته می‌شود. nginx فقط به سوکتِ /run وصل # از این پوشه چیزی نمی‌خواند (sites-enabled/mathapp)؛ سرویسی که این دیتابیس را مستقیم بخواند
# می‌شود و از این پوشه چیزی نمی‌خواند (sites-enabled/mathapp، همان روز خوانده شد)؛ اگر # خطای دسترسی می‌گیرد.
# سرویسِ دیگری این دیتابیس را مستقیم می‌خواند، از این به بعد خطای دسترسی می‌گیرد.
step "مجوزِ فایل‌های داده" step "مجوزِ فایل‌های داده"
chmod o-rwx . || warn "مجوزِ پوشه‌ی پروژه عوض نشد." chmod o-rwx . || warn "مجوزِ پوشه‌ی پروژه عوض نشد."
for f in db.sqlite3 db.sqlite3-wal db.sqlite3-shm db.sqlite3-journal; do for f in db.sqlite3 db.sqlite3-wal db.sqlite3-shm db.sqlite3-journal; do

View File

@@ -1,7 +1,6 @@
# پیش از پروداکشنِ واقعی: چیزهایی که عمداً باز مانده‌اند # پیش از پروداکشنِ واقعی: چیزهایی که عمداً باز مانده‌اند
**تاریخِ ثبت: ۲۰۲۶-۰۹-۲۵ (۳ مهر ۱۴۰۵)، کامیتِ پایه `66066cb`.** بازبینیِ دوباره با سرورِ زنده: **آخرین بازبینی با کد و سرورِ زنده: ۲۰۲۶-۱۰-۰۴ (۱۲ مهر ۱۴۰۵).**
۲۰۲۶-۱۰-۰۴ (مواردِ ۱۹ تا ۲۳ و بازتولیدِ مورد ۸).
این پروژه تا این تاریخ در حالتِ **دمو و ارائه‌ی کارآموزی** است، نه محصولِ نهایی. چند چیز این پروژه تا این تاریخ در حالتِ **دمو و ارائه‌ی کارآموزی** است، نه محصولِ نهایی. چند چیز
عمداً باز گذاشته شده تا توسعه‌دهنده‌ها، فرانت‌کار و ارائه کار کنند. هر کدام برای یک سرورِ عمداً باز گذاشته شده تا توسعه‌دهنده‌ها، فرانت‌کار و ارائه کار کنند. هر کدام برای یک سرورِ
@@ -10,8 +9,9 @@
**برای agentها و توسعه‌دهنده‌های بعدی:** این فهرست ادعاست، نه ضمانت؛ ممکن است از این **برای agentها و توسعه‌دهنده‌های بعدی:** این فهرست ادعاست، نه ضمانت؛ ممکن است از این
تاریخ به بعد چیزی عوض شده باشد. پیش از بستنِ هر مورد، وضعیتِ فعلی‌اش را با خودِ سرور یا تاریخ به بعد چیزی عوض شده باشد. پیش از بستنِ هر مورد، وضعیتِ فعلی‌اش را با خودِ سرور یا
کد بسنجید (ستونِ «چطور بسنجید»). بعد از بستنِ هر مورد، همین‌جا تاریخ و کامیتِ رفعش را کد بسنجید (ستونِ «چطور بسنجید»). مورد که بسته شد، از فهرست برداشته می‌شود و یک خط در
بنویسید؛ مورد را پاک نکنید. «حفاظ‌هایی که هست» (پایینِ همین فایل) می‌گیرد، با نامِ تستی که آن را نگه می‌دارد. تاریخ و
دلیلِ تغییر در پیامِ کامیت است، نه این‌جا.
هر آزمونِ «از بیرون» با `curl` بدونِ توکن انجام شده و در تاریخِ بالا تأیید شده است. هر آزمونِ «از بیرون» با `curl` بدونِ توکن انجام شده و در تاریخِ بالا تأیید شده است.
@@ -26,22 +26,16 @@
| ۳ | داده‌ی دمو و حسابِ مهمانِ عمومیِ `frontend-test-device` | بالا | دیتابیسِ سرور | | ۳ | داده‌ی دمو و حسابِ مهمانِ عمومیِ `frontend-test-device` | بالا | دیتابیسِ سرور |
| ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان می‌دهد | متوسط | کد | | ۴ | `/api/health` جزئیاتِ داخلی را به همه نشان می‌دهد | متوسط | کد |
| ۵ | Swagger، ReDoc و schemaی کاملِ API عمومی‌اند | متوسط | کد | | ۵ | Swagger، ReDoc و schemaی کاملِ API عمومی‌اند | متوسط | کد |
| ۶ | جدولِ رتبه‌بندی نام و شناسه‌ی کودکان را بدونِ ورود نشان می‌دهد | متوسط (حریمِ خصوصیِ کودک) | کد |
| ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد | | ۷ | پنلِ `/admin/` روی دامنه‌ی عمومی | متوسط | nginx + کد |
| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور | | ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب می‌دهد | متوسط | ادمینِ سرور |
| ۹ | کشِ مشترک (Redis) نیست؛ محدودیت‌ها per-worker‌اند | متوسط | `.env` سرور | | ۹ | کشِ مشترک (Redis) نیست؛ محدودیت‌ها per-worker‌اند | متوسط | `.env` سرور |
| ۱۰ | ورودِ مهمان فقط با `deviceId` است | کم (طراحی) | کد | | ۱۰ | ورودِ مهمان فقط با `deviceId` است | کم (طراحی) | کد |
| ۱۱ | توکنِ refresh و کدِ OTP بی‌هش در دیتابیس | کم | کد | | ۱۱ | کدِ OTP بی‌هش در دیتابیس و در فهرستِ پنل | کم | کد |
| ۱۲ | هزینه‌ی دستیارِ AI با ساختِ حسابِ مهمان قابلِ بالا بردن است | کم (مالی) | کد | | ۱۲ | هزینه‌ی دستیارِ AI با ساختِ حسابِ مهمان قابلِ بالا بردن است | کم (مالی) | کد |
| ۱۳ | ~~محدودیتِ قلب در سرور خاموش است (`HEARTS_ENFORCED=0`)~~ **بسته شد ۲۰۲۶-۱۰-۰۲** | کم (کسب‌وکار) | `.env` + فرانت |
| ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت | | ۱۴ | SQLite روی دیسکِ مشترکِ پُر، بدونِ بکاپِ خودکارِ بیرون از ماشین | کم (داده) | زیرساخت |
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور | | ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
| ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت | | ۱۶ | فرانت توکن را در `localStorage` نگه می‌دارد | کم | فرانت |
| ۱۷ | ~~شماره‌ی موبایل نرمال نمی‌شود؛ سقف‌های per-phone با املای دیگر دور زده می‌شوند~~ **بسته شد ۲۰۲۶-۰۹-۲۵** | متوسط | کد | | ۲۰ | یونیتِ systemd `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور |
| ۱۸ | `/api/payment/orders/{id}/pay` بدونِ ورود و بدونِ سنجشِ مالکیت | کم | کد |
| ۱۹ | دیتابیس و بکاپ‌ها برای همه‌ی کاربرانِ ماشینِ مشترک خواندنی بودند | بالا | کد آماده (`deploy.sh`)؛ اعمال با دیپلوی |
| ۲۰ | یونیتِ systemd هنوز `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور |
| ۲۱ | `urllib3 2.7.0` و `python-dotenv 1.0.1` آسیب‌پذیریِ شناخته‌شده داشتند | کم | کد آماده؛ اعمال با دیپلوی |
| ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت | | ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت |
| ۲۳ | سقفِ دستیار برای مهمان‌ها بر اساسِ IP مشترک است؛ کلاسِ پشتِ یک IP بعد از ۲۰ سوال در ساعت ۴۲۹ می‌گیرد | متوسط (دسترس‌پذیری) | تصمیمِ محصولی | | ۲۳ | سقفِ دستیار برای مهمان‌ها بر اساسِ IP مشترک است؛ کلاسِ پشتِ یک IP بعد از ۲۰ سوال در ساعت ۴۲۹ می‌گیرد | متوسط (دسترس‌پذیری) | تصمیمِ محصولی |
@@ -117,19 +111,9 @@
`IsAdminUser` (staffِ جنگو) محدود کنید، یا مسیر را در nginx به IPهای تیم ببندید. `IsAdminUser` (staffِ جنگو) محدود کنید، یا مسیر را در nginx به IPهای تیم ببندید.
- **چطور بسنجید:** `curl -o /dev/null -w "%{http_code}" …/api/schema/` باید ۴۰۱/۴۰۳ بدهد. - **چطور بسنجید:** `curl -o /dev/null -w "%{http_code}" …/api/schema/` باید ۴۰۱/۴۰۳ بدهد.
## ۶. جدولِ رتبه‌بندیِ عمومی — حریمِ خصوصیِ کودک
- **چیست:** `GET /api/leaderboard` بدونِ توکن (تأیید: HTTP 200) برای هر ردیف `rank`،
`userId`، `name` و `xpInPeriod` می‌دهد.
- **خطر:** نامِ کودکان و شناسه‌ی داخلی‌شان برای همه‌ی اینترنت قابلِ جمع‌آوری است.
پاک‌سازیِ نام (حذفِ شماره و لینک، `clean_display_name`) فقط راهِ تماس را می‌گیرد، نه خودِ نام را.
- **رفع:** `IsAuthenticated`؛ حذفِ `userId` از پاسخ (جز برای ردیفِ خودِ کاربر)؛ نمایشِ
نامِ کوچک یا نامِ مستعار.
## ۷. پنلِ ادمین روی دامنه‌ی عمومی ## ۷. پنلِ ادمین روی دامنه‌ی عمومی
- **چیست:** `/admin/` روی `mathapi.tadrisino2.ir`، با ورودِ نام‌کاربری/رمزِ جنگو. از - **چیست:** `/admin/` روی `mathapi.tadrisino2.ir`، با ورودِ نام‌کاربری/رمزِ جنگو. سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)،
۲۰۲۶-۰۹-۲۵ سقفِ ۱۰ تلاش در ۱۵ دقیقه برای هر IP دارد (`AdminLoginThrottleMiddleware`)،
ولی: شمارش per-worker است (مورد ۹)، IP با XFF جعل‌پذیر است (مورد ۸)، و ۲FA ندارد. ولی: شمارش per-worker است (مورد ۹)، IP با XFF جعل‌پذیر است (مورد ۸)، و ۲FA ندارد.
- **خطر:** ادمین همه‌ی محتوا، پلن‌ها، کدهای تخفیف، سفارش‌ها و کاربرها را کنترل می‌کند. - **خطر:** ادمین همه‌ی محتوا، پلن‌ها، کدهای تخفیف، سفارش‌ها و کاربرها را کنترل می‌کند.
- **رفع:** محدود کردنِ `/admin/` در nginx به IPهای تیم یا VPN؛ تغییرِ مسیرِ پیش‌فرض؛ ۲FA؛ - **رفع:** محدود کردنِ `/admin/` در nginx به IPهای تیم یا VPN؛ تغییرِ مسیرِ پیش‌فرض؛ ۲FA؛
@@ -168,38 +152,26 @@
- **رفع:** برای مهمان هم یک رازِ تصادفیِ سمتِ سرور صادر شود و ورودِ دوباره با همان راز باشد، - **رفع:** برای مهمان هم یک رازِ تصادفیِ سمتِ سرور صادر شود و ورودِ دوباره با همان راز باشد،
نه با `deviceId`ِ خام. نه با `deviceId`ِ خام.
## ۱۱. داده‌ی حساس بی‌هش در دیتابیس ## ۱۱. کدِ OTP بی‌هش در دیتابیس
- **چیست:** `Session.refresh_token` و `OTPCode.code` به‌صورتِ متنِ ساده ذخیره می‌شوند - **چیست:** `OTPCode.code` متنِ ساده است و در فهرستِ پنلِ ادمین (`OTPCodeAdmin`) دیده می‌شود.
(`accounts/models.py`) و در پنلِ ادمین (`Session`, `OTPCode`) دیده می‌شوند. توکنِ تمدید (`Session.refresh_token`) هش‌شده است و این‌جا شامل نیست.
- **خطر:** هر کس بکاپِ دیتابیس یا پنلِ ادمین را ببیند نشستِ هر کاربر را می‌رباید. - **چرا باز مانده:** تیم برای آزمایش بی‌پیامک یک ردیفِ `OTPCode` در پنل می‌سازد و کدش را می‌بیند.
بکاپ‌ها در `.deploy-backups/` کنارِ خودِ پروژه روی یک ماشینِ مشترک‌اند. - **خطر:** کسی که پنل یا دیتابیس را ببیند، در چند دقیقه‌ی عمرِ هر کد می‌تواند با آن وارد شود.
- **رفع:** ذخیره‌ی هشِ SHA-256 و مقایسه با هش؛ حذفِ این دو فیلد از نمایشِ ادمین. - **رفع:** ذخیره‌ی هش و حذفِ ستون از فهرستِ پنل، همراه با راهِ دیگری برای ورودِ آزمایشی.
## ۱۲. هزینه‌ی دستیارِ AI ## ۱۲. هزینه‌ی دستیارِ AI
- **چیست:** از ۲۰۲۶-۱۰-۰۲ هر کاربر روزی ۳ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`؛ پیش‌تر ۱۵) - **چیست:** هر کاربر روزی ۳ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`)
و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. مهمان‌ها بر اساسِ IP محدود می‌شوند ولی هر IP ساعتی و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. مهمان‌ها بر اساسِ IP محدود می‌شوند ولی هر IP ساعتی
۲۰ حسابِ مهمانِ تازه می‌سازد (`guest_session`)، و هر حسابِ تازه ۳ سؤالِ رایگانِ تازه دارد. خطر ۶۰ حسابِ مهمانِ تازه می‌سازد (`guest_session`)، و هر حسابِ تازه ۳ سؤالِ رایگانِ تازه دارد. سقفِ
کمتر شد (۳ به‌جای ۱۵)، ولی بسته نیست. طولِ سؤال از ۲۰۲۶-۰۹-۲۵ حداکثر ۲۰۰۰ نویسه است. روزانه‌ی کلِ سامانه (`AI_GLOBAL_DAILY_MAX`، ۳۰۰۰) خرجِ کل را محدود می‌کند. طولِ سؤال حداکثر ۲۰۰۰ نویسه است.
- **خطر:** با چند IP، اعتبارِ GapGPT در چند ساعت تمام می‌شود (اعتبار یک بار در ۲۰۲۶-۰۹-۲۵ - **خطر:** با چند IP، اعتبارِ GapGPT در چند ساعت تمام می‌شود (اعتبار یک بار در ۲۰۲۶-۰۹-۲۵
به صفر رسید، البته به دلیلِ دیگر). به صفر رسید، البته به دلیلِ دیگر).
- **رفع:** سقفِ سراسریِ روزانه برای کلِ اپ؛ دستیار فقط برای کاربرِ واردشده با شماره یا - **رفع:** سقفِ سراسریِ روزانه برای کلِ اپ؛ دستیار فقط برای کاربرِ واردشده با شماره یا
با سقفِ بسیار کمتر برای مهمان؛ هشدار وقتی `aiProvider.state` در `/api/health` برابرِ با سقفِ بسیار کمتر برای مهمان؛ هشدار وقتی `aiProvider.state` در `/api/health` برابرِ
`quota_exhausted` شد. `quota_exhausted` شد.
## ۱۳. ~~محدودیتِ قلب خاموش است~~ — بسته شد ۲۰۲۶-۱۰-۰۲
- **چیست:** با صفر قلب، سرور همچنان جواب را می‌پذیرد، مگر `HEARTS_ENFORCED=1`
(`content/views.py::QuestionAnswerView`). «قلبِ نامحدود» تنها مزیتِ اشتراک است.
- **خطر:** مزیتِ پولی فقط در ظاهرِ اپ اعمال می‌شود؛ با صدا زدنِ مستقیمِ API دور زده می‌شود.
- **چرا باز مانده:** فرانت برای ۴۰۳ِ `no_hearts` هنوز صفحه ندارد؛ روشن کردنش دانش‌آموز را به
صفحه‌ی خطا می‌برد. رفتار با صفر قلب هم تصمیمِ محصولیِ باز است.
- **رفع:** بعد از صفحه‌ی «قلب‌ها تمام شد» در فرانت، `HEARTS_ENFORCED=1` در `.env`.
- **بسته شد ۲۰۲۶-۱۰-۰۲** (بک‌اند `4700ccb`، فرانت `33f5f8d`): `HEARTS_ENFORCED` پیش‌فرض روشن؛ چکِ
قلب داخلِ تراکنش؛ پایانِ درس/جزیره‌ی بی‌جواب با قلبِ صفر هم بسته؛ صفحه‌ی «قلب‌هات تموم شد» با
شمارشِ معکوس. تست‌ها: `content/tests_hearts_gate.py`.
## ۱۴. دیتابیس و بکاپ ## ۱۴. دیتابیس و بکاپ
- **چیست:** SQLite در `~/mathapp-v2/db.sqlite3`، روی دیسکی که بینِ چند سرویس مشترک است و - **چیست:** SQLite در `~/mathapp-v2/db.sqlite3`، روی دیسکی که بینِ چند سرویس مشترک است و
@@ -229,58 +201,7 @@
- **خطر:** هر XSS در سایت هر دو را می‌خواند. - **خطر:** هر XSS در سایت هر دو را می‌خواند.
- **رفع:** CSPِ سخت‌گیرانه روی `math.tadrisino2.ir`؛ در بلندمدت refresh در کوکیِ `HttpOnly`. - **رفع:** CSPِ سخت‌گیرانه روی `math.tadrisino2.ir`؛ در بلندمدت refresh در کوکیِ `HttpOnly`.
## ۱۷. شماره‌ی موبایل نرمال نمی‌شود (افزوده در بازبینیِ دومِ ۲۰۲۶-۰۹-۲۵) ## ۲۰. یونیتِ systemd: DEBUG و کلیدِ ناامن
- **چیست:** `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…` باید دومی ۴۲۹ بگیرد.
- **✅ بسته شد ۲۰۲۶-۰۹-۲۵** (همان روزی که کلیدِ کاوه‌نگار روی سرور رفت؛ کامیت `0580aec`،
روی سرور از ۱۲:۳۲). `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` بگذارید و
همان‌جا بسنجید؛ یا شناسه‌ی غیرترتیبی.
## ۱۹. دیتابیس و بکاپ‌ها برای همه‌ی کاربرانِ ماشین خواندنی بودند (۲۰۲۶-۱۰-۰۴)
- **چیست:** `/home/administrator` با مجوزِ 755، `~/mathapp-v2` و `.deploy-backups` با 775، و
`db.sqlite3` و هر ۳۵ بکاپ با 644. `.env` درست 600 بود.
- **بازتولید شد:** `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` و همین برای یک بکاپ، هر دو
`SQLite format 3` چاپ کردند. هر حسابِ دیگرِ این ماشین (aria، artina، behdad، zahra، git، gitea،
metabase) یا هر سرویسِ نفوذشده‌ی دیگری شماره‌ها، گفتگوهای کودکان و refresh tokenهای بی‌هش (مورد ۱۱،
یعنی ورود به‌جای هر کاربر) را می‌خواند.
- **رفع در کد:** مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`: پوشه‌ی پروژه بی‌دسترسیِ others،
دیتابیس 600، پوشه‌ی بکاپ 700 و فایل‌هایش 600، در هر انتشار (بکاپِ دستیِ تازه دوباره 644 ساخته
می‌شود). خودِ `/home/administrator` دست نخورد چون nginx فرانت را از `~/mathapp_frontend_new/dist`
می‌خواند. بعد از دیپلوی باید دوباره با `sudo -u nobody` سنجیده شود. هش کردنِ refresh token (مورد ۱۱)
هنوز باز است.
## ۲۰. یونیتِ systemd: DEBUG و کلیدِ ناامن (۲۰۲۶-۱۰-۰۴)
- **چیست:** `systemctl cat gunicorn-mathapp` هنوز `Environment="DJANGO_DEBUG=1"` و - **چیست:** `systemctl cat gunicorn-mathapp` هنوز `Environment="DJANGO_DEBUG=1"` و
`Environment="DJANGO_SECRET_KEY=django-insecure-…"` دارد. `EnvironmentFile=.env` بعد از آن می‌آید و `Environment="DJANGO_SECRET_KEY=django-insecure-…"` دارد. `EnvironmentFile=.env` بعد از آن می‌آید و
@@ -292,17 +213,7 @@
ری‌استارت. حذفِ خطِ کلید به‌تنهایی کسی را بیرون نمی‌اندازد اگر `.env` همان کلید را داشته باشد؛ ری‌استارت. حذفِ خطِ کلید به‌تنهایی کسی را بیرون نمی‌اندازد اگر `.env` همان کلید را داشته باشد؛
عوض کردنِ خودِ کلید (مورد ۱) جداست. عوض کردنِ خودِ کلید (مورد ۱) جداست.
## ۲۱. کتابخانه‌های آسیب‌پذیر (۲۰۲۶-۱۰-۰۴) ## ۲۲. هدرهای امنیتیِ فرانت
- **چیست:** پایگاهِ OSV برای ۴۸ بسته‌ی نصب‌شده: `urllib3 2.7.0` (GHSA-8988-9cw3-xx77 و
GHSA-vxq7-64xx-v4gw با شدتِ بالا، GHSA-gh4c-6fx4-qh6g متوسط) و `python-dotenv 1.0.1`
(GHSA-mf9w-mj56-hr94، فقط در `set_key` که پروژه صدا نمی‌زند). اثرِ عملی کم است: سناریوی هر سه‌ی
urllib3 پاسخِ مخربِ سرورِ بالادستی یا پراکسی است (بازار، زرین‌پال، کاوه‌نگار، Qdrant). `npm audit` روی
وابستگی‌های اجراییِ فرانت صفر.
- **رفع در کد:** `python-dotenv==1.2.2`، `urllib3>=2.8.0`، و `gunicorn==26.2.0` (تا امروز فقط دستی
روی سرور نصب بود). هر سه با پایتونِ ۳.۱۰ِ سرور سازگارند و سرور به PyPI دسترسی دارد (سنجیده شد).
## ۲۲. هدرهای امنیتیِ فرانت (۲۰۲۶-۱۰-۰۴)
- **چیست:** `https://math.tadrisino2.ir/` فقط `x-xss-protection` دارد؛ نه CSP، نه `X-Frame-Options`/ - **چیست:** `https://math.tadrisino2.ir/` فقط `x-xss-protection` دارد؛ نه CSP، نه `X-Frame-Options`/
`frame-ancestors`، نه HSTS، نه `X-Content-Type-Options`. API همه‌ی این‌ها را جز HSTS و CSP دارد. `frame-ancestors`، نه HSTS، نه `X-Content-Type-Options`. API همه‌ی این‌ها را جز HSTS و CSP دارد.
@@ -312,26 +223,33 @@
- **رفع:** در nginxِ `mathapp-front` یا در آروان: `Content-Security-Policy` با `frame-ancestors 'none'` - **رفع:** در nginxِ `mathapp-front` یا در آروان: `Content-Security-Policy` با `frame-ancestors 'none'`
و `connect-src` به دامنه‌ی API، و `X-Content-Type-Options: nosniff`. و `connect-src` به دامنه‌ی API، و `X-Content-Type-Options: nosniff`.
## ۲۳. سقفِ دستیار برای مهمان‌ها روی IPِ مشترک (۲۰۲۶-۱۰-۰۴) ## ۲۳. سقفِ دستیار برای مهمان‌ها روی IPِ مشترک
- **چیست:** `GuestAwareAIThrottle` برای مهمان‌ها سطلِ `ai_ask` (۲۰ در ساعت) را بر اساسِ IP می‌سازد تا - **چیست:** `GuestAwareAIThrottle` برای مهمان‌ها سطلِ `ai_ask` (۲۰ در ساعت) را بر اساسِ IP می‌سازد تا
ساختنِ مهمانِ تازه سقف را دور نزند. ساختنِ مهمانِ تازه سقف را دور نزند.
- **بازتولید شد:** ۲۲ مهمانِ متفاوت از یک IP، هر کدام اولین سوالش: ۲۰ تا ۲۰۰، ۲۱اُم و ۲۲اُم ۴۲۹. - **بازتولید شد:** ۲۲ مهمانِ متفاوت از یک IP، هر کدام اولین سوالش: ۲۰ تا ۲۰۰، ۲۱اُم و ۲۲اُم ۴۲۹.
دانش‌آموزانِ مهمانِ یک کلاس پشتِ وای‌فایِ مدرسه یا CGNAT با هم ساعتی ۲۰ سوال دارند (روی سرور با ۳ دانش‌آموزانِ مهمانِ یک کلاس پشتِ وای‌فایِ مدرسه یا CGNAT با هم ساعتی ۲۰ سوال دارند (روی سرور با ۳
workerِ بی‌Redis تا حدودِ ۶۰). workerِ بی‌Redis تا حدودِ ۶۰).
- **چرا تصمیمِ محصولی است:** سقفِ IP جلوی «مهمان بساز، سه سوالِ رایگان بگیر» را می‌گیرد. از - **چرا تصمیمِ محصولی است:** سقفِ IP جلوی «مهمان بساز، سه سوالِ رایگان بگیر» را می‌گیرد.
۲۰۲۶-۱۰-۰۳ سقفِ سراسریِ `AI_GLOBAL_DAILY_MAX` (۳۰۰۰ در روز، حدودِ ۶ دلار) هزینه را جدا محدود سقفِ سراسریِ `AI_GLOBAL_DAILY_MAX` (۳۰۰۰ در روز، حدودِ ۶ دلار) هزینه را جدا محدود
می‌کند، پس می‌شود سطل را per-user کرد و فقط ساختِ مهمان را محدود نگه داشت. تا تصمیم، دست نخورد. می‌کند، پس می‌شود سطل را per-user کرد و فقط ساختِ مهمان را محدود نگه داشت. تا تصمیم، دست نخورد.
--- ---
## چیزهایی که بسته شده‌اند (برای اینکه دوباره باز نشوند) ## حفاظ‌هایی که هست (تا دوباره باز نشوند)
در بازبینیِ ۲۰۲۶-۰۹-۲۵ (کامیتِ `42783f9`) بسته شدند؛ جزئیات در `docs/architecture.md`: هر تغییری در این مسیرها باید تست‌های نام‌برده را سبز نگه دارد. جزئیاتِ پرداخت و تخفیف در
پلنِ هر خرید روی سفارش، دست نخوردنِ اشتراکِ فعال هنگامِ خرید، یک کدِ تخفیف پیش از درگاه، `docs/architecture.md`.
حذفِ `/api/payment/webhook`، امتیاز فقط از جوابِ ثبت‌شده، OTPِ ۶ رقمی با ۵ حدس، خروج از حساب،
حفظِ مهمان هنگامِ ثبت‌نام، `select_for_update` مؤثر با `IMMEDIATE`، سقفِ ورودِ ادمین، سقفِ
طولِ سؤالِ AI، مقایسه‌ی زمان‌ثابتِ `X-Internal-Key`.
هر تغییری در این مسیرها باید تست‌های `payment/tests_audit_fixes.py` و | حفاظ | تست |
`accounts/tests_audit_fixes.py` را سبز نگه دارد. |---|---|
| پلنِ هر خرید روی سفارش؛ خرید اشتراکِ فعال را دست نمی‌زند؛ یک کدِ تخفیف پیش از درگاه؛ امتیاز فقط از جوابِ ثبت‌شده | `payment/tests_audit_fixes.py`، `accounts/tests_audit_fixes.py` |
| لینکِ پرداخت (`/api/payment/orders/{id}/pay`) فقط با امضای کوتاه‌عمرِ همراهِ `paymentUrl` باز می‌شود | `payment/tests.py` (`PaymentRedirectViewTests`) |
| توکنِ تمدید فقط به‌صورتِ هشِ SHA-256 ذخیره می‌شود | `accounts/tests.py` (`RefreshTokenStoredHashedTests`) |
| جدولِ رتبه‌بندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) |
| شماره‌ی موبایل نرمال می‌شود؛ cooldown، سقفِ روزانه‌ی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده می‌شوند | `accounts/tests_otp_limits.py` |
| OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکن‌های قبلی را باطل می‌کند | `accounts/tests_audit_fixes.py` |
| قلبِ صفر جوابِ سوال و پایانِ درس/جزیره را می‌بندد؛ چکش داخلِ تراکنش است | `content/tests_hearts_gate.py` |
| `select_for_update` مؤثر با `transaction_mode: IMMEDIATE`؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` |
| دیتابیس و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | با `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` روی سرور باید خطای دسترسی بدهد |
| کتابخانه‌ها بدونِ آسیب‌پذیریِ شناخته‌شده (`urllib3>=2.8.0`، `python-dotenv 1.2.2`) | پایگاهِ OSV روی `pip freeze` |

View File

@@ -932,7 +932,7 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
### ۱۴-۳ مقیاسِ اجرا: کجا گلوگاه است و بعد چه ### ۱۴-۳ مقیاسِ اجرا: کجا گلوگاه است و بعد چه
وضعِ فعلی: ۳ worker × ۳۲ نخ = ۹۶ درخواستِ هم‌زمان (تا ۲۰۲۶-۱۰-۰۴ ۸ نخ بود)؛ درخواستِ عادی چند ده میلی‌ثانیه، جوابِ دستیار روی سرور میانه‌ی ۵ تا ۸ ثانیه و گاهی ۴۰ ثانیه، که تمامش یک نخ را نگه می‌دارد. لودتستِ همان روز: با ۸ نخ از حدودِ ۲۰ پرسشِ هم‌زمان صفحه‌های عادی چند ثانیه صف کشیدند؛ با ۳۲ نخ تا ۷۵ پرسش نه. توکن stateless است، پس هر تعداد نسخه‌ی دیگر از بک‌اند پشتِ nginx بی‌تغییرِ کد کار می‌کند — به شرطِ دیتابیس و کشِ مشترک. وضعِ فعلی: ۳ worker × ۳۲ نخ = ۹۶ درخواستِ هم‌زمان؛ درخواستِ عادی چند ده میلی‌ثانیه، جوابِ دستیار روی سرور میانه‌ی ۵ تا ۸ ثانیه و گاهی ۴۰ ثانیه، که تمامش یک نخ را نگه می‌دارد. در لودتست تا ۷۵ پرسشِ هم‌زمان صفحه‌های عادی صف نکشیدند و از ۱۰۰ به بعد کشیدند. توکن stateless است، پس هر تعداد نسخه‌ی دیگر از بک‌اند پشتِ nginx بی‌تغییرِ کد کار می‌کند — به شرطِ دیتابیس و کشِ مشترک.
| # | گلوگاه | راه‌حلِ ساده‌ی بعدی | نیاز | | # | گلوگاه | راه‌حلِ ساده‌ی بعدی | نیاز |
|---|---|---|---| |---|---|---|---|

View File

@@ -15,13 +15,12 @@ gthread هر worker را چند نخی می‌کند: ۳ worker × ۸ نخ = ۲
تحمل می‌کند. timeout در gthread به ضربانِ خودِ worker است، نه طولِ هر تحمل می‌کند. timeout در gthread به ضربانِ خودِ worker است، نه طولِ هر
درخواست، پس استریمِ طولانی دیگر کشته نمی‌شود. درخواست، پس استریمِ طولانی دیگر کشته نمی‌شود.
۸ → ۳۲ نخ (۲۰۲۶-۱۰-۰۴، لودتست): هر جوابِ دستیار یک نخ را تا آخرِ جواب نگه می‌دارد ۳۲ نخ: هر جوابِ دستیار یک نخ را تا آخرِ جواب نگه می‌دارد (روی سرور میانه‌ی ۳ تا ۶ ثانیه،
(روی سرور میانه ۵ و p90 ۱۳ ثانیه). با ۳×۸ از حدودِ ۲۰ پرسشِ هم‌زمان، صفحه‌های عادیِ اپ گاهی ۴۰). gthread اتصال را حتی وقتی همه‌ی نخ‌هایش مشغول‌اند می‌پذیرد و workerها یکنواخت پر
پشتِ استریم‌ها صف کشیدند (تا ۲۳ ثانیه در ۵۰ پرسش؛ فرانت بعد از ۱۵ ثانیه خطا می‌دهد)، چون نمی‌شوند، پس با نخِ کم، صفحه‌های عادیِ اپ پشتِ استریم‌های دستیار صف می‌کشند. در لودتستِ
gthread اتصال را حتی وقتی همه‌ی نخ‌هایش مشغول‌اند می‌پذیرد و workerها یکنواخت پر نمی‌شوند. ۲۰۲۶-۱۰-۰۴ (لپ‌تاپ، مدل و Qdrantِ شبیه‌سازی‌شده با زمان‌بندیِ سرور) ۳×۳۲ تا ۷۵ پرسشِ هم‌زمان
با ۳×۳۲ تا ۷۵ پرسشِ هم‌زمان صفحه‌ها زیرِ ۱۱۰ میلی‌ثانیه ماندند. این روی لپ‌تاپ با مدل و صفحه‌ها را زیرِ ۰٫۱ ثانیه نگه داشت و از ۱۰۰ به بعد صف پیدا شد. CPUِ مشترکِ سرور در این بار
Qdrantِ شبیه‌سازی‌شده سنجیده شد؛ CPUِ مشترکِ سرور و رفتارِ GapGPT زیرِ این بار جدا سنجیده نشده. نخِ منتظرِ شبکه تقریباً هزینه ندارد؛ هر نخ اتصالِ SQLiteِ خودش را دارد.
سنجیده نشده‌اند. نخِ منتظرِ شبکه تقریباً هزینه ندارد؛ هر نخ اتصالِ SQLiteِ خودش را دارد.
""" """
worker_class = 'gthread' worker_class = 'gthread'
@@ -39,10 +38,9 @@ max_requests_jitter = 200
def post_worker_init(worker): def post_worker_init(worker):
""" """
کتابخانه‌های Qdrant و OpenAI را همان لحظه‌ی بالا آمدنِ worker بار می‌کند، نه سرِ اولین کتابخانه‌های Qdrant و OpenAI را همان لحظه‌ی بالا آمدنِ worker بار می‌کند، نه سرِ اولین
سوالِ دستیار. در لودتستِ ۲۰۲۶-۱۰-۰۴ اولین دورِ پرسش بعد از ری‌استارت روی سرور ~۶ ثانیه سوالِ دستیار. بدونِ این، اولین پرسش‌ها بعد از هر شروعِ worker (ری‌استارت، یا تعویض با
پیش از رسیدن به مدل معطل ماند و دورهای بعد ~۱ ثانیه؛ روی لپ‌تاپ همین تفاوت ۱٫۷ ثانیه بود max_requests) چند ثانیه پیش از رسیدن به مدل معطل می‌مانند: روی سرور ~۶ ثانیه در برابرِ ~۱،
(import ِ qdrant_client به‌تنهایی ~۰٫۷ ثانیه). با max_requests هر worker گاه‌به‌گاه از نو روی لپ‌تاپ ۱٫۷ ثانیه (import ِ qdrant_client به‌تنهایی ~۰٫۷).
بالا می‌آید، پس بدونِ این هر چند هزار درخواست یک دانش‌آموز همین تأخیر را می‌دید.
ساختنِ کلاینت تماسِ شبکه‌ای ندارد. شکستِ این گرم‌کردن نباید worker را بخواباند (کلِ اپ ساختنِ کلاینت تماسِ شبکه‌ای ندارد. شکستِ این گرم‌کردن نباید worker را بخواباند (کلِ اپ
به‌خاطرِ یک بهینه‌سازی پایین نمی‌آید)؛ ثبت می‌شود و اولین سوال همان مسیرِ عادی را می‌رود. به‌خاطرِ یک بهینه‌سازی پایین نمی‌آید)؛ ثبت می‌شود و اولین سوال همان مسیرِ عادی را می‌رود.

View File

@@ -5,6 +5,7 @@
پیاده‌سازی شده، نه یک HTTP call به خودمون. پیاده‌سازی شده، نه یک HTTP call به خودمون.
""" """
from django.conf import settings from django.conf import settings
from django.core import signing
from django.db import IntegrityError from django.db import IntegrityError
from django.db import transaction as db_transaction from django.db import transaction as db_transaction
from django.utils import timezone from django.utils import timezone
@@ -53,7 +54,29 @@ def call_payment_gateway(order: Order) -> str:
باری که کاربر واقعاً این URL را باز کند زده می‌شود (نگاه کنید به باری که کاربر واقعاً این URL را باز کند زده می‌شود (نگاه کنید به
payment/zarinpal.py و PaymentRedirectView در views.py). payment/zarinpal.py و PaymentRedirectView در views.py).
""" """
return f'{settings.PUBLIC_BASE_URL}/api/payment/orders/{order.id}/pay' return f'{settings.PUBLIC_BASE_URL}/api/payment/orders/{order.id}/pay?t={pay_link_token(order.id)}'
# لینکِ پرداخت بی‌توکنِ ورود باز می‌شود (مرورگر هدرِ Bearer نمی‌فرستد) و شناسه‌ی سفارش ترتیبی
# است؛ امضای کوتاه‌عمر جلوی این را می‌گیرد که غریبه با پیمایشِ شناسه‌ها روی سفارشِ دیگران
# authority بسازد (و کدِ تخفیفِ صاحبش را ببندد) یا وضعیتِ سفارششان را از ریدایرکت بخواند.
# امضا با همان DJANGO_SECRET_KEY است؛ تا وقتی آن کلید روی سرور همان کلیدِ داخلِ ریپوست،
# این امضا هم برای کسی که ریپو را دیده جعل‌شدنی است. یک روز: لینک بلافاصله بعد از ساختِ
# سفارش باز می‌شود؛ یک روز صفحه‌ای را هم که باز مانده پوشش می‌دهد.
PAY_LINK_SALT = 'tadrisino-pay-link'
PAY_LINK_MAX_AGE_SECONDS = 24 * 3600
def pay_link_token(order_id: int) -> str:
return signing.TimestampSigner(salt=PAY_LINK_SALT).sign(str(order_id))
def pay_link_is_valid(order_id: int, token: str) -> bool:
try:
value = signing.TimestampSigner(salt=PAY_LINK_SALT).unsign(token, max_age=PAY_LINK_MAX_AGE_SECONDS)
except signing.BadSignature: # SignatureExpired هم زیرکلاسِ همین است
return False
return value == str(order_id)
def create_order(*, user_id: int, amount: int, currency: str = 'IRT', def create_order(*, user_id: int, amount: int, currency: str = 'IRT',

View File

@@ -1,5 +1,6 @@
import importlib import importlib
import os import os
import time
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from django.core.cache import cache from django.core.cache import cache
@@ -14,7 +15,7 @@ from . import bazaar, zarinpal
from .bazaar import BazaarPurchaseResult from .bazaar import BazaarPurchaseResult
from .models import Order, Transaction from .models import Order, Transaction
from .permissions import HasInternalServiceKey from .permissions import HasInternalServiceKey
from .services import call_payment_gateway from .services import PAY_LINK_MAX_AGE_SECONDS, call_payment_gateway, pay_link_token
class SecretDefaultsFailClosedTests(TestCase): class SecretDefaultsFailClosedTests(TestCase):
@@ -467,7 +468,13 @@ class CallPaymentGatewayTests(TestCase):
url = call_payment_gateway(order) url = call_payment_gateway(order)
self.assertEqual(url, f'https://api.tadrisino.ir/api/payment/orders/{order.id}/pay') self.assertTrue(url.startswith(f'https://api.tadrisino.ir/api/payment/orders/{order.id}/pay?t='))
def _pay_path(order):
"""همان paymentUrlی که کلاینت می‌گیرد، بی‌دامنه."""
url = call_payment_gateway(order)
return url[url.index('/api/'):]
class PaymentRedirectViewTests(TestCase): class PaymentRedirectViewTests(TestCase):
@@ -495,14 +502,14 @@ class PaymentRedirectViewTests(TestCase):
mock_request_payment.return_value = zarinpal.ZarinpalRequestResult( mock_request_payment.return_value = zarinpal.ZarinpalRequestResult(
authority='A-ref', payment_url='https://www.zarinpal.com/pg/StartPay/A-ref', authority='A-ref', payment_url='https://www.zarinpal.com/pg/StartPay/A-ref',
) )
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay') response = self.client.get(_pay_path(self.order))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response['Referrer-Policy'], 'strict-origin-when-cross-origin') self.assertEqual(response['Referrer-Policy'], 'strict-origin-when-cross-origin')
self.assertEqual(self.client.get('/api/health')['Referrer-Policy'], 'same-origin') self.assertEqual(self.client.get('/api/health')['Referrer-Policy'], 'same-origin')
@override_settings(ZARINPAL_MERCHANT_ID='', ZARINPAL_CALLBACK_URL='') @override_settings(ZARINPAL_MERCHANT_ID='', ZARINPAL_CALLBACK_URL='')
def test_unconfigured_gateway_returns_503(self): def test_unconfigured_gateway_returns_503(self):
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay') response = self.client.get(_pay_path(self.order))
self.assertEqual(response.status_code, 503) self.assertEqual(response.status_code, 503)
@override_settings( @override_settings(
@@ -515,7 +522,7 @@ class PaymentRedirectViewTests(TestCase):
authority='A-first', payment_url='https://www.zarinpal.com/pg/StartPay/A-first', authority='A-first', payment_url='https://www.zarinpal.com/pg/StartPay/A-first',
) )
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay') response = self.client.get(_pay_path(self.order))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, 'https://www.zarinpal.com/pg/StartPay/A-first') self.assertEqual(response.url, 'https://www.zarinpal.com/pg/StartPay/A-first')
@@ -532,7 +539,7 @@ class PaymentRedirectViewTests(TestCase):
self.order.gateway_authority = 'A-existing' self.order.gateway_authority = 'A-existing'
self.order.save(update_fields=['gateway_authority']) self.order.save(update_fields=['gateway_authority'])
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay') response = self.client.get(_pay_path(self.order))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, 'https://www.zarinpal.com/pg/StartPay/A-existing') self.assertEqual(response.url, 'https://www.zarinpal.com/pg/StartPay/A-existing')
@@ -543,13 +550,35 @@ class PaymentRedirectViewTests(TestCase):
self.order.status = 'paid' self.order.status = 'paid'
self.order.save(update_fields=['status']) self.order.save(update_fields=['status'])
response = self.client.get(f'/api/payment/orders/{self.order.id}/pay') response = self.client.get(_pay_path(self.order))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
mock_request_payment.assert_not_called() mock_request_payment.assert_not_called()
@patch('payment.views.zarinpal.request_payment')
def test_link_without_or_with_foreign_signature_is_404(self, mock_request_payment):
# شناسه‌ی سفارش ترتیبی است؛ غریبه نباید با پیمایشِ /orders/<n>/pay روی سفارشِ دیگران
# authority بسازد یا وضعیتش را از ریدایرکت بخواند.
other = Order.objects.create(
user_id=2, amount=100000, currency='IRT', provider='gateway', idempotency_key='pay-view-other',
)
for path in (
f'/api/payment/orders/{self.order.id}/pay',
f'/api/payment/orders/{self.order.id}/pay?t={pay_link_token(other.id)}',
f'/api/payment/orders/{self.order.id}/pay?t=garbage',
):
self.assertEqual(self.client.get(path).status_code, 404, path)
mock_request_payment.assert_not_called()
self.order.refresh_from_db()
self.assertEqual(self.order.gateway_authority, '')
def test_expired_link_is_404(self):
path = _pay_path(self.order)
with patch('django.core.signing.time.time', return_value=time.time() + PAY_LINK_MAX_AGE_SECONDS + 5):
self.assertEqual(self.client.get(path).status_code, 404)
def test_unknown_order_is_404(self): def test_unknown_order_is_404(self):
response = self.client.get('/api/payment/orders/999999/pay') response = self.client.get(f'/api/payment/orders/999999/pay?t={pay_link_token(999999)}')
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_bazaar_order_is_404_on_gateway_pay_endpoint(self): def test_bazaar_order_is_404_on_gateway_pay_endpoint(self):
@@ -557,7 +586,7 @@ class PaymentRedirectViewTests(TestCase):
user_id=1, amount=100000, provider='bazaar', user_id=1, amount=100000, provider='bazaar',
bazaar_product_sku='sku', idempotency_key='pay-view-bazaar-1', bazaar_product_sku='sku', idempotency_key='pay-view-bazaar-1',
) )
response = self.client.get(f'/api/payment/orders/{bazaar_order.id}/pay') response = self.client.get(_pay_path(bazaar_order))
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@@ -690,7 +719,7 @@ class PaymentReturnsToTheAppTests(TestCase):
status='paid', idempotency_key='ret-2', status='paid', idempotency_key='ret-2',
) )
response = self.client.get(f'/api/payment/orders/{order.id}/pay') response = self.client.get(_pay_path(order))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual( self.assertEqual(

View File

@@ -2,7 +2,7 @@ from urllib.parse import urlsplit
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django.http import HttpResponse, HttpResponseRedirect from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.utils.html import escape from django.utils.html import escape
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@@ -25,7 +25,7 @@ from .serializers import (
OrderDetailResponseSerializer, OrderDetailResponseSerializer,
ZarinpalCallbackResponseSerializer, ZarinpalCallbackResponseSerializer,
) )
from .services import apply_order_payment_result, create_order, reconcile_pending_order from .services import apply_order_payment_result, create_order, pay_link_is_valid, reconcile_pending_order
def _redirect_to_configured_url(url: str) -> HttpResponseRedirect: def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
@@ -142,10 +142,10 @@ class PaymentRedirectView(APIView):
(مثلاً دکمه‌ی back مرورگر) همان authority قبلی را دوباره استفاده (مثلاً دکمه‌ی back مرورگر) همان authority قبلی را دوباره استفاده
می‌کند، نه یک PaymentRequest تازه. می‌کند، نه یک PaymentRequest تازه.
⚠️ AllowAny است و مالکیتِ سفارش را نمی‌سنجد (مرورگر هدرِ Bearer نمی‌فرستد)؛ AllowAny است چون مرورگر هدرِ Bearer نمی‌فرستد؛ به‌جایش پارامترِ `t`، امضای
شناسه‌ی سفارش ترتیبی است. هر کسی با باز کردنِ `/orders/<n>/pay` برای سفارشِ کوتاه‌عمری که فقط در paymentUrlِ صاحبِ سفارش می‌آید (payment/services.py،
دیگران authority می‌سازد و از آن به بعد صاحبِ سفارش دیگر نمی‌تواند کدِ pay_link_token)، سنجیده می‌شود. بی‌امضا یا امضای سفارشِ دیگر همان ۴۰۴ِ سفارشِ
تخفیف بزند (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد). ناموجود را می‌گیرد تا وجودِ شناسه هم لو نرود.
""" """
permission_classes = [AllowAny] permission_classes = [AllowAny]
@@ -164,11 +164,17 @@ class PaymentRedirectView(APIView):
return response return response
@extend_schema( @extend_schema(
parameters=[OpenApiParameter(
't', OpenApiTypes.STR, required=True,
description='امضای همراهِ paymentUrl؛ کلاینت خودش نمی‌سازد، همان آدرسِ کامل را باز می‌کند.',
)],
responses={302: OpenApiTypes.NONE}, responses={302: OpenApiTypes.NONE},
description='به درگاهِ زرین‌پال ریدایرکت می‌کند؛ بدنه‌ی JSON ندارد.', description='به درگاهِ زرین‌پال ریدایرکت می‌کند؛ بدنه‌ی JSON ندارد.',
tags=['Payment (payment)'], tags=['Payment (payment)'],
) )
def get(self, request, order_id): def get(self, request, order_id):
if not pay_link_is_valid(order_id, request.query_params.get('t', '')):
raise Http404
order = get_object_or_404(Order, pk=order_id, provider='gateway') order = get_object_or_404(Order, pk=order_id, provider='gateway')
if order.status != 'pending': if order.status != 'pending':

View File

@@ -269,7 +269,21 @@ class FourCompanionsCatalogTests(TestCase):
class LeaderboardPeriodValidationTests(TestCase): class LeaderboardPeriodValidationTests(TestCase):
def test_unknown_period_is_a_400(self): def setUp(self):
from accounts.authentication import issue_access_token
from accounts.models import User
from rest_framework.test import APIClient from rest_framework.test import APIClient
self.assertEqual(APIClient().get('/api/leaderboard?period=xyz').status_code, 400)
self.assertEqual(APIClient().get('/api/leaderboard?period=all_time').status_code, 200) self.client = APIClient()
user = User.objects.create(device_id='lb-dev', is_guest=True)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def test_unknown_period_is_a_400(self):
self.assertEqual(self.client.get('/api/leaderboard?period=xyz').status_code, 400)
self.assertEqual(self.client.get('/api/leaderboard?period=all_time').status_code, 200)
def test_requires_login(self):
# نامِ کاربران (بیشترشان کودک) نباید بی‌ورود قابلِ جمع‌آوری باشد؛ مهمان هم توکن دارد.
from rest_framework.test import APIClient
self.assertEqual(APIClient().get('/api/leaderboard').status_code, 401)

View File

@@ -91,7 +91,7 @@ class ActivityFeedsStreakAndLeaderboardTests(TestCase):
) )
for period in ('weekly', 'all_time'): for period in ('weekly', 'all_time'):
entries = self.client.get(f'/api/leaderboard?period={period}').json()['entries'] entries = self.client.get(f'/api/leaderboard?period={period}', **self.auth).json()['entries']
self.assertEqual(entries[0]['userId'], str(self.user.id), period) self.assertEqual(entries[0]['userId'], str(self.user.id), period)
self.assertEqual(entries[0]['name'], 'سارا') self.assertEqual(entries[0]['name'], 'سارا')
self.assertEqual(entries[0]['xpInPeriod'], 30, period) self.assertEqual(entries[0]['xpInPeriod'], 30, period)

View File

@@ -127,8 +127,14 @@ LEADERBOARD_CACHE_TTL = 60
class LeaderboardView(APIView): class LeaderboardView(APIView):
"""GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه""" """
GET /api/leaderboard?period= — رتبه‌بندی هفتگی/ماهانه
فقط برای کاربرِ واردشده (مهمان هم توکن دارد): پاسخ نامِ کاربران، که بیشترشان کودک‌اند،
و شناسه‌ی داخلی‌شان را دارد و بدونِ ورود برای هر کسی در اینترنت قابلِ جمع‌آوری بود.
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'read' throttle_scope = 'read'
throttle_classes = [GuestAwareReadThrottle] throttle_classes = [GuestAwareReadThrottle]

View File

@@ -6,11 +6,10 @@ requests==2.33.1
redis==5.2.1 redis==5.2.1
python-dotenv==1.2.2 python-dotenv==1.2.2
whitenoise==6.11.0 whitenoise==6.11.0
# همان نسخه‌ای که سرور با آن اجرا می‌شود (یونیتِ systemd آن را از venv صدا می‌زند). تا # سرور با همین اجرا می‌شود (یونیتِ systemd آن را از venv صدا می‌زند؛ تنظیمات در gunicorn.conf.py).
# ۲۰۲۶-۱۰-۰۴ در این فایل نبود و فقط دستی روی سرور نصب شده بود.
gunicorn==26.2.0 gunicorn==26.2.0
# وابستگیِ غیرمستقیمِ requests و qdrant-client. ۲.۷.۰ سه آسیب‌پذیریِ شناخته‌شده داشت # وابستگیِ غیرمستقیمِ requests و qdrant-client؛ نسخه‌های پایین‌تر آسیب‌پذیریِ شناخته‌شده دارند
# (GHSA-8988-9cw3-xx77، GHSA-vxq7-64xx-v4gw، GHSA-gh4c-6fx4-qh6g؛ OSV، ۲۰۲۶-۱۰-۰۴). # (GHSA-8988-9cw3-xx77، GHSA-vxq7-64xx-v4gw، GHSA-gh4c-6fx4-qh6g).
urllib3>=2.8.0 urllib3>=2.8.0
# RAG / AI tutor (اپ rag). کلاینت‌ها lazy import می‌شن؛ نبودِ این‌ها فقط # RAG / AI tutor (اپ rag). کلاینت‌ها lazy import می‌شن؛ نبودِ این‌ها فقط

41
tools/loadtest/README.md Normal file
View File

@@ -0,0 +1,41 @@
# لودتستِ دستیار
سه اسکریپت، برای اینکه «چند دانش‌آموزِ هم‌زمان» هر بار با یک روش سنجیده شود. خروجیِ هر اجرا یک
JSON است با زمانِ هر پرسش و هر درخواستِ صفحه‌ی عادی؛ جمع‌بندی در خودِ خروجیِ ترمینال چاپ می‌شود.
| فایل | کار |
|---|---|
| `mkusers.py` | کاربرانِ آزمایشیِ غیرمهمان (شماره‌ی جعلیِ `0900…`، بی‌پیامک، بی‌سیگنال) با توکن و سه سوالِ متفاوت از بانکِ سوال برای هر کدام |
| `loadtest.py` | N نفر هم‌زمان می‌پرسند (استریم، دقیقاً مثلِ فرانت) و B نفرِ دیگر در خانه/آزمون/خلاصه/سهمیه می‌چرخند |
| `fake_upstream.py` | جایگزینِ GapGPT و Qdrant برای آزمایشگاه: embedding، جوابِ استریم با زمانِ لاگ‌نرمال (پیش‌فرض: میانه‌ی ۵٫۲ و صدکِ ۹۰ِ ۱۳ ثانیه، از جواب‌های واقعیِ سرور) و جست‌وجوی Qdrant |
## آزمایشگاه (بی‌هزینه، روی لپ‌تاپ)
روی یک کپی از پروژه اجرا کنید تا دیتابیسِ اصلیِ لوکال پر نشود. Qdrant هم شبیه‌سازی می‌شود، چون
از لپ‌تاپ پهنای باندِ بالارفتن (هر بردار ~۳۰ کیلوبایت) جست‌وجوها را سریالی نشان می‌دهد؛ از خودِ
سرور ۲۰ جست‌وجوی هم‌زمان ۰٫۲۶ ثانیه است.
```bash
python tools/loadtest/fake_upstream.py & # :8901 (FAKE_SIGMA=0 یعنی همیشه ۵٫۲ ثانیه)
export DJANGO_DEBUG=0 CACHE_URL= NUM_PROXIES=0 AI_DAILY_QUOTA_FREE=1000 HEARTS_ENFORCED=0 RAG_FORCE_MOCK=0 \
DJANGO_ALLOWED_HOSTS=127.0.0.1 OPENAI_BASE_URL=http://127.0.0.1:8901/v1 OPENAI_API_KEY=x \
QDRANT_URL=http://127.0.0.1:8901 QDRANT_API_KEY=x
LT_N=150 python manage.py shell -c "exec(open('tools/loadtest/mkusers.py').read())" | grep ^LTJSON | cut -c7- > users.json
gunicorn --bind 127.0.0.1:8800 config.wsgi:application & # همان gunicorn.conf.py ِ سرور
python tools/loadtest/loadtest.py http://127.0.0.1:8800 users.json 50 10 0 out.json --writes
```
پیش از هر اجرا gunicorn را از نو بالا بیاورید (کشِ جوابِ هر worker خالی شود) و مطمئن شوید پروسه‌ی
قبلی واقعاً رفته است؛ وگرنه سوال‌های تکراری از کش در ۰٫۱ ثانیه جواب می‌گیرند و عدد دروغ می‌گوید.
## سرورِ اصلی (هزینه‌ی واقعی: ~۰٫۱۸ سنت برای هر جواب)
۱. بکاپ از دیتابیس (`docs/operations.md`). ۲. کاربرانِ آزمایشی را روی سرور با `mkusers.py` بسازید
(`LT_PREFIX` یک پیشوندِ جعلی که روی سرور نیست) و خروجی را فقط در یک فایلِ 600 نگه دارید.
۳. `loadtest.py` را با `https://mathapi.tadrisino2.ir` و بدونِ `--writes` اجرا کنید.
۴. زمانِ داخلِ GapGPT برای هر پیام در `ai_tutor.Message.latency_ms` است؛ اختلافِ آن با زمانِ کلاینت
یعنی کارِ سرورِ ما و صفِ انتظار برای نخِ آزاد. ۵. `manage.py delete_users --ids … --yes` و پاک کردنِ
فایلِ توکن‌ها.
سقفِ ساعتیِ دستیار برای مهمان‌ها بر اساسِ IP است؛ با کاربرِ مهمان از یک ماشین بیش از ~۲۰ پرسش در
ساعت ممکن نیست، برای همین `mkusers.py` کاربرِ غیرمهمان می‌سازد.

View File

@@ -0,0 +1,130 @@
"""
Fake GapGPT + Qdrant for load testing (embeddings, streaming chat, Qdrant points/query). Local only.
Timing profile is calibrated from the last 54 real assistant answers on the server
(Message.latency_ms, gpt-4.1-mini via GapGPT): median 5.2 s, p90 13.1 s, max 30 s.
Lognormal(median=FAKE_MEDIAN, sigma=FAKE_SIGMA), capped at FAKE_CAP.
FAKE_MODE=buffered -> wait the whole latency, then emit all chunks (what GapGPT was
observed to do: first chunk arrives with the last).
FAKE_MODE=stream -> first token after 20% of latency, rest spread over the remainder.
"""
import json
import math
import os
import random
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
MEDIAN = float(os.environ.get('FAKE_MEDIAN', '5.2'))
SIGMA = float(os.environ.get('FAKE_SIGMA', '0.715'))
CAP = float(os.environ.get('FAKE_CAP', '30'))
MODE = os.environ.get('FAKE_MODE', 'buffered')
EMB_DELAY = float(os.environ.get('FAKE_EMB_DELAY', '0.35'))
QDRANT_DELAY = float(os.environ.get('FAKE_QDRANT_DELAY', '0.08'))
PORT = int(os.environ.get('FAKE_PORT', '8901'))
ANSWER = ('برای حلِ این مسئله اول داده‌ها را می‌نویسیم. '
'بعد قدم‌به‌قدم جلو می‌رویم و هر مرحله را وارسی می‌کنیم. ') * 14 + '✅ جواب: ۱۲'
active = 0
peak = 0
lock = threading.Lock()
class H(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, *a):
pass
def _json(self, obj):
body = json.dumps(obj).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._json({'active': active, 'peak': peak})
def do_POST(self):
global active, peak
n = int(self.headers.get('Content-Length') or 0)
req = json.loads(self.rfile.read(n) or b'{}')
if '/points/query' in self.path:
time.sleep(QDRANT_DELAY)
pts = [{'id': i + 1, 'version': 1, 'score': 0.5 - i * 0.01,
'payload': {'lesson_title': 'درس نمونه', 'problem': 'مسئله‌ی نمونه ' * 30,
'generalSolution': 'روشِ کلی ' * 40, 'solution': 'راه‌حل ' * 60}}
for i in range(int(req.get('limit') or 4))]
self._json({'result': {'points': pts}, 'status': 'ok', 'time': 0.001})
return
if self.path.endswith('/embeddings'):
time.sleep(EMB_DELAY)
vec = [random.uniform(-1, 1) for _ in range(1536)]
norm = math.sqrt(sum(v * v for v in vec))
self._json({'object': 'list', 'model': req.get('model'),
'data': [{'object': 'embedding', 'index': 0, 'embedding': [v / norm for v in vec]}],
'usage': {'prompt_tokens': 20, 'total_tokens': 20}})
return
with lock:
active += 1
peak = max(peak, active)
try:
latency = min(CAP, MEDIAN * math.exp(random.gauss(0, SIGMA)))
words = ANSWER.split(' ')
pieces = [' '.join(words[i:i + 5]) + ' ' for i in range(0, len(words), 5)]
if not req.get('stream'):
time.sleep(latency)
self._json({'id': 'x', 'object': 'chat.completion', 'created': int(time.time()),
'model': req.get('model'),
'choices': [{'index': 0, 'message': {'role': 'assistant', 'content': ANSWER},
'finish_reason': 'stop'}],
'usage': {'prompt_tokens': 1400, 'completion_tokens': 400, 'total_tokens': 1800}})
return
self.send_response(200)
self.send_header('Content-Type', 'text/event-stream')
self.send_header('Transfer-Encoding', 'chunked')
self.end_headers()
def send(obj):
data = f'data: {json.dumps(obj, ensure_ascii=False)}\n\n'.encode()
self.wfile.write(f'{len(data):x}\r\n'.encode() + data + b'\r\n')
self.wfile.flush()
def chunk(text, finish=None):
return {'id': 'x', 'object': 'chat.completion.chunk', 'created': int(time.time()),
'model': req.get('model'),
'choices': [{'index': 0, 'delta': {'content': text} if text else {},
'finish_reason': finish}]}
if MODE == 'buffered':
time.sleep(latency)
gap = 0.0
else:
time.sleep(latency * 0.2)
gap = latency * 0.8 / len(pieces)
for p in pieces:
send(chunk(p))
if gap:
time.sleep(gap)
send(chunk('', 'stop'))
send({'id': 'x', 'object': 'chat.completion.chunk', 'created': int(time.time()),
'model': req.get('model'), 'choices': [],
'usage': {'prompt_tokens': 1400, 'completion_tokens': 400, 'total_tokens': 1800}})
last = b'data: [DONE]\n\n'
self.wfile.write(f'{len(last):x}\r\n'.encode() + last + b'\r\n0\r\n\r\n')
self.wfile.flush()
finally:
with lock:
active -= 1
if __name__ == '__main__':
ThreadingHTTPServer.daemon_threads = True
ThreadingHTTPServer.request_queue_size = 256
print(f'fake llm on :{PORT} mode={MODE} median={MEDIAN}', flush=True)
ThreadingHTTPServer(('127.0.0.1', PORT), H).serve_forever()

128
tools/loadtest/loadtest.py Normal file
View File

@@ -0,0 +1,128 @@
"""
Load test: N students ask the assistant at the same moment (stream=true, exactly like the
frontend), while B other students keep browsing the app (home, quiz, summary, quota).
usage: python loadtest.py BASE_URL users.json N_ASKERS N_BROWSERS ROUND OUT.json [--stagger S]
users.json: output of mkusers.py; the first N_ASKERS users ask, the next N_BROWSERS browse.
ROUND: which of each user's prepared questions to ask (0..2) — distinct text, so no answer-cache hits.
Timing per ask: t_headers (HTTP status line), t_first (first `chunk` event), t_done (`done` event).
"""
import asyncio
import json
import sys
import time
import httpx
BASE, USERS, N, B, ROUND, OUT = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]), int(sys.argv[5]), sys.argv[6]
WRITES = '--writes' in sys.argv
STAGGER = float(sys.argv[sys.argv.index('--stagger') + 1]) if '--stagger' in sys.argv else 0.0
users = json.load(open(USERS))
askers, browsers = users[:N], users[N:N + B]
T0 = None
stop = asyncio.Event()
async def ask(client, u, idx):
await asyncio.sleep(idx * STAGGER)
a = u['asks'][ROUND]
rec = {'userId': u['userId'], 'grade': u['grade'], 'start': time.monotonic() - T0}
t = time.monotonic()
try:
async with client.stream('POST', f'{BASE}/api/ai/ask', json={
'userQuestion': a['question'], 'lessonId': str(a['lessonId']), 'stream': True,
}, headers={'Authorization': f"Bearer {u['token']}"}, timeout=httpx.Timeout(180, connect=30)) as r:
rec['status'] = r.status_code
rec['t_headers'] = time.monotonic() - t
if r.status_code != 200:
rec['body'] = (await r.aread()).decode(errors='replace')[:300]
return rec
buf, nchunks = '', 0
async for text in r.aiter_text():
buf += text
while '\n\n' in buf:
part, buf = buf.split('\n\n', 1)
ev = next((l[7:] for l in part.split('\n') if l.startswith('event: ')), '')
if ev == 'chunk':
nchunks += 1
rec.setdefault('t_first', time.monotonic() - t)
elif ev == 'done':
rec['t_done'] = time.monotonic() - t
d = json.loads(next(l[6:] for l in part.split('\n') if l.startswith('data: ')))
rec['chunksUsed'] = len(d.get('usedChunks') or [])
rec['messageId'] = d.get('messageId')
elif ev == 'error':
rec['error'] = part[-200:]
rec['nchunks'] = nchunks
rec['t_end'] = time.monotonic() - t
except Exception as exc: # recorded, never hidden: a timeout is a result here
rec['exc'] = f'{type(exc).__name__}: {exc}'[:200]
rec['t_end'] = time.monotonic() - t
return rec
async def browse(client, u):
recs = []
paths = ['/api/home', f"/api/nodes/{u['nodeId']}/quiz", '/api/user/summary', '/api/ai/quota']
if WRITES:
paths.append('answer')
quiz = None
i = 0
while not stop.is_set():
p = paths[i % len(paths)]
i += 1
t = time.monotonic()
try:
if p == 'answer':
q = quiz['questions'][i % len(quiz['questions'])]
r = await client.post(f"{BASE}/api/questions/{q['id']}/answer", json={'selectedOptionId': int(q['options'][0]['id'])},
headers={'Authorization': f"Bearer {u['token']}"}, timeout=60)
p = '/x/answer'
else:
r = await client.get(f'{BASE}{p}', headers={'Authorization': f"Bearer {u['token']}"}, timeout=60)
if '/quiz' in p and r.status_code == 200:
quiz = r.json()
recs.append({'path': p.split('/')[2], 'status': r.status_code, 'dt': time.monotonic() - t, 'at': t - T0})
except Exception as exc:
recs.append({'path': p.split('/')[2], 'status': 0, 'exc': type(exc).__name__, 'dt': time.monotonic() - t, 'at': t - T0})
await asyncio.sleep(1.0)
return recs
async def main():
global T0
limits = httpx.Limits(max_connections=200, max_keepalive_connections=200)
async with httpx.AsyncClient(limits=limits, http2=False) as client:
T0 = time.monotonic()
btasks = [asyncio.create_task(browse(client, u)) for u in browsers]
await asyncio.sleep(3 if browsers else 0) # baseline browsing before the burst
burst_at = time.monotonic() - T0
results = await asyncio.gather(*(ask(client, u, i) for i, u in enumerate(askers)))
burst_end = time.monotonic() - T0
await asyncio.sleep(2 if browsers else 0)
stop.set()
brecs = [r for t in btasks for r in await t]
json.dump({'base': BASE, 'n': N, 'browsers': B, 'round': ROUND, 'stagger': STAGGER,
'burst_at': burst_at, 'burst_end': burst_end, 'asks': results, 'browse': brecs},
open(OUT, 'w'), ensure_ascii=False, indent=1)
ok = [r for r in results if r.get('t_done')]
td = sorted(r['t_done'] for r in ok)
tf = sorted(r['t_first'] for r in ok if r.get('t_first'))
q = lambda a, p: a[min(len(a) - 1, int(p * len(a)))] if a else None
print(f'N={N} ok={len(ok)}/{N} done p50={q(td, .5) and round(q(td, .5), 1)} p90={q(td, .9) and round(q(td, .9), 1)} '
f'max={td and round(td[-1], 1)} first p50={q(tf, .5) and round(q(tf, .5), 1)}')
bad = [r for r in results if not r.get('t_done')]
for r in bad:
print(' FAIL', {k: r.get(k) for k in ('userId', 'status', 'body', 'error', 'exc', 't_end')})
if brecs:
during = sorted(r['dt'] for r in brecs if burst_at <= r['at'] <= burst_end)
before = sorted(r['dt'] for r in brecs if r['at'] < burst_at)
errs = [r for r in brecs if r['status'] != 200]
w = sorted(r['dt'] for r in brecs if r['path'] == 'answer' and burst_at <= r['at'] <= burst_end)
if w:
print(f' writes during: n={len(w)} p50={round(q(w, .5) * 1000)}ms max={round(w[-1] * 1000)}ms statuses={sorted(set(r["status"] for r in brecs if r["path"] == "answer"))}')
print(f' browse before p50={q(before, .5) and round(q(before, .5) * 1000)}ms | during p50={q(during, .5) and round(q(during, .5) * 1000)}ms '
f'p90={q(during, .9) and round(q(during, .9) * 1000)}ms max={during and round(during[-1] * 1000)}ms n={len(during)} errors={len(errs)}')
asyncio.run(main())

30
tools/loadtest/mkusers.py Normal file
View File

@@ -0,0 +1,30 @@
# Run with: manage.py shell -c "exec(open('tools/loadtest/mkusers.py').read())" env: LT_N, LT_PREFIX, LT_SEED
# Creates N non-guest test users (fake 0900… numbers, no signals, no SMS) and prints
# [{userId, token, grade, asks:[{lessonId, question}], nodeId, questionIds}] as JSON.
import json
import os
import random
from accounts.authentication import issue_access_token
from accounts.models import User
from content.models import Lesson, Question
N = int(os.environ['LT_N'])
PREFIX = os.environ.get('LT_PREFIX', '0900990')
GRADES = [8, 9, 10, 11, 12, 4, 5, 6, 7, 3]
rnd = random.Random(int(os.environ.get('LT_SEED', '7')))
out = []
for i in range(N):
grade = GRADES[i % len(GRADES)]
phone = f'{PREFIX}{i:04d}'
user, _ = User.objects.get_or_create(phone=phone, defaults={'is_guest': False, 'name': f'تست بار {i}', 'grade_id': grade})
qs = list(Question.objects.filter(lesson__node__unit__grade_id=grade).select_related('lesson__node')
.values_list('id', 'prompt', 'lesson_id', 'lesson__node_id')[:4000])
picks = rnd.sample(qs, 6)
out.append({
'userId': user.id, 'token': issue_access_token(user.id), 'grade': grade,
'asks': [{'lessonId': p[2], 'question': f'این سوال رو قدم به قدم توضیح بده: {p[1][:300]}'} for p in picks[:3]],
'nodeId': picks[3][3], 'answerQuestionIds': [p[0] for p in picks[3:]],
})
print('LTJSON' + json.dumps(out, ensure_ascii=False))