Spike readiness: AI gets its own slice of threads, cheaper hot paths, classroom-friendly login

- /api/ai/ask: at most AI_MAX_CONCURRENT_PER_WORKER (12 of 32 threads) answers per worker;
  beyond that 503 assistant_busy + Retry-After before any quota is reserved. Without it a
  burst of questions (3-40 s each) took every thread and the map and quizzes queued behind.
  The slot is held until the stream is actually finished or closed.
- Answer submission 21 -> 14 queries, 15.4 -> 11.0 ms: one query for all choices, locked
  get_or_create for node progress and streak, and today's streak recorded once per day
  via the cache instead of a transaction per answer.
- /api/home 12.4 -> 8.6 ms: the user's completions are read by user_id instead of an IN list
  of ~600 lesson ids; the dict is returned directly (serializer stays the Swagger contract,
  HomeMatchesItsSerializerTests keeps them equal).
- otp_login per-IP 30 -> 300/hour: a class of 30 on one school Wi-Fi filled it; guessing is
  bounded per code and per phone.
- /api/health aiConcurrency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 20:55:24 +03:30
parent feaa5eb540
commit 2c708088e4
11 changed files with 279 additions and 19 deletions

View File

@@ -125,7 +125,7 @@ API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
**Structured logging**: `config.middleware.APIRequestLoggingMiddleware` (last in `MIDDLEWARE`, closest to the view) logs every `/api/` request as JSON (method/path/status/duration/user, no body/headers/secrets) via `config/log_formatters.py`.
**Postgres** is the server's DB since 2026-10-08 (own cluster `14/zareian`, port 5440, localhost only; DB `zareian_mathapp`, role `zareian_app`; selected by `DB_NAME`/`DB_USER`/`DB_PASSWORD`/`DB_HOST`/`DB_PORT` in `.env`; passwords in `~/mathapp-v2/.pg_password`, `.redis_password`). It is separate from the company's `main` cluster on 5432 — never touch that. Without `DB_NAME` the app falls back to SQLite (local dev, tests); both run the full suite green. Data was moved with `dumpdata`/`loaddata`; model-count comparison matched. Guards that relied on SQLite's `IMMEDIATE` now rely on real row locks; concurrency tests pass on Postgres. **Connections come from a per-worker pool** (`OPTIONS.pool`, `DB_POOL_MAX_SIZE` 8; `CONN_MAX_AGE` is gone and `DB_CONN_MAX_AGE` in `.env` stops startup): Postgres here has `max_connections=60`, and the old one-connection-per-thread setup (3 workers × 32 threads) turned 21% of requests into 500s at 100 concurrent users in a local load test against a 60-connection Postgres; with the pool 0 errors. A full pool gives 503 + `Retry-After` (`config/db.py`), not 500; `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`. The AI tutor and the OTP SMS call hand their connection back before the network wait (`release_db_connection`). Worker count lives in `gunicorn.conf.py` (4; the systemd unit no longer passes `--workers`). Not load-tested on the server itself. `deploy.sh` backs up with `pg_dump -Fc` before `migrate`; the off-site live copy is in «بکاپ: کپیِ زنده» below. Operator guide: `~/Desktop/گزارشات Tadrisino/گزارش-۲۹/راهنمای-Postgres-و-Redis.html`. The pre-move `db.sqlite3` and migration dumps were deleted from the server; no SQLite file is left there.
**Postgres** is the server's DB since 2026-10-08 (own cluster `14/zareian`, port 5440, localhost only; DB `zareian_mathapp`, role `zareian_app`; selected by `DB_NAME`/`DB_USER`/`DB_PASSWORD`/`DB_HOST`/`DB_PORT` in `.env`; passwords in `~/mathapp-v2/.pg_password`, `.redis_password`). It is separate from the company's `main` cluster on 5432 — never touch that. Without `DB_NAME` the app falls back to SQLite (local dev, tests); both run the full suite green. Data was moved with `dumpdata`/`loaddata`; model-count comparison matched. Guards that relied on SQLite's `IMMEDIATE` now rely on real row locks; concurrency tests pass on Postgres. **Connections come from a per-worker pool** (`OPTIONS.pool`, `DB_POOL_MAX_SIZE` 8; `CONN_MAX_AGE` is gone and `DB_CONN_MAX_AGE` in `.env` stops startup): Postgres here has `max_connections=60`, and the old one-connection-per-thread setup (3 workers × 32 threads) turned 21% of requests into 500s at 100 concurrent users in a local load test against a 60-connection Postgres; with the pool 0 errors. A full pool gives 503 + `Retry-After` (`config/db.py`), not 500; `/api/health` → `dbPool` checks `workers × pool + 6 ≤ max_connections`. The AI tutor and the OTP SMS call hand their connection back before the network wait (`release_db_connection`). Worker count lives in `gunicorn.conf.py` (4; the systemd unit no longer passes `--workers`). **The AI tutor has its own slice of threads** (`AI_MAX_CONCURRENT_PER_WORKER`, 12 of 32, `ai_tutor/concurrency.py`): beyond that `/api/ai/ask` answers 503 `Retry-After: 10` before reserving quota, so a chat spike cannot starve the map and quizzes. The frontend spreads retries over 1–2× `Retry-After`. Not load-tested on the server itself. `deploy.sh` backs up with `pg_dump -Fc` before `migrate`; the off-site live copy is in «بکاپ: کپیِ زنده» below. Operator guide: `~/Desktop/گزارشات Tadrisino/گزارش-۲۹/راهنمای-Postgres-و-Redis.html`. The pre-move `db.sqlite3` and migration dumps were deleted from the server; no SQLite file is left there.
**SMS** goes through Kavenegar (`KAVENEGAR_API_KEY`/`KAVENEGAR_SENDER`); failures return explicit 503, not a fake success.

