ZARINPAL_REDIRECT_URL was empty on the server, so after a real (non-sandbox)
payment the callback answered with {"orderId","status"} on the API domain and
the student was stranded there. It now defaults to FRONTEND_URL
(https://math.tadrisino2.ir/) with ?orderId=&status=; reopening a finished
order's pay link goes back to the app the same way. "-" keeps the old JSON.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
226 lines
14 KiB
Markdown
226 lines
14 KiB
Markdown
# عملیات
|
||
|
||
دیپلوی، پیکربندی، سلامت و بازیابی. برای شروعِ توسعهی محلی `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`) |
|
||
| `FRONTEND_URL` · `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `https://math.tadrisino2.ir/?orderId=…&status=paid\|failed` برمیگردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی |
|
||
| `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`، و پیش از باز شدنِ سرویس روی کاربرانِ واقعی
|
||
خالیاش کنید.
|
||
|
||
### حسابِ مهمانِ تستِ فرانت
|
||
|
||
مسیرِ مهمان (`POST /api/auth/session` با یک `deviceId`) اصلاً به پیامک کار
|
||
ندارد و همیشه کار میکند — هر رشتهای بهعنوانِ `deviceId` یک حسابِ تازه
|
||
میسازد. از ۲۰۲۶-۰۹-۲۴ پاسخش `refreshToken` هم دارد (مثلِ ورود با شماره)، پس
|
||
مهمان بعد از انقضای توکنِ یکساعته با `POST /api/auth/refresh` ادامه میدهد.
|
||
|
||
یک `deviceId` مشخص روی پروداکشن از قبل داده دارد:
|
||
|
||
```bash
|
||
curl -X POST https://api.math.tadrisino2.ir/api/auth/session \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"type":"guest","deviceId":"frontend-test-device"}'
|
||
```
|
||
|
||
کاربرِ شمارهی ۶، «حساب تست فرانت». رویش ۲۴ ردیفِ `Notification` و ۲۰ ردیفِ
|
||
`LeaderboardEntry` (هفتگی و کل، با ترتیبِ عمداً متفاوت) نشسته تا تیمِ فرانت
|
||
بتواند آن صفحهها را قبل از وجودِ ترافیکِ واقعی ببیند.
|
||
|
||
⚠️ **این داده دستی روی دیتابیسِ پروداکشن نوشته شده و در گیت نیست.** پیش از باز
|
||
شدنِ سرویس روی کاربرانِ واقعی باید برود، وگرنه اعلانها و لیدربردِ ساختگی را
|
||
کاربرِ واقعی میبیند. `purge_demo_data` اینها را **نمیگیرد** — آن فرمان فقط
|
||
دنبالِ پیشوندِ `[SEED]`/`[DEMO]` روی Grade/Plan/Character/User میگردد. پاک
|
||
کردن یعنی حذفِ دستیِ ردیفهای `Notification` و `LeaderboardEntry` از پنلِ
|
||
ادمین (اول بکاپ، بخشِ بالا).
|
||
|
||
## سرور
|
||
|
||
`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`).
|