diff --git a/README.md b/README.md index 0946cae..189e65d 100644 --- a/README.md +++ b/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:///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:///api/schema/ # باید 200 برگردونه (OpenAPI schema) -curl -i https:///api/docs/ # باید 200 برگردونه (Swagger UI) -curl -i -X POST https:///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` هشدارش می‌دهد. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..3a05403 --- /dev/null +++ b/docs/architecture.md @@ -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`). diff --git a/docs/content-authoring.md b/docs/content-authoring.md new file mode 100644 index 0000000..7ff8c96 --- /dev/null +++ b/docs/content-authoring.md @@ -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ِ قبلی و ساختِ دوباره +``` diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..2622c1f --- /dev/null +++ b/docs/docker.md @@ -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` اجرا می‌شود. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..f61ee38 --- /dev/null +++ b/docs/operations.md @@ -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/`، نصبِ +وابستگی‌ها، `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-.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`).