The frontend plays node quizzes, but a node only completed once every one of its
8-13 lessons was completed separately, so students were stuck after the first
island of each unit. POST /api/nodes/{id}/quiz/complete now completes the node
(any score; score only sets stars/xp) and returns nextNodeId.
Also closes the other backend gaps found while auditing the frontend:
- /api/ai/ask without lessonId now picks the book from the user's grade instead
of skipping retrieval, so the "ask Pi" tab is grounded in the textbook.
- guest sessions return a refreshToken; they used to go silently 401 after 1h.
- lesson_complete throttle 10/hour -> 120/hour (a lesson here is 1-2 questions).
- progressPercent is per grade, not over all 2,617 lessons of every grade.
- a one-year plan (299,000 toman) is created by migration when no plan exists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8.8 KiB
معماری
تصمیمهایی که از خواندنِ کد واضح نیستند و دلیلشان.
احرازِ هویت، سیستمِ خودمان است
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 و دور زدنِ محدودیت ممکن میشود.
پرداخت: هرگز مستقیم به درگاه
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 اگر بیاید همچنان
دقیقتر است، چون فصل را هم معلوم میکند.
دیتابیس
هنوز 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).