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:
423
README.md
423
README.md
@@ -1,331 +1,150 @@
|
||||
# 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، پروفایل کاربر |
|
||||
| `content` | پایهها، مباحث، درسها، سوالها، پیشرفت |
|
||||
| `progress` | آمار، لیدربرد، streak |
|
||||
| `ai_tutor` | مکالمات دستیار هوش مصنوعی |
|
||||
| `rag` | لایهی RAG: بازیابی از Qdrant + تولید با GPT، پشتِ `POST /api/ai/ask` |
|
||||
| `subscription` / `payment` / `discount` | پلن، سفارش، تراکنش، کد تخفیف |
|
||||
| `notifications`, `affiliate`, `support`, `family` | امکانات جانبی |
|
||||
| `statuslog` | لاگ عمومی تغییر status، مستقل از بقیهی اپها |
|
||||
| `accounts` | هویت، session، ورود با OTP |
|
||||
| `content` | برنامهی درسی، درس، سوال، پیشرفت روی نقشه |
|
||||
| `progress` | آمار، لیدربرد، streak، شخصیتهای بازی |
|
||||
| `ai_tutor` · `rag` | دستیارِ هوشِ مصنوعی: بازیابی از Qdrant + تولید با GPT |
|
||||
| `subscription` · `payment` · `discount` | پلن، سفارش، تراکنش، کدِ تخفیف |
|
||||
| `notifications` · `affiliate` · `support` · `family` · `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` تعریف میشود. اندپوینتِ جدید یعنی **هر دو**.
|
||||
|
||||
جزئیاتِ بیشتر: [`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
|
||||
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 و ساختِ دوباره
|
||||
ssh -t administrator@78.38.35.249 \
|
||||
'cd ~/mathapp-v2 && ./deploy.sh; sudo systemctl restart zareian-mathapp'
|
||||
```
|
||||
|
||||
### مستندات API
|
||||
| | |
|
||||
|---|---|
|
||||
| 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/` |
|
||||
`deploy.sh` کد را میگیرد، وابستگیها را نصب میکند، مهاجرت میزند، محتوا را
|
||||
seed میکند، فایلهای استاتیک را جمع میکند و در پایان **ثابت میکند که سرویس
|
||||
واقعاً ریاستارت شده** — نه اینکه فقط فرض کند. ریاستارت جداست چون `sudo`
|
||||
داخلِ اسکریپت نمیتواند رمز بپرسد.
|
||||
|
||||
**شروع سریع در Swagger:**
|
||||
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`).
|
||||
پس از هر انتشار، از بیرون و بدونِ SSH تأیید کنید:
|
||||
|
||||
### دستورهای پرکاربرد
|
||||
```bash
|
||||
python manage.py createsuperuser # اختیاری، برای /admin
|
||||
python manage.py test # اجرای کل تستسوییت
|
||||
curl https://api.math.tadrisino2.ir/api/health
|
||||
```
|
||||
|
||||
### دستیار هوش مصنوعی (RAG)
|
||||
`200` یعنی سالم، `503` یعنی چیزی واقعاً خراب است. هیچ سکرتی برنمیگرداند.
|
||||
|
||||
`POST /api/ai/ask` پاسخ را از روی محتوای واقعیِ کتاب میسازد: embedding سوال →
|
||||
جستجوی Qdrant محدود به همان درس/فصل → تولید با GPT. تیکههای استفادهشده در
|
||||
`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`).
|
||||
رویهی کامل — متغیرهای محیطی، ورودِ توسعهدهندهها، بکاپ، عیبیابی:
|
||||
[`docs/operations.md`](docs/operations.md).
|
||||
|
||||
---
|
||||
|
||||
## ۲. راهنمای دیپلوی پروداکشن
|
||||
## مستندات
|
||||
|
||||
### بالا آوردنِ نسخهی جدید — یک دستور
|
||||
|
||||
```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/` → «افزودن» (انتخابِ درس با جستجو) |
|
||||
| وارد کردنِ **فلهای** سوال | همان صفحه → «وارد کردنِ سوالها از فایل» |
|
||||
| گرفتنِ خروجیِ سوالها | چند سوال را انتخاب کنید → اکشنِ «دریافتِ فایل» |
|
||||
| وارد کردنِ **برنامهی درسی** (پایه/فصل/درس) | `/admin/content/grade/` → «وارد کردنِ برنامهی درسی از فایل» |
|
||||
| گرفتنِ خروجیِ برنامهی درسی | پایهها را انتخاب کنید → اکشنِ «دریافتِ فایلِ برنامهی درسی» |
|
||||
| [`docs/operations.md`](docs/operations.md) | دیپلوی، متغیرهای محیطی، سلامت، بکاپ، ورودِ توسعهدهندهها |
|
||||
| [`docs/content-authoring.md`](docs/content-authoring.md) | افزودن و ویرایشِ محتوا از پنلِ ادمین، ساختِ انبوهِ سوال |
|
||||
| [`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) | استخراجِ محتوا از کتاب با مدلِ ویژن و هزینههایش |
|
||||
|
||||
خروجیِ هر کدام دقیقاً همان قالبیست که ورودیاش میپذیرد، پس چرخهی
|
||||
«بگیر → ویرایش کن → برگردان» بسته است و برای گرفتنِ نمونه لازم نیست کسی
|
||||
مستندات بخواند.
|
||||
---
|
||||
|
||||
دو تضمین:
|
||||
- **سوالها:** فایلِ نامعتبر هیچ چیزی نمینویسد — یا همه وارد میشوند یا هیچکدام.
|
||||
- **برنامهی درسی:** چیزی پاک نمیشود. هر چه در فایل هست ساخته/بهروز میشود و
|
||||
هر چه نیست دستنخورده میماند، پس یک فایلِ ناقص محتوای سرور را از بین نمیبرد.
|
||||
## محدودیتهای شناختهشده
|
||||
|
||||
### ساختِ انبوهِ سوال از کتاب
|
||||
اینها عمداً نوشته شدهاند تا کسی دوباره کشفشان نکند:
|
||||
|
||||
وقتی کتابی تازه روی 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
|
||||
```
|
||||
|
||||
خروجی چند فایلِ JSON است بهعلاوهی یک `*-RULES.txt` که قواعدِ ساختِ سوال را
|
||||
دارد. سوالها را با آن قواعد بنویسید و از «وارد کردنِ فلهای سوال» در جدولِ
|
||||
بالا واردشان کنید. همان فرمان بدونِ `--export` کار را به مدلِ پولی میسپارد،
|
||||
ولی گرانتر است و کیفیتِ بهتری نمیدهد. جزئیات و هزینههای اندازهگیریشده در
|
||||
`docs/extraction-playbook.md`.
|
||||
|
||||
`--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`).
|
||||
- **دیتابیس هنوز SQLite است**، نه Postgres. برای حجمِ فعلی کافیست، ولی
|
||||
تکنویسنده بودنش اولین گلوگاهِ مقیاس است.
|
||||
- **بکاپِ خودکار وجود ندارد.** `db.sqlite3` در گیت نیست و تنها نسخهی دادهی
|
||||
کاربران است. روشِ بکاپِ دستی در [`docs/operations.md`](docs/operations.md)
|
||||
آمده؛ خودکار کردنش اولین کارِ ارزشمندِ بعدی است.
|
||||
- **`CACHE_URL` روی سرور ست نشده**، پس محدودیتِ نرخ بینِ workerها مشترک نیست.
|
||||
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` هشدارش میدهد.
|
||||
|
||||
98
docs/architecture.md
Normal file
98
docs/architecture.md
Normal 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
118
docs/content-authoring.md
Normal 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
43
docs/docker.md
Normal 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
198
docs/operations.md
Normal 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`).
|
||||
Reference in New Issue
Block a user