76
ai_tutor/concurrency.py Normal file
View File

@@ -0,0 +1,76 @@
"""
سقفِ جواب‌های هم‌زمانِ دستیار در هر worker (AI_MAX_CONCURRENT_PER_WORKER).
جوابِ دستیار یک نخِ gunicorn را تا پایانِ کار (۳ تا ۴۰ ثانیه، منتظرِ Qdrant و مدل) نگه می‌دارد. بدونِ
سقف، اگر چند ده دانش‌آموز با هم سوال بپرسند همه‌ی نخ‌های worker گرفته می‌شوند و نقشه، آزمون و ثبتِ
جواب (که هرکدام چند میلی‌ثانیه‌اند) پشتِ آن‌ها صف می‌کشند: یک بخشِ کند کلِ اپ را می‌خواباند. این همان
الگوی bulkhead است: هر بخش سهمِ جدای خودش را دارد. درخواستی که جا نگیرد فوراً ۵۰۳ می‌گیرد.
سمافور مالِ همین پروسه است (هر worker جدا). هم‌زمانیِ واقعی زیرِ بارِ gunicorn با مدلِ واقعی آزموده نشده؛
تست‌ها رفتارِ گرفتن/پس‌دادن را در مسیرِ JSON و استریم نگه می‌دارند (ai_tutor/tests_concurrency.py).
"""
import threading
from django.conf import settings
_lock = threading.Lock()
_in_flight = 0
def limit() -> int:
return settings.AI_MAX_CONCURRENT_PER_WORKER
def try_acquire() -> bool:
"""بی‌انتظار: یا جا هست و گرفته می‌شود، یا False. انتظار نخ را همان‌طور نگه می‌داشت که می‌خواهیم نشود."""
global _in_flight
with _lock:
if _in_flight >= limit():
return False
_in_flight += 1
return True
def release() -> None:
global _in_flight
with _lock:
if _in_flight <= 0:
# پس‌دادنِ بی‌گرفتن یعنی باگ در جفت‌شدنِ گرفتن/پس‌دادن؛ شمارنده منفی نمی‌شود تا سقف بی‌اثر نشود.
raise RuntimeError('ai concurrency slot released more times than acquired')
_in_flight -= 1
def in_flight() -> int:
return _in_flight
class ReleaseWhenDone:
"""
محتوای استریم را می‌پیچد تا جا وقتی پس داده شود که استریم واقعاً تمام شد: یا با پایانِ خواندن،
یا با close() که جنگو هنگامِ بستنِ پاسخ صدا می‌زند (کاربر وسطِ جواب صفحه را بست، یا پاسخ اصلاً
خوانده نشد). generatorی که شروع نشده با close() بلوکِ finallyاش را اجرا نمی‌کند؛ برای همین
پس‌دادن این‌جاست، نه داخلِ generator.
"""
def __init__(self, iterable):
self._iterable = iterable
self._released = False
def _release_once(self):
if not self._released:
self._released = True
release()
def __iter__(self):
try:
yield from self._iterable
finally:
self._release_once()
def close(self):
try:
close = getattr(self._iterable, 'close', None)
if close is not None:
close()
finally:
self._release_once()

