Docs brought to the current state: CLAUDE.md rewritten, README formal, handbook and diagrams redrawn

- CLAUDE.md: 1,225 → ~430 lines, current state only (the dated status log stays in git history).
  Server rules moved to the top: what is ours on 249/247 and what is not, non-negotiable rules,
  announce-before-acting, password handling, and the full "deploy it" procedure with verification.
- README: formal entry page with current numbers (10 grades, 18,522 questions, 61 API operations,
  973 tests, PostgreSQL/Redis).
- Removed docs/frontend/ (Figma-era guide with guest login and its own api.ts; the frontend repo has
  the real client). Swagger's external-docs link now points at the frontend repo.
- architecture/operations/security/content-authoring/loadtest/textbooks: SQLite, guest login,
  demo accounts, question packs and the port-80 note replaced by what is true now.
- Technical handbook text updated throughout; all seven diagrams redrawn (PostgreSQL, phone-only
  login, subscriptions, current data model); PDF rebuilt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 18:45:51 +03:30
parent eb95209ea8
commit a4e0c576bb
20 changed files with 752 additions and 2134 deletions

1583
CLAUDE.md

File diff suppressed because it is too large Load Diff

174
README.md
View File

@@ -1,117 +1,118 @@
# Tadrisino API
بک‌اندِ اپلیکیشنِ آموزشیِ تدریسینو — یک REST API روی Django که برنامه‌ی درسی،
آزمون‌ها، پیشرفتِ کاربر، دستیارِ هوشِ مصنوعی، اشتراک و پرداخت را سرو می‌کند.
بک‌اندِ **تدریسینو**، اپِ آموزشِ ریاضی به سبکِ دولینگو برای دانش‌آموزانِ پایه‌ی سوم تا دوازدهم. دانش‌آموز روی نقشه‌ای
از جزیره‌ها جلو می‌رود، هر جزیره آزمونی کوتاه از مسئله‌های خودِ کتابِ درسی است، و هر جا گیر کند از دستیارِ هوشِ
مصنوعی می‌پرسد که جوابش را از همان کتاب می‌سازد.
فرانت‌اند ریپوی جداگانه‌ای است ([`MathApp_frontend`](https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend)).
**قراردادِ بینِ دو طرف، اسکیمای OpenAPI همین ریپوست** — نه حافظه‌ی کسی، نه یک
فایلِ دستی. هر تغییری در API باید از آنجا دیده شود.
این مخزن یک REST API روی Django است: برنامه‌ی درسی و آزمون، پیشرفت و گیمیفیکیشن، دستیارِ مبتنی بر RAG، اشتراک
و پرداخت. فرانت‌اند مخزنِ جداگانه‌ای است ([`Tadrisino/MathApp_frontend`](https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend)).
**قراردادِ میانِ دو طرف اسکیمای OpenAPIِ همین مخزن است**؛ هر تغییری در API از آن‌جا دیده می‌شود.
| | |
|---|---|
| **وضعیت** | روی پروداکشن، در حالِ سرویس‌دهی |
| **آدرسِ زنده** | `https://mathapi.tadrisino2.ir` · [سلامت](https://mathapi.tadrisino2.ir/api/health) |
| **استک** | Python 3.11+ · Django 5.2 · DRF · SQLite · Qdrant (RAG) |
| **API** | ۵۹ اندپوینت · مستنداتِ زنده در `/api/docs/` |
| **تست** | ۷۷۹ تست، همه سبز (`python manage.py test`) |
| **محتوا** | ۸ پایه · ۵۸ فصل · ۲۶۳ نود · ۲٬۶۱۷ درس · ۲٬۵۴۵ سوال |
| **سرویسِ زنده** | `https://mathapi.tadrisino2.ir` · اپ: `https://math.tadrisino2.ir` |
| **فناوری** | Python 3.11 · Django 5.2 · Django REST Framework · PostgreSQL 14 · Redis · Qdrant · gunicorn پشتِ nginx و ArvanCloud |
| **API** | ۶۱ عملیات روی ۵۹ مسیر، مستند با OpenAPI (`/api/docs/`) |
| **محتوا** | ۱۰ پایه · ۵ کتاب · ۸۹ فصل · ۱٬۱۴۵ جزیره · ۵٬۴۰۶ درس · ۱۸٬۵۲۲ سوال |
| **تست** | ۹۷۳ تستِ خودکار، همه سبز |
| **سرویس‌های بیرونی** | GapGPT (مدلِ زبانی) · Qdrant (جستجوی کتاب) · کاوه‌نگار (پیامک) · زرین‌پال و کافه‌بازار (پرداخت) |
---
## امکانات
| بخش | چه می‌کند |
|---|---|
| **ورود** | فقط با شماره‌ی موبایل و کدِ پیامکی؛ توکنِ دسترسیِ امضاشده‌ی یک‌ساعته و توکنِ تمدید |
| **برنامه‌ی درسی** | پایه ← کتاب ← فصل ← جزیره ← درس ← سوال؛ کلِ محتوا به‌صورتِ JSONِ کامیت‌شده در `content/seed/` |
| **بازی** | آزمونِ هر جزیره، قفلِ ترتیبی، قلب، ستاره و امتیاز، زنجیره‌ی روزانه، جدولِ رتبه‌بندی، همراه‌ها |
| **دستیار** | پاسخِ گام‌به‌گام از روی همان کتاب (Qdrant + GapGPT)، فقط ریاضی، با شکلِ توصیفی که اپ می‌کشد |
| **اشتراک** | فصلِ اولِ هر کتاب رایگان؛ اشتراکِ یک/سه/شش‌ماهه از زرین‌پال یا کافه‌بازار، با کدِ تخفیف و برنامه‌ی معرفی |
| **پنلِ ادمین** | ویرایشِ محتوا، ورود و خروجِ فله‌ای سوال، پلن‌ها، سفارش‌ها (لغو و بازپرداخت)، تیکت‌ها، گزارش‌های کاربران |
| **پایش** | `/api/health` (دیتابیس، مهاجرت، کش، سرویس‌های بیرونی، بکاپ)، لاگِ JSON برای هر درخواست، دفترِ رویدادِ پرداخت |
---
## شروعِ سریع
```bash
python3 -m venv venv && source venv/bin/activate
python3.11 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # برای توسعه‌ی محلی: DJANGO_DEBUG=1 را ست کنید
cp .env.example .env # برای توسعه‌ی محلی DJANGO_DEBUG=1 را ست کنید
python manage.py migrate
python manage.py seed_content # برنامه‌ی درسی و سوال‌های واقعی از content/seed/
python manage.py runserver
python manage.py seed_content # برنامه‌ی درسی و بانکِ سوال از content/seed/
python manage.py createsuperuser # برای /admin/
python manage.py runserver # http://127.0.0.1:8000
```
سرویس روی `http://127.0.0.1:8000` بالا می‌آید. `.env` خودکار خوانده می‌شود —
لازم نیست چیزی را `export` کنید.
بدونِ `DB_NAME` در `.env` دیتابیسِ محلی SQLite است و نیازی به نصبِ Postgres نیست. `.env` خودکار خوانده می‌شود.
| | |
| آدرس | چیست |
|---|---|
| Swagger UI | `/api/docs/` |
| Redoc | `/api/redoc/` |
| اسکیمای OpenAPI | `/api/schema/` |
| پنلِ ادمین | `/admin/` |
| سلامتِ سرویس | `/api/health` |
| `/api/docs/` · `/api/redoc/` · `/api/schema/` | مستنداتِ زنده‌ی API (روی سرور فقط با ورود به پنلِ ادمین) |
| `/admin/` | پنلِ ادمین |
| `/api/health` | سلامتِ سرویس |
**اولین درخواست در Swagger:** در حالتِ `DJANGO_DEBUG=1` با `POST /api/auth/otp/request`
شماره‌ای بفرستید و با `POST /api/auth/login` و کدِ `DEV_MASTER_OTP` وارد شوید؛ `token` را
بردارید، روی **Authorize** کلیک کنید و همان رشته را (بدونِ کلمه‌ی `Bearer`) وارد کنید.
ورودِ مهمان نداریم.
دستورهای پرکاربردِ دیگر:
**اولین درخواست:** با `DJANGO_DEBUG=1`، `POST /api/auth/otp/request` با یک شماره، سپس `POST /api/auth/login` با
کدی که در فیلدِ `devOtp` پاسخ آمده. `token` را در Swagger زیرِ **Authorize** بگذارید.
```bash
python manage.py test # کلِ تست‌سوییت
python manage.py createsuperuser # دسترسی به /admin
python manage.py seed_test_data # داده‌ی ساختگی برای توسعه (فقط محلی)
python manage.py test --noinput # کلِ تست‌ها (سریال؛ --parallel روی macOS گیر می‌کند)
python manage.py test --noinput content # تست‌های یک اپ
python manage.py spectacular --file schema.yaml --format openapi # خروجیِ قرارداد برای فرانت
```
برای استکِ لاگِ محلی (Loki و Grafana) [`docs/docker.md`](docs/docker.md) را ببینید.
---
## معماری
هر اپ یک مسئولیت دارد و مدل‌های خودش را نگه می‌دارد:
یک برنامه‌ی جنگو در قالبِ «مونولیتِ ماژولار»: هر اپ یک مسئولیت دارد و مدل‌های خودش را نگه می‌دارد.
| اپ | مسئولیت |
|---|---|
| `accounts` | هویت، session، ورود با OTP |
| `content` | برنامه‌ی درسی، درس، سوال، پیشرفت روی نقشه |
| `progress` | آمار، لیدربرد، streak، شخصیت‌های بازی |
| `ai_tutor` · `rag` | دستیارِ هوشِ مصنوعی: بازیابی از Qdrant + تولید با GPT |
| `subscription` · `payment` · `discount` | پلن، سفارش، تراکنش، کدِ تخفیف |
| `notifications` · `affiliate` · `support` · `campaigns` | امکاناتِ جانبی |
| `statuslog` | لاگِ عمومیِ تغییرِ وضعیت، مستقل از بقیه |
| `config` | تنظیمات، روت‌های اصلی، middleware |
| `accounts` | هویت، ورود با کدِ پیامکی، نشست، پروفایل |
| `content` | کتاب‌ها، برنامه‌ی درسی، درس، سوال، آزمونِ جزیره، بازخوردِ کاربر |
| `progress` | آمار، زنجیره‌ی روزانه، قلب، رتبه‌بندی، همراه‌ها، گزارشِ هفتگی |
| `ai_tutor` · `rag` | دستیار: گفتگو، بازیابی از Qdrant، تولید با GapGPT، ورودِ کتاب |
| `subscription` · `payment` · `discount` · `affiliate` | پلن و اشتراک، سفارش و تراکنش، کدِ تخفیف، برنامه‌ی معرفی |
| `notifications` · `support` · `campaigns` · `statuslog` | اعلان، پشتیبانی، کمپین، لاگِ تغییرِ وضعیت |
| `config` | تنظیمات، مسیرها، middleware، سلامت |
سه تصمیمِ معماری که غیرِبدیهی‌اند و دانستنشان لازم است:
سه قاعده که از خواندنِ یک فایل پیدا نیست:
- **احرازِ هویت، سیستمِ خودمان است نه جنگو.** توکنِ bearerِ امضاشده و stateless
با `django.core.signing` (بدونِ کتابخانه‌ی JWT)، پشتیبانی‌شده با مدلِ
`accounts.Session`. `accounts.User` عمداً `AUTH_USER_MODEL` **نیست** — یک
مدلِ بیزنسیِ مستقل است. هدرِ نامعتبر یعنی درخواستِ ناشناس، نه خطا؛ هر ویو
خودش تصمیم می‌گیرد که آیا ورود لازم است یا نه (`accounts/authentication.py`).
- **اپ‌ها با قرارداد از هم جدا شده‌اند، نه با ForeignKey.** ارجاعِ بینِ اپ‌ها با
`user_id` و `object_id` + `label` انجام می‌شود تا هر اپ بعداً قابلِ جدا شدن
باشد. **FK بینِ اپ‌ها اضافه نکنید.**
- **محدودیتِ نرخ (throttle) متمرکز است.** هر اندپوینتِ حساس یک
`throttle_scope` دارد و نرخش در `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']`
داخلِ `config/settings.py` تعریف می‌شود. اندپوینتِ جدید یعنی **هر دو**.
- **احرازِ هویت سیستمِ خودِ پروژه است.** `accounts.User` عمداً `AUTH_USER_MODEL` نیست؛ توکن با `django.core.signing`
امضا می‌شود (`accounts/authentication.py`). هدرِ نامعتبر یعنی درخواستِ ناشناس، و هر ویو خودش تصمیم می‌گیرد ورود
لازم است یا نه.
- **اپ‌ها با قرارداد جدا شده‌اند، نه با ForeignKey.** ارجاعِ بینِ اپ‌ها با `user_id` یا `object_id` + `label` است تا
هر اپ بعداً جداشدنی بماند.
- **محدودیتِ نرخ متمرکز است.** هر اندپوینتِ حساس یک `throttle_scope` دارد و نرخش در `config/settings.py` است؛ IPِ
واقعی فقط از پروکسی‌های ArvanCloud پذیرفته می‌شود.
جزئیاتِ بیشتر: [`docs/architecture.md`](docs/architecture.md).
شرحِ کامل و دلیلِ هر تصمیم در [`docs/architecture.md`](docs/architecture.md) و
[`docs/technical-handbook.md`](docs/technical-handbook.md) آمده است.
---
## دیپلوی
## استقرار
کلِ انتشار یک خط است:
سرویس روی یک سرورِ مشترکِ شرکت اجرا می‌شود و مالِ این پروژه فقط پوشه‌ی `~/mathapp-v2` و سرویسِ
`zareian-mathapp` است. کلِ انتشار:
```bash
ssh -t administrator@78.38.35.249 \
'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
ssh -t administrator@78.38.35.249 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
```
`deploy.sh` کد را می‌گیرد، وابستگی‌ها را نصب می‌کند، مهاجرت می‌زند، محتوا را
seed می‌کند، فایل‌های استاتیک را جمع می‌کند و در پایان **ثابت می‌کند که سرویس
واقعاً ری‌استارت شده** — نه اینکه فقط فرض کند. ری‌استارت جداست چون `sudo`
داخلِ اسکریپت نمی‌تواند رمز بپرسد.
پس از هر انتشار، از بیرون و بدونِ SSH تأیید کنید:
`deploy.sh` کد را با `origin/main` یکی می‌کند، وابستگی‌ها را نصب می‌کند، پیش از `migrate` از دیتابیس بکاپ می‌گیرد،
محتوا را seed و فایل‌های استاتیک را جمع می‌کند و سلامتِ سرویس را می‌سنجد. سپس از بیرون:
```bash
curl https://mathapi.tadrisino2.ir/api/health
curl https://mathapi.tadrisino2.ir/api/health # {"status": "ok"}
```
`200` یعنی سالم، `503` یعنی چیزی واقعاً خراب است. هیچ سکرتی برنمی‌گرداند.
رویه‌ی کامل — متغیرهای محیطی، ورودِ توسعه‌دهنده‌ها، بکاپ، عیب‌یابی:
[`docs/operations.md`](docs/operations.md).
هر تغییرِ دیتابیس لحظه‌ای به سرورِ بکاپِ جداگانه‌ای می‌رود. رویه‌ی کامل (متغیرهای محیطی، بکاپ و بازیابی، nginx،
عوض کردنِ دامنه، فروش در کافه‌بازار): [`docs/operations.md`](docs/operations.md).
---
@@ -119,31 +120,20 @@ curl https://mathapi.tadrisino2.ir/api/health
| سند | موضوع |
|---|---|
| [`docs/operations.md`](docs/operations.md) | دیپلوی، متغیرهای محیطی، سلامت، بکاپ، ورودِ توسعه‌دهنده‌ها |
| [`docs/technical-handbook.md`](docs/technical-handbook.md) · [PDF](docs/technical-handbook.pdf) | **مستندِ فنیِ کامل و تحویلِ پروژه:** معماری، مدلِ سناریو و رابطه‌ای، API، Qdrant، گیمیفیکیشن، امنیت، استقرار، مقیاس |
| [`docs/security-before-production.md`](docs/security-before-production.md) | **پیش از کاربرِ واقعی:** هرچه برای دمو عمداً باز مانده، خطرش و راهِ بستنش |
| [`docs/content-authoring.md`](docs/content-authoring.md) | افزودن و ویرایشِ محتوا از پنلِ ادمین، ساختِ انبوهِ سوال |
| [`docs/technical-handbook.md`](docs/technical-handbook.md) · [PDF](docs/technical-handbook.pdf) | مستندِ فنیِ کامل برای تحویل و پیوستنِ توسعه‌دهنده‌ی تازه |
| [`docs/architecture.md`](docs/architecture.md) | تصمیم‌های معماری و دلیلشان |
| [`docs/frontend/README.md`](docs/frontend/README.md) | راهنمای اتصالِ فرانت‌اند + کلاینتِ تایپ‌دار |
| [`docs/docker.md`](docs/docker.md) | استکِ لاگِ محلی (Loki + Alloy + Grafana) |
| [`docs/extraction-playbook.md`](docs/extraction-playbook.md) | استخراجِ محتوا از کتاب با مدلِ ویژن و هزینه‌هایش |
| [`docs/operations.md`](docs/operations.md) | استقرار، پیکربندی، سلامت، بکاپ و بازیابی، nginx، پرداخت |
| [`docs/security-before-production.md`](docs/security-before-production.md) | آنچه پیش از کاربرِ واقعی باید بسته شود، و حفاظ‌های موجود |
| [`docs/content-authoring.md`](docs/content-authoring.md) | ویرایشِ محتوا از پنلِ ادمین و ساختِ انبوهِ سوال |
| [`docs/extraction-playbook.md`](docs/extraction-playbook.md) | خواندنِ کتابِ درسی با مدلِ تصویری، هزینه و انتخابِ مدل |
| [`docs/ai-cost-and-guardrails.md`](docs/ai-cost-and-guardrails.md) · [`docs/model-prices.md`](docs/model-prices.md) | هزینه‌ی دستیار، حاشیه‌ی سودِ اشتراک‌ها، دفترِ قیمتِ مدل‌ها |
| [`tools/loadtest/README.md`](tools/loadtest/README.md) | آزمونِ بار |
| [`CLAUDE.md`](CLAUDE.md) | راهنمای agentهای کدنویس، به‌ویژه قواعدِ کار روی سرورِ مشترک |
---
## محدودیت‌های شناخته‌شده
## وضعیت
اینها عمداً نوشته شده‌اند تا کسی دوباره کشفشان نکند:
- **`CACHE_URL` روی سرور خالی است، عمداً**، پس محدودیتِ نرخ بینِ workerها مشترک نیست. Redisِ روی آن ماشین
مالِ تیم‌های دیگر است و به آن وصل نمی‌شویم؛ Redisِ اختصاصیِ خودمان یک تصمیمِ باز پیش از لانچ است.
`/api/health` این را هشدار می‌دهد.
- **`KAVENEGAR_API_KEY` تنظیم نشده**، پس `POST /api/auth/otp/request` برای
شماره‌های واقعی `503` می‌دهد. راهِ ورودِ تیمِ توسعه در
[`docs/operations.md`](docs/operations.md) توضیح داده شده.
- **پوششِ محتوا یکدست نیست.** پایه‌های هشتم تا دوازدهم کاملند (هر نود چهار
سوال)؛ در پایه‌های سوم/ششم/هفتم ۳۹ نود از ۱۴۵ کمتر از چهار سوال دارند —
هیچ‌کدام خالی نیست، پس روی نقشه نودِ غیرقابلِ بازی وجود ندارد.
- **محتوای ویدیویی وجود ندارد**، هرچند `Lesson.kind` و `video_url` می‌توانند
بیانش کنند.
- **`DEV_MASTER_OTP` (کد `0000`) باید پشتِ `DEBUG` بماند.** روشن بودنش یعنی هر
کسی می‌تواند به‌جای هر کاربری وارد شود. `/api/health` هشدارش می‌دهد.
سرویس زنده است و با حساب‌های آزمونگر سنجیده می‌شود؛ هنوز به روی کاربرِ عمومی باز نشده. آنچه پیش از آن باید بسته
شود در [`docs/security-before-production.md`](docs/security-before-production.md) فهرست شده است. پرداختِ واقعی (زرین‌پال
و کافه‌بازار) تا انتهای مسیر هنوز آزموده نشده است.

View File

@@ -687,8 +687,8 @@ SPECTACULAR_SETTINGS = {
'VERSION': '1.0.0',
'CONTACT': {'name': 'تیمِ بک‌اندِ تدریسینو'},
'EXTERNAL_DOCS': {
'description': 'راهنمای اتصالِ فرانت‌اند',
'url': 'https://gitea.tadrisino2.ir/Tadrisino/MathApp/src/branch/main/docs/frontend/README.md',
'description': 'فرانت‌اند: تنها کلاینتِ این API (src/api/)',
'url': 'https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend',
},
# ترتیبِ تگ‌ها در Swagger به همین ترتیب است، نه الفبایی — یعنی مسیرِ
# واقعیِ یک کاربر: اول وارد شو، بعد محتوا بگیر، بعد پیشرفت و بقیه.

View File

@@ -8,20 +8,18 @@
نسخه‌ی رسمی و به‌روزِ کتاب‌ها: <https://irtextbook.ir>
(از خارج ایران در دسترس نیست؛ دانلود باید از داخل ایران انجام شود.)
## اولویت (به‌روزشده ۱۴۰۵/۰۶/۲۱)
## کتاب‌های ثبت‌شده
سینیور اولویت را عوض کرد: **از پایه ششم به بالا**، با تمرکز روی پایه‌های
ترانزیشنی ۶، ۷، ۹ و ۱۰. کتاب‌های ابتدایی (اول/دوم/چهارم/پنجم) که قبلاً
دانلود شده‌اند فعلاً در نوبت نیستند.
فهرستِ کامل در `rag/services/textbooks.py` است؛ همه روی Qdrant‌اند، هر کتاب در collectionِ خودش.
| پایه | وضعیت |
| کتاب | خوانده‌شده با |
|---|---|
| سوم، ششم، هفتم | روی Qdrant هست (ساخته‌ی تیم دیگر) — PDF نداریم جز هفتم |
| هشتم، نهم، دهم | ingest شده؛ بازخوانیِ صفحه‌های شکل‌دار مانده |
| یازدهم، دوازدهم | در نوبتِ ingest (۱۴۰۵/۰۶/۲۸) |
| هندسه دهم/یازدهم/دوازدهم، آمار و احتمال یازدهم، گسسته دوازدهم | ثبت و ingest شد ۲۰۲۶-۱۰-۱۱ (`subject` = «هندسه»، «آمار و احتمال»، «گسسته»؛ هر کدام collectionِ خودش) |
| چهارم، پنجم | ingest شد ۲۰۲۶-۱۰-۰۳ (درخواستِ دکتر: «از سوم به بعد»)؛ پنجم چاپِ ۱۴۰۵ است |
| اول، دوم | PDF موجود است، خارج از اولویتِ فعلی |
| ریاضیِ سوم، ششم، هفتم | تیمِ دیگرِ شرکت (پیش از قاعده‌ی «شکل را در متن بنویس»؛ بیشترِ مسئله‌های وابسته به شکل این‌جاست) |
| ریاضیِ چهارم، پنجم، هشتم، نهم، دهم | ما، با `gpt-4.1-mini` |
| حسابانِ یازدهم و دوازدهم | ما |
| هندسه‌ی دهم، یازدهم، دوازدهم · آمار و احتمالِ یازدهم · گسسته‌ی دوازدهم | ما |
اولِ و دومِ دبستان PDF دارند ولی ثبت نشده‌اند.
⚠️ کتابِ ریاضیِ یازدهم و دوازدهم چند رشته دارد (تجربی، ریاضی‌فیزیک، انسانی).
نامِ فایل و `Textbook.grade` باید رشته را مشخص کند، وگرنه دو کتابِ متفاوت

View File

@@ -21,7 +21,7 @@
(`POST /api/auth/logout`) refreshِ همان دستگاه را باطل می‌کند و
`User.tokens_valid_after` را می‌گذارد؛ هر توکنِ access که پیش از آن لحظه صادر شده
رد می‌شود (دقتِ ثانیه‌ای). دستگاه‌های دیگر با refreshِ خودشان بی‌صدا توکنِ تازه
می‌گیرند. پیش از ۲۰۲۶-۰۹-۲۵ خروج وجود نداشت.
می‌گیرند.
**کدِ ورود ۶ رقمی است و هر کد بعد از ۵ حدسِ غلط باطل می‌شود**
(`OTPCode.failed_attempts`، شمارش در دیتابیس، نه در کشِ per-worker).
@@ -53,15 +53,15 @@
`X-Forwarded-For` قابلِ اعتماد است و **باید با تعدادِ واقعیِ پروکسی‌ها یکی
باشد**، وگرنه جعلِ IP و دور زدنِ محدودیت ممکن می‌شود.
روی پروداکشن این عدد **۲** است (پیش‌فرضِ کد از ۲۰۲۶-۰۹-۲۴): ArvanCloud جلوی
سرور است و بعد nginxِ خودِ ماشین. تا آن روز ۱ بود و جنگو IPِ آروان را IPِ کاربر
می‌دید؛ یعنی همه‌ی کاربرانِ بی‌ورود یک سهمِ مشترک داشتند. `clientIp` در `/api/health`
روی پروداکشن این عدد **۲** است: ArvanCloud جلوی سرور است و بعد nginxِ خودِ ماشین. با عددِ کمتر، IPِ آروان
IPِ کاربر دیده می‌شود و همه‌ی کاربرانِ بی‌ورود یک سهمِ مشترک می‌گیرند. `clientIp` در `/api/health` (با کلیدِ داخلی)
نشان می‌دهد سرور صدازننده را با چه IPای می‌شناسد — باید IPِ عمومیِ خودتان باشد.
⚠️ سرور روی پورتِ ۸۰ مستقیم هم (بدونِ آروان) جواب می‌دهد. کسی که IPِ سرور را
بداند می‌تواند از آن راه XFF جعل کند و محدودیتِ IP را دور بزند. ورودِ OTP جدا
به ازای هر شماره هم محدود است، پس این راه به حدسِ کد نمی‌رسد. بستنِ کاملش (پورتِ
۸۰ فقط برای رنج‌های آروان، یا `set_real_ip_from` در nginx) کارِ ادمینِ سرور است.
`X-Forwarded-For` فقط وقتی پذیرفته می‌شود که درخواست واقعاً از ArvanCloud آمده باشد
(`config.middleware.TrustedProxyMiddleware`، فهرستِ `TRUSTED_PROXY_NETWORKS`؛ آدرسِ TCP را nginx در `X-Real-IP`
می‌گذارد). وگرنه کسی که مستقیم به IPِ سرور بزند و در هر درخواست XFFِ تازه بفرستد، از همه‌ی سقف‌های IP-محور رد
می‌شود. لایه‌ی دوم در nginx است: هر درخواستی که از آروان نیامده ۴۰۳ می‌گیرد (`ops/nginx/zareian-arvan-only.conf`).
وقتی آروان رنجِ IPِ تازه بگیرد، هر دو فهرست با هم تازه می‌شوند (`docs/operations.md` «nginx»).
## پرداخت: هرگز مستقیم به درگاه
@@ -78,7 +78,7 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
**fail-closed** می‌شوند (`503` یا نامعتبر)، نه موفقیتِ ساختگی. همین قاعده برای
پیامک هم برقرار است.
قاعده‌هایی که بازبینیِ ۲۰۲۶-۰۹-۲۵ اضافه کرد (هرکدام با تست در
قاعده‌های پرداخت (هرکدام با تست در
`payment/tests_audit_fixes.py`):
| قاعده | چون پیش‌تر… |
@@ -110,8 +110,8 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
جستجوی Qdrant محدود به همان درس/فصل → تولید با GPT. تکه‌های استفاده‌شده در
`usedChunks` برمی‌گردند، پس پاسخ قابلِ ردیابی به منبع است.
- نیازمندِ `OPENAI_API_KEY` و `QDRANT_API_KEY`. بدونِ کلید، همان پاسخِ stubِ
قبلی برمی‌گردد (سازگاریِ عقب‌رو).
- نیازمندِ `OPENAI_API_KEY` و `QDRANT_API_KEY`. بی‌کلید و با `DEBUG=0` پاسخ ۵۰۳ است، نه جوابِ ساختگی
(`StubNeverReachesProductionTests`)؛ مسیرِ نمونه فقط با `DEBUG` یا `RAG_FORCE_MOCK` در دسترس است.
- `"stream": true` پاسخ را به‌صورتِ SSE توکن‌به‌توکن می‌فرستد؛ قراردادِ JSON
بدونِ این فلگ دست‌نخورده است.
- `python manage.py rag_catalog` فهرستِ زنده‌ی collectionهای Qdrant را نشان
@@ -122,7 +122,11 @@ PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره م
کیفیتِ محتوایی که این لایه می‌خواند در
[`content-authoring.md`](content-authoring.md) توضیح داده شده.
### شکلِ پاسخ (پرامپتِ `tutor-v3-stepwise-plain`، ۲۰۲۶-۰۹-۲۵)
### شکلِ پاسخ
پرامپتِ فعلی `tutor-v12-age-tone` است (`rag/services/generation.py`، `PROMPT_VERSION`). جدولِ زیر قاعده‌هایی است که
از `tutor-v3` به بعد در آن مانده‌اند؛ فقط‌ریاضی‌بودن در `docs/ai-cost-and-guardrails.md` و لحن بر اساسِ سن (۱۸+ لحنِ
بزرگسال در هر پایه، کشِ جواب با لحن کلید می‌خورد) در `tone_for`.
| چه | چرا |
|---|---|
@@ -145,7 +149,7 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
هزینه‌ی هر پاسخ از `usage.cost`ِ خودِ GapGPT خوانده می‌شود، و `--context-cache` بخش‌های
بازیابی‌شده را ذخیره می‌کند تا چند اجرا (چند مدل یا چند نسخه‌ی پرامپت) دقیقاً یک context ببینند.
**انتخابِ فعلی: `gpt-4.1-mini` (۲۰۲۶-۰۹-۲۶).** دورِ اول ۱۴ مدل روی ۳۰ مسئله: پنج مدل
**انتخابِ فعلی: `gpt-4.1-mini`.** دورِ اول ۱۴ مدل روی ۳۰ مسئله: پنج مدل
بی‌خطا بودند و 4.1-mini ارزان‌ترینشان بود. دورِ دوم 4o-mini در برابرِ 4.1-mini روی ۱۰۲ مسئله
با سه نسخه‌ی پرامپت: ۹۵–۹۶ در برابرِ ۸۵–۹۰. نوسانِ 4o-mini با پرامپتِ یکسان تا ۵ مسئله بود،
پس این فاصله واقعی است. خطاهای 4o-mini بیشتر حسابِ ساده بود (۸۴۵۱ − ۷۲۳۴ = ۱۳۱۷). هر پاسخ
@@ -172,7 +176,7 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
`/api/health` آن را در `integrations.aiProvider` و `warnings` نشان می‌دهد.
پیش‌تر `openai: true` فقط یعنی «کلید تنظیم شده».
### شکل در جواب (پرامپتِ `tutor-v10-shape-anchor`، ۲۰۲۶-۰۹-۲۹)
### شکل در جواب
مدل HTML یا SVG نمی‌نویسد. در پایانِ جوابِ شکل‌دار یک بلوکِ کوتاهِ `<diagram>{JSON}</diagram>` می‌گذارد
که فقط **توصیف** است («مثلثی با AB = 6، AC = 8 و زاویه‌ی قائمه در A»). فرانت (`src/diagram/`) از روی
@@ -211,9 +215,8 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
آزمونش تمام شده `completed` می‌بیند، و مسیرِ قدیمیِ «همه‌ی درس‌ها تک‌تک» هم هنوز
کار می‌کند.
**جزیره با درس‌ها هم تمام می‌شود، ولی فقط درس‌های سوال‌دار شمرده می‌شوند**
(۲۰۲۶-۰۹-۲۵). ۵۸۵ درس هنوز سوال ندارند و اپ برایشان دکمه‌ی شروع ندارد؛ با شرطِ
«همه‌ی درس‌ها» ۱۸۱ جزیره از ۲۶۹ هرگز تمام نمی‌شد. هر نود در `/api/home`
**جزیره با درس‌ها هم تمام می‌شود، ولی فقط درس‌های سوال‌دار شمرده می‌شوند**؛ با شرطِ «همه‌ی درس‌ها»، جزیره‌ای که
درسِ بی‌سوال دارد هرگز تمام نمی‌شد. هر نود در `/api/home`
`nextLessonId` دارد (اولین درسِ سوال‌دارِ تمام‌نشده) و **عضوِ اولِ `lessonIds`
همیشه همان است** — فرانت با کلیک روی جزیره `lessonIds[0]` را باز می‌کند و پیش‌تر
هر بار درسِ اول را می‌گرفت. جزیره‌ای که یک بار تمام شد (`NodeProgress` یا
@@ -222,7 +225,34 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
`progressPercent` در `/api/home` برای همان پایه‌ای است که نقشه‌اش نشان داده
می‌شود: درس‌های سوال‌دارِ تمام‌شده به‌اضافه‌ی درس‌های جزیره‌های تمام‌شده، تقسیم
بر درس‌های سوال‌دارِ آن پایه. (قبلاً مخرج همه‌ی ۲٬۶۱۷ درسِ همه‌ی پایه‌ها بود.)
بر درس‌های سوال‌دارِ آن پایه.
**آزمونِ هر جزیره ثابت است:** مبنای درهم‌ریزی فقط `nodeId:userId` است، پس دانش‌آموز هر بار همان سوال‌ها را
می‌بیند (تصمیمِ مالک؛ `NodeQuizStabilityTests`).
**پایان فقط برای جزیره‌ی باز و آزمونِ جواب‌داده:** `quiz/complete` و `lessons/{id}/complete` جزیره‌ی قفل را ۴۰۳
`node_locked` می‌دهند (همان قاعده‌ی `/api/home`، `content.views.node_is_unlocked`) و کاربری که همه‌ی سوال‌ها را
جواب نداده ۴۰۹ `answers_missing`. تعدادِ لازم `max(totalQuestions ِ کلاینت، min(4، سوال‌های جزیره))` است تا کلاینت
نتواند کمش کند. جزیره یا درسِ بی‌سوال، اگر باز باشد، بی‌ستاره و بی‌امتیاز رد می‌شود تا زنجیره را نبندد.
## کتاب‌ها و دیوارِ پرداخت
هر فصل (`Unit`) به یک کتاب (`Subject`: ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) تعلق دارد. `/api/home?subject=<key>`
نقشه‌ی همان کتاب را می‌دهد و کتابِ بی‌ربط به پایه ۴۰۰ است؛ `subjects` در پاسخ فقط کتاب‌های همان پایه است.
**فصلِ اولِ هر کتاب رایگان است** (`content.views.unit_is_free`). بیرون از آن، بی‌اشتراکِ فعال، سوال‌های درس، آزمونِ
جزیره، ثبتِ جواب و پایانِ درس/جزیره ۴۰۲ `subscription_required` می‌دهند و `/api/home` برای هر فصل
`requiresSubscription` دارد (`content/tests_paywall.py`).
## متنِ خروجی: ساده و بی‌اعراب
تصمیمِ شرکت: هیچ اعراب و «ی»ِ اضافه‌ی نوشته‌شده («پایه‌ی سوم») به کاربر نمی‌رسد؛ تنوین می‌ماند. این **در خروجی**
انجام می‌شود، نه در داده: `config.renderers.PlainPersianJSONRenderer` روی هر پاسخِ JSON و `rag.services.mathtext.to_plain`
روی پاسخِ دستیار (استریم هم). دیتابیس، seed و ادمین متنِ اصلی را نگه می‌دارند، چون بازنویسیِ عنوان‌ها اتصالِ سوال به
درس (که با مسیرِ عنوان است) و کلیدهای `SeedRecord` را می‌شکست. تست: `config/tests_plain_persian.py`.
هیچ LaTeX یا markdownی هم به دانش‌آموز نمی‌رسد: `content/plain_math.py` روی seed و ورودِ سوال، `to_plain` روی دستیار،
و `plainMath.ts` در فرانت به‌عنوانِ آخرین تور؛ این دو پیاده‌سازی باید هم‌رفتار بمانند.
## دستیار و سوالِ تمرینی
@@ -242,10 +272,6 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
## استریک و جدولِ رتبه‌بندی، زنده از فعالیت
تا ۲۰۲۶-۰۹-۲۴ هیچ‌جای اپ `StreakRecord` نمی‌نوشت و `GET /api/leaderboard` فقط
جدولی را می‌خواند که با اجرای دستیِ `rebuild_leaderboard` پر می‌شد؛ یعنی برای
کاربرِ واقعی «روز پیاپی» همیشه صفر بود و XPاش هیچ‌وقت در جدول دیده نمی‌شد.
- **استریک** (`progress/services.py`): پاسخ به سوال، پایانِ درس و پایانِ جزیره
امروز را (به وقتِ تهران) روزِ فعال ثبت می‌کنند. نمایش با
`effective_current_streak` است: اگر آخرین روزِ فعال نه امروز است نه دیروز،
@@ -260,12 +286,11 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
قلب برمی‌گردد** (`HEART_REFILL_INTERVAL`) و **۲۴ ساعت بعد از اولین قلبی که از
حالتِ پر کم شد، همه یک‌جا پر می‌شوند** (`HEART_FULL_REFILL_AFTER`، فیلدِ
`full_refill_at`). `GET /api/user/hearts` هر دو زمان را می‌دهد: `nextRefillAt`
و `fullRefillAt`. تا ۲۰۲۶-۰۹-۲۴ هر قلب ۲۴ ساعت بود؛ تایمرِ قدیمیِ بلندتر از ۵
ساعت در اولین خواندن کوتاه می‌شود.
و `fullRefillAt`.
**با صفر قلب، بک‌اند جواب را رد می‌کند** (۴۰۳ با `code: "no_hearts"`، `nextRefillAt` و
`fullRefillAt`). `HEARTS_ENFORCED` از ۲۰۲۶-۱۰-۰۲ پیش‌فرض روشن است و فرانت برای این پاسخ صفحه‌ی
«قلب‌هات تموم شد» را با شمارشِ معکوس دارد (`src/components/NoHeartsScreen.tsx`). از همان روز:
`fullRefillAt`). `HEARTS_ENFORCED` پیش‌فرض روشن است و فرانت برای این پاسخ صفحه‌ی
«قلب‌هات تموم شد» را با شمارشِ معکوس دارد (`src/components/NoHeartsScreen.tsx`). سه جزئیات:
- چکِ قلب داخلِ همان تراکنشی است که قلب را کم می‌کند. پیش‌تر بیرونش بود و دو جوابِ غلطِ هم‌زمان با
یک قلبِ باقی‌مانده هر دو پذیرفته می‌شدند (با دو thread بازتولید شد).
- پایانِ درس یا جزیره بی‌قلب و بی‌جواب دادنِ سوال‌هایش ۴۰۳ می‌گیرد؛ پیش‌تر `quiz/complete` اجازه
@@ -283,16 +308,16 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
## دیتابیس
هنوز SQLite است (`OPTIONS.timeout=20` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند).
مهاجرت به Postgres انجام نشده.
روی سرور **PostgreSQL 14** است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost) و بی `DB_NAME` در `.env` برنامه
SQLite را برمی‌دارد (توسعه‌ی محلی و تست‌ها). کلِ تست‌ها روی هر دو سبزند. قفل‌های ردیفی (`select_for_update`) در
پرداخت، سقفِ کدِ تخفیف، قلب و سهمیه‌ی دستیار روی Postgres قفلِ واقعی‌اند؛ روی SQLite همان تراکنش‌ها با
`transaction_mode: IMMEDIATE` سریالی می‌شوند، چون SQLite `FOR UPDATE` را بی‌صدا نادیده می‌گیرد.
**`transaction_mode: IMMEDIATE`** (۲۰۲۶-۰۹-۲۵): هر `atomic()` قفلِ نوشتن را از
اول می‌گیرد. SQLite از `SELECT … FOR UPDATE` پشتیبانی نمی‌کند و جنگو
`select_for_update` را رویش بی‌صدا نادیده می‌گیرد؛ یعنی تا آن روز همه‌ی
«قفلِ ردیف»ها در پرداخت، سقفِ کدِ تخفیف، قلب و سهمیه‌ی دستیار عملاً قفل نبودند.
حالا کلِ تراکنش‌ها سریالی‌اند (درشت ولی درست). فراخوانی‌های شبکه‌ای (AI،
زرین‌پال) بیرون از `atomic()` هستند و باید بمانند. بعد از Postgres این گزینه
برداشته می‌شود.
**اتصال‌ها از poolِ هر worker می‌آیند** (`OPTIONS.pool`، `DB_POOL_MAX_SIZE`): Postgresِ سرور `max_connections=60`
دارد و الگوی «یک اتصال برای هر نخ» (۴ worker × ۳۲ نخ) آن را پر می‌کرد. poolِ پر ۵۰۳ با `Retry-After` می‌دهد، نه ۵۰۰
(`config/db.py`)، و `/api/health` → `dbPool` بودجه‌ی `workers × pool + 6 ≤ max_connections` را می‌سنجد. فراخوانی‌های
شبکه‌ای (AI، زرین‌پال، پیامک) بیرون از `atomic()` هستند و دستیار و پیامک پیش از انتظارِ شبکه اتصال را پس می‌دهند
(`release_db_connection`).
تست‌ها روی فایلِ `.test-db.sqlite3` اجرا می‌شوند، نه دیتابیسِ حافظه‌ای: آن یکی
shared-cache است و قفلش جدول‌به‌جدول و بی‌انتظار است، رفتاری که روی فایلِ واقعی
@@ -302,14 +327,12 @@ shared-cache است و قفلش جدول‌به‌جدول و بی‌انتظا
`Grade.number` هم هست. پایه‌ی تازه id را از شماره‌اش می‌گیرد، نه از شمارنده‌ی
دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده می‌شود. پایه‌ی
بی‌شماره ([DEMO]/[SEED]) idِ بالای ۱۰۰ می‌گیرد. همه‌جا — API، ادمین، فیلترِ
سوال‌ها — پایه‌ها به‌ترتیبِ شماره‌اند. تا migration‌ِ `content.0008` id ترتیبِ
واردشدنِ کتاب‌ها بود (نهم=۱، هشتم=۵).
سوال‌ها — پایه‌ها به‌ترتیبِ شماره‌اند.
⚠️ **`.iterator()` را دورِ حلقه‌ای که می‌نویسد یا I/O بیرونی می‌زند نگذارید.**
کرسرِ خواندن در تمامِ طولِ حلقه باز می‌ماند و روی SQLite جدول را برای هر
نویسنده‌ی هم‌زمان قفل می‌کند — قفلی که تا پایانِ حلقه رها نمی‌شود و هیچ
retryای نجاتش نمی‌دهد. این یک بارْ ماه‌ها به‌شکلِ یک تستِ «flaky» ظاهر شد؛
باگِ واقعی بود، نه تصادفِ زمان‌بندی. به‌جایش نتیجه را با `list(...)` مادی کنید.
کرسرِ خواندن در تمامِ طولِ حلقه باز می‌ماند و قفل و اتصال را تا پایانِ حلقه نگه می‌دارد؛ روی SQLite هیچ retryای
نجاتش نمی‌دهد. این یک بارْ ماه‌ها به‌شکلِ یک تستِ «flaky» ظاهر شد؛ باگِ واقعی بود، نه تصادفِ زمان‌بندی. به‌جایش
نتیجه را با `list(...)` مادی کنید.
## لاگِ ساختاریافته

View File

@@ -10,13 +10,17 @@
| فایل | محتوا |
|---|---|
| `curriculum.json` | ۸ پایه، ۵۸ فصل، ۲۶۳ نود، ۲٬۶۱۷ درس |
| `questions.json` | ۲٬۵۴۵ سوالِ چندگزینه‌ای |
| `curriculum.json` | ۱۰ پایه (سوم تا دوازدهم)، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس؛ با `subject`، متنِ درس، مثال، قدم‌ها و شکلِ درس (`figures`) |
| `questions.json` | ۱۸٬۵۲۲ سوالِ چهارگزینه‌ای، بعضی با شکل (`figure`) |
هر فصل باید `subject` داشته باشد (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته)؛ فایلِ بی‌آن رد می‌شود. شکل‌ها
(`Lesson.figures`، `Question.figure`) با `rag.services.diagram.validate` در ورود اعتبارسنجی می‌شوند و شکلِ نامعتبر کلِ
فایل را رد می‌کند. هیچ LaTeX یا markdownی وارد نمی‌شود: `content/plain_math.py` متن را در ورود ساده می‌کند.
`deploy.sh` در هر انتشار `seed_content` را اجرا می‌کند، پس یک سرورِ تازه خودش
را با یک فرمان پر می‌کند — **بدونِ Qdrant، بدونِ اعتبارِ API، بدونِ نشستِ SSH.**
> **کد و محتوا دو مسیرِ جدا دارند.** `db.sqlite3` در gitignore است. سوالی که
> **کد و محتوا دو مسیرِ جدا دارند.** دیتابیس در گیت نیست. سوالی که
> فقط در دیتابیسِ محلیِ شماست به سرور نمی‌رسد مگر از راهِ
> `seed_content --export --only questions` و کامیتِ `content/seed/questions.json`.
> پوش کردنِ کد به‌تنهایی محتوا را نمی‌برد.
@@ -38,6 +42,10 @@
دیتابیسِ بی‌محتوا (سرورِ تازه) دفتر را نادیده می‌گیرد و همه‌چیز را از فایل می‌سازد.
نتیجه‌اش این است که برای برگرداندنِ چیزی که در ادمین پاک شده، همان‌جا دوباره بسازیدش
(یا ردیفِ `SeedRecord`ش را پاک کنید)؛ دیپلوی دیگر آن را برنمی‌گرداند.
- **مثال، قدم‌ها و شکلِ درس فقط در خانه‌ی خالی نشانده می‌شوند.** برای عوض کردنِ شکلِ درسی که روی سرور هست، `figures`
آن درس را در ادمین خالی کنید و دوباره دیپلو کنید. شناسه‌ی درس روی سرور با لپ‌تاپ فرق دارد.
- **جزیره‌های تمرین («تمرین: …») استثنا هستند:** گزینه‌های سوالِ موجودشان در جا (با همان شناسه) با فایل یکی می‌شود و
سوالی که از فایل حذف شده و هنوز جوابی ندارد پاک می‌شود (`seed_sync._refresh_practice`). سوالِ دستیِ ادمین هرگز.
`seed_content --export` فایل‌ها را از دیتابیسِ محلی بازنویسی می‌کند. اگر
دیتابیس سوالی نداشته باشد از بازنویسیِ یک `questions.json` غیرخالی خودداری
@@ -114,6 +122,12 @@ python manage.py generate_questions_from_chunks --subject ریاضی --grade ن
`solution_verdict='agrees'` تضمینِ کامل نیست، فقط فیلترِ اول: چند بار خطای
حسابِ خودِ کتاب در همین مرحله گرفته شده است.
## بازخوردِ دانش‌آموز
زیرِ هر سوالِ آزمون و هر درس سه دکمه است: «خوب بود»، «خوب نبود» و «گزارشِ ایراد». در ادمین، فهرستِ سوال‌ها و درس‌ها
ستونِ 👍/👎 دارد و «گزارش‌های سوال» و «گزارش‌های درس» صفِ بررسی‌اند. این‌ها نخستین جایی‌اند که سوالِ غلط یا مبهم پیدا
می‌شود.
## داده‌ی ساختگی برای توسعه
`seed_test_data` یک دیتاستِ کوچک و idempotent می‌سازد که همه‌ی اپ‌ها را پوشش

View File

@@ -1,146 +0,0 @@
# راهنمای اتصالِ فرانت‌اند به این بک‌اند
این پوشه برای تیمِ فرانت (`MathApp_frontend`) است. `api.ts` یک کلاینتِ
تایپ‌دار است که از روی OpenAPI واقعیِ همین سرور نوشته شده — نامِ فیلدها حدس
نیست.
```bash
cp docs/frontend/api.ts <پروژه‌ی-فرانت>/src/lib/api.ts
```
```ts
import { createClient } from './lib/api';
export const api = createClient({ baseUrl: import.meta.env.VITE_API_URL ?? '/api' });
```
## نگاشتِ صفحاتِ فعلیِ فرانت به API
فرانت الان (`src/app/App.tsx`) خروجیِ Figma Make است و هیچ فراخوانیِ APIای
ندارد؛ همه‌ی داده‌ها هاردکد است. این جدول می‌گوید هر صفحه به چه چیزی وصل شود.
| صفحه‌ی فرانت | فراخوانی | نکته |
|---|---|---|
| `SplashScreen` | `api.auth.isSignedIn()` | «قبلاً حساب دارم» → صفحه‌ی ورود |
| — (**وجود ندارد، باید ساخته شود**) | `auth.requestOtp` → `auth.login` | پایین ببینید |
| `OnboardingScreen` | — | ایستا |
| `ProfileSetupScreen` | `profile.update({name})` + `profile.setGrade(id)` | `GRADES` هاردکد را با `api.grades()` عوض کنید |
| `HomeScreen` | `api.home()` + `api.summary()` | بدونِ آرگومان، پایه‌ی خودِ کاربر ملاک است |
| `SkillMapScreen` | `api.home()` → `units[].nodes[]` | `status` = `locked / unlocked / in_progress / completed` |
| `LessonScreen` | `lesson.intro(id)` + `lesson.questions(id)` | پایین ببینید |
| `QuestionScreen` | `lesson.answer(qid, optId)` → `lesson.complete(id)` | `mascotMood` حالتِ رُبو را می‌دهد |
| `AskAIScreen` | `ai.askStream({userQuestion, lessonId}, onDelta)` | فقط `lessonId` کافی‌ست |
| `SubscriptionScreen` | `subscription.plans()` + `subscription.purchase(planId)` | قیمت‌های هاردکد را بردارید |
## چهار چیزی که الان با فرانت جور نیست
**۱. صفحه‌ی ورود وجود ندارد.** فرانت فقط اسم و پایه می‌پرسد، ولی احرازِ هویتِ
ما OTP با شماره‌ی موبایل است. دو راه:
- **مهمان** (سریع‌ترین): `api.auth.guest(deviceId)` — بدونِ شماره، کاربر
بلافاصله وارد می‌شود و بعداً می‌تواند شماره‌اش را وصل کند.
- **کامل**: یک صفحه‌ی «شماره + کد» بین Splash و ProfileSetup.
در حالتِ توسعه (`DJANGO_DEBUG=1`) پاسخِ `requestOtp` فیلدِ `devOtp` دارد، پس
برای تست لازم نیست پیامک واقعی برسد.
**برای تستِ مسیرِ کامل روی سرورِ واقعی، شماره‌ی تست بگیرید.** روی
`mathapi.tadrisino2.ir` سرویسِ پیامک هنوز تنظیم نشده، پس `requestOtp` برای یک
شماره‌ی معمولی خطای ۵۰۳ می‌دهد. بک‌اند چند شماره‌ی تست با کدِ ثابت دارد
(`TEST_LOGIN_PHONES` در `.env` سرور): برای آن شماره‌ها پیامکی ارسال نمی‌شود و
همان کد همیشه جواب می‌دهد، دقیقاً مثل مسیرِ واقعی. یک جفت «شماره + کد» برای
خودتان بخواهید — ساختنش کارِ یک خط ویرایشِ `.env` و یک ری‌استارت است.
> کدِ شماره‌ی تست در پاسخِ API برنمی‌گردد. اگر `devOtp` خالی آمد، یعنی همین
> مسیر فعال است، نه اینکه چیزی خراب باشد.
**یک حسابِ تستِ آماده هم روی پروداکشن هست** که از قبل داده دارد، تا برای دیدنِ
صفحه‌های اعلان و لیدربرد مجبور نباشید منتظرِ تولیدِ داده‌ی واقعی بمانید:
```bash
curl -X POST https://mathapi.tadrisino2.ir/api/auth/session \
-H 'Content-Type: application/json' \
-d '{"type":"guest","deviceId":"frontend-test-device"}'
```
- کاربرِ شماره‌ی ۶، با نامِ «حساب تست فرانت». هیچ پیامکی و هیچ توکنی که دست
به دست شود در کار نیست — همان `deviceId` کافی‌ست.
- `GET /api/notifications` روی این حساب چند اعلانِ واقعی دارد. **پاسخ در کلیدِ
`items` است**، نه `results`.
- `GET /api/leaderboard?period=weekly` و `?period=all_time` هر کدام ۶ ردیف
می‌دهند، با **ترتیبِ عمداً متفاوت** بینِ دو بازه، تا بشود دید پارامترِ
`period` واقعاً اثر می‌کند.
- این داده دستی روی دیتابیسِ پروداکشن ساخته شده و در گیت نیست؛ پیش از باز
شدنِ سرویس روی کاربرانِ واقعی باید پاک شود (`docs/operations.md`).
⚠️ این حساب **مشترک** است. هر کسی که همین `deviceId` را بفرستد وارد همان حساب
می‌شود، پس رویش چیزی که مالِ خودتان باشد نگذارید. برای کارِ روزمره یک
`deviceId` مخصوصِ خودتان بسازید — هر رشته‌ای قبول است.
**۲. فهرستِ پایه‌ها از `api.grades()` می‌آید** (انجام شده). ترتیب همان ترتیبِ
کلاس‌هاست و `id` هر پایه برابرِ شماره‌اش است: `gradeId: 8` یعنی پایه‌ی هشتم.
فیلدِ `number` هم همین عدد را صریح می‌دهد. پیش از ۲۰۲۶-۰۹-۲۳ idها ترتیبِ
واردشدنِ کتاب‌ها بودند (هشتم=۵)؛ اپ چون همیشه `gradeId` را از سرور می‌گیرد،
تغییری لازم نداشت.
**۳. صفحه‌ی درس، ویدیو دارد.** `LessonScreen` فهرستی از
`{type: "video" | "quiz"}` نشان می‌دهد. بک‌اند هنوز مفهومِ ویدیو ندارد —
`lessons/{id}/intro` فقط متن و حالتِ ماسکوت می‌دهد. فعلاً فقط `quiz` را نشان
دهید؛ اگر ویدیو واقعاً لازم است بگویید تا در مدل اضافه کنیم.
**۴. «سطح» در آمار.** `HomeScreen` سه عدد نشان می‌دهد: روز پیاپی، امتیاز، سطح.
دوتای اول را داریم (`streak.current`، `xp`)؛ «سطح» را نداریم. یا از روی `xp`
سمتِ فرانت حساب کنید، یا بگویید تا در `/api/user/summary` اضافه کنیم.
## چیزهایی که سمتِ بک‌اند برایتان درست شد
- **CORS** اضافه شد. origin خود را در `CORS_ALLOWED_ORIGINS` بدهید (در حالتِ
توسعه پورت‌های ۳۰۰۰ و ۵۱۷۳ پیش‌فرض بازند).
- **`PATCH /api/user/profile`** اضافه شد — قبلاً پروفایل فقط خواندنی بود و
هیچ راهی برای ثبتِ اسم نبود.
- **`GET /api/home` بدونِ پارامتر** حالا پایه‌ی ذخیره‌شده‌ی کاربر را می‌خواند.
قبلاً اگر `?grade=` نمی‌فرستادید نقشه خالی برمی‌گشت.
- **`POST /api/ai/ask` با فقط `lessonId`** حالا خودش می‌فهمد آن درس مالِ کدام
پایه و فصلِ کتاب است. قبلاً باید `subject` + `grade` + `lessonNumber` را
خودتان می‌فرستادید وگرنه پاسخ بدونِ استناد به کتاب تولید می‌شد.
## چند قاعده که وقت‌تان را می‌خرد
**`Idempotency-Key` بفرستید** روی هر چیزی که پول/امتیاز/قلب جابه‌جا می‌کند
(`answer`, `complete`, `purchase`). یک UUID که تا موفق شدنِ درخواست ثابت
بماند. بدونِ آن، قطعیِ شبکه + دکمه‌ی دوباره = دوبار کم شدنِ قلب.
**۴۰۱ یعنی توکن منقضی شده، نه اینکه کاربر بیرون است.** کلاینت خودش یک‌بار
`refresh` می‌زند و درخواست را تکرار می‌کند؛ فقط اگر آن هم شکست خورد کاربر را
به صفحه‌ی ورود ببرید.
**۴۲۹ و ۵۰۳ را جدا نشان دهید.** ۴۲۹ یعنی سقفِ روزانه‌ی سوال از دستیار پر شده
(الان ۱۵ تا) یا throttle؛ پیامش «فردا برگرد» است. ۵۰۳ یعنی سرویسِ AI موقتاً
قطع است؛ پیامش «چند لحظه دیگر دوباره». `ApiError.isRateLimited` و
`.isProviderDown` همین را می‌دهند.
**پرداخت را در مرورگر باز کنید.** `purchase` یک `paymentUrl` می‌دهد که به
سرورِ خودمان اشاره می‌کند، نه به زرین‌پال. اپ نباید مستقیم با درگاه حرف بزند.
**امتیاز موقعِ اتمامِ درس در مجموع می‌نشیند، نه سرِ هر سوال.** پاسخِ
`answer` فیلدِ `xpEarned` دارد ولی آن ارزشِ همان سوال است؛ `user/summary`
تا وقتی `complete` صدا نخورد تکان نمی‌خورد. در UI می‌توانید خوش‌بینانه
جمع بزنید، ولی عددِ معتبر بعد از `complete` می‌آید.
**`complete` دو فیلدِ اجباری دارد:** `correctCount` و `totalQuestions`.
سرور `correctCount` را با تعدادِ واقعیِ سوال‌های آن درس سقف می‌زند، پس
عددِ متورم امتیاز نمی‌سازد.
**`usedChunks` خالی یعنی پاسخ به کتاب مستند نیست.** اگر خواستید، در UI
تفکیکش کنید («بر اساسِ کتابِ درسی» در برابر «پاسخِ عمومی»).
## اجرای محلی
```bash
# بک‌اند
DJANGO_DEBUG=1 python manage.py runserver # 127.0.0.1:8000
# فرانت
VITE_API_URL=http://127.0.0.1:8000/api npm run dev
```
مستنداتِ زنده‌ی همه‌ی endpointها: `http://127.0.0.1:8000/api/docs/`

View File

@@ -1,446 +0,0 @@
/**
* کلاینتِ API تدریسینو — برای فرانتِ React/TypeScript (MathApp_frontend).
*
* از روی OpenAPI واقعیِ همین بک‌اند نوشته شده، نه از روی حدس. نامِ فیلدها
* دقیقاً همان چیزی‌ست که سرور برمی‌گرداند (camelCase).
*
* نکته‌های مهمِ اتصال:
* - احرازِ هویت توکنِ Bearer است، نه کوکی. پس `credentials: 'include'` لازم
* نیست و CORS سمتِ سرور هم عمداً بدونِ credentials تنظیم شده.
* - توکنِ دسترسی امضاشده و کوتاه‌عمر است؛ با ۴۰۱ باید `refresh` صدا زد.
* این کلاینت خودش یک‌بار refresh می‌کند و درخواست را تکرار.
* - برای پرسش از دستیار کافی‌ست `lessonId` بدهید؛ سرور خودش می‌فهمد آن درس
* مالِ کدام پایه و فصل است و پاسخ را به کتاب مستند می‌کند.
*/
export type Json = Record<string, unknown>;
export interface Tokens {
token: string;
refreshToken: string;
}
/* ─── انواعِ پاسخ ───────────────────────────────────────────────────────── */
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed';
export interface HomeNode {
id: string;
title: string;
status: NodeStatus;
lessonIds: number[];
}
export interface HomeUnit {
id: string;
title: string;
nodes: HomeNode[];
}
export interface HomeResponse {
units: HomeUnit[];
stats: { progressPercent: number; xp: number; coins: number };
}
export interface Grade {
/** برابرِ شماره‌ی پایه: ۸ یعنی هشتم. */
id: number;
/** شماره‌ی پایه (۱ تا ۱۲)؛ برای پایه‌های نمونه null. */
number: number | null;
title: string;
icon: string;
color: string;
lessonCount: number;
}
export interface UserSummary {
userId: string;
name: string;
avatar: string;
gradeId: number | null;
xp: number;
coins: number;
streak: { current: number; longest: number };
subscription: string;
}
export interface UserProfile {
userId: string;
phone: string | null;
name: string;
avatar: string;
isGuest: boolean;
gradeId: number | null;
birthYear: number | null;
parentPhone: string;
bio: string;
}
export interface LessonIntro {
lessonId: string;
title: string;
introText: string;
/** برای انتخابِ حالتِ ماسکوت در صفحه‌ی درس */
mascotAnimation: string;
/**
* صفر یعنی متنِ درس هست ولی آزمونش هنوز ساخته نشده — «به‌زودی» نشان دهید،
* نه کویزِ خالی. `complete` هم `totalQuestions: 0` را نمی‌پذیرد.
*/
questionCount: number;
}
export interface QuestionOption {
id: string;
value: string;
}
export interface Question {
id: string;
type: 'multiple_choice' | 'count_objects';
prompt: string;
options: QuestionOption[];
}
export interface AnswerResult {
correct: boolean;
correctOptionId: string;
xpEarned: number;
heartsRemaining: number;
/** حالتِ ماسکوت: درست → happy، غلط → encourage */
mascotMood: string;
}
export interface LessonComplete {
stars: number;
xpEarned: number;
coinsEarned: number;
stats: { progressPercent: number; xp: number; coins: number };
}
export interface Hearts {
currentHearts: number;
maxHearts: number;
nextRefillAt: string | null;
isUnlimited: boolean;
}
export interface AskResponse {
conversationId: string;
messageId: string;
answer: string;
suggestedFollowUps: string[];
remainingQuota: number;
/** تکه‌های کتاب که پاسخ رویشان بنا شده؛ خالی یعنی پاسخِ عمومی */
usedChunks: { title: string; summary: string }[];
}
export interface Plan {
id: string;
title: string;
price: number;
durationDays: number;
features: string[];
}
/* ─── خطا ───────────────────────────────────────────────────────────────── */
export class ApiError extends Error {
constructor(
readonly status: number,
readonly detail: string,
readonly body: Json,
) {
super(detail);
this.name = 'ApiError';
}
/** ۴۲۹: throttle یا اتمامِ سهمیه‌ی روزانه‌ی دستیار */
get isRateLimited() {
return this.status === 429;
}
/** ۵۰۳: سرویسِ هوش مصنوعی/پیامک موقتاً در دسترس نیست */
get isProviderDown() {
return this.status === 503;
}
}
/* ─── کلاینت ────────────────────────────────────────────────────────────── */
export interface ClientOptions {
baseUrl?: string;
/** خواندن/نوشتنِ توکن؛ پیش‌فرض localStorage */
storage?: {
get(): Tokens | null;
set(t: Tokens | null): void;
};
}
const STORAGE_KEY = 'tadrisino.tokens';
function defaultStorage() {
return {
get(): Tokens | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Tokens) : null;
} catch {
return null;
}
},
set(t: Tokens | null) {
try {
if (t) localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
else localStorage.removeItem(STORAGE_KEY);
} catch {
/* حالتِ خصوصیِ مرورگر — نادیده */
}
},
};
}
export function createClient(options: ClientOptions = {}) {
const baseUrl = (options.baseUrl ?? '/api').replace(/\/$/, '');
const storage = options.storage ?? defaultStorage();
async function raw<T>(
method: string,
path: string,
body?: unknown,
extraHeaders: Record<string, string> = {},
retryOn401 = true,
): Promise<T> {
const tokens = storage.get();
const headers: Record<string, string> = { ...extraHeaders };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (tokens?.token) headers['Authorization'] = `Bearer ${tokens.token}`;
const response = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (response.status === 401 && retryOn401 && tokens?.refreshToken) {
const refreshed = await refresh(tokens.refreshToken).catch(() => null);
if (refreshed) return raw<T>(method, path, body, extraHeaders, false);
storage.set(null);
}
const text = await response.text();
const parsed: Json = text ? (JSON.parse(text) as Json) : {};
if (!response.ok) {
const detail =
(parsed.detail as string) ??
(parsed.error as string) ??
`خطای ${response.status}`;
throw new ApiError(response.status, detail, parsed);
}
return parsed as T;
}
async function refresh(refreshToken: string) {
const response = await fetch(`${baseUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) return null;
const data = (await response.json()) as Tokens;
storage.set({ token: data.token, refreshToken: data.refreshToken });
return data;
}
return {
/* ── ورود (صفحه‌ای که فعلاً در فرانت نیست و باید ساخته شود) ────────── */
auth: {
/** مهمان: بدونِ شماره، فقط با شناسه‌ی دستگاه. برای «شروع سریع». */
async guest(deviceId: string) {
const data = await raw<{ userId: string; token: string; isNewUser: boolean; isGuest: boolean }>(
'POST', '/auth/session', { type: 'guest', deviceId },
);
storage.set({ token: data.token, refreshToken: '' });
return data;
},
/** گامِ ۱: ارسالِ پیامکِ کد. در حالتِ توسعه `devOtp` هم برمی‌گردد. */
requestOtp(phone: string) {
return raw<{ success: boolean; devOtp?: string; note?: string }>(
'POST', '/auth/otp/request', { phone },
);
},
/** گامِ ۲: تأییدِ کد و گرفتنِ توکن. نامِ فیلد در سرور `otp` است. */
async login(phone: string, otp: string) {
const data = await raw<{ userId: string } & Tokens>(
'POST', '/auth/login', { phone, otp },
);
storage.set({ token: data.token, refreshToken: data.refreshToken });
return data;
},
logout() {
storage.set(null);
},
isSignedIn() {
return Boolean(storage.get()?.token);
},
},
/* ── صفحه‌ی «بیا باهم آشنا بشیم» ──────────────────────────────────── */
profile: {
get: () => raw<UserProfile>('GET', '/user/profile'),
/** اسم/آواتار/… . فقط فیلدهایی که می‌فرستید عوض می‌شوند. */
update: (patch: Partial<Pick<UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
raw<UserProfile>('PATCH', '/user/profile', patch),
setGrade: (gradeId: number) =>
raw<{ success: boolean; gradeId: number }>('POST', '/user/grade', { gradeId }),
},
/** فهرستِ پایه‌ها را از سرور بگیرید، هاردکد نکنید — پایه‌ها اضافه می‌شوند. */
grades: () => raw<{ grades: Grade[] }>('GET', '/grades').then((r) => r.grades),
/* ── صفحه‌ی خانه و نقشه‌ی مهارت ────────────────────────────────────── */
/** بدونِ gradeId، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */
home: (gradeId?: number) =>
raw<HomeResponse>('GET', gradeId ? `/home?gradeId=${gradeId}` : '/home'),
summary: () => raw<UserSummary>('GET', '/user/summary'),
hearts: () => raw<Hearts>('GET', '/user/hearts'),
streakCalendar: () =>
raw<{ currentStreak: number; longestStreak: number; activeDays: string[] }>(
'GET', '/user/streak/calendar',
),
leaderboard: (period: 'weekly' | 'monthly' = 'weekly') =>
raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`),
/* ── صفحه‌ی درس و سوال ─────────────────────────────────────────────── */
lesson: {
intro: (lessonId: number) => raw<LessonIntro>('GET', `/lessons/${lessonId}/intro`),
questions: (lessonId: number) =>
raw<{ questions: Question[] }>('GET', `/lessons/${lessonId}/questions`).then((r) => r.questions),
/**
* `idempotencyKey` را بدهید تا اگر شبکه قطع شد و کاربر دوباره زد،
* امتیاز/قلب دوبار حساب نشود.
*/
answer: (questionId: number, selectedOptionId: number, idempotencyKey?: string) =>
raw<AnswerResult>(
'POST', `/questions/${questionId}/answer`, { selectedOptionId },
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
),
/**
* پایانِ درس. `correctCount` را سرور با تعدادِ واقعیِ سوال‌ها سقف می‌زند.
* مجموعِ XPِ کاربر **اینجا** به‌روز می‌شود، نه سرِ هر پاسخ.
*/
complete: (
lessonId: number,
result: { correctCount: number; totalQuestions: number },
idempotencyKey?: string,
) =>
raw<LessonComplete>(
'POST', `/lessons/${lessonId}/complete`, result,
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
),
},
/* ── صفحه‌ی «از رُبو بپرس» ─────────────────────────────────────────── */
ai: {
/**
* `lessonId` را بدهید تا پاسخ به کتابِ همان درس مستند شود؛ لازم نیست
* subject/grade/chapter را خودتان حساب کنید.
* `conversationId` را از پاسخِ قبلی پاس بدهید تا گفتگو ادامه پیدا کند.
*/
ask: (input: {
userQuestion: string;
lessonId?: number | string;
conversationId?: string;
questionId?: number | string;
idempotencyKey?: string;
}) => {
const { idempotencyKey, ...body } = input;
return raw<AskResponse>(
'POST', '/ai/ask', body,
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
);
},
/**
* همان ask ولی توکن‌به‌توکن (SSE). اولین کلمه ~۳.۵ ثانیه به‌جای ~۱۰.
* برای حبابِ چتِ «در حالِ تایپ» همین را استفاده کنید.
*/
async askStream(
input: { userQuestion: string; lessonId?: number | string; conversationId?: string },
onDelta: (text: string) => void,
): Promise<{ conversationId?: string; messageId?: string }> {
const tokens = storage.get();
const response = await fetch(`${baseUrl}/ai/ask`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(tokens?.token ? { Authorization: `Bearer ${tokens.token}` } : {}),
},
body: JSON.stringify({ ...input, stream: true }),
});
if (!response.ok || !response.body) {
throw new ApiError(response.status, 'استریم شروع نشد', {});
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let done: { conversationId?: string; messageId?: string } = {};
for (;;) {
const { value, done: finished } = await reader.read();
if (finished) break;
buffer += decoder.decode(value, { stream: true });
// رویدادها با یک خطِ خالی از هم جدا می‌شوند.
const parts = buffer.split('\n\n');
buffer = parts.pop() ?? '';
for (const part of parts) {
const event = /^event: (.+)$/m.exec(part)?.[1];
const dataLine = /^data: (.+)$/m.exec(part)?.[1];
if (!event || !dataLine) continue;
const payload = JSON.parse(dataLine) as Json;
if (event === 'chunk') onDelta(String(payload.delta ?? ''));
else if (event === 'done') done = payload as typeof done;
else if (event === 'error') {
throw new ApiError(503, String(payload.detail ?? 'خطای دستیار'), payload);
}
}
}
return done;
},
/** 👍/👎 روی یک پاسخ. `reason` اختیاری ولی خیلی مفید است. */
feedback: (messageId: string | number, isHelpful: boolean, reason?: string) =>
raw<{ success: boolean }>('POST', `/ai/messages/${messageId}/feedback`, {
isHelpful,
...(reason ? { reason } : {}),
}),
history: (conversationId: string | number) =>
raw<{ conversationId: string; lessonId: string | null; messages: unknown[] }>(
'GET', `/ai/conversations/${conversationId}/messages`,
),
},
/* ── صفحه‌ی اشتراک ─────────────────────────────────────────────────── */
subscription: {
plans: () => raw<{ plans: Plan[] }>('GET', '/subscription/plans').then((r) => r.plans),
status: () =>
raw<{ active: boolean; planId: string | null; expiresAt: string | null }>(
'GET', '/subscription/status',
),
/**
* `paymentUrl` را در مرورگر باز کنید. آن آدرس خودش سراغِ زرین‌پال
* می‌رود؛ اپ نباید مستقیم با درگاه حرف بزند.
*/
purchase: (planId: string, idempotencyKey?: string) =>
raw<{ orderId: string; paymentUrl: string }>(
'POST', '/subscription/purchase', { planId },
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
),
},
notifications: {
list: () => raw<{ items: unknown[]; unreadCount: number }>('GET', '/notifications'),
markRead: (id: number) => raw<Json>('POST', `/notifications/${id}/read`),
},
};
}
export type TadrisinoClient = ReturnType<typeof createClient>;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

