diff --git a/CLAUDE.md b/CLAUDE.md index ff21cb5..28e009d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/ai_tutor/concurrency.py b/ai_tutor/concurrency.py new file mode 100644 index 0000000..45d7e58 --- /dev/null +++ b/ai_tutor/concurrency.py @@ -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() diff --git a/ai_tutor/tests_concurrency.py b/ai_tutor/tests_concurrency.py new file mode 100644 index 0000000..96a9197 --- /dev/null +++ b/ai_tutor/tests_concurrency.py @@ -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() diff --git a/ai_tutor/views.py b/ai_tutor/views.py index 9962bc6..5917fc6 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -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 diff --git a/config/health.py b/config/health.py index b4001f9..7ac2449 100644 --- a/config/health.py +++ b/config/health.py @@ -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 غلط diff --git a/config/settings.py b/config/settings.py index 5f9f0f5..0bfe88e 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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', '') diff --git a/content/services.py b/content/services.py index 4c0c9c5..b8360c2 100644 --- a/content/services.py +++ b/content/services.py @@ -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): diff --git a/content/tests_frontend_contract.py b/content/tests_frontend_contract.py index 55ce741..92a8633 100644 --- a/content/tests_frontend_contract.py +++ b/content/tests_frontend_contract.py @@ -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) diff --git a/content/views.py b/content/views.py index 83f3745..39a340a 100644 --- a/content/views.py +++ b/content/views.py @@ -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 diff --git a/docs/operations.md b/docs/operations.md index 1f9ca09..53147c4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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` | diff --git a/progress/services.py b/progress/services.py index c821130..1a6dd65 100644 --- a/progress/services.py +++ b/progress/services.py @@ -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)