# معماری تصمیم‌هایی که از خواندنِ کد واضح نیستند و دلیلشان. ## احرازِ هویت، سیستمِ خودمان است `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`](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`).