After

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 307 KiB

View File

@@ -169,35 +169,6 @@ Postgres 5440 (localhost) ◄── تونلِ SSH ─── zareian-pg-tunnel
- **کلِ ۲۴۹ رفت:** همان بازیابی تا انتهای WAL روی ماشینِ تازه، و سفارش‌های چند ثانیه‌ی آخر را با پنلِ زرین‌پال/بازار
تطبیق دهید.
بکاپِ دستیِ SQLite (توسعه‌ی محلی):
```bash
cd /home/administrator/mathapp-v2
mkdir -p .deploy-backups
venv/bin/python - <<'PY'
import sqlite3, datetime
dst = ".deploy-backups/db-%s.sqlite3" % datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
src, out = sqlite3.connect("db.sqlite3"), sqlite3.connect(dst)
with out:
src.backup(out) # نسخه‌ی سازگار حتی اگر هم‌زمان کسی بنویسد
out.close(); src.close()
print("backup:", dst, sqlite3.connect(dst).execute("PRAGMA integrity_check").fetchone()[0])
PY
```
`sqlite3` به‌عنوانِ فرمانِ خط فرمان روی آن سرور نصب **نیست** — از پایتونِ خودِ
پروژه استفاده کنید، نه `cp`؛ کپیِ ساده وسطِ یک نوشتن، فایلِ نیمه‌کاره می‌دهد.
بازیابی: سرویس را بخوابانید، فایل را برگردانید، دوباره بالا بیاورید.
```bash
sudo systemctl stop zareian-mathapp
cp .deploy-backups/db-<timestamp>.sqlite3 db.sqlite3
sudo systemctl start zareian-mathapp
curl https://mathapi.tadrisino2.ir/api/health
```
## متغیرهای محیطی
`.env` خودکار خوانده می‌شود (`python-dotenv`) و مقادیرش **با پیش‌فرض‌های کد
@@ -258,13 +229,12 @@ pendingِ همان کاربر را دوباره با زرین‌پال می‌س
## عوض کردنِ دامنه
آخرین جابه‌جایی: ۲۰۲۶-۰۹-۲۶، API از `api.math.tadrisino2.ir` به `mathapi.tadrisino2.ir`
(رکوردِ DNSِ آدرسِ قبلی حذف شده). فرانت همان `math.tadrisino2.ir` ماند.
دامنه‌های فعلی: API روی `mathapi.tadrisino2.ir` و فرانت روی `math.tadrisino2.ir`.
دامنه در کد فقط یک‌جا آمده: پیش‌فرضِ `PUBLIC_BASE_URL` و `FRONTEND_URL` در
`config/settings.py`. `ALLOWED_HOSTS`، `CSRF_TRUSTED_ORIGINS`، `CORS_ALLOWED_ORIGINS`،
callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_URI` و آدرسِ سلامت در
`deploy.sh` همه از همین دو ساخته می‌شوند (۲۰۲۶-۰۹-۲۶). فرانت هم دامنه را در کد
`deploy.sh` همه از همین دو ساخته می‌شوند (`config/tests_domains.py`). فرانت هم دامنه را در کد
ندارد (README همان ریپو).
ترتیبِ کار، از بیرون به داخل:
@@ -291,7 +261,7 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
## لغو و بازپرداختِ سفارش (از پنلِ ادمین)
از ۲۰۲۶-۱۰-۰۲. همه از `/admin/payment/order/` انجام می‌شود و SSH لازم نیست:
همه از `/admin/payment/order/` انجام می‌شود و SSH لازم نیست:
1. سفارش را با شماره‌اش یا شناسه‌ی کاربر (`user_id`) پیدا کنید. شماره‌ی فاکتورِ `TD-000123` یعنی سفارشِ ۱۲۳.
2. تیکش را بزنید و از فهرستِ «Action» یکی را انتخاب کنید:
@@ -362,33 +332,32 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
## سرور
`mathapi.tadrisino2.ir` روی یک **ماشینِ مشترکِ شرکت** اجرا می‌شود
(`administrator@78.38.35.249`)، نه سروری که مالِ این پروژه باشد. روی همان ماشین
Gitea، سرویسِ ایمیل، DNS، چند دیتابیس و اپ‌های تیم‌های دیگر در حالِ کارند.
`mathapi.tadrisino2.ir` روی یک **ماشینِ مشترکِ شرکت** اجرا می‌شود (`administrator@78.38.35.249`)، نه سروری که مالِ این
پروژه باشد. روی همان ماشین Gitea، ایمیل، DNS، چند دیتابیس و اپ‌های تیم‌های دیگر در حالِ کارند، و حسابِ ما `sudo`ِ
کامل دارد؛ تنها مانع میانِ یک فرمانِ بی‌دقت و داده‌ی کلِ شرکت، خودِ اجراکننده است. سرورِ بکاپ (`78.38.35.247`) هم
مشترک است.
مالِ ما **دقیقاً یک دایرکتوری** است: `/home/administrator/mathapp-v2`، که با
`gunicorn-mathapp.service` سرو می‌شود. همان یونیت با نامِ `zareian-mathapp` هم
پاسخ می‌دهد و **همیشه همین نام را بنویسید**:
مالِ ما روی ۲۴۹: پوشه‌های `~/mathapp-v2` (بک‌اند) و `~/mathapp_frontend_new` (فرانت)، سرویسِ `gunicorn-mathapp` با
نامِ دومِ **`zareian-mathapp`** (همیشه همین نام را بنویسید)، `zareian-redis`، clusterِ Postgresِ `14/zareian`، تایمرِ
`zareian-applog-archive`، و دو سایت و دو snippetِ nginx (بخشِ بعد). روی ۲۴۷ فقط `~/zareian-pgbackup/` و یونیت‌های
`zareian-*`.
```bash
sudo systemctl restart zareian-mathapp # مالِ ما
sudo systemctl restart gunicorn # مالِ ما نیست — اپِ تیمِ دیگر
```
قواعدی که قابلِ مذاکره نیستند:
قواعدی که قابلِ مذاکره نیستند (نسخه‌ی کامل، با فهرستِ آنچه مالِ ما نیست و رویه‌ی دیپلو، در `CLAUDE.md` «Working on
the servers»):
1. **هیچ فرمانِ مخربی بیرون از `~/mathapp-v2` اجرا نشود.** مسیر را کامل
بنویسید، به دایرکتوریِ جاری تکیه نکنید.
2. **`git clean` آنجا هرگز اجرا نشود.** `db.sqlite3`، `.env` و
`.django_secret_key` هیچ‌کدام در گیت نیستند و همان‌جا زندگی می‌کنند؛
`reset --hard` کاری با آن‌ها ندارد ولی `clean -fd` دیتابیس، پیکربندی و
همه‌ی نشست‌های فعال را نابود می‌کند.
3. **پروژه هرگز پاک و «از نو clone» نشود** — به همان دلیل. برای برگرداندنِ
درخت به حالتِ سالم از `git reset --hard origin/main` استفاده کنید.
4. **`.env` فقط از راهِ Django خوانده شود.** `cat .env` نه، و مقادیرش در هیچ
چتی paste نشود.
5. **`df -h /` را قبل از هر کارِ پرنوشت چک کنید.** دیسک مدتی‌ست حدودِ ۹۴٪ پر
است؛ پر شدنش سرویسِ ایمیل و Gitea را هم با ما پایین می‌کشد.
1. **اول فقط نگاه کنید**، بعد تغییر بدهید؛ هیچ فرمانِ مخربی بیرون از پوشه‌های ما، و مسیرها را کامل بنویسید.
2. **`git clean` هرگز، و پروژه هرگز پاک و «از نو clone» نشود.** `.env`، `.django_secret_key`، لاگ‌ها و بکاپ‌ها در گیت
نیستند و همان‌جا زندگی می‌کنند؛ راهِ برگشت `git reset --hard origin/main` است.
3. **فقط `zareian-mathapp` ری‌استارت شود**؛ nginx مشترک است: `nginx -t` و بعد `reload`، هرگز `restart`.
4. **پیش از ویرایشِ هر چیز زیرِ `/etc` یا `.env` بکاپِ تاریخ‌دار** در `~/mathapp-v2/.deploy-backups/`.
5. **`.env` فقط از راهِ Django خوانده شود**؛ `cat .env` نه، و مقادیرش در هیچ چتی paste نشود.
6. **`df -h /` پیش از هر کارِ پرنوشت.** پر شدنِ دیسک ایمیل و Gitea را هم پایین می‌کشد.
7. **از منابعِ تیم‌های دیگر استفاده نشود** (Redis، Postgres، صف، سایتِ nginx)؛ نیاز به آن‌ها درخواست از ادمینِ سرور است.
## nginx (دو سایتِ ما)
@@ -426,6 +395,5 @@ sudo systemctl restart gunicorn # مالِ ما نیست — اپِ
---
> **پیش از آمدنِ کاربرِ واقعی** فهرستِ [`security-before-production.md`](security-before-production.md)
> را کامل ببندید: کلیدِ امضا، حساب‌های دمو، جزئیاتِ عمومیِ `/api/health`، مستنداتِ عمومیِ API،
> جدولِ رتبه‌بندیِ عمومی، پنلِ ادمین، پورتِ ۸۰ و دسترسیِ سرور.
> را کامل ببندید (پنلِ ادمینِ عمومی، کدِ ورودِ بی‌هش، سقفِ پیامک، دسترسیِ مشترکِ سرور، …).

View File

@@ -2,8 +2,7 @@
**آخرین بازبینی با کد و سرورِ زنده: ۲۰۲۶-۱۰-۱۳ (۲۱ مهر ۱۴۰۵): حساب‌های دمو، nginx (فقط آروان، CSP)، جعلِ IP، سقفِ پیامک.**
این پروژه تا این تاریخ در حالتِ **دمو و ارائه‌ی کارآموزی** است، نه محصولِ نهایی. چند چیز
عمداً باز گذاشته شده تا توسعه‌دهنده‌ها، فرانت‌کار و ارائه کار کنند. هر کدام برای یک سرورِ
سرویس زنده است ولی هنوز به روی کاربرِ عمومی باز نشده. چند چیز عمداً یا به‌خاطرِ تصمیمِ بازِ طراحی باز مانده است. هر کدام برای یک سرورِ
عمومی با کاربرِ واقعی (کودک، پرداختِ واقعی) **خطر است** و باید پیش از آمدنِ اولین کاربرِ
واقعی بسته شود.
@@ -53,15 +52,13 @@
## ۱۲. هزینه‌ی دستیارِ AI
- **چیست:** هر کاربر روزی ۲ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`)
و بعد فقط با اعتبارِ خریده‌شده؛ ۲۰ در ساعت. هر شماره‌ی تازه ۲ سؤالِ رایگان می‌گیرد و ساختنِ شماره با سقفِ پیامک
(بند ۲۴) محدود است. سقفِ
روزانه‌ی کلِ سامانه (`AI_GLOBAL_DAILY_MAX`، ۳۰۰۰) خرجِ کل را محدود می‌کند. طولِ سؤال حداکثر ۲۰۰۰ نویسه است.
- **خطر:** با چند IP، اعتبارِ GapGPT در چند ساعت تمام می‌شود (اعتبار یک بار در ۲۰۲۶-۰۹-۲۵
به صفر رسید، البته به دلیلِ دیگر).
- **رفع:** سقفِ سراسریِ روزانه برای کلِ اپ؛ دستیار فقط برای کاربرِ واردشده با شماره یا
با سقفِ بسیار کمتر برای مهمان؛ هشدار وقتی `aiProvider.state` در `/api/health` برابرِ
`quota_exhausted` شد.
- **چیست:** هر کاربرِ بی‌اشتراک روزی ۲ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`) و مشترک ۴؛ ۲۰ در ساعت. هر شماره‌ی تازه ۲
سؤالِ رایگان می‌گیرد و ساختنِ شماره با سقفِ پیامک (بند ۲۴) محدود است. سقفِ روزانه‌ی کلِ سامانه (`AI_GLOBAL_DAILY_MAX`،
۳۰۰۰) بیشترین خرجِ روزانه را محدود می‌کند. طولِ سؤال حداکثر ۲۰۰۰ نویسه است.
- **خطر:** با چند IP و چند شماره، تا سقفِ سراسری خرج می‌شود (~۲ میلیون تومان در روز) و سقفِ سراسری برای کاربرانِ
واقعی هم پر می‌شود.
- **رفع:** هشدارِ فعال (نه فقط در `/api/health`) وقتی `aiProvider.state` برابرِ `quota_exhausted` شد یا سقفِ سراسری از
نیمه گذشت؛ سهمیه‌ی کمتر برای حسابِ تازه‌ساخته.
## ۱۵. دسترسیِ سرور
@@ -102,7 +99,7 @@
## ۲۷. کلیدِ جواب با حسابِ دوم، امتیازِ دوباره
- **چیست:** جوابِ غلط `correctOptionId` را برمی‌گرداند و برای همه یکی است؛ پایانِ درس و پایانِ
جزیره هر کدام جدا امتیاز می‌دهند. یک مهمانِ دورریختنی کلید را درمی‌آورد و حسابِ اصلی همه را درست
جزیره هر کدام جدا امتیاز می‌دهند. یک حسابِ دومِ دورریختنی کلید را درمی‌آورد و حسابِ اصلی همه را درست
می‌زند (۴ سوال، ۸۰ امتیاز). رتبه‌بندی و آمارِ درصدِ غلط آلوده می‌شوند.
- **رفع (تصمیمِ محصولی):** امتیازِ هر سوال یک بار؛ گزینه‌ی درست فقط بعد از پایانِ آزمون.
@@ -110,8 +107,8 @@
- **چیست:** استفاده‌ی دوباره از یک توکنِ تمدیدِ باطل‌شده فقط ۴۰۱ می‌گیرد؛ اگر دزد اول تمدید کند،
صاحبِ حساب بیرون می‌افتد و دزد می‌ماند. «خروج از همه‌ی دستگاه‌ها» هم نیست.
- **چرا باز مانده:** باطل کردنِ کلِ زنجیره با استفاده‌ی دوباره، حساب‌های دمو را که در ارائه چند
نفر هم‌زمان با آن‌ها کار می‌کنند بیرون می‌اندازد.
- **چرا باز مانده:** در دورانِ ارائه حساب‌های دمو را چند نفر هم‌زمان به کار می‌بردند و باطل کردنِ زنجیره بیرونشان
می‌انداخت. آن حساب‌ها پاک شده‌اند؛ مانعی نمانده، فقط نوشته نشده است.
- **رفع:** شناسه‌ی خانواده روی `Session`؛ با استفاده‌ی دوباره کلِ خانواده باطل شود؛ گزینه‌ی خروج از همه‌جا.
## ۲۹. پیشنهادِ ثبت‌نام بعد از حذفِ حساب
@@ -157,10 +154,10 @@
| شماره‌ی موبایل نرمال می‌شود؛ cooldown، سقفِ روزانه‌ی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده می‌شوند | `accounts/tests_otp_limits.py` |
| OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکن‌های قبلی را باطل می‌کند | `accounts/tests_audit_fixes.py` |
| قلبِ صفر جوابِ سوال را می‌بندد؛ چکش داخلِ تراکنش است (بی‌جواب‌ماندن و جزیره‌ی قفل جداگانه در ردیفِ بعدی بسته شده‌اند) | `content/tests_hearts_gate.py` |
| `select_for_update` مؤثر با `transaction_mode: IMMEDIATE`؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` |
| `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` می‌گیرد، `integrity_check` می‌کند و اگر نشد migrate را اجرا نمی‌کند | مرحله‌ی «بکاپِ دیتابیس»: ۷ بار روی لپ‌تاپ و یک بار روی سرور (۶ مگابایت، `integrity_check: ok`) |
| `select_for_update` مؤثر (قفلِ ردیفِ Postgres؛ روی SQLiteِ محلی `transaction_mode: IMMEDIATE`)؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` |
| `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` می‌گیرد (`pg_dump -Fc` روی Postgres) و اگر نشد migrate را اجرا نمی‌کند | مرحله‌ی «بکاپِ دیتابیس» در خروجیِ هر دیپلو |
| دیتابیس Postgresِ اختصاصی است و هر تغییرش لحظه‌ای روی سرورِ دیگری (۲۴۷) کپی می‌شود، با نسخه‌ی کاملِ روزانه و بازیابی تا هر لحظه از ۷ روزِ اخیر؛ قطعیِ ۲۴۷ اپ را نمی‌خواباند و بیش از ۱ گیگ روی دیسکِ ۲۴۹ جمع نمی‌شود (`docs/operations.md` «بکاپ و بازیابی») | `config/tests_offsite_backup.py` برای هشدارِ `/api/health`؛ روی سرور: بازیابی روی ۲۴۷ با شمارش‌های برابرِ ۲۴۹ و یک ردیفِ آزمایشی که ۲ ثانیه بعد آن‌جا بود |
| دیتابیس و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | با `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` روی سرور باید خطای دسترسی بدهد |
| `.env`، کلیدِ امضا، لاگ‌ها و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | روی سرور `sudo -u nobody ls ~/mathapp-v2/.deploy-backups` باید خطای دسترسی بدهد |
| پانزده مسیرِ خواندنی و نوشتنیِ کم‌خطر سقفِ نرخ دارند (بانکِ سوال و intro بی‌ورود، `progress/nodes`، `user/hearts`، اعلان، تیکت، پلن‌ها، وضعیتِ اشتراک، سفارش، پیشنهادها، affiliate، تاریخچه و فیدبکِ دستیار، خروج). با نرخِ ۳ در دقیقه درخواستِ چهارم ۴۲۹ می‌گیرد | `content/tests_read_throttles.py` |
| ساختنِ دستیِ `OTPCode` و `Session` در پنل فقط برای ابرکاربر (کارمندِ عادی ۴۰۳) | `accounts/tests_security_review.py` (`StaffCannotMintLoginsTests`) |
| پایانِ درس و پایانِ آزمونِ جزیره جوابِ ثبت‌شده به همه‌ی سوال‌ها می‌خواهند (۴۰۹ `answers_missing`)، روی جزیره‌ی قفل ۴۰۳ `node_locked` می‌دهند (همان قاعده‌ی نقشه)، جزیره یا درسِ بی‌سوال چیزی برای جواب‌دادن ندارد و اگر باز است بی‌امتیاز رد می‌شود (قفل بودنش هنوز چک می‌شود)، و تعدادِ سوالِ آزمون را کلاینت نمی‌تواند کمتر از آزمونِ استانداردِ ۴ تایی بگوید (ستاره‌ی دروغین) | `content/tests_finish_requires_answers.py`، `content/tests_node_completion.py` |

View File

@@ -4,10 +4,10 @@
| | |
|---|---|
| تاریخ | ۱۰ مهر ۱۴۰۵ (۲ اکتبر ۲۰۲۶) |
| تاریخ | ۲۲ مهر ۱۴۰۵ (۱۴ اکتبر ۲۰۲۶) |
| نویسنده | علیرضا زارعیان — بک‌اند و فرانت‌اند |
| مخاطب | توسعه‌دهنده‌ی تازه‌وارد، مدیرِ فنی، و هر کسی که باید درباره‌ی ادامه‌ی پروژه تصمیم بگیرد |
| کدِ مبنا | بک‌اند `8a4753b` (مخزنِ `Tadrisino/MathApp` ) · فرانت `33f5f8d` (مخزنِ `Tadrisino/MathApp_frontend` ) |
| کدِ مبنا | شاخه‌ی `main` ِ هر دو مخزن در همین تاریخ: `Tadrisino/MathApp` (بک‌اند) و `Tadrisino/MathApp_frontend` (فرانت و پوسته‌ی اندروید) |
| نشانی‌ها | اپ: `math.tadrisino2.ir` · API: `mathapi.tadrisino2.ir` · مستنداتِ زنده‌ی API: `/api/docs/` |
> این فایل نسخه‌ی Markdownِ «مستند-فنی-تدریسینو» است. نسخه‌ی PDF با همان محتوا و صفحه‌آراییِ کامل کنارِ همین فایل است: `technical-handbook.pdf`.
@@ -18,12 +18,13 @@
| شاخص | مقدار |
|---|---|
| سوم، ششم، هفتم، هشتم تا دوازدهم | **۸ پایه** |
| سؤالِ چهارگزینه‌ای از مسئله‌های کتاب | **۲٬۵۴۵** |
| اندپوینتِ API، مستند در Swagger | **۵۹** |
| تستِ خودکار، همه سبز | **۷۷۹** |
| پایه‌های سوم تا دوازدهم | **۱۰ پایه** |
| کتاب (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته) | **۱۵ کتاب در ۵ درس** |
| سؤالِ چهارگزینه‌ای | **۱۸٬۵۲۲** |
| عملیاتِ API، مستند در Swagger | **۶۱** |
| تستِ خودکار، همه سبز | **۹۷۳** |
| اپِ جنگو، هرکدام یک مسئولیت | **۱۴** |
| تکه‌ی کتاب در Qdrant (۱۱ collection) | **۳٬۵۱۷** |
| تکه‌ی کتاب در Qdrant (۱۸ collection) | **۵٬۸۱۸** |
| همراهِ گیمیفیکیشن، هرکدام ۶ حالتِ چهره | **۴** |
| بک‌اند و فرانت؛ یک قراردادِ API | **۲ مخزن** |
@@ -31,7 +32,6 @@
| کاربر | چه می‌کند | کجای کد |
|---|---|---|
| دانش‌آموزِ مهمان | بدونِ شماره وارد می‌شود، پایه‌اش را انتخاب می‌کند و بازی می‌کند. اگر بعداً با شماره ثبت‌نام کند، همان حساب می‌ماند و امتیازش گم نمی‌شود. | `accounts/views.py` |
| دانش‌آموز | از هر سنی (بزرگسالی که فقط یک مبحث را می‌خواهد هم پایه‌ی همان مبحث را انتخاب می‌کند). با شماره‌ی موبایل و کدِ پیامکی وارد می‌شود؛ جزیره‌ها را بازی می‌کند، از دستیار می‌پرسد، امتیاز و سکه و زنجیره‌ی روزانه جمع می‌کند، همراه انتخاب می‌کند، اشتراک می‌خرد، تیکتِ پشتیبانی می‌فرستد. | `content/` `progress/` `ai_tutor/` |
| والد | حسابِ جدا ندارد: «گزارشِ والدین» را در حسابِ خودِ دانش‌آموز می‌بیند — زمانِ تمرین، سوال‌ها و مقایسه با هفته‌ی پیش، با دو نمودار. | `progress/report.py` |
| ادمین (کارمندِ شرکت) | از پنلِ `/admin/` سؤال، درس، پلن، همراه و تیکت را بدونِ برنامه‌نویس ویرایش می‌کند؛ سؤال‌ها را فله‌ای وارد و خارج می‌کند. | `*/admin.py` |
@@ -43,6 +43,7 @@
| مرداد ۱۴۰۵ | طراحیِ معماری و مدل‌های داده؛ هویتِ کاربر؛ جداسازیِ اپ‌ها. بازبینیِ امنیتیِ اول و بستنِ حفره‌ها، محدودیتِ نرخ در کلِ سیستم. |
| شهریور | دستیار از پاسخِ ساختگی به RAGِ واقعی روی کتاب رسید؛ اتصالِ زرین‌پال؛ گیمیفیکیشن؛ خواندنِ پنج کتابِ ریاضی و ساختِ هزاران سؤالِ تأییدشده؛ حسابانِ ۱۱ و ۱۲. |
| ۲ تا ۷ مهر | فرانت به ما سپرده شد و از نو ساخته شد؛ هر دو روی دامنه‌ی تازه زنده شدند؛ پیامکِ واقعی؛ شکل در جوابِ دستیار؛ همراهِ چهارم؛ بازبینیِ نهایی روی سایتِ زنده. |
| ۸ تا ۲۲ مهر | ورودِ مهمان حذف شد؛ کلیدِ امضای تازه؛ PostgreSQL و Redisِ اختصاصی و کپیِ زنده‌ی دیتابیس روی سرورِ دوم؛ دیوارِ پرداخت و اشتراکِ یک/سه/شش‌ماهه از زرین‌پال و کافه‌بازار؛ پایه‌های چهارم و پنجم و پنج کتابِ تازه (هندسه، آمار، گسسته)؛ جزیره‌های تمرین و ۱۸ هزار سؤال؛ سخت‌کردنِ سرور و آزمونِ بارِ هزار نفره. |
### مدلِ سناریو (Use Case)
@@ -50,11 +51,11 @@
![مدل سناریو (Use Case)](images/technical-handbook/use-case-model.png)
*شکلِ ۱-۱ — مدلِ سناریو: سه نقشِ کاربری (والد با حسابِ خودِ دانش‌آموز)، یازده کارِ اصلی، و سه سرویسِ بیرونی.*
*شکلِ ۱-۱ — مدلِ سناریو: نقش‌های کاربری (والد با حسابِ خودِ دانش‌آموز)، کارهای اصلی، و سرویس‌های بیرونی.*
### واحدهای محتوا (این واژه‌ها در کلِ سند تکرار می‌شوند)
پایه Grade ۸ ← فصل Unit ۵۸ ← جزیره Node ۲۶۳ ← درس Lesson ۲٬۶۱۷ ← سؤال Question ۲٬۵۴۵ ← گزینه Choice
پایه Grade ۱۰ ← کتاب Subject ۵ ← فصل Unit ۸۹ ← جزیره Node ۱٬۱۴۵ ← درس Lesson ۵٬۴۰۶ ← سؤال Question ۱۸٬۵۲۲ ← گزینه Choice
**«درس» در این پروژه یعنی یک «تیپ‌مسئله»ی کتاب**، نه یک جلسه‌ی کلاس. برای همین درس خیلی ریز است و اپ در سطحِ **جزیره** (Node) بازی می‌شود: آزمونِ هر جزیره از درس‌هایش سؤال برمی‌دارد.
@@ -72,15 +73,15 @@
| لایه | انتخاب | چرا | چرا نه جایگزین | هزینه / ریسک |
|---|---|---|---|---|
| بک‌اند | **Django 5.2** + **Django REST Framework** | ORM و migration و **پنلِ ادمینِ آماده** در یک بسته؛ پنلِ ادمین یعنی محتوا بدونِ برنامه‌نویس ویرایش می‌شود. DRF احرازِ هویت، مجوز، محدودیتِ نرخ و سریال‌سازی را استاندارد می‌دهد. پایتون همان زبانِ کتابخانه‌های هوشِ مصنوعی است ( `openai`، `qdrant-client`، `pymupdf` ). | FastAPI سریع‌تر و async است ولی ادمین، ORM و migration را باید جدا سرهم کرد. Node.js زبانِ بک‌اند را از زبانِ لایه‌ی AI جدا می‌کرد. | جنگو پیش‌فرض همگام (sync) است؛ جوابِ طولانیِ دستیار یک نخ را نگه می‌دارد. با ۲۴ نخِ هم‌زمان حل شد (بخشِ ۱۴). |
| بک‌اند | **Django 5.2** + **Django REST Framework** | ORM و migration و **پنلِ ادمینِ آماده** در یک بسته؛ پنلِ ادمین یعنی محتوا بدونِ برنامه‌نویس ویرایش می‌شود. DRF احرازِ هویت، مجوز، محدودیتِ نرخ و سریال‌سازی را استاندارد می‌دهد. پایتون همان زبانِ کتابخانه‌های هوشِ مصنوعی است ( `openai`، `qdrant-client`، `pymupdf` ). | FastAPI سریع‌تر و async است ولی ادمین، ORM و migration را باید جدا سرهم کرد. Node.js زبانِ بک‌اند را از زبانِ لایه‌ی AI جدا می‌کرد. | جنگو پیش‌فرض همگام (sync) است؛ جوابِ طولانیِ دستیار یک نخ را نگه می‌دارد. با ۴ worker × ۳۲ نخ و سهمِ جدای دستیار حل شد (بخشِ ۱۴). |
| قراردادِ API | **OpenAPI** با drf-spectacular | اسکیما از خودِ کد ساخته می‌شود، پس هیچ‌وقت از کد عقب نمی‌ماند. Swagger در `/api/docs/` قراردادِ مشترکِ دو مخزن است. | سندِ دستی یا «در ذهنِ کسی» — که دقیقاً باعثِ ناهمخوانیِ اولیه‌ی فرانت و بک‌اند شد. | — |
| فرانت | **React 18** + **TypeScript** (strict) + **Vite** + **Tailwind** | فرانتِ اولیه را توسعه‌دهنده‌ی قبلی با React ساخته بود؛ در بازسازی نگه داشته شد چون بزرگ‌ترین اکوسیستم را دارد و نیروی جایگزینش راحت پیدا می‌شود. TypeScript ناهمخوانیِ شکلِ داده با API را در زمانِ build می‌گیرد. Vite buildِ سریع و Tailwind رنگ و فاصله‌ی یکدست. وبِ موبایل‌محور یعنی بدونِ نصب و بدونِ فروشگاه روی هر گوشی باز می‌شود. | React Native / Flutter نیازِ انتشار در فروشگاه و build جدا داشت؛ نسخه‌ی اندروید (کافه‌بازار) بعداً می‌تواند اضافه شود. | بدونِ کتابخانه‌ی router و state؛ کمتر وابستگی، ولی الگوی داخلی را باید خواند (بخشِ ۴). |
| دیتابیس | **SQLite** (فعلاً) | روی سرورِ مشترکِ شرکت صفر نگهداری دارد: یک فایل، بکاپ با یک دستور. برای حجمِ فعلی کافی است. کد از ORM استفاده می‌کند و به SQLite گره نخورده. | Postgres روی همان سرور موجود است؛ مهاجرت به هماهنگی با ادمینِ سرور نیاز دارد و به مرحله‌ی بعد موکول شده است. | **فقط یک نویسنده در لحظه.** اولین گلوگاهِ مقیاس است (بخشِ ۱۴). |
| فرانت | **React 18** + **TypeScript** (strict) + **Vite** + **Tailwind** | فرانتِ اولیه را توسعه‌دهنده‌ی قبلی با React ساخته بود؛ در بازسازی نگه داشته شد چون بزرگ‌ترین اکوسیستم را دارد و نیروی جایگزینش راحت پیدا می‌شود. TypeScript ناهمخوانیِ شکلِ داده با API را در زمانِ build می‌گیرد. Vite buildِ سریع و Tailwind رنگ و فاصله‌ی یکدست. وبِ موبایل‌محور یعنی بدونِ نصب و بدونِ فروشگاه روی هر گوشی باز می‌شود. | React Native / Flutter نیازِ انتشار در فروشگاه و build جدا داشت. نسخه‌ی اندروید یک پوسته‌ی WebView روی همین سایت است که فقط پرداختِ کافه‌بازار را بومی انجام می‌دهد ( `android/` در مخزنِ فرانت). | بدونِ کتابخانه‌ی router و state؛ کمتر وابستگی، ولی الگوی داخلی را باید خواند (بخشِ ۴). |
| دیتابیس و کش | **PostgreSQL 14** (clusterِ اختصاصی) · **Redis** (اختصاصی) | قفلِ ردیفیِ واقعی برای پرداخت، قلب و سهمیه؛ چند نویسنده‌ی هم‌زمان؛ کپیِ زنده‌ی لحظه‌ای به سرورِ دوم با ابزارِ خودِ Postgres. Redis سقف‌های نرخ و کش را بینِ workerها مشترک می‌کند. توسعه‌ی محلی و تست‌ها روی SQLite می‌مانند. | Postgres و Redisِ موجودِ ماشین مالِ تیم‌های دیگرند؛ برای این‌که به داده‌ی دیگران دست نزنیم، نمونه‌ی جدای خودمان را داریم. | دو سرویسِ بیشتر برای نگهداری؛ اتصال‌ها از poolِ هر worker می‌آیند چون `max_connections` ِ سرور ۶۰ است. |
| جست‌وجوی کتاب | **Qdrant** (بانکِ برداری) | تیمِ دیگرِ شرکت کتاب‌های سوم/ششم/هفتم را از قبل در یک Qdrantِ مشترک گذاشته بود؛ همان را ادامه دادیم تا داده دوباره ساخته نشود. فیلتر روی فیلدِ «فصل» دارد. | pgvector به Postgres نیاز داشت؛ FAISS سرور ندارد و بینِ دو تیم اشتراک‌پذیر نیست. | سرویسِ مشترک با تیمِ دیگر؛ پاک‌کردن باید با احتیاط باشد (بخشِ ۹). |
| مدلِ زبانی | **GapGPT** ← `gpt-4.1-mini` | GapGPT یک واسطِ سازگار با OpenAI است که از داخلِ ایران جواب می‌دهد. مدل با سنجش انتخاب شد، نه با جدولِ قیمت: روی ۱۰۲ مسئله‌ی تصحیح‌شده ۹۵–۹۶ درست در برابرِ ۸۵–۹۰ برای `gpt-4o-mini` ؛ هر جواب با پرامپتِ امروز حدودِ ۰٫۲ سنت (≈ ۶۰۰ تومان؛ `docs/ai-cost-and-guardrails.md`). دستیار فقط درباره‌ی ریاضی حرف می‌زند و سقفِ روزانه‌اش برای کاربرِ رایگان ۳ و برای مشترک به اندازه‌ی پلن است. | OpenAIِ مستقیم از ایران در دسترس نیست. مدل‌های گران‌تر در دورِ اولِ سنجش (۱۴ مدل) مزیتی نشان ندادند. | کُندیِ سرویس (حتی «سلام» ۷ ثانیه) دستِ ما نیست؛ اعتبار دو بار تمام شده. |
| ورود | **توکنِ امضاشده‌ی خودمان** + کدِ پیامکی | کاربرها با شماره وارد می‌شوند، نه نام‌کاربری/رمز؛ مدلِ کاربرِ جنگو به این نمی‌خورد. توکن با `django.core.signing` امضا می‌شود؛ یک وابستگیِ کمتر از کتابخانه‌ی JWT. | JWT همان کار را می‌کند با یک کتابخانه‌ی اضافه. | امنیتش به کلیدِ امضا بسته است (بخشِ ۱۲، موردِ ۱). |
| مدلِ زبانی | **GapGPT** ← `gpt-4.1-mini` | GapGPT یک واسطِ سازگار با OpenAI است که از داخلِ ایران جواب می‌دهد. مدل با سنجش انتخاب شد، نه با جدولِ قیمت: روی ۱۰۲ مسئله‌ی تصحیح‌شده ۹۵–۹۶ درست در برابرِ ۸۵–۹۰ برای `gpt-4o-mini` ؛ هر جواب با پرامپتِ امروز حدودِ ۰٫۲ سنت (≈ ۶۰۰ تومان؛ `docs/ai-cost-and-guardrails.md`). دستیار فقط درباره‌ی ریاضی حرف می‌زند و سقفِ روزانه‌اش برای کاربرِ رایگان ۲ و برای مشترک ۴ است. | OpenAIِ مستقیم از ایران در دسترس نیست. مدل‌های گران‌تر در دورِ اولِ سنجش (۱۴ مدل) مزیتی نشان ندادند. | کُندیِ سرویس (حتی «سلام» ۷ ثانیه) دستِ ما نیست؛ اعتبار دو بار تمام شده. |
| ورود | **توکنِ امضاشده‌ی خودمان** + کدِ پیامکی | کاربرها با شماره وارد می‌شوند، نه نام‌کاربری/رمز؛ مدلِ کاربرِ جنگو به این نمی‌خورد. توکن با `django.core.signing` امضا می‌شود؛ یک وابستگیِ کمتر از کتابخانه‌ی JWT. | JWT همان کار را می‌کند با یک کتابخانه‌ی اضافه. | امنیتش به کلیدِ امضا بسته است؛ کلید روی خودِ سرور ساخته شده و در هیچ مخزنی نیست (بخشِ ۱۲). |
| پیامک / پرداخت | **کاوه‌نگار** · **زرین‌پال** · **کافه‌بازار** | سرویس‌های ایرانیِ رایج؛ کاوه‌نگار با «ارسال با الگو» که روی خطِ شرکت فعال است؛ زرین‌پال برای وب، بازار برای خریدِ درون‌برنامه‌ایِ اندروید. | — | پرداختِ واقعی تا انتها هنوز آزموده نشده. |
| اجرا | **gunicorn** (gthread) پشتِ **nginx** پشتِ **ArvanCloud** | الگوی استانداردِ اجرای جنگو. آروان CDN و سپرِ حمله‌ی ایرانی است. WhiteNoise فایل‌های استاتیکِ ادمین را از خودِ جنگو سرو می‌کند. | — | سرور مشترک است؛ مالِ ما فقط یک پوشه و یک سرویس. |
| اجرا | **gunicorn** (gthread) پشتِ **nginx** پشتِ **ArvanCloud** | الگوی استانداردِ اجرای جنگو. آروان CDN و سپرِ حمله‌ی ایرانی است و nginx فقط درخواستِ رسیده از آروان را می‌پذیرد. WhiteNoise فایل‌های استاتیکِ ادمین را از خودِ جنگو سرو می‌کند. | — | سرور مشترک است؛ مالِ ما فقط پوشه‌ها و سرویس‌های خودمان (بخشِ ۱۳). |
> #### اصلِ کلیِ انتخاب‌ها
>
@@ -96,17 +97,17 @@
### درونِ بک‌اند: «یک برنامه، چند اپِ مستقل» (Modular Monolith)
بک‌اند یک برنامه‌ی جنگوست که به ۱۴ «اپ» تقسیم شده (اپِ `family` ۱۰ مهر ۱۴۰۵ حذف شد). هر اپ یک پوشه است با مدل‌ها، APIها و تست‌های خودش. این همان الگویی است که اجازه می‌دهد چند نفر هم‌زمان روی بک‌اند کار کنند (بخشِ ۱۴).
بک‌اند یک برنامه‌ی جنگوست که به ۱۴ «اپ» تقسیم شده. هر اپ یک پوشه است با مدل‌ها، APIها و تست‌های خودش. این همان الگویی است که اجازه می‌دهد چند نفر هم‌زمان روی بک‌اند کار کنند (بخشِ ۱۴).
| اپ | مسئولیت | مدل‌ها (جدول‌ها) |
|---|---|---|
| `accounts` | هویت، ورود با کدِ پیامکی، نشستِ ورود، پروفایل | `User Profile Session OTPCode` |
| `content` | برنامه‌ی درسی، سؤال، آزمونِ جزیره، ثبتِ جواب و پیشرفت | `Grade Unit Node Lesson Question Choice UserAnswer UserLessonCompletion UserNodeCompletion NodeProgress QuestionReport` |
| `accounts` | هویت، ورود با کدِ پیامکی، نشستِ ورود، پروفایل | `User Profile Session OTPCode LoginEvent` |
| `content` | کتاب‌ها، برنامه‌ی درسی، سؤال، آزمونِ جزیره، ثبتِ جواب و پیشرفت، رأی و گزارشِ دانش‌آموز | `Grade Subject Unit Node Lesson Question Choice UserAnswer UserLessonCompletion UserNodeCompletion NodeProgress ContentVote QuestionReport LessonReport SeedRecord` |
| `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` |
| `payment` | سفارش، تراکنش، زرین‌پال، بازار، لغو و بازپرداخت از ادمین، دفترِ رویدادِ خرید | `Order Transaction PaymentEvent BazaarCredential` |
| `discount` | کدِ تخفیف و پیشنهادها | `DiscountCode Redemption Offer UserOffer` |
| `affiliate` | معرفی به دوستان و پورسانت | `AffiliateAccount Conversion` |
| `notifications` | اعلان‌های داخلِ اپ | `Notification` |
@@ -148,19 +149,22 @@ MathApp/
├── config/ تنظیمات و سیم‌کشیِ کلِ پروژه
│ ├── settings.py همه‌ی تنظیمات؛ خواندنِ .env؛ نرخِ throttleها؛ کلیدِ امضا؛ مدل‌های AI
│ ├── urls.py ریشه‌ی مسیرها: /admin، /api/docs، /api/health و include هر اپ
│ ├── middleware.py لاگِ JSONِ هر درخواست، no-store، سقفِ ورودِ ادمین
│ ├── middleware.py IPِ واقعی فقط از آروان، لاگِ JSONِ هر درخواست، no-store، سقفِ ورودِ ادمین
│ ├── exceptions.py هر خطا → پاسخِ JSONِ یکدست، بی‌جزئیاتِ داخلی
│ ├── health.py GET /api/health: دیتابیس، migration، کش، محتوا، هشدارها
│ ├── health.py GET /api/health: دیتابیس، migration، کش، pool، بکاپ، هشدارها
│ ├── db.py · cache.py poolِ اتصالِ Postgres (۵۰۳ به‌جای ۵۰۰)، Redisِ تحمل‌پذیر
│ ├── renderers.py خروجیِ JSON بی‌اعراب و بی «ی»ِ اضافه
│ └── log_formatters.py قالبِ JSONِ لاگ
├── accounts/ content/ progress/ ai_tutor/ rag/ subscription/ payment/
├── discount/ affiliate/ notifications/ support/ campaigns/ statuslog/
├── content/seed/ curriculum.json و questions.json — کلِ محتوا به‌صورتِ فایل در git
├── data/textbooks/ PDFِ کتاب‌ها (در git نیست؛ فقط README)
├── templates/tadrisino/ پوسته‌ی راست‌به‌چپِ Swagger و ReDoc
├── tools/fake_openai.py جایگزینِ GapGPT برای تستِ بی‌هزینه
├── tools/ fake_openai.py (جایگزینِ GapGPT) · loadtest/ (آزمونِ بار)
├── ops/ نسخه‌ی اصلیِ nginx، بکاپِ Postgres و آرشیوِ لاگ روی سرورها
├── docs/ operations، architecture، security-before-production، content-authoring، …
├── deploy.sh کلِ انتشار در یک اسکریپت (بخشِ ۱۳)
├── gunicorn.conf.py ۸ نخ در هر worker، timeoutِ ۱۲۰
├── gunicorn.conf.py ۴ worker × ۳۲ نخ، timeoutِ ۱۲۰
├── requirements.txt Django 5.2، DRF، drf-spectacular، openai، qdrant-client، pymupdf، …
├── docker-compose.yml فقط محلی: Loki + Alloy + Grafana برای دیدنِ لاگ
└── manage.py
@@ -198,7 +202,7 @@ src/
├── state/ داده‌ی مشترک (پروفایل، نقشه) و گفتگوی دستیار
├── screens/ یک فایل برای هر صفحه: HomeScreen، QuizScreen، AskScreen، ReportScreen، …
├── components/ اجزای مشترک: مسیرِ جزیره‌ها، برگه‌ی پایین، نوارِ پایین
├── mascots/ سیگما، دلتا، پی، تدی — SVG از کد، ۶ حالتِ چهره
├── mascots/ سیگما، دلتا، پی، امگا — SVG از کد، ۶ حالتِ چهره
├── diagram/ JSONِ شکلِ دستیار → SVG؛ مختصات، ریشه، زاویه این‌جا حساب می‌شود
└── lib/ رقمِ فارسی، تاریخِ شمسی، MathText، پیامِ خطای دوستانه، صدا
```
@@ -230,7 +234,7 @@ Authorization: Bearer 10:1tQx9k:Xy3… ← user_id:زمان:امضا
Idempotency-Key: 7f3c… {"selectedOptionId": 7290}
```
**4. شبکه** — اینترنت ← **ArvanCloud** ← **nginx** روی سرور ← سوکتِ یونیکس ← یک نخِ آزاد از **gunicorn** ( `gunicorn.conf.py` ).
**4. شبکه** — اینترنت ← **ArvanCloud** ← **nginx** روی سرور (فقط درخواستِ رسیده از آروان) ← سوکتِ یونیکس ← یک نخِ آزاد از **gunicorn** ( `gunicorn.conf.py` ).
**5. بک‌اند · middleware** — `config/settings.py → MIDDLEWARE` — لایه‌های عمومی به‌ترتیب: امنیت، **CORS** (آیا درخواست از `math.tadrisino2.ir` آمده؟)، فایل‌های استاتیک، سقفِ ورودِ ادمین، …، `APINoStoreMiddleware` (پاسخ در کشِ آروان نماند)، و در آخر `APIRequestLoggingMiddleware` که در پایان یک خطِ JSON لاگ می‌نویسد ( `config/middleware.py` ).
@@ -241,7 +245,7 @@ path('api/', include('content.urls')), # config/urls.py
path('questions/<int:question_id>/answer', QuestionAnswerView.as_view()), # content/urls.py
```
**7. بک‌اند · هویت** — `accounts/authentication.py` — امضا و انقضای توکن را بی‌رجوع به دیتابیس می‌سنجد و `request.user` را می‌سازد. بی‌هدر یعنی «مهمانِ ناشناس»، نه خطا؛ ویو تصمیم می‌گیرد.
**7. بک‌اند · هویت** — `accounts/authentication.py` — امضا و انقضای توکن را بی‌رجوع به دیتابیس می‌سنجد و `request.user` را می‌سازد. بی‌هدر یعنی «درخواستِ ناشناس»، نه خطا؛ ویو تصمیم می‌گیرد.
```
value = TimestampSigner(salt='tadrisino-access-token').unsign(token, max_age=3600)
@@ -294,7 +298,7 @@ with transaction.atomic():
هر نمودار از راست به چپ خوانده می‌شود: ستون‌ها بازیگرها هستند، پیکان‌ها درخواست‌ها. پیکانِ خط‌چین یعنی پاسخ. شماره‌ها ترتیبِ زمانی‌اند.
### ۶-۱ ورود: مهمان، بعد ثبت‌نام با شماره
### ۶-۱ ورود با شماره و کدِ پیامکی
![سناریوی ورود](images/technical-handbook/scenario-login.png)
@@ -331,17 +335,17 @@ with transaction.atomic():
> - **کافه‌بازار:** اپ با Poolakey اشتراک می‌خرد و سرور توکن را از خودِ بازار می‌سنجد؛ بازار تمدیدِ خودکار را خبر نمی‌دهد، پس سرور نزدیکِ پایانِ دوره خودش می‌پرسد ( `sync_bazaar_renewals` ). کدِ تخفیف با «تخفیف پویا»ی بازار به قیمت می‌رسد.
> - **لغو و بازپرداخت از پنلِ ادمین:** دو action روی سفارش‌ها؛ بازپرداخت پس از صفحه‌ی تأیید روزهای اشتراکِ همان سفارش را پس می‌گیرد. خودِ پول از پنلِ درگاه برگردانده می‌شود ( `docs/operations.md` ).
>
> آزموده‌نشده زرین‌پال درخواستِ پرداخت را با کدِ ۱۰۰ می‌پذیرد، ولی یک پرداختِ واقعی تا انتها هنوز انجام نشده؛ خریدِ بازار منتظرِ ساختِ اپِ اندروید است.
> آزموده‌نشده: زرین‌پال درخواستِ پرداخت را با کدِ ۱۰۰ می‌پذیرد، ولی یک پرداختِ واقعی تا انتها هنوز انجام نشده؛ خریدِ بازار منتظرِ نسخه‌ی تازه‌ی اپِ اندروید است.
### ۶-۵ گزارشِ والدین
والد حسابِ جدا و کدِ دعوت ندارد (تا مهرِ ۱۴۰۵ داشت؛ به تصمیمِ پس از ارائه حذف شد). صفحه‌ی «گزارشِ والدین» در پروفایلِ خودِ دانش‌آموز است.
والد حسابِ جدا ندارد؛ صفحه‌ی «گزارشِ والدین» در پروفایلِ خودِ دانش‌آموز است.
| # | درخواست | چه می‌شود |
|---|---|---|
| ۱ | `GET /api/user/weekly-report` | برای هر یک از ۷ روزِ اخیر و ۷ روزِ قبلش: زمانِ تمرین، سوالِ حل‌شده و درست، امتیاز، درس و جزیره‌ی تمام‌شده، سوال از دستیار. فقط دیتای خودِ صاحبِ توکن. |
| ۲ | — | فرانت جمله‌ی مقایسه («آفرین! این هفته ۲۰ دقیقه بیشتر…») و دو نمودار می‌سازد: زمانِ هر روز در برابرِ همان روزِ هفته‌ی پیش، و سوال‌های درست/نادرستِ هر روز. |
| ۳ | `GET /api/progress/nodes` | پارامترِ `userId` برداشته شد؛ هر کاربر فقط پیشرفتِ خودش را می‌بیند. |
| ۳ | `GET /api/progress/nodes` | هر کاربر فقط پیشرفتِ خودش را می‌بیند؛ شناسه‌ی کاربرِ دیگر پذیرفته نمی‌شود. |
> **تخمینی:** «زمانِ تمرین» اندازه‌گیری نیست: از فاصله‌ی جواب‌ها و پرسش‌ها حساب می‌شود (فاصله‌ی بیش از ۱۰ دقیقه وقفه است). خواندنِ درس بدونِ جواب دادن در آن نیست، پس عدد معمولاً از زمانِ واقعی کمتر است.
@@ -350,25 +354,29 @@ with transaction.atomic():
جدول‌های اصلی، دسته‌بندی‌شده بر اساسِ اپ. `→` یعنی کلیدِ خارجیِ واقعی (داخلِ همان اپ)، و `user_id` یعنی ارجاعِ سبک به اپِ دیگر.
**accounts هویت**
- `User phone, name, is_guest, device_id, grade_id, tokens_valid_after`
- `Profile → User · birth_year, parent_phone`
- `Session → User · refresh_token, expires_at, revoked`
- `User phone, name, grade_id, tokens_valid_after`
- `Profile → User · birth_date (شمسی در API)، birth_year`
- `Session → User · refresh_token (هش‌شده), expires_at, revoked`
- `OTPCode phone, code, expires_at, failed_attempts`
- `LoginEvent ثبتِ هر ورود`
**content محتوا**
- `Grade id = شماره‌ی پایه (۸ = هشتم)`
- `Unit → Grade · title, blurb, order`
- `Subject key, title (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته)`
- `Unit → Grade, → Subject · title, blurb, order`
- `Node → Unit · order (جزیره)`
- `Lesson → Node · kind, example, steps`
- `Question → Lesson · prompt, type`
- `Lesson → Node · kind, example, steps, figures`
- `Question → Lesson · prompt, type, figure`
- `Choice → Question · value, is_correct`
- `SeedRecord دفترِ آنچه seed تحویل داده (تا کارِ ادمین برگردانده نشود)`
**content فعالیتِ کاربر**
- `UserAnswer user_id, → Question, → Choice`
- `UserLessonCompletion user_id, → Lesson`
- `UserNodeCompletion user_id, → Node, stars, xp_earned, coins_earned`
- `NodeProgress user_id, → Node, status`
- `QuestionReport «این سؤال ایراد دارد»`
- `ContentVote user_id، سؤال یا درس، ۱ / −۱`
- `QuestionReport · LessonReport «ایراد دارد»`
**progress بازی**
- `UserStats user_id, xp, coins, selected_character_id`
@@ -390,6 +398,7 @@ with transaction.atomic():
- `Subscription user_id, → Plan, status, expires_at`
- `Order user_id, plan_id, subscription_id, amount, list_price, plan_title, duration_days, status (… canceled, refunded), provider, gateway_authority, bazaar_purchase_token, bazaar_valid_until`
- `Transaction → Order · gateway_ref_id, status, paid_at`
- `PaymentEvent order_id, event, detail — هر قدمِ مسیرِ خرید`
- `BazaarCredential توکنِ اتصال به بازار`
**discount · affiliate بازاریابی**
@@ -421,7 +430,7 @@ with transaction.atomic():
| یکتا: `CampaignDelivery(کمپین، کاربر، کانال)` | پیامِ تکراری وقتی کمپین دو بار هم‌زمان اجرا شود |
| یکتا: `LessonChunk(collection, qdrant_point_id)` و `IngestedPage(collection, page)` | تکه‌ی تکراری در Qdrant وقتی خواندنِ کتاب از سر گرفته می‌شود |
> **کد و محتوا دو مسیرِ جدا دارند.** `db.sqlite3` در git نیست. محتوا (برنامه‌ی درسی و سؤال‌ها) به‌صورتِ JSON در `content/seed/` است و `deploy.sh` در هر انتشار آن را وارد می‌کند. داده‌ی کاربرها فقط در فایلِ دیتابیسِ سرور است.
> **کد و محتوا دو مسیرِ جدا دارند.** دیتابیس در git نیست. محتوا (برنامه‌ی درسی و سؤال‌ها) به‌صورتِ JSON در `content/seed/` است و `deploy.sh` در هر انتشار آن را وارد می‌کند. داده‌ی کاربرها فقط در Postgresِ سرور (و کپیِ زنده‌اش روی سرورِ بکاپ) است.
## ۸. API: قواعد و فهرستِ کامل
@@ -434,12 +443,13 @@ with transaction.atomic():
| نشانیِ پایه | `https://mathapi.tadrisino2.ir/api/…` — دامنه در کد نیامده و از تنظیمات ( `PUBLIC_BASE_URL` ) خوانده می‌شود. |
| ورود | هدرِ `Authorization: Bearer <token>`. توکن یک ساعت اعتبار دارد و با `POST /api/auth/refresh` تازه می‌شود. |
| قالبِ داده | JSON؛ نامِ فیلدها camelCase ( `selectedOptionId` )، شناسه‌ها در پاسخ رشته‌اند. |
| خطا | همیشه JSON با `detail` (و گاهی `code` مثلِ `no_hearts` ): ۴۰۰ ورودیِ بد، ۴۰۱ توکنِ نامعتبر، ۴۰۳ اجازه نداری، ۴۰۴، ۴۲۹ زیاد درخواست دادی، ۵۰۳ سرویسِ بیرونی در دسترس نیست. |
| خطا | همیشه JSON با `detail` (و گاهی `code` مثلِ `no_hearts`، `node_locked`، `answers_missing`، `subscription_required` ): ۴۰۰ ورودیِ بد، ۴۰۱ توکنِ نامعتبر، ۴۰۲ اشتراک لازم است، ۴۰۳ اجازه نداری، ۴۰۴، ۴۰۹ پیش‌شرط برقرار نیست، ۴۲۹ زیاد درخواست دادی، ۵۰۳ سرویسِ بیرونی یا ظرفیت در دسترس نیست (با `Retry-After` ). |
| متن | متنِ فارسیِ پاسخ‌ها بی‌اعراب و بی «ی»ِ اضافه است، و هیچ LaTeX یا markdownی ندارد. |
| تکرارِ امن | هدرِ اختیاریِ `Idempotency-Key` روی خرید، دستیار، جواب، پایانِ درس و جزیره: درخواستِ تکراری با همان کلید، همان نتیجه‌ی قبلی را برمی‌گرداند. |
| سقفِ نرخ | هر اندپوینتِ حساس یک `throttle_scope` دارد؛ نرخ‌ها همه یک‌جا در `config/settings.py → DEFAULT_THROTTLE_RATES`. |
| استریم | `POST /api/ai/ask` با `"stream": true` پاسخ را SSE می‌فرستد: رویدادهای `chunk`، `done`، `error`. |
### فهرستِ ۵۹ اندپوینت
### فهرستِ اندپوینت‌ها (۶۱ عملیات)
عمومی بی‌ورود · ورود توکن لازم · داخلی با هدرِ کلیدِ داخلی (سرور-به-سرور)
@@ -451,11 +461,11 @@ with transaction.atomic():
| POST | `/api/auth/refresh` | عمومی | توکنِ تازه با refresh token |
| POST | `/api/auth/logout` | ورود | خروج؛ توکن‌های قبلی باطل |
| GET · PATCH | `/api/user/profile` | ورود | خواندن و ویرایشِ پروفایل (تاریخِ تولدِ شمسی، شناسه‌ی نمایشی) |
| POST | `/api/user/delete` | ورود | حذفِ حساب؛ شماره‌دار با کدِ پیامکیِ تازه، مهمان با تایپِ «حذف» |
| POST | `/api/user/delete` | ورود | حذفِ حساب با کدِ پیامکیِ تازه |
| POST | `/api/user/grade` | ورود | ثبتِ پایه |
| **محتوا و آزمون — `content`** | | | |
| GET | `/api/grades` | عمومی | فهرستِ پایه‌ها |
| GET | `/api/home` | ورود | نقشه‌ی جزیره‌ها + آمار، در یک درخواست |
| GET | `/api/home` | ورود | نقشه‌ی جزیره‌ها + آمار، در یک درخواست؛ `?subject=` کتاب را انتخاب می‌کند |
| GET | `/api/nodes/{id}/lessons` | عمومی | درس‌های یک جزیره |
| GET | `/api/nodes/{id}/quiz` | عمومی | آزمونِ ۴ سؤالیِ جزیره |
| POST | `/api/nodes/{id}/quiz/complete` | ورود | پایانِ جزیره؛ ستاره، امتیاز، سکه، جزیره‌ی بعد |
@@ -463,7 +473,8 @@ with transaction.atomic():
| GET | `/api/lessons/{id}/questions` | عمومی | سؤال‌های یک درس |
| POST | `/api/lessons/{id}/complete` | ورود | پایانِ درس |
| POST | `/api/questions/{id}/answer` | ورود | ثبتِ جواب |
| POST | `/api/questions/{id}/report` | ورود | گزارشِ ایرادِ سؤال |
| POST | `/api/questions/{id}/report` · `/api/lessons/{id}/report` | ورود | گزارشِ ایرادِ سؤال یا درس |
| POST | `/api/questions/{id}/vote` · `/api/lessons/{id}/vote` | ورود | 👍/👎 روی سؤال یا درس ( `value` ۱، −۱ یا ۰) |
| GET | `/api/progress/nodes` | ورود | وضعیتِ خودِ کاربر روی جزیره‌ها |
| **پیشرفت و بازی — `progress`** | | | |
| GET | `/api/user/summary` | ورود | پروفایل + آمار + زنجیره در یک درخواست |
@@ -479,10 +490,10 @@ with transaction.atomic():
| POST | `/api/ai/messages/{id}/feedback` | ورود | 👍/👎 روی یک جواب |
| POST | `/api/rag/ingest/text` · `/pdf` | داخلی | افزودنِ متن یا PDF به Qdrant |
| GET | `/api/rag/chunks` | داخلی | مرورِ تکه‌های ذخیره‌شده |
| **بسته، پرداخت، تخفیف** | | | |
| **اشتراک، پرداخت، تخفیف** | | | |
| GET | `/api/subscription/plans` | عمومی | اشتراک‌های در حالِ فروش |
| POST | `/api/subscription/purchase` | ورود | شروعِ خرید؛ سفارش و لینکِ پرداخت |
| GET | `/api/subscription/status` | ورود | اعتبارِ سوال، قلبِ نامحدود تا کِی، اشتراکِ قدیمی (و بازسنجیِ پرداختِ جاافتاده) |
| GET | `/api/subscription/status` | ورود | اشتراکِ فعال و پایانش (و بازسنجیِ پرداختِ جاافتاده و تمدیدِ بازار) |
| POST | `/api/payment/bazaar/orders/{id}/start` | ورود | شروعِ خریدِ بازار (SKU و قیمتِ امضاشده) |
| GET | `/api/payment/orders/{id}` | ورود | وضعیتِ سفارش |
| GET | `/api/payment/invoices` | ورود | پرداخت‌ها و فاکتورهای کاربر (شماره‌ی فاکتور، تخفیف، کدِ پیگیری) |
@@ -501,7 +512,7 @@ with transaction.atomic():
| POST | `/api/support/bug-reports` | ورود | گزارشِ باگ |
| GET | `/api/user/weekly-report` | ورود | «گزارشِ والدین»: ۷ روزِ اخیر در کنارِ ۷ روزِ قبلش |
| **سیستم** | | | |
| GET | `/api/health` | عمومی | سلامتِ سرویس (بخشِ ۱۳) |
| GET | `/api/health` | عمومی (جزئیات با کلیدِ داخلی) | سلامتِ سرویس (بخشِ ۱۳) |
## ۹. دستیارِ هوشِ مصنوعی و داده‌ی Qdrant
@@ -513,14 +524,14 @@ Qdrant یک «بانکِ برداری» است: هر تکه‌ی کتاب به
| collection (کتاب) | تکه | صفحه‌ی خوانده‌شده | چه کسی ساخت |
|---|---|---|---|
| ریاضی هشتم | ۵۱۸ | ۱۵۰ | ما — خواندنِ PDF با مدلِ بینایی |
| ریاضی نهم | ۴۵۰ | ۱۴۳ | ما |
| ریاضی دهم | ۴۵۸ | ۱۷۰ | ما |
| حسابان یازدهم | ۳۷۶ | ۱۵۱ | ما |
| حسابان دوازدهم | ۳۲۶ | ۱۴۶ | ما |
| ریاضی چهارم · پنجم | ۵۹۰ · ۵۹۴ | ۱۵۴ · ۱۹۶ | ما — خواندنِ PDF با مدلِ بینایی |
| ریاضی هشتم · نهم · دهم | ۵۱۸ · ۴۵۰ · ۴۵۸ | ۱۵۰ · ۱۴۳ · ۱۷۰ | ما |
| حسابان یازدهم · دوازدهم | ۳۷۶ · ۳۲۶ | ۱۵۱ · ۱۴۶ | ما |
| هندسه دهم · یازدهم · دوازدهم | ۲۲۹ · ۱۷۱ · ۱۹۲ | ۸۸ · ۷۲ · ۷۸ | ما |
| آمار و احتمال یازدهم · گسسته دوازدهم | ۲۷۱ · ۲۵۴ | ۱۲۱ · ۸۴ | ما |
| ریاضی سوم · ششم · هفتم | ۴۳۶ · ۴۳۷ · ۳۷۹ | — | تیمِ دیگرِ شرکت؛ با `rag_import_collection` خوانده شد |
| علوم تجربی سوم · مطالعات اجتماعی ششم و چهارم | ۷۰ · ۴۰ · ۲۷ | — | تیمِ دیگر (در اپِ ریاضی استفاده نمی‌شود) |
| جمع | ۳٬۵۱۷ | ۷۶۰ | نشانی: `qdrant.tadrisino2.ir` — سرویسِ مشترک با تیمِ دیگر |
| جمع | ۵٬۸۱۸ | ۱٬۷۵۳ | نشانی: `qdrant.tadrisino2.ir` — سرویسِ مشترک با تیمِ دیگر |
#### مشخصاتِ فنی
@@ -555,7 +566,7 @@ Qdrant یک «بانکِ برداری» است: هر تکه‌ی کتاب به
5. LessonChunk ردیف در دیتابیس (منبعِ اصلی، با بردار)
6. Qdrant upsert نقطه + payload IngestedPage برای ازسرگیری
قاعده‌ی مهمِ پرامپت ( `rag/services/pdf.py` ): اگر مسئله به «شکلِ زیر» ارجاع می‌دهد، مدل باید اندازه‌ها و برچسب‌های شکل را **داخلِ متن** بنویسد. این قاعده سهمِ مسئله‌های بی‌استفاده‌ی وابسته به شکل را از ~۴۲٪ به ~۱۴٪ رساند. هزینه‌ی خواندنِ هر پنج کتاب در یک روز حدودِ ۳٫۴۴ دلار بود.
قاعده‌ی مهمِ پرامپت ( `rag/services/pdf.py` ): اگر مسئله به «شکلِ زیر» ارجاع می‌دهد، مدل باید اندازه‌ها و برچسب‌های شکل را **داخلِ متن** بنویسد. این قاعده سهمِ مسئله‌های بی‌استفاده‌ی وابسته به شکل را از ~۴۲٪ به ~۱۴٪ رساند. هزینه‌ی خواندنِ پنج کتابِ اول در یک روز حدودِ ۳٫۴۴ دلار بود.
### ۹-۳ کنترلِ کیفیت: راستی‌آزماییِ راه‌حل‌های کتاب
@@ -563,10 +574,10 @@ Qdrant یک «بانکِ برداری» است: هر تکه‌ی کتاب به
| رأی | تعداد | اثر |
|---|---|---|
| agrees راه‌حلِ کتاب درست است | ۲٬۴۶۶ | فقط از این‌ها سؤال ساخته می‌شود |
| disagrees راه‌حلِ کتاب غلط است | ۳۵۴ | راه‌حلش به دستیار داده نمی‌شود؛ فقط روش و صورتِ مسئله |
| unjudgeable بی‌شکل قابلِ قضاوت نیست | ۵۰۸ | کنار می‌ماند |
| بررسی‌نشده | ۱۸۹ | علوم و مطالعاتِ تیمِ دیگر |
| agrees راه‌حلِ کتاب درست است | ۴٬۱۹۱ | فقط از این‌ها سؤال ساخته می‌شود |
| disagrees راه‌حلِ کتاب غلط است | ۶۴۴ | راه‌حلش به دستیار داده نمی‌شود؛ فقط روش و صورتِ مسئله |
| unjudgeable بی‌شکل قابلِ قضاوت نیست | ۷۴۲ | کنار می‌ماند |
| بررسی‌نشده | ۲۴۱ | بیشترش علوم و مطالعاتِ تیمِ دیگر |
متنِ اصلیِ کتاب هیچ‌وقت بازنویسی نمی‌شود؛ جوابِ مستقل جدا در `checked_answer` ذخیره می‌شود تا آدم بتواند داوری کند.
@@ -591,9 +602,9 @@ Qdrant یک «بانکِ برداری» است: هر تکه‌ی کتاب به
|---|---|---|
| کدام کتاب؟ | `rag/services/lesson_context.py` | از `lessonId` زنجیره‌ی درس → جزیره → فصل → پایه؛ یا پایه‌ی خودِ کاربر |
| جست‌وجو | `retrieval.py` · `qdrant_store.py` · `embedding.py` | متنِ جست‌وجو = سؤال + سؤالِ قبلیِ کاربر (برای «نفهمیدم») |
| پرامپت | `generation.py` | نسخه‌ی `tutor-v10-shape-anchor` ؛ لحنِ سه‌سطحی (دبستان/متوسطه‌ی اول/دبیرستان)؛ متنِ ساده بی‌LaTeX؛ حلِ مرحله‌به‌مرحله با وارسی و «✅ جواب»؛ ۶ پیامِ آخرِ گفتگو |
| پرامپت | `generation.py` | نسخه‌ی `tutor-v12-age-tone` ؛ فقط ریاضی؛ لحنِ سه‌سطحی (دبستان/متوسطه‌ی اول/دبیرستان) و لحنِ بزرگسال برای ۱۸+؛ متنِ ساده بی‌LaTeX؛ حلِ مرحله‌به‌مرحله با وارسی و «✅ جواب»؛ ۶ پیامِ آخرِ گفتگو |
| جوابِ درست/غلطِ دانش‌آموز | `generation.py → question_focus` | با `questionId` دستیار صورتِ سؤال و آخرین جوابِ دانش‌آموز را می‌بیند و می‌گوید اشتباه کجا بود |
| تمیزکاری | `mathtext.py` | هرچه LaTeX از دستِ مدل در برود به یونیکد برمی‌گردد ( `x²`، `√3` ) |
| تمیزکاری | `mathtext.py` | هرچه LaTeX از دستِ مدل در برود به یونیکد برمی‌گردد ( `x²`، `√3` ) و اعراب حذف می‌شود |
| شکل | `diagram.py` ← فرانت `src/diagram/` | مدل فقط توصیفِ JSON می‌نویسد؛ سرور جدا، تعمیر و با فهرستِ سفید وارسی می‌کند؛ اپ مختصات را حساب و SVG رسم می‌کند. ۱۵ نوع شکل — شرحِ کامل در کادرِ بنفشِ پایین. |
| ثبت | `ai_tutor/models.py → Message` | مدل، نسخه‌ی پرامپت، تعدادِ تکه، تأخیر و شکل برای هر جواب ذخیره می‌شود ← کیفیت قابلِ اندازه‌گیری است |
@@ -698,7 +709,7 @@ 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 (تا با ساختِ حسابِ مهمانِ تازه دور زده نشود) |
| سقف‌ها | ۲ سؤالِ رایگان در روز (مشترک ۴)؛ ۲۰ در ساعت؛ ۳٬۰۰۰ در روز برای کلِ سامانه؛ حداکثر ۱۲ جوابِ هم‌زمان در هر worker (بیشتر از آن ۵۰۳ «دستیار شلوغه» پیش از کم شدنِ سهمیه) |
### ۹-۷ فقط ریاضی، و چه چیزی هزینه دارد
@@ -720,12 +731,12 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| فایل | محتوا |
|---|---|
| `content/seed/curriculum.json` | ۸ پایه، ۵۸ فصل، ۲۶۳ جزیره، ۲٬۶۱۷ درس |
| `content/seed/questions.json` | ۲٬۵۴۵ سؤالِ چهارگزینه‌ای |
| `content/seed/curriculum.json` | ۱۰ پایه، ۵ کتاب، ۸۹ فصل، ۱٬۱۴۵ جزیره، ۵٬۴۰۶ درس، با مثال، قدم‌ها و شکلِ درس |
| `content/seed/questions.json` | ۱۸٬۵۲۲ سؤالِ چهارگزینه‌ای، بعضی با شکل |
- `seed_content` در هر انتشار اجرا می‌شود؛ **هیچ‌وقت چیزی پاک نمی‌کند** و اجرای دوباره‌اش بی‌خطر است.
- سؤال با **مسیرِ عنوان** (پایه › فصل › جزیره › درس) به درس وصل می‌شود، نه با شناسه — چون شناسه‌ها بینِ دو دیتابیس فرق دارند.
- متنی که ادمین در پنل نوشته با انتشارِ بعدی بازنویسی نمی‌شود؛ seed فقط خانه‌ی خالی را پر می‌کند.
- کارِ ادمین با انتشارِ بعدی برنمی‌گردد: دفترِ `SeedRecord` به یاد دارد seed چه چیزی تحویل داده، پس سؤالِ پاک‌شده یا فصلِ تغییرنام‌داده دوباره ساخته نمی‌شود؛ مثال، قدم‌ها و شکل فقط خانه‌ی خالی را پر می‌کنند.
### کارهای روزمره از پنلِ ادمین
@@ -736,7 +747,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| خروجیِ سؤال یا برنامه‌ی درسی | انتخاب ← اکشنِ «دریافتِ فایل» (همان قالبی که ورودی می‌پذیرد) |
| پایه‌ی تازه | `/admin/content/grade/add/` — عنوانِ «پایه چهارم»، شناسه خودکار ۴ |
| پلن، همراه، تیکت، کدِ تخفیف | صفحه‌ی اپِ مربوط در پنل |
| سؤال‌های گزارش‌شده | `QuestionReport` — دانش‌آموز «این سؤال ایراد دارد» زده |
| بازخوردِ دانش‌آموز | ستونِ 👍/👎 در فهرستِ سؤال‌ها و درس‌ها؛ صفِ «گزارش‌های سوال» و «گزارش‌های درس» |
### سؤال‌ها چطور ساخته شدند
@@ -748,7 +759,9 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
بازده حدودِ ۸۷–۹۲٪ بود؛ بقیه عمداً رد شدند (بیشتر مسئله‌هایی که بی‌شکلِ کتاب خودکفا نمی‌شوند). همین مسیر با API پولی هم ممکن است ولی اندازه‌گیری‌شده ~۰٫۷۷ سنت برای هر سؤال گران‌تر بود بی‌کیفیتِ بهتر. نویسنده‌های سؤال چند بار خطای حسابِ خودِ کتاب را هم گرفتند.
**پوششِ فعلی:** هر جزیره‌ی هر ۸ پایه دستِ‌کم ۴ سؤال دارد. ۵۹۰ درس و ۵۸ فصل متنِ معرفی و مثال دارند ( `fill_lesson_texts`، بی‌هزینه، هرگز همان مسئله‌ی آزمون).
**جزیره‌های تمرین:** آخرِ هر فصل از پایه‌ی سوم تا دوازدهم یک تا چند جزیره‌ی «تمرین» است (۱۶۰ جزیره، ~۱۳٫۷ هزار سؤال). جوابِ این سؤال‌ها با برنامه **محاسبه** شده، نه حفظ، و گزینه‌های غلط از جنسِ خطای رایجِ دانش‌آموزند. قالبِ سؤال‌های هر خانواده یکی است و فقط عددها عوض می‌شوند؛ بازبینیِ معلم هنوز انجام نشده است.
**شکلِ درس:** درس‌های هندسه، آمار و گسسته شکلِ ثابت دارند ( `Lesson.figures` )، دستی نوشته و با چشم بازبینی‌شده؛ هنگامِ نمایش هیچ مدلی صدا زده نمی‌شود.
## ۱۱. گیمیفیکیشن و همراه‌ها
@@ -776,10 +789,11 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| امتیاز (XP) | پایانِ درس یا جزیره: **۱۰ × جوابِ درست**، فقط بارِ اول. عددِ جوابِ درستی که اپ می‌فرستد به جواب‌های درستِ **ثبت‌شده** محدود می‌شود؛ صدا زدنِ مستقیمِ API امتیازِ مجانی نمی‌دهد. | `content/views.py → NodeQuizCompleteView` |
| ستاره | ۹۰٪ به بالا ۳ · ۶۰٪ به بالا ۲ · بیشتر از صفر ۱ | همان |
| سکه | **۵ × ستاره**، فقط بارِ اول | همان |
| قلب | ۵ قلب؛ هر جوابِ غلط یکی کم؛ هر **۵ ساعت** یکی برمی‌گردد و **۲۴ ساعت** بعد از اولین قلبِ ازدست‌رفته همه پر می‌شوند. با قلبِ صفر نه جوابی پذیرفته می‌شود نه پایانِ درس یا جزیره‌ی بی‌جواب ( `403 no_hearts` ). اشتراکِ فعال یا روزهای قلبِ بسته = قلبِ نامحدود. | `progress/models.py → UserHearts` · `content/views.py` |
| قلب | ۵ قلب؛ هر جوابِ غلط یکی کم؛ هر **۵ ساعت** یکی برمی‌گردد و **۲۴ ساعت** بعد از اولین قلبِ ازدست‌رفته همه پر می‌شوند. با قلبِ صفر نه جوابی پذیرفته می‌شود نه پایانِ درس یا جزیره‌ی بی‌جواب ( `403 no_hearts` ). اشتراکِ فعال = قلبِ نامحدود. | `progress/models.py → UserHearts` · `content/views.py` |
| زنجیره (Streak) | جواب، پایانِ درس یا جزیره امروز را (به وقتِ تهران) روزِ فعال می‌کند. اگر آخرین روزِ فعال نه امروز باشد نه دیروز، صفر نشان داده می‌شود. | `progress/services.py` |
| جدولِ رتبه | هفتگی (امتیازِ ۷ روزِ اخیر) و همه‌ی زمان‌ها؛ زنده حساب و ۶۰ ثانیه کش می‌شود. | `progress/leaderboard.py` |
| پیشرفت روی نقشه | پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی ( `nextNodeId` ) باز می‌شود؛ نمره فقط ستاره و امتیاز را تعیین می‌کند (تصمیمِ محصولی: ساده بماند). | `content/services.py` |
| پیشرفت روی نقشه | پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی ( `nextNodeId` ) باز می‌شود؛ نمره فقط ستاره و امتیاز را تعیین می‌کند (تصمیمِ محصولی: ساده بماند). جزیره‌ی قفل ( `403 node_locked` ) و آزمونِ جواب‌نداده ( `409 answers_missing` ) پذیرفته نمی‌شوند. آزمونِ هر جزیره برای هر دانش‌آموز ثابت است. | `content/views.py` |
| دیوارِ پرداخت | فصلِ اولِ هر کتاب رایگان؛ بقیه با اشتراکِ فعال ( `402 subscription_required` ). | `content/views.py → unit_is_free` |
| اعلان | «جزیره را با ۳ ستاره تمام کردی و ۴۰ امتیاز گرفتی» — با رقمِ فارسی. | `notifications/services.py` |
> **قلب واقعی است** ( `HEARTS_ENFORCED` پیش‌فرض روشن): قلبِ صفر بازی را متوقف می‌کند تا قلبِ بعدی برگردد. چکِ قلب داخلِ همان تراکنشی است که قلب را کم می‌کند، پس دو جوابِ هم‌زمان با یک قلبِ باقی‌مانده هر دو پذیرفته نمی‌شوند؛ و پایانِ آزمون بی‌جواب دادن هم بسته است. کسی که آخرین قلبش را روی آخرین سوال از دست داده، جزیره را تمام می‌کند. تست‌ها: `content/tests_hearts_gate.py` .
@@ -790,7 +804,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
## ۱۲. امنیت و احرازِ هویت
سه دور بازبینیِ بدبینانه روی کلِ بک‌اند انجام شد (مرداد، ۳ مهر و ۱۰ مهر)، و یک وارسیِ نهایی روی سایتِ زنده (۷ مهر) با این سؤال: «یک دانش‌آموزِ متقلب، یک مهاجم یا یک والدِ سردرگم چطور این را می‌شکند؟» هر یافته با یک تست بسته شد. این بخش صادقانه می‌گوید چه بسته است و چه هنوز باز.
چند دور بازبینیِ بدبینانه روی کلِ بک‌اند انجام شد (مرداد، ۳، ۱۰ و ۲۱ مهر)، با وارسی روی سایت و سرورِ زنده، با این سؤال: «یک دانش‌آموزِ متقلب، یک مهاجم یا یک والدِ سردرگم چطور این را می‌شکند؟» هر یافته با یک تست بسته شد. این بخش صادقانه می‌گوید چه بسته است و چه هنوز باز.
### ۱۲-۱ هویت و توکن
@@ -806,36 +820,33 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| حوزه | چه کرده‌ایم | کجا |
|---|---|---|
| دسترسی به داده‌ی دیگران | شناسه‌ی کاربر همیشه از توکن خوانده می‌شود، هرگز از ورودی؛ هر کاربر فقط داده‌ی خودش را می‌بیند (گزارشِ والدین هم در حسابِ خودِ دانش‌آموز است). | همه‌ی `views.py` ها |
| سقفِ نرخ | روی همه‌ی نقاطِ حساس (ورود، دستیار، خرید، کدِ تخفیف، جواب…)؛ مهمان بر اساسِ IPِ واقعی ( `NUM_PROXIES=2` : آروان + nginx). | `config/settings.py` |
| پیامک و هزینه‌اش | شماره به `09XXXXXXXXX` نرمال می‌شود؛ فاصله بینِ دو درخواست، ۱۰ در روز برای هر شماره، ۳۰۰ در روز برای کلِ سامانه (ضدِ SMS pumping) — شمارش در دیتابیس، نه حافظه‌ی موقت. | `accounts/phone.py` · `tests_otp_limits.py` |
| سقفِ نرخ | روی همه‌ی نقاطِ حساس (ورود، دستیار، خرید، کدِ تخفیف، جواب…)؛ درخواستِ بی‌ورود بر اساسِ IPِ واقعی، که فقط از پروکسی‌های آروان پذیرفته می‌شود (جعلِ `X-Forwarded-For` اثری ندارد). nginx هر درخواستِ نرسیده از آروان را رد می‌کند. | `config/middleware.py → TrustedProxyMiddleware` · `ops/nginx/` |
| پیامک و هزینه‌اش | شماره به `09XXXXXXXXX` نرمال می‌شود؛ فاصله بینِ دو درخواست، ۱۰ در روز برای هر شماره، سقفِ روزانه برای کلِ سامانه (روی سرور ۲٬۰۰۰، ضدِ SMS pumping) — شمارش در دیتابیس، نه حافظه‌ی موقت. | `accounts/phone.py` · `tests_otp_limits.py` |
| پرداخت | فقط تأییدِ سرور-به-سرور؛ برگشتِ جعلی اثری ندارد؛ webhookِ عمومیِ ناامن حذف شد؛ هر درگاهی بی‌کلید «بسته» می‌ماند (fail-closed)، نه موفقیتِ ساختگی. هر سفارش فقط یک بار روز می‌دهد (قیدِ یکتا روی تراکنش)؛ وضعیتِ سفارش در ادمین فقط با actionِ لغو/بازپرداخت عوض می‌شود. | `payment/` · `subscription/` |
| تقلبِ امتیاز | امتیاز فقط برای جوابِ ثبت‌شده؛ جایزه فقط بارِ اول. | `content/views.py` |
| درخواستِ تکراری و هم‌زمانی | Idempotency-Key در شش نقطه؛ `transaction.atomic` ؛ قیدِ یکتا در دیتابیس؛ SQLite در حالتِ `IMMEDIATE` تا قفل‌ها واقعاً قفل کنند. | `config/settings.py` |
| درخواستِ تکراری و هم‌زمانی | Idempotency-Key در شش نقطه؛ `transaction.atomic` ؛ قیدِ یکتا در دیتابیس؛ قفلِ ردیفیِ Postgres. | `config/settings.py` |
| خطا و لاگ | هر خطا JSONِ یکدست بی‌جزئیاتِ داخلی؛ لاگ بدونِ بدنه، هدر، توکن یا رمز. | `config/exceptions.py` · `middleware.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` |
| حذفِ حساب | با کدِ پیامکیِ تازه (همان سقفِ حدسِ ورود)؛ سفارش‌ها برای حسابداری می‌مانند؛ توکنِ قبلی بعد از حذف کار نمی‌کند. | `accounts/views.py → DeleteAccountView` |
| مرورگر | CORS فقط برای دامنه‌ی فرانت؛ CSP و هدرهای امنیتی روی سایتِ فرانت؛ پاسخِ API با `no-store` در کشِ آروان نمی‌ماند؛ نامِ نمایشی از شماره و لینک پاک می‌شود؛ سقفِ حدسِ رمزِ ادمین برای هر IP. | `config/middleware.py` · `ops/nginx/` |
| کلیدها | همه در `.env` ِ سرور؛ کلیدِ امضای توکن روی خودِ سرور ساخته شده و در هیچ مخزنی نیست؛ `/api/health` بی‌کلیدِ داخلی فقط «ok/error» می‌گوید و Swagger فقط برای ادمینِ واردشده باز است. هیچ ورودِ آزمایشی با کدِ ثابت روی سرور نیست. | `.env.example` |
### ۱۲-۳ آنچه عمداً باز مانده (پیش از کاربرِ واقعی باید بسته شود)
### ۱۲-۳ آنچه هنوز باز است (پیش از کاربرِ عمومی باید بسته شود)
این موارد در مرحله‌ی نمایشی (دمو) برای کارِ تیم عمداً باز گذاشته شده‌اند؛ فهرستِ کامل با خطر و راهِ بستنِ هرکدام در `docs/security-before-production.md` است (۱۸ مورد).
فهرستِ کامل با خطر و راهِ بستنِ هرکدام در `docs/security-before-production.md` است؛ مهم‌ترین‌ها:
| # | مورد | خطر | راهِ بستن |
|---|---|---|---|
| ۱ | کلیدِ امضای توکن روی سرور همان کلیدِ پیش‌فرضِ داخلِ مخزن است؛ هر کس مخزن را ببیند می‌تواند توکن جعل کند. | بحرانی | حذف از `.env` تا تنظیمات خودش کلیدِ تصادفی بسازد (کد آماده است)؛ همه یک بار دوباره وارد می‌شوند. |
| ۲ | ۷ شماره‌ی دمو با کدِ ثابت روی سرورِ واقعی | بالا | پاک کردنِ `TEST_LOGIN_PHONES` |
| ۳ | داده و حساب‌های دمو و آزمایشی در دیتابیسِ واقعی | بالا | پاک‌سازی پس از بکاپ |
| ۴ | دسترسیِ سرور: حسابِ مشترک با رمز و sudoِ کامل | بالا | کلیدِ SSH و حسابِ محدود — کارِ ادمینِ سرور |
| ۵ | `/api/health`، Swagger و جدولِ رتبه بی‌ورود عمومی‌اند؛ پنلِ ادمین روی دامنه‌ی عمومی | متوسط | محدود کردن با ورود یا IP |
| ۶ | سرور بی‌آروان هم روی پورتِ ۸۰ جواب می‌دهد (امکانِ جعلِ IP برای دور زدنِ سقف) | متوسط | بستنِ پورت جز برای آروان — ادمینِ سرور |
| ۷ | کشِ مشترک (Redis) تنظیم نشده؛ سقف‌های نرخ برای هر worker جداست | متوسط | Redisِ اختصاصیِ خودمان (به Redisِ مشترکِ ماشین وصل نمی‌شویم؛ تصمیمِ باز) |
| ۸ | کدِ پیامکی و refresh token بی‌هش در دیتابیس؛ توکن در `localStorage` ِ مرورگر | کم | هش کردن؛ کوکیِ httpOnly |
| ۹ | آزمونِ جزیره قفل بودنِ جزیره را نمی‌سنجد (می‌شود جزیره‌ی جلوتر را با API تمام کرد) | کم | بررسیِ جزیره‌ی قبلی در `NodeQuizCompleteView` |
| مورد | خطر | راهِ بستن |
|---|---|---|
| سقفِ روزانه‌ی پیامکِ کلِ سامانه را یک ناشناس با شماره‌های تصادفی پر می‌کند و ورود برای همه بسته می‌شود | بالا | CAPTCHA یا مانعِ مشابه پس از یک حد؛ شمارش بر اساسِ شبکه — تصمیمِ طراحی |
| دسترسیِ سرور: حسابِ مشترک با رمز و sudoِ کامل | بالا | کلیدِ SSH و حسابِ محدود — کارِ ادمینِ سرور |
| پنلِ `/admin/` روی دامنه‌ی عمومی (با سقفِ تلاش، بی ۲FA) | متوسط | محدود به IP یا VPN، ۲FA |
| با دانستنِ شماره‌ی کسی، ورودِ او تا ۲۴ ساعت بسته می‌شود | متوسط | گره زدنِ کد به درخواست‌کننده — تصمیمِ طراحی |
| کدِ پیامکی بی‌هش در دیتابیس؛ توکن در `localStorage` ِ مرورگر | کم | هش کردن؛ کوکیِ httpOnly |
| کلیدِ جوابِ هر سوال با حسابِ دوم درمی‌آید | کم | گزینه‌ی درست فقط پس از پایانِ آزمون — تصمیمِ محصولی |
> #### جمع‌بندیِ وضعیتِ امنیت
>
> لایه‌ی برنامه بازبینی و تست شده است: دسترسی، سقفِ نرخ، پرداخت، تکرارِ درخواست و تقلبِ امتیاز بسته‌اند. چیزهایی که باز مانده عمدی و مستند است — مهم‌ترینِ آن‌ها کلیدِ امضای سرور است که کدِ رفعش آماده است و اعمالش به زمانی موکول شده که خروجِ همه‌ی کاربرها از حساب مشکلی ایجاد نکند. بخشی از موارد نیز به زیرساخت مربوط است و بر عهده‌ی ادمینِ سرور است.
> لایه‌ی برنامه بازبینی و تست شده است: دسترسی، سقفِ نرخ، جعلِ IP، پرداخت، تکرارِ درخواست و تقلبِ امتیاز بسته‌اند؛ کلیدِ امضا عوض شده، حساب‌ها و ورودهای نمایشی پاک شده‌اند و nginx فقط از آروان می‌پذیرد. آنچه مانده بیشتر تصمیمِ طراحی (پیامک و ورود) یا کارِ ادمینِ سرور است.
## ۱۳. استقرار (Deploy) و نگهداری
@@ -850,7 +861,7 @@ ssh -t administrator@<server> 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl re
1. ۱ · fetch از Gitea
2. ۲ · reset تغییرِ دستی ← patch در .deploy-backups/
3. ۳ · pip install
4. ۴ · migrate
4. ۴ · بکاپِ دیتابیس (pg_dump) و migrate
5. ۵ · seed_content محتوا
6. ۶ · collectstatic
7. ۷ · restart + اثباتِ ری‌استارت
@@ -874,11 +885,12 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
|---|---|
| `PUBLIC_BASE_URL` · `FRONTEND_URL` | دامنه‌های فعلی. همه‌ی تنظیماتِ دامنه (CORS، callbackِ زرین‌پال، …) از همین دو ساخته می‌شوند؛ جابه‌جاییِ دامنه یعنی عوض کردنِ همین دو. |
| `DJANGO_SECRET_KEY` | یک کلیدِ تصادفی ساخته و در `.django_secret_key` نگه داشته می‌شود (حالتِ مطلوب). |
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | دستیار جوابِ ثابتِ نمایشی می‌دهد. |
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | دستیار ۵۰۳ صریح می‌دهد؛ جوابِ نمایشی فقط در حالتِ توسعه. |
| `KAVENEGAR_API_KEY` · `KAVENEGAR_OTP_TEMPLATE` | درخواستِ کد ۵۰۳ صریح می‌گیرد. الگو باید `SMSVerify` بماند. |
| `ZARINPAL_MERCHANT_ID` · `BAZAAR_*` | پرداخت بسته می‌ماند (۵۰۳). |
| `CACHE_URL` | کشِ جدا برای هر worker؛ `/api/health` هشدار می‌دهد. |
| `HEARTS_ENFORCED` · `TEST_LOGIN_PHONES` | روشن (قلب واقعی؛ `0` خاموشش می‌کند) · هیچ حسابِ آزمایشی. |
| `CACHE_URL` | روی سرور به Redisِ اختصاصیِ خودمان اشاره می‌کند. بی آن کش برای هر worker جداست و `/api/health` هشدار می‌دهد. |
| `DB_NAME` · `DB_USER` · `DB_PASSWORD` · `DB_HOST` · `DB_PORT` · `DB_POOL_MAX_SIZE` | Postgres و اندازه‌ی poolِ هر worker. بی `DB_NAME` برنامه SQLite را برمی‌دارد (فقط برای محلی). |
| `HEARTS_ENFORCED` · `TEST_LOGIN_PHONES` | روشن (قلب واقعی؛ `0` خاموشش می‌کند) · خالی؛ ورودِ آزمایشی فقط با کدِ یک‌بارمصرفی که ابرکاربر در پنل می‌سازد. |
| `AI_DAILY_QUOTA_FREE` | ۲ سوالِ رایگان در روز (پیش‌فرض). |
| `DJANGO_DEBUG` | خاموش — و **هرگز روی سرور روشن نشود** (کدِ ورودِ همگانیِ `0000` را فعال می‌کند). |
@@ -888,18 +900,20 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
> #### `GET /api/health`
>
> بی‌ورود و بی‌SSH می‌گوید: دیتابیس سالم است؟ migrationِ جامانده هست؟ کش کار می‌کند؟ چند پایه/درس/سؤال؟ کدام سرویسِ بیرونی تنظیم است؟ اعتبارِ دستیار تمام شده ( `aiProvider` )؟ هشدارهای امنیتی و ترافیکِ اخیر. ۲۰۰ = سالم، ۵۰۳ = خرابیِ واقعی.
> بی‌ورود فقط «ok/error» می‌گوید. با هدرِ `X-Internal-Key`: دیتابیس سالم است؟ migrationِ جامانده هست؟ کش و poolِ اتصال؟ کپیِ زنده‌ی بکاپ عقب نیست؟ کدام سرویسِ بیرونی تنظیم است و اعتبارِ دستیار تمام شده ( `aiProvider` )؟ هشدارهای امنیتی و ترافیکِ اخیر. ۲۰۰ = سالم، ۵۰۳ = خرابیِ واقعی.
> #### بکاپ
>
> دیتابیس در git نیست و تنها نسخه‌ی داده‌ی کاربرهاست. بکاپ با `sqlite3.Connection.backup()` ِ پایتون (نه `cp` ) پیش از هر انتشارِ مهم، در `.deploy-backups/`. هنوز خودکار نیست — ارزشمندترین کارِ بعدی.
> هر تغییرِ دیتابیس لحظه‌ای به سرورِ دوم (۲۴۷) می‌رود ( `pg_receivewal` از راهِ تونلِ محدود)، با یک نسخه‌ی کاملِ روزانه؛ بازیابی تا هر لحظه از ۷ روزِ اخیر ممکن است و آزموده شده. `deploy.sh` هم پیش از هر `migrate` یک `pg_dump` می‌گیرد. دفترِ رویدادهای خرید هر ۶ ساعت به CSV و لاگِ برنامه هر شب فشرده و برای همیشه روی ۲۴۷ نگه داشته می‌شود. رویه: `docs/operations.md`.
> #### سرور مالِ ما نیست؛ ما یکی از مستأجرهاییم
>
> - مالِ ما فقط `/home/administrator/mathapp-v2` و سرویسِ `zareian-mathapp` است. `gunicorn` ِ خالی سرویسِ تیمِ دیگری است.
> - هرگز `git clean` یا «پاک کن و از نو clone کن»: `db.sqlite3`، `.env` و کلیدِ امضا در همان پوشه‌اند و در git نیستند.
> - پیش از هر کارِ پرحجم `df -h /` : دیسک حدودِ ۹۵٪ پر است و پر شدنش Gitea و ایمیلِ شرکت را هم می‌خواباند.
> - مالِ ما فقط پوشه‌های `~/mathapp-v2` و `~/mathapp_frontend_new`، سرویس‌های `zareian-*` و clusterِ Postgresِ `zareian` است. `gunicorn` ِ خالی سرویسِ تیمِ دیگری است.
> - هرگز `git clean` یا «پاک کن و از نو clone کن»: `.env`، کلیدِ امضا، لاگ‌ها و بکاپ‌ها در همان پوشه‌اند و در git نیستند.
> - nginx مشترک است: `nginx -t` و بعد `reload`، هرگز `restart`.
> - پیش از هر کارِ پرحجم `df -h /` : پر شدنِ دیسک Gitea و ایمیلِ شرکت را هم می‌خواباند.
> - `.env` فقط از راهِ جنگو خوانده شود؛ مقدارش هرگز در چت یا گزارش نیاید.
> - قواعدِ کامل برای هر کسی (یا agentی) که روی سرور کار می‌کند: `CLAUDE.md` ، بخشِ «Working on the servers».
## ۱۴. مقیاس‌پذیری و پیوستنِ توسعه‌دهنده‌ی تازه
@@ -912,7 +926,7 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
| ۱۴ اپ با مرزِ روشن | هر نفر صاحبِ چند اپ می‌شود (مثلاً یکی پرداخت و اشتراک، یکی محتوا و گیمیفیکیشن، یکی دستیار)؛ تغییرِ هر کس کمتر به فایلِ دیگری می‌خورد. |
| بی‌FK بینِ اپ‌ها | تغییرِ جدول‌های یک اپ migrationِ اپِ دیگر را نمی‌شکند؛ هر اپ بعداً قابلِ جدا شدن به سرویسِ مستقل است. |
| قراردادِ OpenAPI | فرانت و بک‌اند بی‌جلسه روی یک شکلِ داده توافق دارند؛ `tests_frontend_contract.py` تغییرِ ناخواسته را می‌گیرد. |
| ۷۷۹ تستِ خودکار | تازه‌وارد بی‌ترس تغییر می‌دهد: اگر چیزی را شکست، تست قبل از سرور خبر می‌دهد. |
| ۹۷۳ تستِ خودکار | تازه‌وارد بی‌ترس تغییر می‌دهد: اگر چیزی را شکست، تست قبل از سرور خبر می‌دهد. |
| قواعدِ یک‌جا | نرخ‌ها، دامنه‌ها و تنظیمات فقط در `config/settings.py` ؛ پیامِ خطا، لاگ و هویت یک مسیرِ مشترک دارند. |
| مستندات در مخزن | `docs/` + همین سند + Swagger + توضیحِ «چرا» کنارِ کدِ حساس. |
@@ -929,25 +943,26 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
### ۱۴-۳ مقیاسِ اجرا: کجا گلوگاه است و بعد چه
وضعِ فعلی: ۳ worker × ۳۲ نخ = ۹۶ درخواستِ هم‌زمان؛ درخواستِ عادی چند ده میلی‌ثانیه، جوابِ دستیار روی سرور میانه‌ی ۵ تا ۸ ثانیه و گاهی ۴۰ ثانیه، که تمامش یک نخ را نگه می‌دارد. در لودتست تا ۷۵ پرسشِ هم‌زمان صفحه‌های عادی صف نکشیدند و از ۱۰۰ به بعد کشیدند. توکن stateless است، پس هر تعداد نسخه‌ی دیگر از بک‌اند پشتِ nginx بی‌تغییرِ کد کار می‌کند — به شرطِ دیتابیس و کشِ مشترک.
وضعِ فعلی: ۴ worker × ۳۲ نخ؛ هر worker حداکثر ۸ اتصالِ Postgres از pool قرض می‌گیرد و حداکثر ۱۲ نخ به دستیار می‌دهد، تا رگبارِ سوال نقشه و آزمون را نخواباند. توکن stateless است، پس هر تعداد نسخه‌ی دیگر از بک‌اند پشتِ nginx بی‌تغییرِ کد کار می‌کند.
**آزمونِ بار روی خودِ سرور** (بازیکنانِ شبیه‌سازی‌شده که هر ۱ تا ۵ ثانیه یک درخواست می‌زنند): ۳۰۰ نفر ۸۸ درخواست در ثانیه با میانه‌ی ۰٫۱ ثانیه؛ ۶۰۰ نفر ۱۳۷ درخواست با میانه‌ی ۱٫۴ ثانیه و بی‌خطا؛ ۱٬۰۰۰ نفر به سقفِ ~۱۳۵ درخواست در ثانیه رسید (صدکِ ۹۵ ۱۵ ثانیه). گلوگاه CPUِ ماشینِ مشترک است، نه دیتابیس: دو سرویسِ خرابِ تیم‌های دیگر مدام ری‌استارت می‌شوند و حدودِ ۳ از ۸ هسته را می‌گیرند.
| # | گلوگاه | راه‌حلِ ساده‌ی بعدی | نیاز |
|---|---|---|---|
| ۱ | SQLite فقط یک نویسنده دارد | Postgres (همین حالا روی سرور هست)؛ تغییرِ تنظیمات + انتقالِ داده؛ حالتِ `IMMEDIATE` برداشته می‌شود و قفلِ ردیف واقعی می‌شود. | ادمینِ سرور |
| ۲ | کش و سقفِ نرخِ جدا برای هر worker | Redisِ اختصاصیِ خودمان، بعد `CACHE_URL`. Redisِ موجودِ ماشین مالِ دیگران است و وصل نمی‌شویم. | تصمیمِ باز + ساختِ Redis |
| ۳ | جوابِ دستیار نخ را نگه می‌دارد | صفِ کار (مثلاً Celery، که RabbitMQ و Redis‌اش روی سرور هست) یا ویوی async برای `/api/ai/ask`. | کد |
| ۴ | یک سرور برای همه | چند نسخه‌ی gunicorn پشتِ nginx؛ فرانت از CDNِ آروان. | زیرساخت |
| ۵ | هزینه‌ی دستیار با کاربرِ زیاد | کش، سقفِ روزانه (هست)، و سنجشِ مدلِ ارزان‌تر روی همان ابزارِ `rag_model_benchmark`. | سنجش |
| ۱ | CPUِ ماشینِ مشترک | رفعِ دو سرویسِ خرابِ دیگران، هسته‌ی بیشتر و ۸ worker (برآورد ~۲۷۰ درخواست در ثانیه) | ادمینِ سرور |
| ۲ | جوابِ دستیار نخ را نگه می‌دارد | صفِ کار یا ویوی async برای `/api/ai/ask` | کد |
| ۳ | یک سرور برای همه | چند نسخه‌ی gunicorn پشتِ nginx یا سرورِ دوم؛ فرانت از CDNِ آروان | زیرساخت |
| ۴ | هزینه‌ی دستیار با کاربرِ زیاد | کش، سقفِ روزانه (هست)، و سنجشِ مدلِ ارزان‌تر با `rag_model_benchmark` | سنجش |
بهینه‌سازی‌های انجام‌شده: رفعِ N+1 در صفحه‌ی اصلی (از ۱+N کوئری به ۳ کوئریِ ثابت)، کشِ نقشه، کشِ جدولِ رتبه (۶۰ ثانیه)، کشِ جوابِ دستیار، ادغامِ چند درخواست در یکی ( `/api/home`، `/api/user/summary`، `/api/nodes/{id}/quiz` )، و حجمِ بارِ اولِ فرانت از ۵۰۵ به ۸۵ کیلوبایت.
بهینه‌سازی‌های انجام‌شده: رفعِ N+1 در صفحه‌ی اصلی، کشِ نقشه و جدولِ رتبه، کشِ جوابِ دستیار، poolِ اتصال (پیش از آن ۱۰۰ کاربرِ هم‌زمان ۲۱٪ خطای ۵۰۰ می‌گرفتند؛ بعد صفر)، ادغامِ چند درخواست در یکی ( `/api/home`، `/api/user/summary`، `/api/nodes/{id}/quiz` )، و حجمِ بارِ اولِ فرانت از ۵۰۵ به ۸۵ کیلوبایت.
## ۱۵. تست و کیفیت
| چه | چطور |
|---|---|
| تستِ خودکارِ بک‌اند | **۷۷۹ تست**، همه سبز ( `python manage.py test` ). روی فایلِ دیتابیسِ واقعی ( `.test-db.sqlite3` ) اجرا می‌شوند تا رفتارِ قفل‌ها مثلِ سرور باشد. هر باگِ پیداشده در بازبینی‌ها یک تستِ اختصاصی دارد ( `tests_audit_fixes.py`، `tests_otp_limits.py`، `tests_hearts_gate.py`، …). هم‌زمانی با دو thread واقعی آزموده می‌شود (اعتبارِ دستیار، آخرین قلب). |
| تستِ خودکارِ بک‌اند | **۹۷۳ تست**، همه سبز ( `python manage.py test --noinput` ). روی فایلِ دیتابیس ( `.test-db.sqlite3` ) اجرا می‌شوند و روی Postgres هم سبزند. هر باگِ پیداشده در بازبینی‌ها یک تستِ اختصاصی دارد ( `tests_audit_fixes.py`، `tests_otp_limits.py`، `tests_hearts_gate.py`، …). هم‌زمانی با دو thread واقعی آزموده می‌شود (اعتبارِ دستیار، آخرین قلب). |
| قرارداد با فرانت | `content/tests_frontend_contract.py` و build با TypeScriptِ strict در فرانت. |
| تستِ روی سایتِ زنده | هر صفحه با حساب‌های نمایشی روی سرورِ واقعی؛ مرورگرِ خودکار (puppeteer) برای وارسیِ صفحه‌ها. |
| تستِ روی سایتِ زنده | با حساب‌های آزمونگر روی سرورِ واقعی؛ مرورگرِ خودکار (puppeteer) برای وارسیِ صفحه‌ها؛ آزمونِ بار با `tools/loadtest/`. |
| کیفیتِ دستیار | `rag_model_benchmark` (مسئله‌های واقعیِ کتاب از مسیرِ واقعی، با هزینه‌ی واقعی)، `ai_feedback_report` (👍/👎 به تفکیکِ نسخه‌ی پرامپت)، وارسیِ چشمیِ نمونه پیش از اعتماد به هر اجرای انبوه. |
| بی‌هزینه آزمودن | `tools/fake_openai.py` جای GapGPT می‌نشیند. |
| پایش | لاگِ JSONِ هر درخواست + `/api/health` ؛ محلی با Grafana/Loki ( `docs/docker.md` ). |
@@ -956,40 +971,40 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
## ۱۶. وضعیتِ فعلی، محدودیت‌ها و تصمیم‌های باز
### وضعیت در تاریخِ نگارشِ سند (۱۰ مهر ۱۴۰۵، روی سرورِ زنده)
### وضعیت در تاریخِ نگارشِ سند (۲۲ مهر ۱۴۰۵، روی سرورِ زنده)
| بخش | وضعیت | توضیح |
|---|---|---|
| اپ و API | زنده | هر دو روی آخرین نسخه؛ همه‌ی صفحه‌ها با حساب‌های نمایشی وارسی شد |
| محتوا | کامل | ۸ پایه؛ هیچ جزیره‌ای زیرِ ۴ سؤال نیست |
| پیامکِ ورود | کار می‌کند | کدِ واقعی به گوشی رسید (الگوی SMSVerify)؛ هر پیامک هزینه دارد |
| دستیار + شکل | کار می‌کند | کُند (~۳۰ ثانیه، از سمتِ GapGPT)؛ ~یک شکل از پنج ایراد دارد |
| اپ و API | زنده | با حساب‌های آزمونگر وارسی می‌شود؛ هنوز به روی کاربرِ عمومی باز نشده |
| محتوا | کامل برای سوم تا دوازدهم | ۱۵ کتاب؛ همه‌ی جزیره‌ها سؤال دارند؛ جزیره‌های تمرینِ آخرِ هر فصل |
| پیامکِ ورود | کار می‌کند | الگوی SMSVerify؛ هر پیامک هزینه دارد؛ سقفِ روزانه‌ی کلِ سامانه ۲٬۰۰۰ |
| دستیار + شکل | کار می‌کند | فقط ریاضی؛ ~یک شکل از پنج ایراد دارد |
| زرین‌پال | نیمه | درخواستِ پرداخت پذیرفته می‌شود؛ پرداختِ واقعی تا انتها آزموده نشده |
| کافه‌بازار | منتظرِ اپ | اتصالِ API برقرار؛ سه اشتراک در پیشخان (۱۰۳۴۲–۱۰۳۴۴) و روی پلن‌ها؛ منتظرِ ساختِ اپِ اندروید و یک خریدِ واقعی |
| اشتراک و قلب | زنده | سه اشتراک روی سایت؛ قلبِ صفر بازی را متوقف می‌کند |
| پیامِ گروهی | پایه | ذخیره و هدف‌گیری هست؛ زمان‌بندیِ خودکار نه |
| بکاپِ خودکار | ندارد | دستی پیش از هر انتشار |
| کافه‌بازار | منتظرِ APKِ تازه | سه اشتراک (۱۰۳۴۲–۱۰۳۴۴) روی پلن‌ها؛ کدِ اپ آماده ولی build و خریدِ واقعی انجام نشده |
| اشتراک و قلب | زنده | فصلِ اولِ هر کتاب رایگان؛ قلبِ صفر بازی را متوقف می‌کند |
| بکاپ | خودکار | کپیِ زنده روی سرورِ دوم، بازیابی آزموده شده |
| ظرفیت | سنجیده | ۶۰۰ بازیکنِ هم‌زمان بی‌خطا؛ سقف CPUِ ماشینِ مشترک |
| پیامِ گروهی | پایه | ذخیره و هدف‌گیری هست؛ ارسالِ خودکار نه |
### تصمیم‌هایی که با مدیریت است، نه با کد
| موضوع | چرا تصمیم لازم است |
|---|---|
| عوض کردنِ کلیدِ امضا | کد آماده؛ همه یک بار از حساب بیرون می‌افتند — زمانش را باید تعیین کرد. |
| پاک‌سازیِ داده‌ی دمو | حساب‌های آزمایشیِ شماره‌دار ۱۰ مهر پاک شدند (با بکاپ)؛ ۷ شماره‌ی نمایشی و حساب‌هایشان عمداً مانده‌اند. |
| مانع در برابرِ پر کردنِ سقفِ پیامک | CAPTCHA تجربه‌ی ورود را سخت‌تر می‌کند؛ بی‌آن یک مهاجم ورود را برای همه می‌بندد. |
| سقفِ دستیار و سهمیه‌ی رایگان | بعد از دو هفته مصرفِ واقعی بازبینی شود؛ سود در `docs/ai-cost-and-guardrails.md` حساب شده. |
| یک فصلِ رایگان برای هر کتاب | تعبیرِ ما از «یک فصل رایگان» برای پایه‌های چندکتابی؛ تأییدِ نهایی با مدیریت. |
| سیاستِ بازپرداخت | الان روزهای اشتراکِ همان سفارش پس گرفته می‌شود؛ پول از پنلِ درگاه. |
| Redis و Postgres | هماهنگی با ادمینِ سرور؛ هر دو روی همان ماشین موجودند. |
| پورتِ ۸۰ و دسترسیِ SSH | کارِ ادمینِ سرور. |
| پرداختِ درگاه داخلِ اپِ بازار | بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ خودش را محدود می‌کند؛ پیش از انتشار روشن شود. |
| CPU و دسترسیِ SSH | کارِ ادمینِ سرور. |
| پیشرفت با هر نمره | در حالِ حاضر جزیره با هر نمره‌ای تمام می‌شود؛ اگر حدِ قبولی لازم است، یک تصمیمِ آموزشی است. |
### کارِ بعدی، به ترتیبِ ارزش
1. بستنِ موارد ۱ تا ۳ِ امنیت و پاک‌سازیِ داده‌ی دمو.
2. بکاپِ خودکارِ دیتابیس، بیرون از همان سرور.
3. یک پرداختِ واقعیِ کامل با زرین‌پال.
4. Redisِ اختصاصی (`CACHE_URL`) و بعد Postgres.
1. یک پرداختِ واقعیِ کامل با زرین‌پال، و ساختِ APKِ تازه و یک خریدِ واقعی از کافه‌بازار.
2. بستنِ مواردِ باز در `docs/security-before-production.md` (پیامک، پنلِ ادمین، دسترسیِ سرور).
3. بازبینیِ سؤال‌های جزیره‌های تمرین با چشمِ معلم، به کمکِ رأی و گزارشِ دانش‌آموزان.
4. رفعِ گلوگاهِ CPU با ادمینِ سرور پیش از هر لانچِ بزرگ.
5. صفِ کار برای دستیار؛ سنجشِ شکل روی نمونه‌ی بزرگ‌تر.
6. تعریفِ سه بسته در پنلِ کافه‌بازار و اپِ اندرویدی با Poolakey.
## پیوست
@@ -1020,7 +1035,7 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt
cp .env.example .env # DJANGO_DEBUG=1 برای محلی
python manage.py migrate && python manage.py seed_content && python manage.py runserver
python manage.py test # کلِ تست‌ها
python manage.py test --noinput # کلِ تست‌ها
python manage.py spectacular --file schema.yaml # قراردادِ API
# محتوا و دستیار
@@ -1045,12 +1060,14 @@ npm run build # type-check + build در dist/
| `README.md` (هر دو مخزن) | درِ ورودی و راه‌اندازی |
| `docs/architecture.md` | تصمیم‌های معماری و دلیلشان، با جزئیات |
| `docs/operations.md` | انتشار، متغیرها، بکاپ، جابه‌جاییِ دامنه، ورودِ تیم |
| `docs/security-before-production.md` | ۱۸ موردِ امنیتیِ باز با خطر و راهِ بستن |
| `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` | افزودنِ کتابِ تازه |
| `tools/loadtest/README.md` | آزمونِ بار، روی لپ‌تاپ و روی سرورِ مشترک |
| `CLAUDE.md` | راهنمای agentهای کدنویس و قواعدِ کار روی سرورِ مشترک |
| `/api/docs/` | قراردادِ زنده و قابلِ امتحانِ API |
تدریسینو · مستندِ فنی · علیرضا زارعیان · مهرِ ۱۴۰۵ <br> عددها از کد، اسکیمای API، دیتابیس و سنجش‌های ثبت‌شده در مخزن استخراج شده‌اند.

