make the README an entry point and move procedures into docs/

The README had grown into a 295-line mixture of onboarding, an ops runbook, a
content-authoring workflow, bulk question generation and a Docker log stack. A
reader arriving at the repo could not tell in ten seconds what this service is,
whether it is live, or where to start, and five of its claims no longer matched
the code:

- it said to export .env values by hand; settings.py has loaded .env through
  python-dotenv for a while now
- it listed six variables as "everything the app actually reads"; .env.example
  has carried many more than that for some time
- it called DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS and CACHE_URL required in
  production; all three now have working defaults, and the app boots without
  them on purpose so a partial .env cannot take the service down
- it claimed there is no insecure SECRET_KEY fallback; there is one, under DEBUG
- it required Python 3.12 while the project runs on 3.11 (Docker uses 3.12)

The README now states what the service is, where it runs, how healthy it is and
how to get it running in six commands, then links onward. Procedures moved to
docs/operations.md, docs/content-authoring.md, docs/architecture.md and
docs/docker.md, which is where someone follows a procedure rather than skims it.
Nothing was dropped; the deploy one-liner now includes the separate sudo restart
that deploy.sh cannot perform itself, and backup/restore is written down for the
first time.

Every number in the README was measured rather than quoted: content counts from
the live /api/health and content/seed/curriculum.json, 55 endpoints from the
generated OpenAPI schema, 481 tests from a full run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 01:12:04 +03:30
parent 5aa10eb7b7
commit 5492788bf8
5 changed files with 578 additions and 302 deletions

423
README.md
View File

