docs: handbook, diagrams and guides brought up to 2026-10-02

- technical-handbook: question packs instead of subscriptions, real
  hearts with the new wait screen (screenshot), math-only tutor and what
  is charged (new 9-7), admin cancel/refund, Bazaar planId, account
  deletion, 59 endpoints, 779 tests, 14 apps, status and open decisions.
- Diagrams redrawn: use-case (buy a pack, refunds), data model
  (QuestionCredit, HeartsPass, Profile.birth_date), pack payment sequence.
- The PDF is now built from the Markdown by docs/build_handbook.py
  (pandoc + Chrome, font in docs/assets); the hand-made HTML it used to
  come from was not in the repo and had been lost.
- architecture, operations, security-before-production (item 13 closed),
  ai-cost-and-guardrails and README follow the same changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 23:10:50 +03:30
parent 8a4753bd15
commit 7683920b0b
13 changed files with 196 additions and 55 deletions

View File

@@ -4,10 +4,10 @@
| | |
|---|---|
| تاریخ | ۸ مهر ۱۴۰۵ (۳۰ سپتامبر ۲۰۲۶) |
| تاریخ | ۱۰ مهر ۱۴۰۵ (۲ اکتبر ۲۰۲۶) |
| نویسنده | علیرضا زارعیان — بک‌اند و فرانت‌اند |
| مخاطب | توسعه‌دهنده‌ی تازه‌وارد، مدیرِ فنی، و هر کسی که باید درباره‌ی ادامه‌ی پروژه تصمیم بگیرد |
| کدِ مبنا | بک‌اند `957c9d0` (مخزنِ `Tadrisino/MathApp` ) · فرانت `0e56d9c` (مخزنِ `Tadrisino/MathApp_frontend` ) |
| کدِ مبنا | بک‌اند `8a4753b` (مخزنِ `Tadrisino/MathApp` ) · فرانت `33f5f8d` (مخزنِ `Tadrisino/MathApp_frontend` ) |
| نشانی‌ها | اپ: `math.tadrisino2.ir` · API: `mathapi.tadrisino2.ir` · مستنداتِ زنده‌ی API: `/api/docs/` |
> این فایل نسخه‌ی Markdownِ «مستند-فنی-تدریسینو» است. نسخه‌ی PDF با همان محتوا و صفحه‌آراییِ کامل کنارِ همین فایل است: `technical-handbook.pdf`.
@@ -20,9 +20,9 @@
|---|---|
| سوم، ششم، هفتم، هشتم تا دوازدهم | **۸ پایه** |
| سؤالِ چهارگزینه‌ای از مسئله‌های کتاب | **۲٬۵۴۵** |
| اندپوینتِ API، مستند در Swagger | **۶۱** |
| تستِ خودکار، همه سبز | **۶۸۵** |
| اپِ جنگو، هرکدام یک مسئولیت | **۱۵** |
| اندپوینتِ API، مستند در Swagger | **۵۹** |
| تستِ خودکار، همه سبز | **۷۷۹** |
| اپِ جنگو، هرکدام یک مسئولیت | **۱۴** |
| تکه‌ی کتاب در Qdrant (۱۱ collection) | **۳٬۵۱۷** |
| همراهِ گیمیفیکیشن، هرکدام ۶ حالتِ چهره | **۴** |
| بک‌اند و فرانت؛ یک قراردادِ API | **۲ مخزن** |
@@ -96,7 +96,7 @@
### درونِ بک‌اند: «یک برنامه، چند اپِ مستقل» (Modular Monolith)
بک‌اند یک برنامه‌ی جنگوست که به ۱۵ «اپ» تقسیم شده. هر اپ یک پوشه است با مدل‌ها، APIها و تست‌های خودش. این همان الگویی است که اجازه می‌دهد چند نفر هم‌زمان روی بک‌اند کار کنند (بخشِ ۱۴).
بک‌اند یک برنامه‌ی جنگوست که به ۱۴ «اپ» تقسیم شده (اپِ `family` ۱۰ مهر ۱۴۰۵ حذف شد). هر اپ یک پوشه است با مدل‌ها، APIها و تست‌های خودش. این همان الگویی است که اجازه می‌دهد چند نفر هم‌زمان روی بک‌اند کار کنند (بخشِ ۱۴).
| اپ | مسئولیت | مدل‌ها (جدول‌ها) |
|---|---|---|
@@ -105,8 +105,8 @@
| `progress` | امتیاز، سکه، قلب، زنجیره، جدولِ رتبه، همراه‌ها | `UserStats UserHearts StreakRecord ActiveDay LeaderboardEntry Character` |
| `ai_tutor` | گفتگو با دستیار، سهمیه، بازخوردِ 👍/👎 | `Conversation Message MessageFeedback AskIdempotency` |
| `rag` | خواندنِ کتاب، Qdrant، ساختِ پرامپت، شکل‌ها | `LessonChunk LessonChunkImage IngestedPage` |
| `subscription` | پلن‌ها و اشتراکِ کاربر | `Plan Subscription` |
| `payment` | سفارش، تراکنش، زرین‌پال، بازار | `Order Transaction BazaarCredential` |
| `subscription` | پلن‌ها (بسته‌ی سوال، اشتراکِ قدیمی)، دفترِ اعتبارِ سوال، قلبِ نامحدودِ بسته | `Plan Subscription QuestionCredit HeartsPass` |
| `payment` | سفارش، تراکنش، زرین‌پال، بازار، لغو و بازپرداخت از ادمین | `Order Transaction BazaarCredential` |
| `discount` | کدِ تخفیف و پیشنهادها | `DiscountCode Redemption Offer UserOffer` |
| `affiliate` | معرفی به دوستان و پورسانت | `AffiliateAccount Conversion` |
| `notifications` | اعلان‌های داخلِ اپ | `Notification` |
@@ -314,18 +314,22 @@ with transaction.atomic():
سه نکته‌ی طراحی: (۱) تماس با GapGPT **بیرون از تراکنشِ دیتابیس** است، وگرنه ۳۰ ثانیه انتظار دیتابیس را قفل می‌کرد؛ (۲) اگر GapGPT خطا داد، سهمیه‌ی رزروشده پس داده می‌شود و کاربر ۵۰۳ می‌گیرد، نه ۵۰۰؛ (۳) اولین پیامِ هر گفتگو کش می‌شود — سؤالِ تکراری از کش ۰٫۰۱ ثانیه‌ای برمی‌گردد در برابرِ ~۱۱ ثانیه.
### ۶-۴ خریدِ اشتراک با زرین‌پال
### ۶-۴ خریدِ بسته‌ی سوال با زرین‌پال
![سناریوی خرید اشتراک](images/technical-handbook/scenario-subscription-payment.png)
![سناریوی خرید بسته‌ی سوال](images/technical-handbook/scenario-subscription-payment.png)
*کد: `subscription/views.py` · `payment/services.py` · `payment/views.py` ( `PaymentRedirectView`، `ZarinpalCallbackView` ) · `payment/zarinpal.py` · تست‌ها: `payment/tests_audit_fixes.py`*
*کد: `subscription/views.py` · `payment/services.py` · `subscription/services.py → grant_pack` · `payment/views.py` ( `PaymentRedirectView`، `ZarinpalCallbackView` ) · `payment/zarinpal.py` · تست‌ها: `payment/tests_audit_fixes.py` · `subscription/tests_packs.py` · `payment/tests_refund.py`*
از ۱۰ مهر ۱۴۰۵ آنچه فروخته می‌شود **بسته‌ی سوال** است، نه اشتراکِ ماهانه/سالانه: ۲۰ / ۶۰ / ۱۵۰ سوال از دستیار به ۴۵ / ۱۱۹ / ۲۵۹ هزار تومان، بی‌انقضا، با ۷ / ۳۰ / ۶۰ روز قلبِ نامحدودِ هدیه. پرداختِ تأییدشده یک ردیفِ مثبت در دفترِ `QuestionCredit` و روزهای `HeartsPass` می‌سازد و به اشتراکِ کسی دست نمی‌زند. پلن‌های اشتراکیِ قبلی پاک نشدند ( `on_sale=False` ) تا مشترکِ فعلی سقفش را نگه دارد.
> #### چرا این‌قدر پیچیده؟
>
> - **ساختِ سفارش به زرین‌پال وابسته نیست:** اگر زرین‌پال قطع باشد، سفارش ساخته می‌شود و فقط رفتن به درگاه عقب می‌افتد.
> - **برگشتِ کاربر هیچ چیزی را ثابت نمی‌کند:** هر کس می‌تواند آدرسِ برگشت را با `Status=OK` بسازد. فقط تأییدِ سرور-به-سرور سفارش را «پرداخت‌شده» می‌کند.
> - **پرداختِ جاافتاده بازسنجی می‌شود:** اگر زرین‌پال موقعِ برگشت جواب نداد، هر بار که اپ وضعیتِ اشتراک را می‌خواند سفارش دوباره سنجیده می‌شود (و دستورِ `payment_reconcile` ).
> - **پلن روی خودِ سفارش است** ؛ کدِ تخفیفِ ۱۰۰٪ بی‌درگاه تمام می‌کند؛ تمدید از تاریخِ انقضای فعلی حساب می‌شود.
> - **پرداختِ جاافتاده بازسنجی می‌شود:** اگر زرین‌پال موقعِ برگشت جواب نداد، هر بار که اپ وضعیتِ خرید را می‌خواند سفارش دوباره سنجیده می‌شود (و دستورِ `payment_reconcile` ).
> - **پلن روی خودِ سفارش است** ؛ کدِ تخفیفِ ۱۰۰٪ بی‌درگاه تمام می‌کند؛ تأییدِ تکراری اعتبارِ دوباره نمی‌دهد (قیدِ یکتا روی سفارش).
> - **کافه‌بازار:** سفارشِ بازار با `planId` ساخته می‌شود ( `POST /api/payment/orders` )؛ تا ۱۰ مهر این فیلد نبود و خریدِ بسته از بازار هیچ اعتباری نمی‌داد (در بازبینی پیدا و با تست بسته شد).
> - **لغو و بازپرداخت از پنلِ ادمین:** دو action روی سفارش‌ها؛ بازپرداخت پس از صفحه‌ی تأیید، اعتبارِ خرج‌نشده و روزهای قلب یا اشتراکِ همان سفارش را پس می‌گیرد. خودِ پول از پنلِ درگاه برگردانده می‌شود ( `docs/operations.md` ).
>
> آزموده‌نشده زرین‌پال درخواستِ پرداخت را با کدِ ۱۰۰ می‌پذیرد، ولی یک پرداختِ واقعی تا انتها هنوز انجام نشده.
@@ -337,7 +341,7 @@ with transaction.atomic():
|---|---|---|
| ۱ | `GET /api/user/weekly-report` | برای هر یک از ۷ روزِ اخیر و ۷ روزِ قبلش: زمانِ تمرین، سوالِ حل‌شده و درست، امتیاز، درس و جزیره‌ی تمام‌شده، سوال از دستیار. فقط دیتای خودِ صاحبِ توکن. |
| ۲ | — | فرانت جمله‌ی مقایسه («آفرین! این هفته ۲۰ دقیقه بیشتر…») و دو نمودار می‌سازد: زمانِ هر روز در برابرِ همان روزِ هفته‌ی پیش، و سوال‌های درست/نادرستِ هر روز. |
| ۳ | `GET /api/progress/nodes?userId=` | فقط شناسه‌ی خودِ کاربر پذیرفته است؛ هر شناسه‌ی دیگری ۴۰۳. |
| ۳ | `GET /api/progress/nodes` | پارامترِ `userId` برداشته شد؛ هر کاربر فقط پیشرفتِ خودش را می‌بیند. |
> **تخمینی:** «زمانِ تمرین» اندازه‌گیری نیست: از فاصله‌ی جواب‌ها و پرسش‌ها حساب می‌شود (فاصله‌ی بیش از ۱۰ دقیقه وقفه است). خواندنِ درس بدونِ جواب دادن در آن نیست، پس عدد معمولاً از زمانِ واقعی کمتر است.
@@ -382,9 +386,11 @@ with transaction.atomic():
- `IngestedPage collection, page`
**subscription · payment پول**
- `Plan price, duration_days, features, badge`
- `Plan kind (pack/subscription), price, question_credits, hearts_days, on_sale, ai_daily_quota`
- `Subscription user_id, → Plan, status, expires_at`
- `Order user_id, plan_id, amount, status, gateway_authority`
- `QuestionCredit user_id, delta (+خرید / −سوال / −بازپرداخت), order_id, message_id`
- `HeartsPass user_id, expires_at قلبِ نامحدودِ بسته`
- `Order user_id, plan_id, amount, list_price, plan_title, status (… canceled, refunded), gateway_authority`
- `Transaction → Order · gateway_ref_id, status, paid_at`
- `BazaarCredential توکنِ اتصال به بازار`
@@ -413,6 +419,7 @@ with transaction.atomic():
|---|---|
| یکتا: `UserAnswer(user_id, question, idempotency_key)` | ثبتِ دوباره‌ی یک جواب با تکرارِ درخواست |
| یکتا: `Redemption(کاربر، کد)` | استفاده‌ی دوباره از یک کدِ تخفیف |
| یکتا: `QuestionCredit(order_id)` برای ردیفِ خرید | اعتبارِ دوباره وقتی تأییدِ پرداخت دو بار برسد |
| یکتا: `CampaignDelivery(کمپین، کاربر، کانال)` | پیامِ تکراری وقتی کمپین دو بار هم‌زمان اجرا شود |
| یکتا: `LessonChunk(collection, qdrant_point_id)` و `IngestedPage(collection, page)` | تکه‌ی تکراری در Qdrant وقتی خواندنِ کتاب از سر گرفته می‌شود |
@@ -434,7 +441,7 @@ with transaction.atomic():
| سقفِ نرخ | هر اندپوینتِ حساس یک `throttle_scope` دارد؛ نرخ‌ها همه یک‌جا در `config/settings.py → DEFAULT_THROTTLE_RATES`. |
| استریم | `POST /api/ai/ask` با `"stream": true` پاسخ را SSE می‌فرستد: رویدادهای `chunk`، `done`، `error`. |
### فهرستِ ۶۱ اندپوینت
### فهرستِ ۵۹ اندپوینت
عمومی بی‌ورود · ورود توکن لازم · داخلی با هدرِ کلیدِ داخلی (سرور-به-سرور)
@@ -446,7 +453,8 @@ with transaction.atomic():
| POST | `/api/auth/login` | عمومی | ورود با شماره و کد |
| POST | `/api/auth/refresh` | عمومی | توکنِ تازه با refresh token |
| POST | `/api/auth/logout` | ورود | خروج؛ توکن‌های قبلی باطل |
| GET · PATCH | `/api/user/profile` | ورود | خواندن و ویرایشِ پروفایل |
| GET · PATCH | `/api/user/profile` | ورود | خواندن و ویرایشِ پروفایل (تاریخِ تولدِ شمسی، شناسه‌ی نمایشی) |
| POST | `/api/user/delete` | ورود | حذفِ حساب؛ شماره‌دار با کدِ پیامکیِ تازه، مهمان با تایپِ «حذف» |
| POST | `/api/user/grade` | ورود | ثبتِ پایه |
| **محتوا و آزمون — `content`** | | | |
| GET | `/api/grades` | عمومی | فهرستِ پایه‌ها |
@@ -459,7 +467,7 @@ with transaction.atomic():
| POST | `/api/lessons/{id}/complete` | ورود | پایانِ درس |
| POST | `/api/questions/{id}/answer` | ورود | ثبتِ جواب |
| POST | `/api/questions/{id}/report` | ورود | گزارشِ ایرادِ سؤال |
| GET | `/api/progress/nodes` | ورود | وضعیتِ کاربر (یا فرزند) روی جزیره‌ها |
| GET | `/api/progress/nodes` | ورود | وضعیتِ خودِ کاربر روی جزیره‌ها |
| **پیشرفت و بازی — `progress`** | | | |
| GET | `/api/user/summary` | ورود | پروفایل + آمار + زنجیره در یک درخواست |
| GET | `/api/user/hearts` | ورود | قلب‌ها و زمانِ پر شدن |
@@ -469,15 +477,16 @@ with transaction.atomic():
| POST | `/api/characters/select` | ورود | انتخابِ همراه (فقط بازشده) |
| **دستیار — `ai_tutor` و `rag`** | | | |
| POST | `/api/ai/ask` | ورود | پرسش؛ JSON یا استریم |
| GET | `/api/ai/quota` | ورود | سوال‌های رایگانِ باقیِ امروز و اعتبارِ بسته |
| GET | `/api/ai/conversations/{id}/messages` | ورود | تاریخچه‌ی گفتگو |
| POST | `/api/ai/messages/{id}/feedback` | ورود | 👍/👎 روی یک جواب |
| POST | `/api/rag/ingest/text` · `/pdf` | داخلی | افزودنِ متن یا PDF به Qdrant |
| GET | `/api/rag/chunks` | داخلی | مرورِ تکه‌های ذخیره‌شده |
| **اشتراک، پرداخت، تخفیف** | | | |
| GET | `/api/subscription/plans` | عمومی | پلن‌ها |
| **بسته، پرداخت، تخفیف** | | | |
| GET | `/api/subscription/plans` | عمومی | بسته‌های سوالِ در حالِ فروش |
| POST | `/api/subscription/purchase` | ورود | شروعِ خرید؛ سفارش و لینکِ پرداخت |
| GET | `/api/subscription/status` | ورود | وضعیتِ اشتراک (و بازسنجیِ پرداختِ جاافتاده) |
| POST | `/api/payment/orders` | ورود | ساختِ سفارش |
| GET | `/api/subscription/status` | ورود | اعتبارِ سوال، قلبِ نامحدود تا کِی، اشتراکِ قدیمی (و بازسنجیِ پرداختِ جاافتاده) |
| POST | `/api/payment/orders` | داخلی | ساختِ سفارش (سفارشِ بازار، با `planId` ِ بسته) |
| GET | `/api/payment/orders/{id}` | ورود | وضعیتِ سفارش |
| GET | `/api/payment/invoices` | ورود | پرداخت‌ها و فاکتورهای کاربر (شماره‌ی فاکتور، تخفیف، کدِ پیگیری) |
| GET | `/api/payment/orders/{id}/pay` | عمومی | رفتن به درگاهِ زرین‌پال |
@@ -692,7 +701,19 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| انتخابِ مدلِ جواب: ۱۰۲ مسئله‌ی تصحیح‌شده‌ی دستی | `gpt-4.1-mini` : ۹۵–۹۶ درست · `gpt-4o-mini` : ۸۵–۹۰ |
| شکل‌ها: ۶۴ مسئله‌ی تصادفیِ کتاب، وارسیِ چشمیِ تک‌تک | ۴۰ درست، ۱۲ قابلِ قبول، ۷ غلط، ۵ بی‌شکل — حدودِ یکی از پنج هنوز ایراد دارد؛ برای همین شکل همیشه کنارِ حلِ متنی می‌آید |
| سرعت | جوابِ کامل با شکل ~۳۰ ثانیه؛ اندازه‌گیری شد که در خودِ GapGPT است («فقط بگو سلام» = ۷ ثانیه) |
| سقف‌ها | ۱۵ سؤال در روز برای هر کاربر؛ ۲۰ در ساعت؛ مهمان‌ها بر اساسِ IP (تا با ساختِ حسابِ مهمانِ تازه دور زده نشود) |
| سقف‌ها | ۳ سؤالِ رایگان در روز، بعد از اعتبارِ بسته؛ ۲۰ در ساعت؛ مهمان‌ها بر اساسِ IP (تا با ساختِ حسابِ مهمانِ تازه دور زده نشود) |
### ۹-۷ فقط ریاضی، و چه چیزی هزینه دارد
دستیار فقط درباره‌ی ریاضی حرف می‌زند (`rag/services/guardrails.py`). سه لایه دارد: (۱) صافیِ سرور پیش از مدل و بی‌هزینه، فقط برای ترفندهای آشکار («دستورهای قبلی را نادیده بگیر»، «پرامپتت را بگو»)؛ (۲) قاعده‌ی سخت در پرامپت که هر چیزِ غیرِ ریاضی را با علامتی ثابت جواب می‌دهد؛ (۳) سرور آن علامت را با متنِ ثابتِ رد عوض می‌کند. جمله‌ای درباره‌ی آسیب به خود رد نمی‌شود و جوابی مهربان با شماره‌ی اورژانسِ اجتماعی (۱۲۳) و صدای مشاور (۱۴۸۰) می‌گیرد؛ از ۱۰ مهر هر شکلِ نوشتنش (با فاصله، بی‌نیم‌فاصله) گرفته می‌شود. آزمونِ زنده: ۷ از ۷ تلاشِ خروج از ریاضی رد شد.
| قاعده‌ی خرج | |
|---|---|
| ترتیب | اول ۳ سوالِ رایگانِ همان روز، بعد اعتبارِ بسته ( `Message.charge` : `free` / `credit` / `none` ) |
| خرج می‌شود | هر سوالی که به مدل برسد، از جمله ردِ «غیرِ ریاضی» توسطِ مدل |
| خرج نمی‌شود | ردِ صافیِ سرور، جوابِ کش‌شده، جوابی که ساخته نشد، و تکرارِ درخواست با همان `Idempotency-Key` |
| هم‌زمانی | دو سوالِ هم‌زمان با یک اعتبار فقط یک جواب می‌گیرند؛ با دو thread واقعی آزموده می‌شود ( `ai_tutor/tests_credit_concurrency.py` ) |
| هزینه | هر سوال حدودِ ۰٫۲ سنت؛ قیمتِ اندازه‌گیری‌شده‌ی هر مدل و تاریخچه‌اش در `docs/model-prices.md` |
## ۱۰. محتوای آموزشی و ساختِ سؤال
@@ -758,17 +779,21 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| امتیاز (XP) | پایانِ درس یا جزیره: **۱۰ × جوابِ درست**، فقط بارِ اول. عددِ جوابِ درستی که اپ می‌فرستد به جواب‌های درستِ **ثبت‌شده** محدود می‌شود؛ صدا زدنِ مستقیمِ API امتیازِ مجانی نمی‌دهد. | `content/views.py → NodeQuizCompleteView` |
| ستاره | ۹۰٪ به بالا ۳ · ۶۰٪ به بالا ۲ · بیشتر از صفر ۱ | همان |
| سکه | **۵ × ستاره**، فقط بارِ اول | همان |
| قلب | ۵ قلب؛ هر جوابِ غلط یکی کم؛ هر **۵ ساعت** یکی برمی‌گردد و **۲۴ ساعت** بعد از اولین قلبِ ازدست‌رفته همه پر می‌شوند. مشترکِ فعال قلب از دست نمی‌دهد. | `progress/models.py → UserHearts` |
| قلب | ۵ قلب؛ هر جوابِ غلط یکی کم؛ هر **۵ ساعت** یکی برمی‌گردد و **۲۴ ساعت** بعد از اولین قلبِ ازدست‌رفته همه پر می‌شوند. با قلبِ صفر نه جوابی پذیرفته می‌شود نه پایانِ درس یا جزیره‌ی بی‌جواب ( `403 no_hearts` ). اشتراکِ فعال یا روزهای قلبِ بسته = قلبِ نامحدود. | `progress/models.py → UserHearts` · `content/views.py` |
| زنجیره (Streak) | جواب، پایانِ درس یا جزیره امروز را (به وقتِ تهران) روزِ فعال می‌کند. اگر آخرین روزِ فعال نه امروز باشد نه دیروز، صفر نشان داده می‌شود. | `progress/services.py` |
| جدولِ رتبه | هفتگی (امتیازِ ۷ روزِ اخیر) و همه‌ی زمان‌ها؛ زنده حساب و ۶۰ ثانیه کش می‌شود. | `progress/leaderboard.py` |
| پیشرفت روی نقشه | پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی ( `nextNodeId` ) باز می‌شود؛ نمره فقط ستاره و امتیاز را تعیین می‌کند (تصمیمِ محصولی: ساده بماند). | `content/services.py` |
| اعلان | «جزیره را با ۳ ستاره تمام کردی و ۴۰ امتیاز گرفتی» — با رقمِ فارسی. | `notifications/services.py` |
> **محدودیتِ قلب در سرور فعلاً خاموش است** ( `HEARTS_ENFORCED=0` ): با صفر قلب، اپ جلوی کاربر را می‌گیرد ولی API هنوز جواب را می‌پذیرد، چون فرانت صفحه‌ی «قلب‌ها تمام شد» را ندارد. روشن کردنش یک خط در `.env` است. تنها مزیتِ واقعیِ اشتراک در کد فعلاً همین «قلبِ نامحدود» است — یک تصمیمِ محصولی.
> **قلب واقعی است** ( `HEARTS_ENFORCED` پیش‌فرض روشن): قلبِ صفر بازی را متوقف می‌کند تا قلبِ بعدی برگردد. چکِ قلب داخلِ همان تراکنشی است که قلب را کم می‌کند، پس دو جوابِ هم‌زمان با یک قلبِ باقی‌مانده هر دو پذیرفته نمی‌شوند؛ و پایانِ آزمون بی‌جواب دادن هم بسته است. کسی که آخرین قلبش را روی آخرین سوال از دست داده، جزیره را تمام می‌کند. تست‌ها: `content/tests_hearts_gate.py` .
![صفحه‌ی قلب‌هات تموم شد](images/technical-handbook/screenshot-06-no-hearts.jpg)
*شکلِ ۱۱-۳ — صفحه‌ی «قلب‌هات تموم شد»: شمارشِ معکوسِ زنده تا قلبِ بعدی، قلبی که کم‌کم پر می‌شود، ساعتِ پر شدنِ همه، پیشنهادِ بسته‌ها و «تا اون موقع از پی بپرس» (پرسیدن قلب خرج نمی‌کند). وقتی وقتش رسید، صفحه خودش از سرور می‌پرسد و «ادامه بده» را نشان می‌دهد. فرانت: `src/components/NoHeartsScreen.tsx` .*
## ۱۲. امنیت و احرازِ هویت
دو دور بازبینیِ بدبینانه روی کلِ بک‌اند انجام شد (مرداد و ۳ مهر)، و یک وارسیِ نهایی روی سایتِ زنده (۷ مهر) با این سؤال: «یک دانش‌آموزِ متقلب، یک مهاجم یا یک والدِ سردرگم چطور این را می‌شکند؟» هر یافته با یک تست بسته شد. این بخش صادقانه می‌گوید چه بسته است و چه هنوز باز.
سه دور بازبینیِ بدبینانه روی کلِ بک‌اند انجام شد (مرداد، ۳ مهر و ۱۰ مهر)، و یک وارسیِ نهایی روی سایتِ زنده (۷ مهر) با این سؤال: «یک دانش‌آموزِ متقلب، یک مهاجم یا یک والدِ سردرگم چطور این را می‌شکند؟» هر یافته با یک تست بسته شد. این بخش صادقانه می‌گوید چه بسته است و چه هنوز باز.
### ۱۲-۱ هویت و توکن
@@ -783,14 +808,15 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| حوزه | چه کرده‌ایم | کجا |
|---|---|---|
| دسترسی به داده‌ی دیگران | شناسه‌ی کاربر همیشه از توکن خوانده می‌شود، هرگز از ورودی؛ دیدنِ پیشرفتِ فرزند فقط با اتصالِ تأییدشده. | همه‌ی `views.py` ها |
| دسترسی به داده‌ی دیگران | شناسه‌ی کاربر همیشه از توکن خوانده می‌شود، هرگز از ورودی؛ هر کاربر فقط داده‌ی خودش را می‌بیند (گزارشِ والدین هم در حسابِ خودِ دانش‌آموز است). | همه‌ی `views.py` ها |
| سقفِ نرخ | روی همه‌ی نقاطِ حساس (ورود، دستیار، خرید، کدِ تخفیف، جواب…)؛ مهمان بر اساسِ IPِ واقعی ( `NUM_PROXIES=2` : آروان + nginx). | `config/settings.py` |
| پیامک و هزینه‌اش | شماره به `09XXXXXXXXX` نرمال می‌شود؛ فاصله بینِ دو درخواست، ۱۰ در روز برای هر شماره، ۳۰۰ در روز برای کلِ سامانه (ضدِ SMS pumping) — شمارش در دیتابیس، نه حافظه‌ی موقت. | `accounts/phone.py` · `tests_otp_limits.py` |
| پرداخت | فقط تأییدِ سرور-به-سرور؛ برگشتِ جعلی اثری ندارد؛ webhookِ عمومیِ ناامن حذف شد؛ هر درگاهی بی‌کلید «بسته» می‌ماند (fail-closed)، نه موفقیتِ ساختگی. | `payment/` |
| پرداخت | فقط تأییدِ سرور-به-سرور؛ برگشتِ جعلی اثری ندارد؛ webhookِ عمومیِ ناامن حذف شد؛ هر درگاهی بی‌کلید «بسته» می‌ماند (fail-closed)، نه موفقیتِ ساختگی. اعتبارِ بسته دفتری است و هر سفارش فقط یک بار اعتبار می‌دهد؛ وضعیتِ سفارش در ادمین فقط با actionِ لغو/بازپرداخت عوض می‌شود. | `payment/` · `subscription/` |
| تقلبِ امتیاز | امتیاز فقط برای جوابِ ثبت‌شده؛ جایزه فقط بارِ اول. | `content/views.py` |
| درخواستِ تکراری و هم‌زمانی | Idempotency-Key در شش نقطه؛ `transaction.atomic` ؛ قیدِ یکتا در دیتابیس؛ SQLite در حالتِ `IMMEDIATE` تا قفل‌ها واقعاً قفل کنند. | `config/settings.py` |
| خطا و لاگ | هر خطا JSONِ یکدست بی‌جزئیاتِ داخلی؛ لاگ بدونِ بدنه، هدر، توکن یا رمز. | `config/exceptions.py` · `middleware.py` |
| محتوای مدل | HTML یا SVGِ تولیدِ مدل هرگز واردِ صفحه نمی‌شود؛ فقط JSONِ وارسی‌شده با فهرستِ سفید. | `rag/services/diagram.py` |
| محتوای مدل | HTML یا SVGِ تولیدِ مدل هرگز واردِ صفحه نمی‌شود؛ فقط JSONِ وارسی‌شده با فهرستِ سفید. دستیار فقط ریاضی (بخشِ ۹-۷). | `rag/services/diagram.py` · `guardrails.py` |
| حذفِ حساب | شماره‌دار با کدِ پیامکیِ تازه (همان سقفِ حدسِ ورود)، مهمان با تایپِ «حذف»؛ حسابِ نمایشی حذف نمی‌شود؛ سفارش‌ها برای حسابداری می‌مانند؛ توکنِ قبلی بعد از حذف کار نمی‌کند. | `accounts/views.py → DeleteAccountView` |
| مرورگر | CORS فقط برای دامنه‌ی فرانت؛ پاسخِ API با `no-store` در کشِ آروان نمی‌ماند؛ نامِ نمایشی از شماره و لینک پاک می‌شود؛ سقفِ حدسِ رمزِ ادمین برای هر IP. | `config/middleware.py` |
| کلیدها | همه در `.env` ِ سرور؛ هیچ کلیدی در git یا در پاسخِ API نیست؛ `/api/health` فقط «بله/خیر» می‌گوید. | `.env.example` |
@@ -855,7 +881,8 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
| `KAVENEGAR_API_KEY` · `KAVENEGAR_OTP_TEMPLATE` | درخواستِ کد ۵۰۳ صریح می‌گیرد. الگو باید `SMSVerify` بماند. |
| `ZARINPAL_MERCHANT_ID` · `BAZAAR_*` | پرداخت بسته می‌ماند (۵۰۳). |
| `CACHE_URL` | کشِ جدا برای هر worker؛ `/api/health` هشدار می‌دهد. |
| `HEARTS_ENFORCED` · `TEST_LOGIN_PHONES` | خاموش · هیچ حسابِ آزمایشی. |
| `HEARTS_ENFORCED` · `TEST_LOGIN_PHONES` | روشن (قلب واقعی؛ `0` خاموشش می‌کند) · هیچ حسابِ آزمایشی. |
| `AI_DAILY_QUOTA_FREE` | ۳ سوالِ رایگان در روز. |
| `DJANGO_DEBUG` | خاموش — و **هرگز روی سرور روشن نشود** (کدِ ورودِ همگانیِ `0000` را فعال می‌کند). |
مقدارهای `.env` با پیش‌فرض‌های کد ادغام می‌شوند؛ یک `.env` ِ ناقص سرویس را نمی‌خواباند. شرحِ کامل در `.env.example` و `docs/operations.md`.
@@ -885,10 +912,10 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
| ویژگی | چرا کارِ هم‌زمان را ممکن می‌کند |
|---|---|
| ۱۵ اپ با مرزِ روشن | هر نفر صاحبِ چند اپ می‌شود (مثلاً یکی پرداخت و اشتراک، یکی محتوا و گیمیفیکیشن، یکی دستیار)؛ تغییرِ هر کس کمتر به فایلِ دیگری می‌خورد. |
| ۱۴ اپ با مرزِ روشن | هر نفر صاحبِ چند اپ می‌شود (مثلاً یکی پرداخت و اشتراک، یکی محتوا و گیمیفیکیشن، یکی دستیار)؛ تغییرِ هر کس کمتر به فایلِ دیگری می‌خورد. |
| بی‌FK بینِ اپ‌ها | تغییرِ جدول‌های یک اپ migrationِ اپِ دیگر را نمی‌شکند؛ هر اپ بعداً قابلِ جدا شدن به سرویسِ مستقل است. |
| قراردادِ OpenAPI | فرانت و بک‌اند بی‌جلسه روی یک شکلِ داده توافق دارند؛ `tests_frontend_contract.py` تغییرِ ناخواسته را می‌گیرد. |
| ۶۸۵ تستِ خودکار | تازه‌وارد بی‌ترس تغییر می‌دهد: اگر چیزی را شکست، تست قبل از سرور خبر می‌دهد. |
| ۷۷۹ تستِ خودکار | تازه‌وارد بی‌ترس تغییر می‌دهد: اگر چیزی را شکست، تست قبل از سرور خبر می‌دهد. |
| قواعدِ یک‌جا | نرخ‌ها، دامنه‌ها و تنظیمات فقط در `config/settings.py` ؛ پیامِ خطا، لاگ و هویت یک مسیرِ مشترک دارند. |
| مستندات در مخزن | `docs/` + همین سند + Swagger + توضیحِ «چرا» کنارِ کدِ حساس. |
@@ -921,7 +948,7 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
| چه | چطور |
|---|---|
| تستِ خودکارِ بک‌اند | **۶۸۵ تست**، همه سبز ( `python manage.py test` ). روی فایلِ دیتابیسِ واقعی ( `.test-db.sqlite3` ) اجرا می‌شوند تا رفتارِ قفل‌ها مثلِ سرور باشد. هر باگِ پیداشده در بازبینی‌ها یک تستِ اختصاصی دارد ( `tests_audit_fixes.py`، `tests_otp_limits.py`، …). |
| تستِ خودکارِ بک‌اند | **۷۷۹ تست**، همه سبز ( `python manage.py test` ). روی فایلِ دیتابیسِ واقعی ( `.test-db.sqlite3` ) اجرا می‌شوند تا رفتارِ قفل‌ها مثلِ سرور باشد. هر باگِ پیداشده در بازبینی‌ها یک تستِ اختصاصی دارد ( `tests_audit_fixes.py`، `tests_otp_limits.py`، `tests_hearts_gate.py`، …). هم‌زمانی با دو thread واقعی آزموده می‌شود (اعتبارِ دستیار، آخرین قلب). |
| قرارداد با فرانت | `content/tests_frontend_contract.py` و build با TypeScriptِ strict در فرانت. |
| تستِ روی سایتِ زنده | هر صفحه با حساب‌های نمایشی روی سرورِ واقعی؛ مرورگرِ خودکار (puppeteer) برای وارسیِ صفحه‌ها. |
| کیفیتِ دستیار | `rag_model_benchmark` (مسئله‌های واقعیِ کتاب از مسیرِ واقعی، با هزینه‌ی واقعی)، `ai_feedback_report` (👍/👎 به تفکیکِ نسخه‌ی پرامپت)، وارسیِ چشمیِ نمونه پیش از اعتماد به هر اجرای انبوه. |
@@ -932,7 +959,7 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
## ۱۶. وضعیتِ فعلی، محدودیت‌ها و تصمیم‌های باز
### وضعیت در تاریخِ نگارشِ سند (۷ مهر ۱۴۰۵، روی سرورِ زنده)
### وضعیت در تاریخِ نگارشِ سند (۱۰ مهر ۱۴۰۵، روی سرورِ زنده)
| بخش | وضعیت | توضیح |
|---|---|---|
@@ -941,7 +968,8 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
| پیامکِ ورود | کار می‌کند | کدِ واقعی به گوشی رسید (الگوی SMSVerify)؛ هر پیامک هزینه دارد |
| دستیار + شکل | کار می‌کند | کُند (~۳۰ ثانیه، از سمتِ GapGPT)؛ ~یک شکل از پنج ایراد دارد |
| زرین‌پال | نیمه | درخواستِ پرداخت پذیرفته می‌شود؛ پرداختِ واقعی تا انتها آزموده نشده |
| کافه‌بازار | منتظر | کد آماده؛ منتظرِ تأییدِ حسابِ شرکت و اپِ اندرویدی |
| کافه‌بازار | منتظر | اتصالِ API برقرار؛ منتظرِ تعریفِ بسته‌ها در پنلِ بازار (محصولِ مصرفی) و اپِ اندرویدی |
| بسته‌ی سوال و قلب | زنده | سه بسته روی سایت؛ قلبِ صفر بازی را متوقف می‌کند |
| پیامِ گروهی | پایه | ذخیره و هدف‌گیری هست؛ زمان‌بندیِ خودکار نه |
| بکاپِ خودکار | ندارد | دستی پیش از هر انتشار |
@@ -950,8 +978,9 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
| موضوع | چرا تصمیم لازم است |
|---|---|
| عوض کردنِ کلیدِ امضا | کد آماده؛ همه یک بار از حساب بیرون می‌افتند — زمانش را باید تعیین کرد. |
| پاک‌سازیِ داده‌ی دمو | پیش از اولین کاربرِ واقعی: ۷ شماره‌ی آزمایشی، حساب‌های دمو و مهمان‌های تستی. |
| مزیتِ اشتراک | در حالِ حاضر فقط «قلبِ نامحدود»؛ دستیار و درس‌ها برای همه آزادند. چه چیزی پولی باشد؟ |
| پاک‌سازیِ داده‌ی دمو | حساب‌های آزمایشیِ شماره‌دار ۱۰ مهر پاک شدند (با بکاپ)؛ ۷ شماره‌ی نمایشی و حساب‌هایشان عمداً مانده‌اند. |
| قیمتِ بسته‌ها و سهمیه‌ی رایگان | بعد از دو هفته مصرفِ واقعی بازبینی شود؛ سود در `docs/ai-cost-and-guardrails.md` حساب شده. |
| سیاستِ بازپرداخت | الان فقط اعتبارِ خرج‌نشده پس گرفته می‌شود؛ پول از پنلِ درگاه. |
| Redis و Postgres | هماهنگی با ادمینِ سرور؛ هر دو روی همان ماشین موجودند. |
| پورتِ ۸۰ و دسترسیِ SSH | کارِ ادمینِ سرور. |
| پیشرفت با هر نمره | در حالِ حاضر جزیره با هر نمره‌ای تمام می‌شود؛ اگر حدِ قبولی لازم است، یک تصمیمِ آموزشی است. |
@@ -963,7 +992,7 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
3. یک پرداختِ واقعیِ کامل با زرین‌پال.
4. `CACHE_URL` (Redis) و بعد Postgres.
5. صفِ کار برای دستیار؛ سنجشِ شکل روی نمونه‌ی بزرگ‌تر.
6. روشن کردنِ `HEARTS_ENFORCED` همراه با صفحه‌ی «قلب‌ها تمام شد» در فرانت.
6. تعریفِ سه بسته در پنلِ کافه‌بازار و اپِ اندرویدی با Poolakey.
## پیوست
@@ -1022,6 +1051,8 @@ npm run build # type-check + build در dist/
| `docs/security-before-production.md` | ۱۸ موردِ امنیتیِ باز با خطر و راهِ بستن |
| `docs/content-authoring.md` | ویرایشِ محتوا و ساختِ انبوهِ سؤال |
| `docs/extraction-playbook.md` | خواندنِ کتاب با مدلِ بینایی: هزینه و درس‌ها |
| `docs/ai-cost-and-guardrails.md` | هزینه‌ی هر سوالِ دستیار، بسته‌ها و قاعده‌ی خرج، محافظِ «فقط ریاضی» |
| `docs/model-prices.md` | دفترِ قیمتِ اندازه‌گیری‌شده‌ی مدل‌ها، با تاریخ؛ اگر قیمت عوض شد ردیف اضافه شود |
| `data/textbooks/README.md` | افزودنِ کتابِ تازه |
| `/api/docs/` | قراردادِ زنده و قابلِ امتحانِ API |