Binary file not shown.

View File

@@ -5,7 +5,7 @@ JSON است با زمانِ هر پرسش و هر درخواستِ صفحه‌ی
| فایل | کار |
|---|---|
| `mkusers.py` | کاربرانِ آزمایشیِ غیرمهمان (شماره‌ی جعلیِ `0900…`، بی‌پیامک، بی‌سیگنال) با توکن و سه سوالِ متفاوت از بانکِ سوال برای هر کدام |
| `mkusers.py` | کاربرانِ آزمایشی (شماره‌ی جعلیِ `0900…`، بی‌پیامک، بی‌سیگنال) با توکن و سه سوالِ متفاوت از بانکِ سوال برای هر کدام |
| `loadtest.py` | N نفر هم‌زمان می‌پرسند (استریم، دقیقاً مثلِ فرانت) و B نفرِ دیگر در خانه/آزمون/خلاصه/سهمیه می‌چرخند |
| `fake_upstream.py` | جایگزینِ GapGPT و Qdrant برای آزمایشگاه: embedding، جوابِ استریم با زمانِ لاگ‌نرمال (پیش‌فرض: میانه‌ی ۵٫۲ و صدکِ ۹۰ِ ۱۳ ثانیه، از جواب‌های واقعیِ سرور) و جست‌وجوی Qdrant |
@@ -37,8 +37,10 @@ python tools/loadtest/loadtest.py http://127.0.0.1:8800 users.json 50 10 0 out.j
یعنی کارِ سرورِ ما و صفِ انتظار برای نخِ آزاد. ۵. `manage.py delete_users --ids … --yes` و پاک کردنِ
فایلِ توکن‌ها.
سقفِ ساعتیِ دستیار برای مهمان‌ها بر اساسِ IP است؛ با کاربرِ مهمان از یک ماشین بیش از ~۲۰ پرسش در
ساعت ممکن نیست، برای همین `mkusers.py` کاربرِ غیرمهمان می‌سازد.
**روی سرورِ مشترک، throttleهای پروداکشن دست نمی‌خورند.** اگر سقفِ نرخ مانع است، یک gunicornِ موقت روی سوکتِ جدا در
`~/mathapp-v2/.lt/` با `READ_THROTTLE_RATE` بالا و `DB_POOL_MAX_SIZE=4 --workers 2` بالا بیاورید (تا با ۳۲ اتصالِ
پروداکشن از ۵۷ نگذرد)، کوتاه اجرا کنید و آخرش `.lt` را پاک کنید. در اسکریپت روی آن gunicornِ پس‌زمینه `wait` نزنید:
تا ابد منتظر می‌ماند.
## بازیکن‌ها (نه دستیار): `mkplayers.py`، `play.py`، `agg.py`