@@ -1,331 +1,150 @@
# Tadrisino API # Tadrisino API
بک‌اند اپلیکیشن آموزشی Tadrisino — Django + Django REST Framework. بک‌اندِ اپلیکیشنِ آموزشیِ تدریسینو — یک REST API روی Django که برنامه‌ی درسی،
آزمون‌ها، پیشرفتِ کاربر، دستیارِ هوشِ مصنوعی، اشتراک و پرداخت را سرو می‌کند.
احراز هویت با OTP، محتوای درسی و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت، فرانت‌اند ریپوی جداگانه‌ای است ([`MathApp_frontend`](https://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend)).
تخفیف، اعلان‌ها، همکاری در فروش، پشتیبانی و حساب خانواده. **قراردادِ بینِ دو طرف، اسکیمای OpenAPI همین ریپوست** — نه حافظه‌ی کسی، نه یک
فایلِ دستی. هر تغییری در API باید از آنجا دیده شود.
| | |
|---|---|
| **وضعیت** | روی پروداکشن، در حالِ سرویس‌دهی |
| **آدرسِ زنده** | `https://api.math.tadrisino2.ir` · [سلامت](https://api.math.tadrisino2.ir/api/health) |
| **استک** | Python 3.11+ · Django 5.2 · DRF · SQLite · Qdrant (RAG) |
| **API** | ۵۵ اندپوینت · مستنداتِ زنده در `/api/docs/` |
| **تست** | ۴۸۱ تست، همه سبز (`python manage.py test`) |
| **محتوا** | ۸ پایه · ۵۸ فصل · ۲۶۳ نود · ۲٬۶۱۷ درس · ۲٬۴۶۳ سوال |
---
## شروعِ سریع
```bash
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 # برنامه‌ی درسی و سوال‌های واقعی از content/seed/
python manage.py runserver
```
سرویس روی `http://127.0.0.1:8000` بالا می‌آید. `.env` خودکار خوانده می‌شود —
لازم نیست چیزی را `export` کنید.
| | |
|---|---|
| Swagger UI | `/api/docs/` |
| Redoc | `/api/redoc/` |
| اسکیمای OpenAPI | `/api/schema/` |
| پنلِ ادمین | `/admin/` |
| سلامتِ سرویس | `/api/health` |
**اولین درخواست در Swagger:** `POST /api/auth/session` با
`{"type":"guest","deviceId":"d_test1"}` بزنید، `token` را بردارید، روی
**Authorize** کلیک کنید و همان رشته را (بدونِ کلمه‌ی `Bearer`) وارد کنید.
دستورهای پرکاربردِ دیگر:
```bash
python manage.py test # کلِ تست‌سوییت
python manage.py createsuperuser # دسترسی به /admin
python manage.py seed_test_data # داده‌ی ساختگی برای توسعه (فقط محلی)
```
---
## معماری
هر اپ یک مسئولیت دارد و مدل‌های خودش را نگه می‌دارد:
| اپ | مسئولیت | | اپ | مسئولیت |
|---|---| |---|---|
| `accounts` | احراز هویت، session، پروفایل کاربر | | `accounts` | هویت، session، ورود با OTP |
| `content` | پایه‌ها، مباحث، درس‌ها، سوال‌ها، پیشرفت | | `content` | برنامه‌ی درسی، درس، سوال، پیشرفت روی نقشه |
| `progress` | آمار، لیدربرد، streak | | `progress` | آمار، لیدربرد، streak، شخصیت‌های بازی |
| `ai_tutor` | مکالمات دستیار هوش مصنوعی | | `ai_tutor` · `rag` | دستیارِ هوشِ مصنوعی: بازیابی از Qdrant + تولید با GPT |
| `rag` | لایه‌ی RAG: بازیابی از Qdrant + تولید با GPT، پشتِ `POST /api/ai/ask` | | `subscription` · `payment` · `discount` | پلن، سفارش، تراکنش، کدِ تخفیف |
| `subscription` / `payment` / `discount` | پلن، سفارش، تراکنش، کد تخفیف | | `notifications` · `affiliate` · `support` · `family` · `campaigns` | امکاناتِ جانبی |
| `notifications`, `affiliate`, `support`, `family` | امکانات جانبی | | `statuslog` | لاگِ عمومیِ تغییرِ وضعیت، مستقل از بقیه |
| `statuslog` | لاگ عمومی تغییر status، مستقل از بقیه‌ی اپ‌ها | | `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` تعریف می‌شود. اندپوینتِ جدید یعنی **هر دو**.
جزئیاتِ بیشتر: [`docs/architecture.md`](docs/architecture.md).
--- ---
## ۱. راه‌اندازی محیط توسعه ## دیپلوی
### پیش‌نیازها کلِ انتشار یک خط است:
- پایتون ۳.۱۲
- pip
### تنظیم محیط
```bash
python3 -m venv venv
source venv/bin/activate # ویندوز: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
```
`.env.example` (تمام متغیرهایی که واقعاً توسط اپ خونده می‌شن):
```env
DJANGO_DEBUG=1
DJANGO_SECRET_KEY=
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
CACHE_URL=
KAVENEGAR_API_KEY=
GRAFANA_ADMIN_PASSWORD=admin
```
مقادیر `.env` رو به شل خودتون export کنید (یا از `direnv`/`python-dotenv` استفاده کنید):
```bash
export DJANGO_DEBUG=1 # ویندوز (PowerShell): $env:DJANGO_DEBUG=1
```
> `DEBUG` دیفالتش `False` است. برای فعال‌کردنِ کمک‌کننده‌های dev-only در Swagger
> (`DEV_MASTER_OTP` و فیلدِ `devOtp` توی پاسخ‌ها)، محلی `DJANGO_DEBUG=1` رو ست کنید.
### سریع‌ترین راه اجرای پروژه
```bash
python manage.py migrate
python manage.py runserver
```
اپ روی `http://127.0.0.1:8000` بالا میاد.
### مایگریشن‌ها
```bash
python manage.py migrate
```
### دیتای تستی (Seed)
یک دیتاست کوچک و idempotent می‌سازه که همه‌ی اپ‌ها رو پوشش می‌ده (کاربر، محتوا، پیشرفت،
اشتراک، پرداخت، تخفیف، دستیار هوش مصنوعی، اعلان، افیلیت، پشتیبانی، خانواده). اجرای مکرر
امن است — با `get_or_create` هیچ رکورد تکراری‌ای نمی‌سازه. پیش‌فرض فقط وقتی `DEBUG=True`
اجرا می‌شه، مگر با `--force`.
```bash ```bash
python manage.py seed_test_data # لوکال (DEBUG=True) ssh -t administrator@78.38.35.249 \
python manage.py seed_test_data --force # staging (DEBUG=False) 'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
python manage.py seed_test_data --reset # پاک‌کردنِ دیتای قبلیِ seed و ساختِ دوباره
``` ```
### مستندات API `deploy.sh` کد را می‌گیرد، وابستگی‌ها را نصب می‌کند، مهاجرت می‌زند، محتوا را
| | | seed می‌کند، فایل‌های استاتیک را جمع می‌کند و در پایان **ثابت می‌کند که سرویس
|---|---| واقعاً ری‌استارت شده** — نه اینکه فقط فرض کند. ری‌استارت جداست چون `sudo`
| Swagger UI | `http://127.0.0.1:8000/api/docs/` | داخلِ اسکریپت نمی‌تواند رمز بپرسد.
| Redoc | `http://127.0.0.1:8000/api/redoc/` |
| OpenAPI schema | `http://127.0.0.1:8000/api/schema/` |
| پنل ادمین | `http://127.0.0.1:8000/admin/` |
**شروع سریع در Swagger:** پس از هر انتشار، از بیرون و بدونِ SSH تأیید کنید:
1. `POST /api/auth/session` با `{"type":"guest","deviceId":"d_test1"}` → یک `token` بگیرید.
2. روی دکمه‌ی **Authorize** بزنید و فقط خودِ `token` رو (بدون کلمه‌ی Bearer) وارد کنید.
3. برای تست لاگین با شماره: `POST /api/auth/otp/request` بزنید، یا در محیط توسعه کد **0000**
رو با هر شماره‌ای در `POST /api/auth/login` امتحان کنید (dev-only، نیاز به `DEBUG=True`).
### دستورهای پرکاربرد
```bash ```bash
python manage.py createsuperuser # اختیاری، برای /admin curl https://api.math.tadrisino2.ir/api/health
python manage.py test # اجرای کل تست‌سوییت
``` ```
### دستیار هوش مصنوعی (RAG) `200` یعنی سالم، `503` یعنی چیزی واقعاً خراب است. هیچ سکرتی برنمی‌گرداند.
`POST /api/ai/ask` پاسخ را از روی محتوای واقعیِ کتاب می‌سازد: embedding سوال → رویه‌ی کامل — متغیرهای محیطی، ورودِ توسعه‌دهنده‌ها، بکاپ، عیب‌یابی:
جستجوی Qdrant محدود به همان درس/فصل → تولید با GPT. تیکه‌های استفاده‌شده در [`docs/operations.md`](docs/operations.md).
`usedChunks` برمی‌گردند.
- نیازمندِ `OPENAI_API_KEY` و `QDRANT_API_KEY` در `.env` (اپ `rag`؛ تنظیماتِ
`OPENAI_*` / `RAG_*` در `config/settings.py`). بدونِ کلید، همان پاسخِ stub قبلی.
- تست در Swagger: `POST /api/ai/ask` با
`{"userQuestion":"...","subject":"ریاضی","grade":"ششم","lessonNumber":2}`.
- `"stream": true` پاسخ را به‌صورت SSE توکن‌به‌توکن می‌فرستد (قراردادِ JSON بدونِ این فلگ دست‌نخورده).
- `python manage.py rag_catalog` فهرستِ زنده‌ی collectionهای Qdrant را نشان می‌دهد (فقط‌خواندنی).
- افزودنِ محتوای خودمان: `POST /api/rag/ingest/text` و `/api/rag/ingest/pdf` (داخلی، هدرِ `X-Internal-Key`).
--- ---
## ۲. راهنمای دیپلوی پروداکشن ## مستندات
### بالا آوردنِ نسخه‌ی جدید — یک دستور | سند | موضوع |
```bash
./deploy.sh
```
همین. اسکریپت به ترتیب کدِ تازه را می‌گیرد، وابستگی‌ها را نصب می‌کند، مهاجرت‌ها
را اعمال می‌کند، فایل‌های استاتیک را جمع می‌کند، سرویس را ری‌استارت می‌کند، و در
پایان **خودش بررسی می‌کند که واقعاً بالا آمده یا نه**. اگر بالا نیامد، علت‌های
رایج را چاپ می‌کند.
چرا این مهم است: قبلاً این‌ها چند کارِ دستی به ترتیبِ درست بودند و فراموش شدنِ
هر کدام اپ را می‌خواباند، با خطایی که گویا نبود. مثلاً اضافه شدنِ یک پکیجِ جدید
به `requirements.txt` بدونِ `pip install` باعثِ `ModuleNotFoundError` موقعِ بالا
آمدن می‌شود و کاربر فقط `502 Bad Gateway` می‌بیند. اسکریپت همیشه `pip install`
را اجرا می‌کند تا این حالت اصلاً پیش نیاید.
بارِ اول، نامِ سرویس را بدهید تا دفعه‌های بعد خودکار ری‌استارت شود:
```bash
TADRISINO_SERVICE=<نامِ سرویس> ./deploy.sh
```
متغیرهای اختیاری: `SKIP_PULL=1` (بدونِ git pull)، `TADRISINO_HEALTH_URL`،
`PIP_INDEX_URL` (اگر pypi.org تایم‌اوت می‌دهد).
### بررسیِ سلامت از بیرون
```bash
curl https://<host>/api/health
```
بدونِ احرازِ هویت و بدونِ SSH می‌گوید دیتابیس سالم است یا نه، مهاجرتِ
اعمال‌نشده مانده یا نه، کش کار می‌کند یا نه، و چند پایه/درس/سوال روی آن سرور
هست. `200` یعنی سالم، `503` یعنی چیزی واقعاً خراب است. هیچ سکرتی برنمی‌گرداند.
این همان چیزی‌ست که جلوی «کد رفته ولی مهاجرت اجرا نشده» را می‌گیرد — حالتی که
اپ ظاهراً بالا می‌آید و بعد زیرِ بارِ واقعی می‌شکند.
### مدیریتِ محتوا بدونِ ترمینال
هیچ محتوایی لازم نیست با دستور روی سرور گذاشته شود.
| کار | کجا |
|---|---| |---|---|
| افزودن/ویرایشِ **یک** سوال | `/admin/content/question/` → «افزودن» (انتخابِ درس با جستجو) | | [`docs/operations.md`](docs/operations.md) | دیپلوی، متغیرهای محیطی، سلامت، بکاپ، ورودِ توسعه‌دهنده‌ها |
| وارد کردنِ **فله‌ای** سوال | همان صفحه → «وارد کردنِ سوال‌ها از فایل» | | [`docs/content-authoring.md`](docs/content-authoring.md) | افزودن و ویرایشِ محتوا از پنلِ ادمین، ساختِ انبوهِ سوال |
| گرفتنِ خروجیِ سوال‌ها | چند سوال را انتخاب کنید → اکشنِ «دریافتِ فایل» | | [`docs/architecture.md`](docs/architecture.md) | تصمیم‌های معماری و دلیلشان |
| وارد کردنِ **برنامه‌ی درسی** (پایه/فصل/درس) | `/admin/content/grade/` → «وارد کردنِ برنامه‌ی درسی از فایل» | | [`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) | استخراجِ محتوا از کتاب با مدلِ ویژن و هزینه‌هایش |
خروجیِ هر کدام دقیقاً همان قالبی‌ست که ورودی‌اش می‌پذیرد، پس چرخه‌ی ---
«بگیر → ویرایش کن → برگردان» بسته است و برای گرفتنِ نمونه لازم نیست کسی
مستندات بخواند.
دو تضمین: ## محدودیت‌های شناخته‌شده
- **سوال‌ها:** فایلِ نامعتبر هیچ چیزی نمی‌نویسد — یا همه وارد می‌شوند یا هیچ‌کدام.
- **برنامه‌ی درسی:** چیزی پاک نمی‌شود. هر چه در فایل هست ساخته/به‌روز می‌شود و
هر چه نیست دست‌نخورده می‌ماند، پس یک فایلِ ناقص محتوای سرور را از بین نمی‌برد.
### ساختِ انبوهِ سوال از کتاب اینها عمداً نوشته شده‌اند تا کسی دوباره کشفشان نکند:
وقتی کتابی تازه روی Qdrant آمده و باید برایش سوال ساخته شود، مسیرِ رایگان این است: - **دیتابیس هنوز SQLite است**، نه Postgres. برای حجمِ فعلی کافی‌ست، ولی
تک‌نویسنده بودنش اولین گلوگاهِ مقیاس است.
```bash - **بکاپِ خودکار وجود ندارد.** `db.sqlite3` در گیت نیست و تنها نسخه‌ی داده‌ی
# اسکلتِ برنامه‌ی درسی از فهرستِ خودِ کتاب (بدونِ هزینه) کاربران است. روشِ بکاپِ دستی در [`docs/operations.md`](docs/operations.md)
python manage.py seed_curriculum_from_textbook --subject ریاضی --grade نهم --dry-run آمده؛ خودکار کردنش اولین کارِ ارزشمندِ بعدی است.
python manage.py seed_curriculum_from_textbook --subject ریاضی --grade نهم - **`CACHE_URL` روی سرور ست نشده**، پس محدودیتِ نرخ بینِ workerها مشترک نیست.
Redis روی همان ماشین بالاست. `/api/health` این را هشدار می‌دهد.
# جفت‌های (درس، مسئله‌ی تأییدشده) را برای سوال‌نویسی بیرون بده (بدونِ هزینه) - **`KAVENEGAR_API_KEY` تنظیم نشده**، پس `POST /api/auth/otp/request` برای
python manage.py generate_questions_from_chunks --subject ریاضی --grade نهم --export /tmp/batches شماره‌های واقعی `503` می‌دهد. راهِ ورودِ تیمِ توسعه در
``` [`docs/operations.md`](docs/operations.md) توضیح داده شده.
- **پوششِ محتوا یکدست نیست.** پایه‌های هشتم تا دوازدهم کاملند (هر نود چهار
خروجی چند فایلِ JSON است به‌علاوه‌ی یک `*-RULES.txt` که قواعدِ ساختِ سوال را سوال)؛ در پایه‌های سوم/ششم/هفتم ۳۹ نود از ۱۴۵ کمتر از چهار سوال دارند —
دارد. سوال‌ها را با آن قواعد بنویسید و از «وارد کردنِ فله‌ای سوال» در جدولِ هیچ‌کدام خالی نیست، پس روی نقشه نودِ غیرقابلِ بازی وجود ندارد.
بالا واردشان کنید. همان فرمان بدونِ `--export` کار را به مدلِ پولی می‌سپارد، - **محتوای ویدیویی وجود ندارد**، هرچند `Lesson.kind` و `video_url` می‌توانند
ولی گران‌تر است و کیفیتِ بهتری نمی‌دهد. جزئیات و هزینه‌های اندازه‌گیری‌شده در بیانش کنند.
`docs/extraction-playbook.md`. - **`DEV_MASTER_OTP` (کد `0000`) باید پشتِ `DEBUG` بماند.** روشن بودنش یعنی هر
کسی می‌تواند به‌جای هر کاربری وارد شود. `/api/health` هشدارش می‌دهد.
`--dry-run` روی هر دو فرمان کار می‌کند و پیش از هر نوشتنی نشان می‌دهد چه ساخته
خواهد شد.
### داده‌ی تستی روی سرور
`seed_test_data` فقط برای محیطِ لوکال است و **نباید** روی سروری که کاربرِ واقعی
دارد اجرا شود؛ ردیف‌هایی با پیشوندِ `[SEED]`/`[DEMO]` می‌سازد که در API دیده
می‌شوند. محتوای واقعی از مسیرِ جدولِ بالا می‌رود، نه از این دستور.
### ورودِ توسعه‌دهنده‌ها به سرور (بدونِ کاوه‌نگار)
تا وقتی `KAVENEGAR_API_KEY` ست نشده، `POST /api/auth/otp/request` خطای صریحِ
۵۰۳ می‌دهد و با `DEBUG=0` هیچ‌کس نمی‌تواند وارد API شود. برای اینکه ورودِ تیمِ
فرانت به یک نفرِ خاص وابسته نباشد، `TEST_LOGIN_PHONES` را در `.env` سرور پر
کنید:
```bash
TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
```
هر جفت یک حسابِ تست است: برای آن شماره پیامکی ارسال نمی‌شود و همان کدِ ثابت
همیشه معتبر است. هر شماره‌ی دیگری مسیرِ عادیِ OTP را می‌رود، بی‌هیچ تغییری.
```bash
curl -X POST https://api.math.tadrisino2.ir/api/auth/otp/request \
-H 'Content-Type: application/json' -d '{"phone":"09120000001"}'
curl -X POST https://api.math.tadrisino2.ir/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"phone":"09120000001","otp":"834512"}'
```
چند نکته که عمدی‌اند:
- **این با `DEV_MASTER_OTP` یکی نیست.** آن کدِ `0000` هر شماره‌ای را قبول
می‌کند و فقط با `DEBUG=1` روشن می‌شود؛ این فقط همین چند شماره را، هرکدام با
کدِ خودش.
- **کد در پاسخِ API برنمی‌گردد.** کسی که شماره را بداند هم نمی‌تواند کد را از
سرور بیرون بکشد، و سقفِ تلاشِ ورودِ هر شماره روی این‌ها هم اعمال می‌شود.
- **`/api/health` تا وقتی این متغیر پر باشد هشدار می‌دهد** (تعداد، نه شماره و
نه کد) تا روی سروری که کاربرِ واقعی دارد فراموش نشود.
- **مسیرِ مهمان دست‌نخورده است.** فرانت که امروز با
`POST /api/auth/session` و `deviceId` کار می‌کند، هیچ تغییری نمی‌بیند.
- خالی گذاشتنِ این متغیر یعنی رفتارِ قبلی، مو‌به‌مو.
### متغیرهای محیطی موردنیاز
| متغیر | لازم در پروداکشن | توضیح |
|---|---|---|
| `DJANGO_DEBUG` | باید ست‌نشده یا `0` باشه | دیفالت‌های امنیتیِ پروداکشن رو فعال می‌کنه |
| `DJANGO_SECRET_KEY` | **بله** | بدونش وقتی `DEBUG=False` است اپ اصلاً بالا نمیاد |
| `DJANGO_ALLOWED_HOSTS` | **بله** | لیستِ host ها، کاما-جدا |
| `CACHE_URL` | **بله** | آدرسِ Redis، مثلاً `redis://host:6379/0` — برای rate limitِ مشترک وقتی `DEBUG=False` لازمه |
| `KAVENEGAR_API_KEY` | **بله** | ارسال واقعیِ OTP از طریق کاوه‌نگار |
| `TEST_LOGIN_PHONES` | نه (فقط تا وقتی تیم دارد توسعه می‌دهد) | شماره‌های تستِ ورود، بخشِ بالا |
| `GRAFANA_ADMIN_PASSWORD` | فقط اگه از استکِ Grafana استفاده می‌کنید | بخشِ Docker پایین رو ببینید |
### تنظیمات پروداکشن
- `DEBUG` باید ست **نشه** (یا `0` باشه). با `DEBUG=False`:
- `SECRET_KEY` اجباریه — هیچ fallback ناامنی وجود نداره.
- `CACHE_URL` (Redis) اجباریه — `LocMemCache` فقط برای dev هست.
- `DEV_MASTER_OTP` و فیلدِ `devOtp` توی پاسخ‌ها غیرفعال می‌شن.
- برای ترافیک واقعی به‌جای SQLیت پیش‌فرض، از یک دیتابیسِ پروداکشن‌گرید استفاده کنید
(پروژه با همون SQLite شیپ می‌شه؛ عوض‌کردنِ engine یک تصمیمِ زیرساختیه، خارج از این ریپو).
- از طریق یک سرورِ WSGI/ASGI (مثلاً gunicorn) جلوی اپ سرو کنید — تنظیماتِ Docker موجود
همچنان `runserver` اجرا می‌کنه و فقط برای dev/local مستند شده (پایین رو ببینید).
### تنظیمات Docker/دیپلوی
`docker-compose.yml` موجود، اپ رو **به‌همراه** یک استکِ لاگِ متمرکز
(Loki + Grafana Alloy + Grafana) اجرا می‌کنه. این یک تنظیمِ **dev/local** هست، نه دیپلویِ
پروداکشن (سرویسِ `django` همچنان `runserver` اجرا می‌کنه، نه یک سرورِ پروداکشن).
```bash
cp .env.example .env # اختیاری — بدونش compose از دیفالت‌های dev استفاده می‌کنه
docker compose up --build
```
| سرویس | آدرس | توضیح |
|---|---|---|
| اپ Django | `http://localhost:8000` | |
| Grafana | `http://localhost:3000` | دسترسیِ anonymous admin، فقط محیطِ محلی |
| Loki | `http://localhost:3100` | ذخیره‌سازیِ لاگ، در معرضِ کاربر نیست |
| Alloy | `http://localhost:12345` | رابطِ دیباگِ اختیاری |
### ترتیبِ لازمِ بالا آمدن
Compose خودش این رو مدیریت می‌کنه:
1. اول `loki` بالا میاد.
2. `alloy` و `grafana` بعد از `loki` بالا میان (`depends_on`).
3. `django` هیچ وابستگی‌ای به استکِ لاگ نداره — خودش `migrate` بعد `runserver` رو
اجرا می‌کنه و منتظرِ Loki/Alloy/Grafana نمی‌مونه.
### دستورهای manage.py داخلِ Docker
اگه فقط از طریقِ Docker اجرا می‌کنید (بدونِ venv محلی)، `python manage.py ...` رو مستقیم
روی سیستم خودتون اجرا نکنید — باید داخلِ کانتینرِ `django` اجرا بشه:
```bash
docker compose exec django python manage.py createsuperuser
docker compose exec django python manage.py seed_test_data
docker compose exec django python manage.py test
```
(`migrate` نیازی به این نیست — طبقِ بالا، خودکار قبل از `runserver` اجرا می‌شه.)
### مایگریشن‌ها
```bash
python manage.py migrate --noinput
```
در Docker، این به‌صورت خودکار به‌عنوان بخشی از دستورِ سرویسِ `django`، قبل از `runserver` اجرا می‌شه.
### راهنمای seed برای staging/تست
- هرگز `seed_test_data` رو روی یک دیتابیسِ پروداکشنِ واقعی اجرا نکنید.
- روی staging (`DEBUG=False`)، صریحاً با `--force` اجرا کنید:
```bash
python manage.py seed_test_data --force
```
- برای پاک‌کردن و ساختِ دوباره‌ی دیتای seed بین چرخه‌های تست، از `--reset` استفاده کنید.
همه‌ی رکوردهای seed‌شده در فیلدهای متنیِ قابل‌مشاهده با تگِ `[SEED]` مشخص شدن، برای
شناساییِ راحت‌تر و پاک‌سازی.
### چطور API دیپلوی‌شده رو verify کنیم
```bash
curl -i https://<host>/api/schema/ # باید 200 برگردونه (OpenAPI schema)
curl -i https://<host>/api/docs/ # باید 200 برگردونه (Swagger UI)
curl -i -X POST https://<host>/api/auth/session \
-H "Content-Type: application/json" \
-d '{"type":"guest","deviceId":"verify-1"}' # باید یک token برگردونه
```
### نکات مهمِ پروداکشن
- `DEV_MASTER_OTP` (کد `0000`) و فیلدِ `devOtp` توی پاسخ‌ها فقط وقتی `DEBUG=True` وجود
دارن — قبل از رفتن روی محیطِ واقعی مطمئن بشید `DEBUG` خاموشه.
- احراز هویت با Bearer token امضاشده و stateless (`django.core.signing`)، هماهنگ با مدل
`Session` — بدون کتابخانه‌ی JWT خارجی.
- `accounts.User` یک مدل بیزنسیِ مستقله، **نه** `AUTH_USER_MODEL` جنگو.
- همه‌ی لاگ‌های ساختاریافته‌ی درخواست (`method`/`path`/`status`/`duration`/`user`، بدونِ
بدنه/هدر/سکرت) روی stdout و — وقتی `LOG_DIR` ست باشه (مثلِ داخلِ Docker) — در
`/var/log/django/app.log` نوشته می‌شن؛ قابلِ مشاهده در Grafana از طریقِ استکِ بالا.
- `POST /api/ai/ask` با تنظیمِ `OPENAI_API_KEY`/`QDRANT_API_KEY` پاسخِ واقعیِ RAG می‌دهد
(اپ `rag`)؛ بدونِ کلید همان stub. جزئیات: بخشِ «دستیار هوش مصنوعی (RAG)» بالاتر.
- ارسالِ OTP از طریقِ کاوه‌نگار (`KAVENEGAR_API_KEY`) انجام می‌شه؛ شکستِ ارسال به‌جای
موفقیتِ ساختگی، خطای صریحِ `503` برمی‌گردونه.
- هر خطای پیش‌بینی‌نشده (نه فقط خطاهای شناخته‌شده‌ی API) همیشه پاسخِ JSON برمی‌گردونه،
نه صفحه‌ی HTML پیش‌فرضِ جنگو، و کامل با traceback لاگ می‌شه (`config/exceptions.py`).

98
docs/architecture.md Normal file
View File

@@ -0,0 +1,98 @@
# معماری
تصمیم‌هایی که از خواندنِ کد واضح نیستند و دلیلشان.
## احرازِ هویت، سیستمِ خودمان است
`accounts.User` عمداً `AUTH_USER_MODEL` **نیست** — یک مدلِ بیزنسیِ مستقل است.
ورود با توکنِ bearerِ امضاشده و stateless انجام می‌شود (`django.core.signing`،
بدونِ کتابخانه‌ی JWT)، که در `accounts/authentication.py` صادر و تأیید می‌شود و
پشتوانه‌اش مدلِ `accounts.Session` است.
هدرِ نبود یا نامعتبر یعنی **درخواستِ ناشناس، نه خطا**. هر ویو با
`permission_classes` خودش تصمیم می‌گیرد که آیا ورود لازم است — یعنی یک
اندپوینتِ عمومی و یک اندپوینتِ خصوصی از یک مسیرِ احراز هویت رد می‌شوند و
اختلافشان فقط در یک جاست.
`DJANGO_SECRET_KEY` همین توکن‌ها را امضا می‌کند. عوض شدنش یعنی **باطل شدنِ
همه‌ی نشست‌های فعال**.
## اپ‌ها با قرارداد جدا شده‌اند، نه با ForeignKey
هر اپ یک مسئولیت دارد و مدل‌های خودش را نگه می‌دارد. ارجاعِ بینِ اپ‌ها با
`user_id` یا `object_id` + `label` انجام می‌شود، نه با FK واقعی.
دلیلش این است که هر اپ بعداً قابلِ جدا شدن بماند. **FK بینِ اپ‌ها اضافه
نکنید** — الگوی ارجاعِ شل را دنبال کنید.
## محدودیتِ نرخ در یک جا تعریف می‌شود
هر اندپوینتِ حساس یک `throttle_scope` دارد و نرخش در
`REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` داخلِ `config/settings.py` است.
اندپوینتِ جدیدِ حساس یعنی **هر دو**: یک `throttle_scope` روی ویو و یک ردیفِ
متناظر در تنظیمات.
کاربرانِ مهمان با IP محدود می‌شوند. `NUM_PROXIES` تعیین می‌کند چند hop از
`X-Forwarded-For` قابلِ اعتماد است و **باید با تعدادِ واقعیِ پروکسی‌ها یکی
باشد**، وگرنه جعلِ IP و دور زدنِ محدودیت ممکن می‌شود.
## پرداخت: هرگز مستقیم به درگاه
`create_order` هیچ‌وقت زرین‌پال را صدا نمی‌زند. `paymentUrl` به اندپوینتِ خودمان
اشاره می‌کند (`GET /api/payment/orders/{id}/pay`)، که در اولین بازدید
PaymentRequest را می‌زند، `Order.gateway_authority` را ذخیره می‌کند و به StartPay
می‌فرستد. **این کار ساختِ سفارش را به در دسترس بودنِ زرین‌پال وابسته نمی‌کند.**
بازگشتِ کاربر به `GET /api/payment/zarinpal/callback` می‌رسد، که **همیشه**
سرور-به-سرور با `payment/zarinpal.py::verify_payment` دوباره تأیید می‌کند. یک
`Status=OK` جعلی در query string به‌تنهایی هیچ کاری نمی‌کند.
هم `payment/zarinpal.py` و هم `payment/bazaar.py` وقتی اعتبارنامه ندارند
**fail-closed** می‌شوند (`503` یا نامعتبر)، نه موفقیتِ ساختگی. همین قاعده برای
پیامک هم برقرار است.
## دستیارِ هوشِ مصنوعی (RAG)
`POST /api/ai/ask` پاسخ را از محتوای واقعیِ کتاب می‌سازد: embedding سوال →
جستجوی Qdrant محدود به همان درس/فصل → تولید با GPT. تکه‌های استفاده‌شده در
`usedChunks` برمی‌گردند، پس پاسخ قابلِ ردیابی به منبع است.
- نیازمندِ `OPENAI_API_KEY` و `QDRANT_API_KEY`. بدونِ کلید، همان پاسخِ stubِ
قبلی برمی‌گردد (سازگاریِ عقب‌رو).
- `"stream": true` پاسخ را به‌صورتِ SSE توکن‌به‌توکن می‌فرستد؛ قراردادِ JSON
بدونِ این فلگ دست‌نخورده است.
- `python manage.py rag_catalog` فهرستِ زنده‌ی collectionهای Qdrant را نشان
می‌دهد (فقط‌خواندنی).
- افزودنِ محتوای خودمان: `POST /api/rag/ingest/text` و `/api/rag/ingest/pdf`
(داخلی، با هدرِ `X-Internal-Key`).
کیفیتِ محتوایی که این لایه می‌خواند در
[`content-authoring.md`](content-authoring.md) توضیح داده شده.
## ساختِ آزمون
اپ در سطحِ **نود** بازی می‌شود (جزیره روی نقشه)، ولی سوال‌ها به **درس** وصلند —
و درس اینجا ریزترین دانه است: یک نوعِ مسئله از کتاب، نه یک جلسه‌ی کلاسی.
`GET /api/nodes/{id}/quiz` آزمون را از درس‌های یک نود می‌سازد، **حداکثر یک سوال
از هر درس**. ترتیب با `nodeId:userId` seed می‌شود، پس رفرش همان آزمون را
برمی‌گرداند. `questionCount` جدا از `requested` گزارش می‌شود تا یک نودِ کم‌سوال
شبیهِ خطا به نظر نرسد.
## دیتابیس
هنوز SQLite است (`OPTIONS.timeout=20` تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند).
مهاجرت به Postgres انجام نشده.
⚠️ **`.iterator()` را دورِ حلقه‌ای که می‌نویسد یا I/O بیرونی می‌زند نگذارید.**
کرسرِ خواندن در تمامِ طولِ حلقه باز می‌ماند و روی SQLite جدول را برای هر
نویسنده‌ی هم‌زمان قفل می‌کند — قفلی که تا پایانِ حلقه رها نمی‌شود و هیچ
retryای نجاتش نمی‌دهد. این یک بارْ ماه‌ها به‌شکلِ یک تستِ «flaky» ظاهر شد؛
باگِ واقعی بود، نه تصادفِ زمان‌بندی. به‌جایش نتیجه را با `list(...)` مادی کنید.
## لاگِ ساختاریافته
`config.middleware.APIRequestLoggingMiddleware` (آخرین middleware، نزدیک‌ترین
به ویو) هر درخواستِ `/api/` را به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌کند:
متد، مسیر، وضعیت، مدت، کاربر — **بدونِ بدنه، هدر یا سکرت**
(`config/log_formatters.py`).

118
docs/content-authoring.md Normal file
View File

@@ -0,0 +1,118 @@
# ساختن و ویرایشِ محتوا
هدفِ این مسیر یک جمله است: **محتوا باید بدونِ توسعه‌دهنده قابلِ ویرایش باشد.**
اجرای یک management command روی SSH آخرین راهِ چاره است، نه روالِ کار.
## محتوا کجا زندگی می‌کند
`content/seed/` کلِ برنامه‌ی درسی و بانکِ سوال را به‌صورتِ JSONِ کامیت‌شده نگه
می‌دارد:
| فایل | محتوا |
|---|---|
| `curriculum.json` | ۸ پایه، ۵۸ فصل، ۲۶۳ نود، ۲٬۶۱۷ درس |
| `questions.json` | ۲٬۴۶۳ سوالِ چندگزینه‌ای |
`deploy.sh` در هر انتشار `seed_content` را اجرا می‌کند، پس یک سرورِ تازه خودش
را با یک فرمان پر می‌کند — **بدونِ Qdrant، بدونِ اعتبارِ API، بدونِ نشستِ SSH.**
> **کد و محتوا دو مسیرِ جدا دارند.** `db.sqlite3` در gitignore است. سوالی که
> فقط در دیتابیسِ محلیِ شماست به سرور نمی‌رسد مگر از راهِ
> `seed_content --export --only questions` و کامیتِ `content/seed/questions.json`.
> پوش کردنِ کد به‌تنهایی محتوا را نمی‌برد.
دو رفتارِ `seed_content` که دانستنشان لازم است:
- **idempotent است و هرگز چیزی پاک نمی‌کند.** هر چه در فایل هست ساخته یا
به‌روز می‌شود؛ هر چه نیست دست‌نخورده می‌ماند. پس یک فایلِ ناقص محتوای سرور را
از بین نمی‌برد.
- **سوال‌ها با مسیرِ عنوان به درس وصل می‌شوند** (پایه › فصل › نود › درس)، نه با
کلیدِ اصلی. شناسه‌ها بینِ هر دو دیتابیس فرق دارند و اتصال با id، سوال‌ها را
بی‌صدا به درسِ اشتباه می‌چسباند.
`seed_content --export` فایل‌ها را از دیتابیسِ محلی بازنویسی می‌کند. اگر
دیتابیس سوالی نداشته باشد از بازنویسیِ یک `questions.json` غیرخالی خودداری
می‌کند (این قبلاً فایل را بی‌صدا پاک می‌کرد)؛ وقتی فقط برنامه‌ی درسی را
می‌خواهید `--export --only curriculum` بزنید.
## از پنلِ ادمین (روالِ عادی)
| کار | کجا |
|---|---|
| افزودن یا ویرایشِ **یک** سوال | `/admin/content/question/` → «افزودن» (انتخابِ درس با جستجو) |
| وارد کردنِ **فله‌ای** سوال | همان صفحه → «وارد کردنِ سوال‌ها از فایل» |
| گرفتنِ خروجیِ سوال‌ها | چند سوال را انتخاب کنید → اکشنِ «دریافتِ فایل» |
| وارد کردنِ **برنامه‌ی درسی** | `/admin/content/grade/` → «وارد کردنِ برنامه‌ی درسی از فایل» |
| گرفتنِ خروجیِ برنامه‌ی درسی | پایه‌ها را انتخاب کنید → اکشنِ «دریافتِ فایلِ برنامه‌ی درسی» |
خروجیِ هر کدام **دقیقاً** همان قالبی‌ست که ورودی‌اش می‌پذیرد، پس چرخه‌ی
«بگیر → ویرایش کن → برگردان» بسته است و برای دیدنِ یک نمونه لازم نیست کسی
مستندات بخواند.
دو تضمین:
- **سوال‌ها:** فایلِ نامعتبر هیچ چیزی نمی‌نویسد — یا همه وارد می‌شوند یا هیچ‌کدام.
- **برنامه‌ی درسی:** چیزی پاک نمی‌شود، به همان دلیلِ بالا.
## ساختِ انبوهِ سوال از یک کتابِ تازه
وقتی کتابی تازه روی Qdrant آمده، مسیرِ رایگان این است:
```bash
# اسکلتِ برنامه‌ی درسی از فهرستِ خودِ کتاب (بدونِ هزینه)
python manage.py seed_curriculum_from_textbook --subject ریاضی --grade نهم --dry-run
python manage.py seed_curriculum_from_textbook --subject ریاضی --grade نهم
# جفت‌های (درس، مسئله‌ی تأییدشده) را برای سوال‌نویسی بیرون بده (بدونِ هزینه)
python manage.py generate_questions_from_chunks --subject ریاضی --grade نهم \
--export /tmp/batches
```
`--dry-run` روی هر دو کار می‌کند و پیش از هر نوشتنی نشان می‌دهد چه ساخته خواهد
شد.
خروجی چند فایلِ JSON است به‌همراهِ یک `*-RULES.txt` که **عینِ همان پرامپتی** را
دارد که به مدلِ پولی می‌رفت — تا کیفیتِ دو مسیر از هم جدا نیفتد. سوال‌ها را با
آن قواعد بنویسید و از «وارد کردنِ فله‌ای سوال» در جدولِ بالا واردشان کنید.
همان فرمان بدونِ `--export` کار را به مدلِ پولی می‌سپارد. **این کار را نکنید
مگر دلیلِ خاصی داشته باشید**: اندازه‌گیری‌شده، گران‌تر است و کیفیتِ بهتری
نمی‌دهد. جزئیات و هزینه‌های واقعی در [`extraction-playbook.md`](extraction-playbook.md).
## کیفیتِ محتوا
کتاب‌ها با یک مدلِ **ویژن از تصویرِ صفحه** خوانده می‌شوند، پس خطای استخراج از
همان‌جا وارد می‌شود: رقمِ بدخوانده‌شده، جداکننده‌ی اعشارِ افتاده، راه‌حلی که به
مسئله‌ی بغلی چسبیده. این‌ها در بازیابی دیده نمی‌شوند و فقط وقتی لو می‌روند که
چیزی به متن اعتماد کند.
`rag_verify_solutions` هر مسئله‌ی استخراج‌شده را مستقلاً دوباره حل می‌کند و
نتیجه را مقایسه می‌کند. `LessonChunk.solution_verdict` سه‌حالته است —
`agrees` / `disagrees` / `unjudgeable`. **ساختِ سوال فقط از chunkهای `agrees`
می‌خواند.**
دو درسی که گران تمام شدند و حالا در پرامپت‌ها کدگذاری شده‌اند:
- **رأیِ دوحالته غلط است.** با دو حالت، هر «نمی‌توانم قضاوت کنم» تبدیل به
«غلط» می‌شود و نرخِ خطا را حدودِ سه برابرِ واقعیت گزارش می‌کند. پیش از
اعتماد به هر اجرای انبوه، چند نمونه را با چشم بخوانید.
- `۳/۱۰۴` در این کتاب‌ها عددِ اعشاریِ ۳٫۱۰۴ است، نه کسرِ ۳ تقسیم بر ۱۰۴.
خودِ `solution` هرگز بازنویسی نمی‌شود — متنِ استخراج‌شده به‌عنوانِ سند می‌ماند و
پاسخِ مستقل جداگانه در `checked_answer` ذخیره می‌شود.
`solution_verdict='agrees'` تضمینِ کامل نیست، فقط فیلترِ اول: چند بار خطای
حسابِ خودِ کتاب در همین مرحله گرفته شده است.
## داده‌ی ساختگی برای توسعه
`seed_test_data` یک دیتاستِ کوچک و idempotent می‌سازد که همه‌ی اپ‌ها را پوشش
می‌دهد. **هرگز روی سروری که کاربرِ واقعی دارد اجرا نشود** — ردیف‌هایی با
پیشوندِ `[SEED]`/`[DEMO]` می‌سازد که در API دیده می‌شوند. محتوای واقعی از
مسیرِ بالا می‌رود، نه از این فرمان.
```bash
python manage.py seed_test_data # فقط با DEBUG=True اجرا می‌شود
python manage.py seed_test_data --force # staging، با DEBUG=False
python manage.py seed_test_data --reset # پاک کردنِ داده‌ی seedِ قبلی و ساختِ دوباره
```

43
docs/docker.md Normal file
View File

@@ -0,0 +1,43 @@
# استکِ محلیِ Docker
`docker-compose.yml` اپ را **به‌همراه** یک استکِ لاگِ متمرکز اجرا می‌کند
(Loki + Grafana Alloy + Grafana).
> این یک تنظیمِ **توسعه/محلی** است، نه دیپلویِ پروداکشن: سرویسِ `django`
> همچنان `runserver` را اجرا می‌کند، نه یک سرورِ WSGI. پروداکشنِ واقعی روی
> `gunicorn` با systemd بالا می‌آید — [`operations.md`](operations.md) را
> ببینید.
```bash
cp .env.example .env # اختیاری — بدونش از پیش‌فرض‌های dev استفاده می‌کند
docker compose up --build
```
| سرویس | آدرس | توضیح |
|---|---|---|
| اپ Django | `http://localhost:8000` | |
| Grafana | `http://localhost:3000` | دسترسیِ anonymous، فقط محیطِ محلی |
| Loki | `http://localhost:3100` | ذخیره‌سازیِ لاگ، در معرضِ کاربر نیست |
| Alloy | `http://localhost:12345` | رابطِ دیباگِ اختیاری |
## ترتیبِ بالا آمدن
Compose خودش مدیریتش می‌کند:
1. اول `loki`.
2. `alloy` و `grafana` بعد از آن (`depends_on`).
3. `django` هیچ وابستگی‌ای به استکِ لاگ ندارد — خودش `migrate` و بعد
`runserver` را اجرا می‌کند و منتظرِ هیچ‌کدام نمی‌ماند.
## اجرای فرمان‌ها داخلِ کانتینر
اگر فقط از Docker استفاده می‌کنید (بدونِ venv محلی)، `python manage.py ...` را
روی سیستمِ خودتان اجرا نکنید — باید داخلِ کانتینرِ `django` اجرا شود:
```bash
docker compose exec django python manage.py createsuperuser
docker compose exec django python manage.py seed_test_data
docker compose exec django python manage.py test
```
`migrate` استثناست: طبقِ بالا خودکار پیش از `runserver` اجرا می‌شود.

198
docs/operations.md Normal file
View File

@@ -0,0 +1,198 @@
# عملیات
دیپلوی، پیکربندی، سلامت و بازیابی. برای شروعِ توسعه‌ی محلی `README.md` را
ببینید.
## دیپلوی
```bash
ssh -t administrator@78.38.35.249 \
'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
```
دو بار رمز می‌پرسد — یک‌بار برای `ssh` و یک‌بار برای `sudo`. **منتظرِ هر پرامپت
بمانید**؛ چسباندنِ چند خط با هم، متنِ صف‌شده را به پرامپتِ رمز می‌دهد و بعد از
سه تلاش قفل می‌شوید.
`deploy.sh` به ترتیب: fetch، برگرداندنِ درخت به `origin/<branch>`، نصبِ
وابستگی‌ها، `migrate`، `seed_content`، `collectstatic`، ری‌استارت، و در پایان
poll کردنِ `/api/health`.
دو رفتارش را بدانید:
- **سرور را تعمیر می‌کند، جلویش نمی‌ایستد.** فایلِ دستی‌ویرایش‌شده قبلاً
`git pull --ff-only` را می‌شکست و چون بقیه‌ی مراحل رد می‌شدند، سرور بی‌صدا
روی کدِ قدیمی می‌ماند. حالا تغییراتِ محلی در `.deploy-backups/` به‌صورتِ
patch ذخیره و بعد با `git reset --hard` دور ریخته می‌شوند، و کار ادامه
می‌یابد. هر اجرای کامل، سرور را دقیقاً روی همان چیزی می‌گذارد که در Gitea است.
- **ری‌استارت را اثبات می‌کند.** زمانِ `ActiveEnterTimestamp` را قبل و بعد
مقایسه می‌کند و اگر عوض نشده باشد با کدِ غیرِصفر خارج می‌شود. قبلاً فقط
آدرسِ عمومی را چک می‌کرد و **پروسه‌ی قدیمی** همان `200` را می‌داد — سرور دو
روز کدِ قدیمی سرو کرد در حالی که همه‌ی فایل‌ها روی دیسک تازه بودند.
> **فایل روی دیسک، فایلِ دیپلوی‌شده نیست.** قبل از باور کردنِ هر چیزی:
> `systemctl show zareian-mathapp -p ActiveEnterTimestamp`
متغیرهای اختیاری: `SKIP_PULL=1`، `TADRISINO_SERVICE`، `TADRISINO_HEALTH_URL`،
`PIP_INDEX_URL` (وقتی pypi.org تایم‌اوت می‌دهد).
## بررسیِ سلامت
```bash
curl https://api.math.tadrisino2.ir/api/health
```
بدونِ احرازِ هویت و بدونِ SSH می‌گوید: دیتابیس سالم است یا نه، مهاجرتِ
اعمال‌نشده مانده یا نه، کش کار می‌کند یا نه، چند پایه/درس/سوال روی آن سرور
هست، کدام سرویسِ بیرونی تنظیم شده (فقط بله/خیر)، و چه هشدارهای امنیتی‌ای
فعال‌اند. `200` سالم، `503` خرابیِ واقعی. **هیچ سکرتی برنمی‌گرداند.**
مهم‌ترین بررسی‌اش «مهاجرتِ اعمال‌نشده» است: شایع‌ترین حالتِ دیپلویِ خراب این
است که کد رفته ولی `migrate` اجرا نشده — اپ بالا می‌آید و بعد زیرِ بارِ واقعی
با خطای ستونِ ناموجود می‌شکند.
تأییدِ سریعِ اینکه API واقعاً جواب می‌دهد:
```bash
curl -i https://api.math.tadrisino2.ir/api/schema/
curl -i -X POST https://api.math.tadrisino2.ir/api/auth/session \
-H 'Content-Type: application/json' \
-d '{"type":"guest","deviceId":"verify-1"}'
```
## بکاپ و بازیابی
`db.sqlite3` در گیت **نیست** و تنها نسخه‌ی داده‌ی کاربران است. `deploy.sh`
پیش از `migrate` بکاپ نمی‌گیرد؛ تا وقتی این خودکار نشده، دستی بگیرید:
```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://api.math.tadrisino2.ir/api/health
```
## متغیرهای محیطی
`.env` خودکار خوانده می‌شود (`python-dotenv`) و مقادیرش **با پیش‌فرض‌های کد
ادغام می‌شوند، نه جایگزینشان**. پس یک `.env` ناقص روی سرور دیگر باعثِ بالا
نیامدن نمی‌شود. `.env.example` توضیحِ کاملِ هر کدام را دارد.
| متغیر | اگر ست نشود چه می‌شود |
|---|---|
| `DJANGO_DEBUG` | خاموش (`0`) — که حالتِ درستِ پروداکشن است |
| `DJANGO_SECRET_KEY` | یک کلیدِ تصادفیِ قوی ساخته و در `.django_secret_key` (مجوز `0600`) ذخیره می‌شود |
| `DJANGO_ALLOWED_HOSTS` | دامنه‌های خودمان پیش‌فرض‌اند |
| `CSRF_TRUSTED_ORIGINS` · `CORS_ALLOWED_ORIGINS` · `PUBLIC_BASE_URL` | پیش‌فرضِ درست دارند |
| `CACHE_URL` | سرویس بالا می‌آید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار می‌دهد |
| `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `503` می‌دهد، نه موفقیتِ ساختگی |
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed می‌ماند (`503`) |
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را می‌دهد |
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |
> **`DJANGO_SECRET_KEY` توکن‌های ورود را امضا می‌کند.** بهترین حالت این است که
> اصلاً ست نشود تا خودِ تنظیمات یکی بسازد — آن‌وقت کلیدی وجود ندارد که لو
> برود. **هرگز نگذارید سرور روی fallbackِ `django-insecure-…` کار کند**: آن
> رشته در همین ریپو کامیت شده، پس هر کسی که ریپو را بخواند می‌تواند به‌جای هر
> کاربری توکن جعل کند. `manage.py check --deploy` با کدِ `security.W009`
> علامتش می‌زند.
> **`DJANGO_DEBUG` هرگز روی سرور `1` نشود.** `DEV_MASTER_OTP` (کد `0000`) را
> روشن می‌کند که هر شماره‌ای را به‌عنوانِ ورودِ معتبر می‌پذیرد، و صفحه‌های خطا
> کلِ تنظیمات را نشان می‌دهند. جایش فقط `.env` است، نه یونیتِ systemd.
## ورودِ توسعه‌دهنده‌ها (بدونِ سرویسِ پیامک)
تا وقتی `KAVENEGAR_API_KEY` ست نشده، با `DEBUG=0` هیچ‌کس نمی‌تواند با شماره
وارد شود. برای اینکه ورودِ تیم به یک نفرِ خاص وابسته نباشد، چند شماره‌ی تست با
کدِ ثابت تعریف کنید:
```bash
TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
```
برای این شماره‌ها پیامکی ارسال نمی‌شود و همان کد همیشه معتبر است. هر شماره‌ی
دیگری مسیرِ عادیِ OTP را می‌رود، بی‌هیچ تغییری.
```bash
curl -X POST https://api.math.tadrisino2.ir/api/auth/otp/request \
-H 'Content-Type: application/json' -d '{"phone":"09120000001"}'
curl -X POST https://api.math.tadrisino2.ir/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"phone":"09120000001","otp":"834512"}'
```
این همان الگویی‌ست که Firebase Auth (با نامِ fictional phone numbers) و Apple
(حسابِ دمو برای بررسیِ App Store) برای همین مسئله دارند.
- **با `DEV_MASTER_OTP` یکی نیست.** آن کدِ `0000` **هر** شماره‌ای را می‌پذیرد و
فقط با `DEBUG=1` روشن می‌شود. این فقط همین چند شماره را، هرکدام با کدِ خودش.
- **کد در پاسخِ API برنمی‌گردد**، پس دانستنِ شماره برای ورود کافی نیست. سقفِ
تلاشِ ورودِ هر شماره روی این‌ها هم اعمال می‌شود، پس حدسِ کد بسته است.
- **مقدارِ نامعتبر سرویس را بالا نمی‌آورد** و پیامِ صریح می‌دهد — بی‌صدا رد
نمی‌شود. کد باید ۴ تا ۶ رقم باشد (`LoginRequestSerializer` سقفِ ۶ کاراکتر
دارد).
- **`/api/health` تا وقتی پر باشد هشدار می‌دهد** — فقط تعداد، نه شماره و نه کد.
- شماره را **دقیقاً** همان‌طور بنویسید که کلاینت می‌فرستد؛ این کدبیس شماره را
normalize نمی‌کند، پس `09121111111` و `+989121111111` دو کاربرِ جدا هستند.
- کدِ تصادفی بگذارید، نه `1234`، و پیش از باز شدنِ سرویس روی کاربرانِ واقعی
خالی‌اش کنید.
## سرور
`api.math.tadrisino2.ir` روی یک **ماشینِ مشترکِ شرکت** اجرا می‌شود
(`administrator@78.38.35.249`)، نه سروری که مالِ این پروژه باشد. روی همان ماشین
Gitea، سرویسِ ایمیل، DNS، چند دیتابیس و اپ‌های تیم‌های دیگر در حالِ کارند.
مالِ ما **دقیقاً یک دایرکتوری** است: `/home/administrator/mathapp-v2`، که با
`gunicorn-mathapp.service` سرو می‌شود. همان یونیت با نامِ `zareian-mathapp` هم
پاسخ می‌دهد و **همیشه همین نام را بنویسید**:
```bash
sudo systemctl restart zareian-mathapp # مالِ ما
sudo systemctl restart gunicorn # مالِ ما نیست — اپِ تیمِ دیگر
```
قواعدی که قابلِ مذاکره نیستند:
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 را هم با ما پایین می‌کشد.
## لاگ‌ها
هر درخواستِ `/api/` به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌شود
(`method`/`path`/`status`/`duration`/`user` — بدونِ بدنه، هدر یا سکرت) توسطِ
`config.middleware.APIRequestLoggingMiddleware`. خروجی روی stdout است و اگر
`LOG_DIR` ست باشد در `app.log` هم نوشته می‌شود.
هر خطای پیش‌بینی‌نشده همیشه پاسخِ JSON برمی‌گرداند — نه صفحه‌ی HTML پیش‌فرضِ
جنگو — و کامل با traceback لاگ می‌شود (`config/exceptions.py`).