Files
MathApp/docs/architecture.md
Zareian 701d8badcc make streaks and the leaderboard live, and stop three AI chats from freezing the API
Found in a deeper pass over what the frontend's flow actually touches:

- Nothing in the app ever wrote StreakRecord: "days in a row" was 0 forever
  for every real user. Answers and lesson/island completions now record the
  day (Tehran time); a broken chain shows 0. A streak error never fails the
  answer itself.
- /api/leaderboard only changed when someone ran rebuild_leaderboard by
  hand. It is now computed live (60s cache); weekly counts island XP too.
- gunicorn ran 3 sync workers with a 30s timeout, so every "ask Pi" stream
  pinned a whole worker (~10s, GapGPT buffers the answer) and three at once
  queued the entire API. gunicorn.conf.py (read automatically from the
  working directory) switches to gthread, 8 threads, 120s timeout.
- Every /api/ response now says Cache-Control: private, no-store, so turning
  on caching in the ArvanCloud panel can never serve one student another's data.
- /api/user/summary named no active plan ever ("subscription app not
  implemented yet" had long stopped being true).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 06:39:50 +03:30

11 KiB
Raw Blame History

معماری

تصمیم‌هایی که از خواندنِ کد واضح نیستند و دلیلشان.

احرازِ هویت، سیستمِ خودمان است

accounts.User عمداً AUTH_USER_MODEL نیست — یک مدلِ بیزنسیِ مستقل است. ورود با توکنِ bearerِ امضاشده و stateless انجام می‌شود (django.core.signing، بدونِ کتابخانه‌ی JWT)، که در accounts/authentication.py صادر و تأیید می‌شود و پشتوانه‌اش مدلِ accounts.Session است.

هدرِ نبود یا نامعتبر یعنی درخواستِ ناشناس، نه خطا. هر ویو با permission_classes خودش تصمیم می‌گیرد که آیا ورود لازم است — یعنی یک اندپوینتِ عمومی و یک اندپوینتِ خصوصی از یک مسیرِ احراز هویت رد می‌شوند و اختلافشان فقط در یک جاست.

DJANGO_SECRET_KEY همین توکن‌ها را امضا می‌کند. عوض شدنش یعنی باطل شدنِ همه‌ی نشست‌های فعال.

اپ‌ها با قرارداد جدا شده‌اند، نه با ForeignKey

هر اپ یک مسئولیت دارد و مدل‌های خودش را نگه می‌دارد. ارجاعِ بینِ اپ‌ها با user_id یا object_id + label انجام می‌شود، نه با FK واقعی.

دلیلش این است که هر اپ بعداً قابلِ جدا شدن بماند. FK بینِ اپ‌ها اضافه نکنید — الگوی ارجاعِ شل را دنبال کنید.

محدودیتِ نرخ در یک جا تعریف می‌شود

هر اندپوینتِ حساس یک throttle_scope دارد و نرخش در REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] داخلِ config/settings.py است. اندپوینتِ جدیدِ حساس یعنی هر دو: یک throttle_scope روی ویو و یک ردیفِ متناظر در تنظیمات.

کاربرانِ مهمان با IP محدود می‌شوند. NUM_PROXIES تعیین می‌کند چند hop از X-Forwarded-For قابلِ اعتماد است و باید با تعدادِ واقعیِ پروکسی‌ها یکی باشد، وگرنه جعلِ IP و دور زدنِ محدودیت ممکن می‌شود.

روی پروداکشن این عدد ۲ است (پیش‌فرضِ کد از ۲۰۲۶-۰۹-۲۴): ArvanCloud جلوی سرور است و بعد nginxِ خودِ ماشین. تا آن روز ۱ بود و جنگو IPِ آروان را IPِ کاربر می‌دید؛ یعنی همه‌ی مهمان‌ها یک سهمِ مشترک داشتند. clientIp در /api/health نشان می‌دهد سرور صدازننده را با چه IPای می‌شناسد — باید IPِ عمومیِ خودتان باشد.