View File

@@ -0,0 +1,92 @@
"""
سقفِ جواب‌های هم‌زمانِ دستیار (ai_tutor/concurrency.py): رگبارِ سوال نباید همه‌ی نخ‌های worker را بگیرد.
هم‌زمانیِ واقعیِ چند نخ با مدلِ واقعی آزموده نشده؛ این‌جا جا را با گرفتنِ دستی پر می‌کنیم.
"""
from unittest import mock
from django.core.cache import cache
from django.core.signals import request_finished
from django.db import close_old_connections
from django.test import TestCase, override_settings
from accounts.authentication import issue_access_token
from accounts.models import User
from ai_tutor import concurrency
from ai_tutor.models import Message
from content.models import Grade
@override_settings(AI_MAX_CONCURRENT_PER_WORKER=1)
class AskConcurrencyCapTests(TestCase):
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
self.user = User.objects.create(phone='09120007801', grade_id=grade.id)
self.assertEqual(concurrency.in_flight(), 0)
def tearDown(self):
self.assertEqual(concurrency.in_flight(), 0, 'هر تست باید جا را پس بدهد')
def _ask(self, **extra):
return self.client.post(
'/api/ai/ask', {'userQuestion': 'توان چیست؟', **extra}, content_type='application/json',
HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}',
)
def _rag(self):
return (
mock.patch('rag.services.config.rag_is_configured', return_value=True),
mock.patch('rag.services.retrieval.retrieve_context',
return_value=([{'title': 'فصل', 'summary': 'متن'}], {'gradeTitle': 'نهم'})),
mock.patch('rag.services.generation.generate_answer', return_value=('جواب', [], {})),
mock.patch('rag.services.generation.stream_answer', side_effect=lambda *a, **k: iter(['جو', 'اب'])),
)
def test_full_means_503_with_retry_after_and_no_quota_used(self):
self.assertTrue(concurrency.try_acquire()) # یک سوالِ دیگر در حالِ جواب است
try:
with self.assertLogs('ai_tutor.views', level='WARNING'):
response = self._ask()
finally:
concurrency.release()
self.assertEqual(response.status_code, 503)
self.assertEqual(response.json()['detail'], 'دستیار الان خیلی شلوغه؛ چند ثانیه‌ی دیگه دوباره بپرس.')
self.assertEqual(response['Retry-After'], '10')
self.assertFalse(Message.objects.exists()) # نه پیامی ساخته شد نه سهمیه‌ای رزرو شد
def test_json_answer_gives_the_slot_back(self):
a, b, c, d = self._rag()
with a, b, c, d:
self.assertEqual(self._ask().status_code, 200)
self.assertEqual(concurrency.in_flight(), 0)
self.assertEqual(self._ask().status_code, 200) # سقفِ ۱: دومی هم جا گرفت
def test_a_failed_answer_gives_the_slot_back(self):
a, b, _, d = self._rag()
failing = mock.patch('rag.services.generation.generate_answer', side_effect=RuntimeError('provider'))
with a, b, failing, d, self.assertLogs('ai_tutor.views', level='ERROR'):
self.assertEqual(self._ask().status_code, 503)
def test_stream_holds_the_slot_until_it_is_read(self):
a, b, c, d = self._rag()
with a, b, c, d:
response = self._ask(stream=True)
self.assertEqual(concurrency.in_flight(), 1) # هنوز خوانده نشده
body = b''.join(response.streaming_content).decode() # کلاینتِ تست پایانش close() می‌زند
self.assertIn('event: done', body)
def test_a_stream_that_is_never_read_still_gives_the_slot_back(self):
a, b, c, d = self._rag()
with a, b, c, d:
response = self._ask(stream=True)
# کاربر پیش از اولین تکه رفت. سیگنالِ request_finished در TestCase اتصالِ تستی را می‌بندد؛ کلاینتِ
# تست هم برای پاسخِ استریم همین‌طور جدایش می‌کند.
request_finished.disconnect(close_old_connections)
try:
response.close()
finally:
request_finished.connect(close_old_connections)
def test_release_without_acquire_is_a_loud_bug(self):
with self.assertRaises(RuntimeError):
concurrency.release()

