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>
9.9 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 و دور زدنِ محدودیت ممکن میشود.
روی پروداکشن این عدد ۲ است (پیشفرضِ کد از ۲۰۲۶-۰۹-۲۴): 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 اگر بیاید همچنان
دقیقتر است، چون فصل را هم معلوم میکند.
دیتابیس
هنوز 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).