⚠️ سرور روی پورتِ ۸۰ مستقیم هم (بدونِ آروان) جواب می‌دهد. کسی که IPِ سرور را بداند می‌تواند از آن راه XFF جعل کند و محدودیتِ IP را دور بزند. ورودِ OTP جدا به ازای هر شماره هم محدود است، پس این راه به حدسِ کد نمی‌رسد. بستنِ کاملش (پورتِ ۸۰ فقط برای رنج‌های آروان، یا set_real_ip_from در nginx) کارِ ادمینِ سرور است.

پرداخت: هرگز مستقیم به درگاه

create_order هیچ‌وقت زرین‌پال را صدا نمی‌زند. paymentUrl به اندپوینتِ خودمان اشاره می‌کند (GET /api/payment/orders/{id}/pay)، که در اولین بازدید PaymentRequest را می‌زند، Order.gateway_authority را ذخیره می‌کند و به StartPay می‌فرستد. این کار ساختِ سفارش را به در دسترس بودنِ زرین‌پال وابسته نمی‌کند.

بازگشتِ کاربر به GET /api/payment/zarinpal/callback می‌رسد، که همیشه سرور-به-سرور با payment/zarinpal.py::verify_payment دوباره تأیید می‌کند. یک Status=OK جعلی در query string به‌تنهایی هیچ کاری نمی‌کند.

هم payment/zarinpal.py و هم payment/bazaar.py وقتی اعتبارنامه ندارند fail-closed می‌شوند (503 یا نامعتبر)، نه موفقیتِ ساختگی. همین قاعده برای پیامک هم برقرار است.

دستیارِ هوشِ مصنوعی (RAG)

POST /api/ai/ask پاسخ را از محتوای واقعیِ کتاب می‌سازد: embedding سوال → جستجوی Qdrant محدود به همان درس/فصل → تولید با GPT. تکه‌های استفاده‌شده در usedChunks برمی‌گردند، پس پاسخ قابلِ ردیابی به منبع است.

  • نیازمندِ OPENAI_API_KEY و QDRANT_API_KEY. بدونِ کلید، همان پاسخِ stubِ قبلی برمی‌گردد (سازگاریِ عقب‌رو).
  • "stream": true پاسخ را به‌صورتِ SSE توکن‌به‌توکن می‌فرستد؛ قراردادِ JSON بدونِ این فلگ دست‌نخورده است.
  • python manage.py rag_catalog فهرستِ زنده‌ی collectionهای Qdrant را نشان می‌دهد (فقط‌خواندنی).
  • افزودنِ محتوای خودمان: POST /api/rag/ingest/text و /api/rag/ingest/pdf (داخلی، با هدرِ X-Internal-Key).

کیفیتِ محتوایی که این لایه می‌خواند در content-authoring.md توضیح داده شده.

ساختِ آزمون

اپ در سطحِ نود بازی می‌شود (جزیره روی نقشه)، ولی سوال‌ها به درس وصلند — و درس اینجا ریزترین دانه است: یک نوعِ مسئله از کتاب، نه یک جلسه‌ی کلاسی.

GET /api/nodes/{id}/quiz آزمون را از درس‌های یک نود می‌سازد، حداکثر یک سوال از هر درس. ترتیب با nodeId:userId seed می‌شود، پس رفرش همان آزمون را برمی‌گرداند. questionCount جدا از requested گزارش می‌شود تا یک نودِ کم‌سوال شبیهِ خطا به نظر نرسد.

پایانِ آزمون، پایانِ جزیره است. POST /api/nodes/{id}/quiz/complete با {correctCount, totalQuestions} جزیره را تمام می‌کند و جزیره‌ی بعدی روی نقشه باز می‌شود — با هر نمره‌ای؛ نمره فقط ستاره و امتیاز را تعیین می‌کند (تصمیمِ صاحبِ پروژه، ۲۰۲۶-۰۹-۲۴: ساده نگهش داریم). پاسخ nextNodeId را هم می‌دهد. امتیاز و سکه فقط بارِ اول. ردیف‌ها در UserNodeCompletionاند؛ _node_status نودی را که آزمونش تمام شده completed می‌بیند، و مسیرِ قدیمیِ «همه‌ی درس‌ها تک‌تک» هم هنوز کار می‌کند.

