Each heart used to take 24 hours, so a student who lost all five waited up to five days. Now one heart returns every 5 hours, and 24 hours after the first heart was lost they all come back at once (the owner's call). /api/user/hearts reports both times (nextRefillAt, fullRefillAt); an existing timer longer than 5 hours is shortened the first time it is read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
12 KiB
Markdown
160 lines
12 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` اگر بیاید همچنان
|
||
دقیقتر است، چون فصل را هم معلوم میکند.
|
||
|
||
## استریک و جدولِ رتبهبندی، زنده از فعالیت
|
||
|
||
تا ۲۰۲۶-۰۹-۲۴ هیچجای اپ `StreakRecord` نمینوشت و `GET /api/leaderboard` فقط
|
||
جدولی را میخواند که با اجرای دستیِ `rebuild_leaderboard` پر میشد؛ یعنی برای
|
||
کاربرِ واقعی «روز پیاپی» همیشه صفر بود و XPاش هیچوقت در جدول دیده نمیشد.
|
||
|
||
- **استریک** (`progress/services.py`): پاسخ به سوال، پایانِ درس و پایانِ جزیره
|
||
امروز را (به وقتِ تهران) روزِ فعال ثبت میکنند. نمایش با
|
||
`effective_current_streak` است: اگر آخرین روزِ فعال نه امروز است نه دیروز،
|
||
صفر نشان داده میشود. خطای استریک هرگز پاسخ یا تکمیل را خراب نمیکند.
|
||
- **رتبهبندی** (`progress/leaderboard.py`): زنده حساب و ۶۰ ثانیه کش میشود.
|
||
`all_time` از `UserStats.xp`، `weekly` از XPی درسها و جزیرههای ۷ روزِ اخیر.
|
||
`rebuild_leaderboard` هم از همین تابع میخواند و فقط جدولِ ادمین را پر میکند.
|
||
|
||
## قلبها، مثلِ دولینگو
|
||
|
||
هر پاسخِ غلط یک قلب کم میکند (مشترکِ فعال قلب از دست نمیدهد). **هر ۵ ساعت یک
|
||
قلب برمیگردد** (`HEART_REFILL_INTERVAL`) و **۲۴ ساعت بعد از اولین قلبی که از
|
||
حالتِ پر کم شد، همه یکجا پر میشوند** (`HEART_FULL_REFILL_AFTER`، فیلدِ
|
||
`full_refill_at`). `GET /api/user/hearts` هر دو زمان را میدهد: `nextRefillAt`
|
||
و `fullRefillAt`. تا ۲۰۲۶-۰۹-۲۴ هر قلب ۲۴ ساعت بود؛ تایمرِ قدیمیِ بلندتر از ۵
|
||
ساعت در اولین خواندن کوتاه میشود. بکاند جوابِ با صفر قلب را رد نمیکند؛
|
||
توقف کار فرانت است.
|
||
|
||
## دیتابیس
|
||
|
||
هنوز 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`).
|