Production is ArvanCloud -> nginx -> gunicorn, but NUM_PROXIES was 1, so DRF took ArvanCloud's edge IP as the client: every guest and every OTP request behind one edge shared a single throttle bucket (20 guest sessions an hour for everyone). Default is now 2, and /api/health reports clientIp so the proxy count can be checked from outside. PATCH /api/notifications/read-all replaces the frontend's one-PATCH-per-item loop, which spent the shared "read" budget 75 requests at a time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
9.9 KiB
Markdown
136 lines
9.9 KiB
Markdown
# معماری
|
||
|
||
تصمیمهایی که از خواندنِ کد واضح نیستند و دلیلشان.
|
||
|
||
## احرازِ هویت، سیستمِ خودمان است
|
||
|
||
`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`](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`).
|