progressPercent در /api/home برای همان پایه‌ای است که نقشه‌اش نشان داده می‌شود: درس‌های تمام‌شده به‌اضافه‌ی همه‌ی درس‌های جزیره‌های تمام‌شده، تقسیم بر درس‌های آن پایه. (قبلاً مخرج همه‌ی ۲٬۶۱۷ درسِ همه‌ی پایه‌ها بود.)

دستیار بدونِ درس

صفحه‌ی «از پی بپرس» از نوارِ پایین باز می‌شود و lessonId ندارد. در این حالت /api/ai/ask کتاب را از پایه‌ی ذخیره‌شده‌ی خودِ کاربر درمی‌آورد (rag.services.lesson_context.resolve_from_grade) و در کلِ همان کتاب جستجو می‌کند؛ قبلاً retrieval رد می‌شد و جواب عمومی بود. lessonId اگر بیاید همچنان دقیق‌تر است، چون فصل را هم معلوم می‌کند.

استریک و جدولِ رتبه‌بندی، زنده از فعالیت

تا ۲۰۲۶-۰۹-۲۴ هیچ‌جای اپ StreakRecord نمی‌نوشت و GET /api/leaderboard فقط جدولی را می‌خواند که با اجرای دستیِ rebuild_leaderboard پر می‌شد؛ یعنی برای کاربرِ واقعی «روز پیاپی» همیشه صفر بود و XPاش هیچ‌وقت در جدول دیده نمی‌شد.

  • استریک (progress/services.py): پاسخ به سوال، پایانِ درس و پایانِ جزیره امروز را (به وقتِ تهران) روزِ فعال ثبت می‌کنند. نمایش با effective_current_streak است: اگر آخرین روزِ فعال نه امروز است نه دیروز، صفر نشان داده می‌شود. خطای استریک هرگز پاسخ یا تکمیل را خراب نمی‌کند.
  • رتبه‌بندی (progress/leaderboard.py): زنده حساب و ۶۰ ثانیه کش می‌شود. all_time از UserStats.xp، weekly از XPی درس‌ها و جزیره‌های ۷ روزِ اخیر. rebuild_leaderboard هم از همین تابع می‌خواند و فقط جدولِ ادمین را پر می‌کند.

دیتابیس

هنوز SQLite است (OPTIONS.timeout=20 تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند). مهاجرت به Postgres انجام نشده.

idِ هر پایه همان شماره‌ی پایه است (سوم=۳، هشتم=۸، دوازدهم=۱۲) و در فیلدِ Grade.number هم هست. پایه‌ی تازه id را از شماره‌اش می‌گیرد، نه از شمارنده‌ی دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده می‌شود. پایه‌ی بی‌شماره ([DEMO]/[SEED]) idِ بالای ۱۰۰ می‌گیرد. همه‌جا — API، ادمین، فیلترِ سوال‌ها — پایه‌ها به‌ترتیبِ شماره‌اند. تا migration‌ِ content.0008 id ترتیبِ واردشدنِ کتاب‌ها بود (نهم=۱، هشتم=۵).

⚠️ .iterator() را دورِ حلقه‌ای که می‌نویسد یا I/O بیرونی می‌زند نگذارید. کرسرِ خواندن در تمامِ طولِ حلقه باز می‌ماند و روی SQLite جدول را برای هر نویسنده‌ی هم‌زمان قفل می‌کند — قفلی که تا پایانِ حلقه رها نمی‌شود و هیچ retryای نجاتش نمی‌دهد. این یک بارْ ماه‌ها به‌شکلِ یک تستِ «flaky» ظاهر شد؛ باگِ واقعی بود، نه تصادفِ زمان‌بندی. به‌جایش نتیجه را با list(...) مادی کنید.

لاگِ ساختاریافته

config.middleware.APIRequestLoggingMiddleware (آخرین middleware، نزدیک‌ترین به ویو) هر درخواستِ /api/ را به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌کند: متد، مسیر، وضعیت، مدت، کاربر — بدونِ بدنه، هدر یا سکرت (config/log_formatters.py).