Grade ids were the order the books were imported in (ninth=1, eighth=5), so gradeId: 5 meant eighth grade and the admin, the question filter and the API all listed grades out of order. Grade.number (1-12) is now read from the title, new grades take it as their id, and migration 0008 moves existing ids plus every reference to them: Unit, the loose grade_id in accounts/ai_tutor/rag, and the grade target inside campaign JSON. Unnumbered demo grades move above 100 and the id sequence is raised past 12 so they never collide with a real grade. The frontend needs no change: it always reads gradeId from the server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
7.1 KiB
Markdown
106 lines
7.1 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 و دور زدنِ محدودیت ممکن میشود.
|
||
|
||
## پرداخت: هرگز مستقیم به درگاه
|
||
|
||
`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` گزارش میشود تا یک نودِ کمسوال
|
||
شبیهِ خطا به نظر نرسد.
|
||
|
||
## دیتابیس
|
||
|
||
هنوز 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`).
|