View File

@@ -17,7 +17,7 @@ from rest_framework.views import APIView
from accounts.models import User as AccountUser
from config.db import release_db_connection
from . import provider_status
from . import concurrency, provider_status
from .models import AskIdempotency, Conversation, Message, MessageFeedback
from .serializers import (
QuotaResponseSerializer,
@@ -256,6 +256,15 @@ def generate_tutor_answer(
return answer, follow_ups
class AIBusy(APIException):
"""همه‌ی جاهای هم‌زمانِ دستیار در این worker پرند (ai_tutor/concurrency.py). سهمیه دست نخورده."""
status_code = 503
default_detail = 'دستیار الان خیلی شلوغه؛ چند ثانیه‌ی دیگه دوباره بپرس.'
default_code = 'assistant_busy'
# هندلرِ DRF از `wait` هدرِ Retry-After می‌سازد.
wait = 10
class AskAIView(APIView):
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
@@ -282,6 +291,24 @@ class AskAIView(APIView):
tags=['AI Tutor (ai_tutor)'],
)
def post(self, request):
# پیش از هر کارِ دیگری، حتی رزروِ سهمیه: درخواستِ ردشده نباید از سهمیه‌ی روزانه کم کند.
if not concurrency.try_acquire():
logger.warning('ai_busy user_id=%s in_flight=%s limit=%s',
request.user.id, concurrency.in_flight(), concurrency.limit())
raise AIBusy()
try:
response = self._ask(request)
except BaseException:
concurrency.release()
raise
if isinstance(response, StreamingHttpResponse):
# جا تا پایانِ استریم دستِ همین درخواست می‌ماند (ReleaseWhenDone).
response.streaming_content = concurrency.ReleaseWhenDone(response.streaming_content)
else:
concurrency.release()
return response
def _ask(self, request):
serializer = AskRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data

View File

@@ -237,6 +237,12 @@ def _db_pool() -> dict:
}
def _ai_concurrency() -> dict:
from ai_tutor import concurrency
return {'maxPerWorker': concurrency.limit(), 'inFlightThisWorker': concurrency.in_flight()}
def _db_pool_warnings(pool: dict) -> list[str]:
if pool.get('state') == 'over_budget':
return [
@@ -355,6 +361,8 @@ class HealthView(APIView):
'traffic': _traffic(),
'offsiteBackup': offsite,
'dbPool': db_pool,
# جواب‌های هم‌زمانِ دستیار در workerی که این را جواب داد (ai_tutor/concurrency.py).
'aiConcurrency': _ai_concurrency(),
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
# IPای که throttle برای همین درخواست می‌بیند. باید IPِ عمومیِ خودِ
# صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط

View File

@@ -552,7 +552,9 @@ REST_FRAMEWORK = {
# می‌توانستند وارد شوند. حفاظِ اصلی 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'),
# ۳۰ تا ۲۰۲۶-۱۰-۱۱: یک کلاسِ سی‌نفره روی وای‌فایِ مدرسه با چند غلطِ تایپی سقف را پر می‌کرد. حدسِ کد
# را سقفِ هر کد (OTP_MAX_FAILED_ATTEMPTS=۵، در دیتابیس) و هر شماره می‌بندد، نه این.
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '300/hour'),
'bazaar_verify': '10/hour',
'bazaar_oauth': '20/hour',
'ai_ask': '20/hour',
@@ -929,6 +931,11 @@ AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '2'))
# تازه می‌توانست خرجِ GapGPT را بی‌سقف بالا ببرد. ۳۰۰۰ با ~۰٫۲ سنت برای هر سوال یعنی حدودِ ۶
# دلار در روز؛ رسیدن به آن ۵۰۳ و لاگِ ERROR می‌دهد. با رشدِ کاربرِ واقعی از .env بالا ببرید.
AI_GLOBAL_DAILY_MAX = int(os.environ.get('AI_GLOBAL_DAILY_MAX', '3000'))
# حداکثر جوابِ هم‌زمانِ دستیار در هر worker (ai_tutor/concurrency.py). هر جواب یک نخ را ۳ تا ۴۰ ثانیه
# نگه می‌دارد؛ بی‌سقف، رگبارِ سوال هر ۳۲ نخِ worker را می‌گرفت و نقشه و آزمون پشتش صف می‌کشیدند. با ۱۲،
# دست‌کم ۲۰ نخ برای بقیه‌ی اپ می‌ماند؛ ۴ worker × ۱۲ = ۴۸ جوابِ هم‌زمان. بیشتر از آن ۵۰۳ «شلوغ است»
# می‌گیرد، پیش از رزروِ سهمیه. سقفِ هم‌زمانیِ خودِ GapGPT را نمی‌دانیم و نسنجیده‌ایم.
AI_MAX_CONCURRENT_PER_WORKER = int(os.environ.get('AI_MAX_CONCURRENT_PER_WORKER', '12'))
# اختیاری: مدلِ جدا فقط برای پایه‌های دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی
# همه‌ی پایه‌ها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده می‌شود.
OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')

