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:
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