View File

@@ -15,11 +15,11 @@ from .models import Lesson, Node, NodeProgress, UserLessonCompletion, UserNodeCo
def _get_or_create_progress(user_id, node):
"""get_or_create ایجاد می‌کند، سپس ردیف با select_for_update دوباره خوانده و قفل می‌شود
تا خواندن/تغییر/ذخیره در برابر concurrent update ایمن باشد. باید داخل transaction.atomic صدا زده شود."""
NodeProgress.objects.get_or_create(user_id=user_id, node=node)
progress = NodeProgress.objects.select_for_update().get(user_id=user_id, node=node)
return progress, False
"""ردیف را با قفل (select_for_update) می‌گیرد یا می‌سازد، در یک کوئری برای حالتِ معمول (ردیف هست).
دو ساختِ هم‌زمان به قیدِ یکتاییِ (user_id, node) می‌خورند و get_or_create دومی را دوباره می‌خواند.
باید داخلِ transaction.atomic صدا زده شود."""
progress, created = NodeProgress.objects.select_for_update().get_or_create(user_id=user_id, node=node)
return progress, created
def _emit_if_changed(progress, old_status, event, user_id):

View File

@@ -200,3 +200,35 @@ class NodeVisualDefaultsTests(TestCase):
first = self._nodes()[0]
self.assertEqual(first['color'], '#123456')
self.assertEqual(first['icon'], 'Trophy')
class HomeMatchesItsSerializerTests(TestCase):
"""
/api/home برای سرعت dict را مستقیم برمی‌گرداند (content/views.py، HomeView)، ولی HomeResponseSerializer
هنوز قراردادِ Swagger است. اگر کسی کلیدی به پاسخ اضافه کند یا نوعی را عوض کند و سریال‌ساز را نه،
این تست می‌افتد: خروجیِ سریال‌ساز روی همان پاسخ باید عینِ خودش باشد.
"""
def test_view_output_is_exactly_the_documented_shape(self):
from accounts.authentication import issue_access_token
from content.serializers import HomeResponseSerializer
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
for order in (1, 2):
unit = Unit.objects.create(grade=grade, title=f'فصل {order}', order=order, blurb='b')
for n in (1, 2):
node = Node.objects.create(unit=unit, title=f'جزیره {n}', order=n)
lesson = Lesson.objects.create(node=node, title='درس')
question = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟')
Choice.objects.create(question=question, value='۱', is_correct=True)
user = User.objects.create(phone='09120000088', grade_id=grade.id)
UserLessonCompletion.objects.create(
user_id=user.id, lesson=Lesson.objects.first(), correct_count=1, stars=3)
body = self.client.get('/api/home', HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}').json()
self.assertEqual(len(body['units']), 2)
self.assertEqual(HomeResponseSerializer(body).data, body)
self.assertIsInstance(body['stats']['progressPercent'], float)
self.assertIsInstance(body['units'][0]['nodes'][0]['id'], str)

View File

@@ -8,6 +8,7 @@ from django.http import Http404
import random
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import status
@@ -163,9 +164,18 @@ def _record_streak(user_id):
فعالیتِ امروز را در استریک ثبت می‌کند. خطایش هرگز نباید ثبتِ پاسخ یا
تمام شدنِ درس را خراب کند — استریک تزئینی است، پیشرفت نیست.
"""
# استریک روزی یک بار عوض می‌شود ولی این تابع سرِ هر جواب صدا زده می‌شود؛ ثبتِ امروز در کشِ Redis
# تراکنشِ سه‌کوئریِ بقیه‌ی جواب‌های امروز را حذف می‌کند. کلید فقط بعد از ثبتِ موفق گذاشته می‌شود؛
# بی‌Redis (fail-open) هر بار مثلِ قبل به دیتابیس می‌رود. پاک‌کردنِ دستیِ استریکِ امروزِ کسی از ادمین
# تا فردا دوباره ساخته نمی‌شود.
today = timezone.localdate()
key = f'streak:recorded:{user_id}:{today.isoformat()}'
if cache.get(key):
return
try:
from progress.services import record_activity
record_activity(user_id)
record_activity(user_id, today)
cache.set(key, 1, 26 * 3600)
except Exception: # noqa: BLE001
logger.exception('streak: ثبتِ فعالیتِ کاربر %s شکست خورد', user_id)
@@ -374,13 +384,14 @@ class HomeView(APIView):
lid for unit in tree for node in unit['nodes']
for lid in (node.get('playable_ids') or node['lesson_ids'])
]
# همه‌ی درس‌های تمام‌شده‌ی کاربر و بعد اشتراک در پایتون، نه `lesson_id IN (…۶۰۰ شناسه…)`: ساختنِ
# آن کوئری در جنگو/psycopg بیشترِ زمانِ CPUِ این صفحه بود (پروفایلِ ۲۰۲۶-۱۰-۱۱)، و ردیف‌های یک
# کاربر با ایندکسِ user_id خوانده می‌شوند.
completed_ids = set()
if all_lesson_ids:
completed_ids = set(
UserLessonCompletion.objects.filter(
user_id=user_id, lesson_id__in=all_lesson_ids
).values_list('lesson_id', flat=True)
)
UserLessonCompletion.objects.filter(user_id=user_id).values_list('lesson_id', flat=True)
).intersection(all_lesson_ids)
tree_node_ids = [int(node['id']) for unit in tree for node in unit['nodes']]
quiz_done = quiz_completed_node_ids(user_id, tree_node_ids)
@@ -445,7 +456,7 @@ class HomeView(APIView):
done_lessons.update(playable_set.intersection(node['lesson_ids']))
progress_percent = (
round(min(len(done_lessons) / len(playable_set), 1.0) * 100, 2)
if playable_set else 0
if playable_set else 0.0
)
stats = UserStats.objects.filter(user_id=user_id).first()
@@ -456,7 +467,10 @@ class HomeView(APIView):
}
data = {'units': units_payload, 'stats': stats_payload}
return Response(HomeResponseSerializer(data).data)
# خودِ dict، نه HomeResponseSerializer(data).data: سریال‌سازِ تو در توی DRF برای ~۳۰ جزیره حدودِ ۱٫۶
# میلی‌ثانیه از ~۸٫۶ِ این پرترافیک‌ترین صفحه بود (پروفایلِ ۲۰۲۶-۱۰-۱۱) و چیزی را تبدیل نمی‌کرد.
# سریال‌ساز همچنان قراردادِ Swagger است؛ HomeMatchesItsSerializerTests برابریِ این دو را نگه می‌دارد.
return Response(data)
class LessonIntroView(APIView):
@@ -546,8 +560,12 @@ class QuestionAnswerView(APIView):
serializer.is_valid(raise_exception=True)
selected_option_id = serializer.validated_data['selectedOptionId']
choice = get_object_or_404(Choice, pk=selected_option_id, question=question)
correct_choice = question.choices.filter(is_correct=True).first()
# یک کوئری برای هر چهار گزینه، به‌جای یکی برای انتخاب و یکی برای گزینه‌ی درست.
choices = list(question.choices.all())
choice = next((c for c in choices if c.id == selected_option_id), None)
if choice is None:
raise Http404
correct_choice = next((c for c in choices if c.is_correct), None)
user_id = request.user.id
xp_earned = 10 if choice.is_correct else 0

View File

@@ -186,6 +186,7 @@ curl https://mathapi.tadrisino2.ir/api/health
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed می‌ماند (`503`) |
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشه‌ی کاری می‌خواندش: ۴ workerِ `gthread` با ۳۲ نخ و timeoutِ ۱۲۰. تعدادِ workerها همین‌جاست؛ یونیتِ systemd دیگر `--workers` ندارد (اگر داشته باشد بر این فایل مقدم است) |
| `DB_POOL_MAX_SIZE` · `DB_POOL_MIN_SIZE` · `DB_POOL_TIMEOUT` | ۸ · ۲ · ۱۰ ثانیه: هر worker حداکثر ۸ اتصالِ Postgres دارد و نخ‌ها آن را فقط تا پایانِ درخواست قرض می‌گیرند. اگر تا ۱۰ ثانیه اتصالی آزاد نشد، درخواست ۵۰۳ با `Retry-After` می‌گیرد (نه ۵۰۰). `workers × DB_POOL_MAX_SIZE + 6` باید زیرِ `max_connections` (روی سرور ۶۰ − ۳) بماند؛ `/api/health` → `dbPool` آن را می‌سنجد و هشدار می‌دهد. `DB_CONN_MAX_AGE` دیگر پذیرفته نمی‌شود (برنامه بالا نمی‌آید) |
| `AI_MAX_CONCURRENT_PER_WORKER` | ۱۲: حداکثر جوابِ هم‌زمانِ دستیار در هر worker (۴ × ۱۲ = ۴۸). هر جواب یک نخ را تا ۴۰ ثانیه نگه می‌دارد؛ سقف نمی‌گذارد رگبارِ سوال همه‌ی ۳۲ نخ را بگیرد و نقشه و آزمون بخوابند. بیشتر از آن ۵۰۳ «دستیار شلوغه» با `Retry-After` می‌گیرد، پیش از رزروِ سهمیه. `/api/health` → `aiConcurrency` |
| `DB_STATEMENT_TIMEOUT_MS` | ۳۰٬۰۰۰: هر کوئریِ بیش از ۳۰ ثانیه لغو می‌شود و تراکنشِ بازِ بیکار بعد از ۶۰ ثانیه بسته می‌شود. برای فرمانِ مدیریتیِ طولانیِ دستی: `DB_STATEMENT_TIMEOUT_MS=0 python manage.py …` |
| `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `FRONTEND_URL/?orderId=…&status=paid\|failed` برمی‌گردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی |
| `BAZAAR_PISHKHAN_API_SECRET` (روشِ جدید) یا `BAZAAR_CLIENT_ID` · `BAZAAR_CLIENT_SECRET` (روشِ قدیمی)، به‌علاوه‌ی `BAZAAR_PACKAGE_NAME` | `POST /api/payment/bazaar/verify` هر خرید را نامعتبر می‌داند (fail-closed)؛ `/api/health` → `bazaar: false` |

View File

@@ -20,8 +20,7 @@ def record_activity(user_id, today=None):
"""امروز را روزِ فعال ثبت می‌کند و استریک را جلو می‌برد. idempotent در یک روز."""
today = today or timezone.localdate()
with transaction.atomic():
StreakRecord.objects.get_or_create(user_id=user_id)
streak = StreakRecord.objects.select_for_update().get(user_id=user_id)
streak, _ = StreakRecord.objects.select_for_update().get_or_create(user_id=user_id)
last = (
ActiveDay.objects.filter(streak=streak)