Postgres here allows 60 connections, but each of the 96 gunicorn threads held its own persistent one: at 100 concurrent users (local Postgres with the same limit) 21% of requests were 500 "remaining connection slots are reserved". Now each worker has a psycopg pool (8), so the same load gives 0 errors and 714 req/s (was 460). - A full pool answers 503 + Retry-After (DRF handler and middleware), not 500. - The AI tutor and the OTP SMS call return their connection before the network wait. - statement_timeout 30 s, idle-in-transaction 60 s; bad pool settings and the old DB_CONN_MAX_AGE stop startup instead of failing every request. - /api/health dbPool: per-worker pool stats and workers x pool + 6 vs max_connections. - Indexes on the columns global counters filter by time (weekly leaderboard, global SMS cap, global AI cap); the leaderboard recomputes once per expiry. - Worker count moves into gunicorn.conf.py (4 on a shared 8-core box). - security doc: Redis item closed (server uses our own Redis since 2026-10-08). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
391 lines
33 KiB
Markdown
391 lines
33 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 تایماوت میدهد)، `DEPLOY_BUNDLE`.
|
||
|
||
- **سلامت از سوکتِ gunicorn** (مسیرش را از یونیت میخواند) با هدرِ `Host` دامنهی خودمان سنجیده
|
||
میشود، چون از داخلِ سرور آدرسِ عمومی از مسیرِ ArvanCloud برمیگردد و ۵۰۴/۰۰۰ میداد (۲۰۲۶-۱۰-۰۷).
|
||
- **اگر سرور به Gitea نمیرسد** (از سرور ۵۰۴، از لپتاپ ۲۰۰): روی لپتاپ
|
||
`git bundle create x.bundle <هشِ origin/main روی سرور>..main`، `scp` به
|
||
`~/mathapp-v2/.deploy-backups/`، و روی سرور
|
||
`DEPLOY_BUNDLE=.deploy-backups/x.bundle ./deploy.sh`. هشِ `origin/main` که چاپ میشود را با لپتاپ
|
||
مقایسه کنید. بدونِ این متغیر جایگزینی نیست و نرسیدن به Gitea دیپلو را میایستاند.
|
||
|
||
## بررسیِ سلامت
|
||
|
||
```bash
|
||
curl https://mathapi.tadrisino2.ir/api/health # عمومی: فقط {"status": "ok"|"error"}
|
||
curl -H "X-Internal-Key: <INTERNAL_SERVICE_KEY>" https://mathapi.tadrisino2.ir/api/health # جزئیات
|
||
```
|
||
|
||
کلید در `.env` سرور است (`INTERNAL_SERVICE_KEY`)؛ جایی ننویسید و در چت نفرستید. مستنداتِ API (`/api/docs/`، `/api/redoc/`، `/api/schema/`) فقط برای ادمینِ واردشده به
|
||
`/admin/` باز است؛ برای فرانت: `python manage.py spectacular --file schema.yaml --format openapi`.
|
||
|
||
بدونِ احرازِ هویت و بدونِ SSH میگوید: دیتابیس سالم است یا نه، مهاجرتِ
|
||
اعمالنشده مانده یا نه، کش کار میکند یا نه، چند پایه/درس/سوال روی آن سرور
|
||
هست، کدام سرویسِ بیرونی تنظیم شده (فقط بله/خیر)، و چه هشدارهای امنیتیای
|
||
فعالاند. `200` سالم، `503` خرابیِ واقعی. **هیچ سکرتی برنمیگرداند.**
|
||
|
||
مهمترین بررسیاش «مهاجرتِ اعمالنشده» است: شایعترین حالتِ دیپلویِ خراب این
|
||
است که کد رفته ولی `migrate` اجرا نشده — اپ بالا میآید و بعد زیرِ بارِ واقعی
|
||
با خطای ستونِ ناموجود میشکند.
|
||
|
||
تأییدِ سریعِ اینکه API واقعاً جواب میدهد:
|
||
|
||
```bash
|
||
curl -i https://mathapi.tadrisino2.ir/api/schema/
|
||
curl -i https://mathapi.tadrisino2.ir/api/health
|
||
```
|
||
|
||
## بکاپ و بازیابی
|
||
|
||
دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`).
|
||
|
||
| لایه | چه چیزی | از دست رفتنِ داده در بدترین حالت | کجا |
|
||
|---|---|---|---|
|
||
| کپیِ زنده (اصلی) | هر تغییرِ دیتابیس لحظهای به سرورِ دیگر میرود + یک نسخهی کامل در روز؛ بازیابی تا هر لحظه از ۷ روزِ اخیر | چند ثانیهی آخر | `administrator@78.38.35.247:~/zareian-pgbackup/` |
|
||
| پیش از migrate | `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمیشود)؛ برای برگشتن از migrationِ خراب | — | `~/mathapp-v2/.deploy-backups/db-before-deploy-*.pgdump` (۲ تای آخر) |
|
||
| ایمیجِ کلِ ماشین | کارِ ادمینِ سرور، روزانه؛ ما محتوایش را نسنجیدهایم | — | دستِ ادمین |
|
||
|
||
بکاپِ شبانهی جداگانه (`backup_db` با cron) حذف شد: کپیِ زنده همان کار را بهتر میکند و ایمیجِ روزانه را ادمین دارد.
|
||
|
||
### کپیِ زنده چطور کار میکند
|
||
|
||
Postgres هر تغییر را اول در یک دفترِ پیوسته (WAL) مینویسد. سرورِ ۲۴۷ این دفتر را با ابزارِ خودِ Postgres
|
||
(`pg_receivewal`) لحظهای میگیرد، نه کپیِ کلِ دیتابیس: فقط تغییرات میروند. روزی یک بار هم یک نسخهی کامل
|
||
(`pg_basebackup`) گرفته میشود؛ ۷ نسخهی آخر میمانند و WALِ قدیمیتر از قدیمیترینشان پاک میشود، پس حجم
|
||
محدود است. بازیابی = آخرین نسخهی کاملِ پیش از لحظهی دلخواه + بازپخشِ WAL تا آن لحظه.
|
||
|
||
```
|
||
۲۴۹ (اپ) ۲۴۷ (بکاپ)
|
||
Postgres 5440 (localhost) ◄── تونلِ SSH ─── zareian-pg-tunnel (127.0.0.1:15440)
|
||
اسلاتِ zareian_247 zareian-pg-wal → wal/ (لحظهای)
|
||
نقشِ zareian_replicator zareian-pg-basebackup.timer → base/ (هر روز ۰۴:۳۰، ۷ تا)
|
||
```
|
||
|
||
- **۲۴۹ را نمیخواباند:** اپ منتظرِ ۲۴۷ نمیماند (تکثیرِ ناهمزمان). اگر ۲۴۷ قطع شود، اسلات تغییرات را نگه میدارد تا
|
||
وصل شود، ولی حداکثر ۱ گیگ (`max_slot_wal_keep_size`)؛ بیش از آن اسلات «lost» میشود و دیسکِ ۲۴۹ امن میماند.
|
||
نسخهی کامل با `--checkpoint=spread --max-rate=20M` گرفته میشود.
|
||
- **امنیت:** کلیدِ SSHِ ۲۴۷ در `authorized_keys`ِ ۲۴۹ با `restrict,port-forwarding,permitopen="127.0.0.1:5440"`
|
||
محدود است (نه شِل، نه پورتِ دیگر). رمزِ نقشِ `zareian_replicator` فقط در `~/.pgpass`ِ ۲۴۷ است. این نقش
|
||
همهی داده را میخواند (طبیعتِ بکاپ)، پس `~/zareian-pgbackup` با مجوز ۷۰۰ است. ترافیک در شبکهی داخلی است
|
||
(۲۴۹ = `192.168.200.164`، ۲۴۷ = `192.168.200.167`).
|
||
- **Postgresِ ۲۴۷ را به کار نمیگیریم:** سرویسِ `postgresql@14-main` آنجا مالِ دیگران است. ما فقط ابزارهای
|
||
Postgres 14 را اجرا میکنیم؛ آزمونِ بازیابی یک Postgresِ موقت روی سوکتِ داخلِ پوشهی خودمان بالا میآورد.
|
||
- **پایش:** `/api/health` (با `X-Internal-Key`) زیرِ `offsiteBackup` وضعیت و عقبماندگی را میدهد و اگر ۲۴۷
|
||
وصل نباشد، عقب باشد یا اسلات lost شده باشد هشدار میدهد (`OFFSITE_WAL_SLOT=zareian_247` در `.env`ِ ۲۴۹).
|
||
شکستِ نسخهی کاملِ روزانه از ۲۴۹ دیده نمیشود: روی ۲۴۷ `systemctl status zareian-pg-basebackup` و
|
||
`journalctl -u zareian-pg-basebackup`. شکستش داده را از دست نمیدهد (WAL از آخرین نسخهی سالم نگه داشته
|
||
میشود)، فقط بازیابی را کندتر میکند.
|
||
|
||
فایلها در `ops/pgbackup/` ریپو. روی ۲۴۷: یونیتها در `/etc/systemd/system/zareian-pg-*`، اسکریپتها در
|
||
`~/zareian-pgbackup/bin/`. هر دو با دست کپی میشوند (`deploy.sh` فقط ۲۴۹ را میشناسد)؛ بعد از تغییرِ
|
||
یکی از آنها: کپی، `sudo systemctl daemon-reload`، و `restart` همان یونیت.
|
||
|
||
### آزمونِ بازیابی (ماهی یک بار)
|
||
|
||
روی ۲۴۷: `~/zareian-pgbackup/bin/restore-check.sh` (یا با یک لحظه: `… '2026-10-10 16:30:00+03:30'`).
|
||
آخرین نسخهی کامل + همهی WAL را در پوشهی موقت بالا میآورد، شمارش و آخرین زمانِ کاربر/ورود/سفارش/تراکنش/
|
||
تغییرِ وضعیت را چاپ میکند و پاک میکند. همان پرسش را روی ۲۴۹ بزنید؛ باید یکی باشند (جز تغییرِ همان چند ثانیه).
|
||
|
||
### اگر ۲۴۷ مدتی قطع بود
|
||
|
||
`/api/health` میگوید `disconnected`. با برگشتنِ ۲۴۷ سرویسها خودشان وصل میشوند و عقبماندگی را میگیرند. اگر
|
||
`lost` شد (بیش از ۱ گیگ تغییر در قطعی): روی ۲۴۷
|
||
`systemctl stop zareian-pg-wal`، روی ۲۴۹ اسلات را بیندازید
|
||
(`SELECT pg_drop_replication_slot('zareian_247')`)، روی ۲۴۷ بسازید
|
||
(`pg_receivewal -h 127.0.0.1 -p 15440 -U zareian_replicator --create-slot --slot=zareian_247`)،
|
||
`systemctl start zareian-pg-wal` و `systemctl start zareian-pg-basebackup` (نسخهی کاملِ تازه؛ WALِ پیش از آن
|
||
بریده است).
|
||
|
||
### بازیابیِ واقعی
|
||
|
||
- **دیتابیسِ ۲۴۹ خراب شد یا دادهای اشتباهی پاک شد:** لحظهی درست را پیدا کنید، روی ۲۴۷ با `restore-check.sh`
|
||
تا آن لحظه آزمایش کنید؛ وقتی درست بود، با `KEEP=1` اجرا کنید تا Postgresِ موقت بماند، از آن با
|
||
`pg_dump -Fc -h <پوشه> -U postgres zareian_mathapp` خروجی بگیرید و روی ۲۴۹ با `pg_restore --clean --no-owner -d zareian_mathapp` برگردد (اپ را پیش از آن stop کنید و از
|
||
وضعیتِ فعلی هم یک `pg_dump` بگیرید). این مسیر روی سرور آزموده نشده؛ با کمک بروید.
|
||
- **کلِ ۲۴۹ رفت:** همان بازیابی تا انتهای WAL روی ماشینِ تازه، و سفارشهای چند ثانیهی آخر را با پنلِ زرینپال/بازار
|
||
تطبیق دهید.
|
||
|
||
بکاپِ دستیِ SQLite (توسعهی محلی):
|
||
|
||
|
||
```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://mathapi.tadrisino2.ir/api/health
|
||
```
|
||
|
||
## متغیرهای محیطی
|
||
|
||
`.env` خودکار خوانده میشود (`python-dotenv`) و مقادیرش **با پیشفرضهای کد
|
||
ادغام میشوند، نه جایگزینشان**. پس یک `.env` ناقص روی سرور دیگر باعثِ بالا
|
||
نیامدن نمیشود. `.env.example` توضیحِ کاملِ هر کدام را دارد.
|
||
|
||
| متغیر | اگر ست نشود چه میشود |
|
||
|---|---|
|
||
| `DJANGO_DEBUG` | خاموش (`0`) — که حالتِ درستِ پروداکشن است |
|
||
| `DJANGO_SECRET_KEY` | یک کلیدِ تصادفیِ قوی ساخته و در `.django_secret_key` (مجوز `0600`) ذخیره میشود |
|
||
| `PUBLIC_BASE_URL` · `FRONTEND_URL` | دامنهی فعلی (`mathapi.tadrisino2.ir` / `math.tadrisino2.ir`). بقیهی تنظیمهای دامنه از همین دو ساخته میشوند؛ بخشِ «عوض کردنِ دامنه» |
|
||
| `DJANGO_ALLOWED_HOSTS` · `CSRF_TRUSTED_ORIGINS` · `CORS_ALLOWED_ORIGINS` | از دو آدرسِ بالا ساخته میشوند؛ مقدارِ env فقط دامنهی *اضافه* است |
|
||
| `CACHE_URL` | سرویس بالا میآید ولی نرخ بینِ workerها مشترک نیست؛ `/api/health` هشدار میدهد. روی سرور به Redisِ اختصاصیِ خودمان (`zareian-redis`، پورت ۶۳۹۰) اشاره میکند، نه Redisِ مشترکِ ماشین. با آن، قطعیِ Redis API را ۵۰۰ نمیکند ولی تا برگشتنش سقفهای مبتنی بر کش اعمال نمیشوند و health ۵۰۳ میدهد (`config/cache.py`) |
|
||
| `KAVENEGAR_API_KEY` | `POST /api/auth/otp/request` خطای صریحِ `503` میدهد، نه موفقیتِ ساختگی |
|
||
| `ZARINPAL_MERCHANT_ID` | مسیرِ پرداخت fail-closed میماند (`503`) |
|
||
| `gunicorn.conf.py` (در ریپو، نه env) | gunicorn خودش از پوشهی کاری میخواندش: ۴ workerِ `gthread` با ۳۲ نخ و timeoutِ ۱۲۰. تعدادِ workerها همینجاست؛ یونیتِ systemd دیگر `--workers` ندارد (اگر داشته باشد بر این فایل مقدم است) |
|
||
| `DB_POOL_MAX_SIZE` · `DB_POOL_MIN_SIZE` · `DB_POOL_TIMEOUT` | ۸ · ۲ · ۱۰ ثانیه: هر worker حداکثر ۸ اتصالِ Postgres دارد و نخها آن را فقط تا پایانِ درخواست قرض میگیرند. اگر تا ۱۰ ثانیه اتصالی آزاد نشد، درخواست ۵۰۳ با `Retry-After` میگیرد (نه ۵۰۰). `workers × DB_POOL_MAX_SIZE + 6` باید زیرِ `max_connections` (روی سرور ۶۰ − ۳) بماند؛ `/api/health` → `dbPool` آن را میسنجد و هشدار میدهد. `DB_CONN_MAX_AGE` دیگر پذیرفته نمیشود (برنامه بالا نمیآید) |
|
||
| `DB_STATEMENT_TIMEOUT_MS` | ۳۰٬۰۰۰: هر کوئریِ بیش از ۳۰ ثانیه لغو میشود و تراکنشِ بازِ بیکار بعد از ۶۰ ثانیه بسته میشود. برای فرمانِ مدیریتیِ طولانیِ دستی: `DB_STATEMENT_TIMEOUT_MS=0 python manage.py …` |
|
||
| `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `FRONTEND_URL/?orderId=…&status=paid\|failed` برمیگردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی |
|
||
| `BAZAAR_PISHKHAN_API_SECRET` (روشِ جدید) یا `BAZAAR_CLIENT_ID` · `BAZAAR_CLIENT_SECRET` (روشِ قدیمی)، بهعلاوهی `BAZAAR_PACKAGE_NAME` | `POST /api/payment/bazaar/verify` هر خرید را نامعتبر میداند (fail-closed)؛ `/api/health` → `bazaar: false` |
|
||
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را میدهد |
|
||
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |
|
||
| `HEARTS_ENFORCED` | روشن (از ۲۰۲۶-۱۰-۰۲): با صفر قلب نه جواب پذیرفته میشود نه پایانِ درس/جزیرهی بیجواب. `0` خاموشش میکند |
|
||
| `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بستهاند (در DEBUG هم؛ پیشفرض ندارد) |
|
||
|
||
**اتصال به بازار — روشِ جدید (ترجیحی):** در پیشخانِ بازار روی برنامه ← «API پیشخان
|
||
بازار» ← «دریافت توکن جدید» (از حسابِ ناشر، نه کارپرداز). همان را در `.env` بهعنوانِ
|
||
`BAZAAR_PISHKHAN_API_SECRET` بگذارید؛ در هدرِ `CAFEBAZAAR-PISHKHAN-API-SECRET` فرستاده
|
||
میشود و redirect و انقضا ندارد. اگر ست باشد روشِ قدیمیِ زیر نادیده گرفته میشود.
|
||
|
||
**اتصال به بازار — روشِ قدیمی (OAuth، یکبار):** Redirect URIِ ثبتشده در پنلِ بازار
|
||
`<PUBLIC_BASE_URL>/api/payment/bazaar/verify` است و `BAZAAR_REDIRECT_URI` خودش از همان ساخته
|
||
میشود؛ این دو باید عیناً یکی باشند. با عوض شدنِ دامنه، اول آدرسِ پنلِ بازار عوض شود، بعد
|
||
لینکِ تازه ساخته شود (لینکِ قبلی آدرسِ قدیمی را در خودش دارد). در ادمین، «اتصال به API بازار» → actionِ «ساختِ لینکِ
|
||
اتصال» (یا `python manage.py bazaar_authorize_link`). صاحبِ حسابِ بازار آن لینک را
|
||
ظرفِ سه روز (`BAZAAR_AUTHORIZE_WINDOW_HOURS`، پیشفرض ۷۲) باز میکند و «اجازه» میزند؛ بازار با `?code=` به همان آدرس برمیگردد
|
||
و GETش refresh token را میگیرد و در دیتابیس نگه میدارد؛ پنجره با همین اتصالِ موفق
|
||
بسته میشود. POSTِ همان آدرس تأییدِ
|
||
خریدِ اپِ اندروید است. آدرسها با سندِ بازار مقایسه شدهاند (۲۰۲۶-۰۹-۲۶)؛ هنوز هیچ خریدِ واقعیای
|
||
از آنها رد نشده. سند سایتی JS است ولی متنش از
|
||
`developers.cafebazaar.ir/wp-json/wp/v2/document?search=...` خوانا است.
|
||
|
||
**پرداختهای تأییدنشده:** اپ هر بار که وضعیتِ اشتراک را میخواند، سفارشهای
|
||
pendingِ همان کاربر را دوباره با زرینپال میسنجد. برای دیدنِ همه یکجا:
|
||
`python manage.py payment_reconcile --days 3`.
|
||
|
||
> **`DJANGO_SECRET_KEY` توکنهای ورود را امضا میکند.** بهترین حالت این است که
|
||
> اصلاً ست نشود تا خودِ تنظیمات یکی بسازد — آنوقت کلیدی وجود ندارد که لو
|
||
> برود. **هرگز نگذارید سرور روی fallbackِ `django-insecure-…` کار کند**: آن
|
||
> رشته در همین ریپو کامیت شده، پس هر کسی که ریپو را بخواند میتواند بهجای هر
|
||
> کاربری توکن جعل کند. `manage.py check --deploy` با کدِ `security.W009`
|
||
> علامتش میزند.
|
||
|
||
> **`DJANGO_DEBUG` هرگز روی سرور `1` نشود.** `DEV_MASTER_OTP` (کد `0000`) را
|
||
> روشن میکند که هر شمارهای را بهعنوانِ ورودِ معتبر میپذیرد، و صفحههای خطا
|
||
> کلِ تنظیمات را نشان میدهند. جایش فقط `.env` است، نه یونیتِ systemd.
|
||
|
||
## عوض کردنِ دامنه
|
||
|
||
آخرین جابهجایی: ۲۰۲۶-۰۹-۲۶، API از `api.math.tadrisino2.ir` به `mathapi.tadrisino2.ir`
|
||
(رکوردِ DNSِ آدرسِ قبلی حذف شده). فرانت همان `math.tadrisino2.ir` ماند.
|
||
|
||
دامنه در کد فقط یکجا آمده: پیشفرضِ `PUBLIC_BASE_URL` و `FRONTEND_URL` در
|
||
`config/settings.py`. `ALLOWED_HOSTS`، `CSRF_TRUSTED_ORIGINS`، `CORS_ALLOWED_ORIGINS`،
|
||
callbackِ زرینپال، برگشت از پرداخت، `BAZAAR_REDIRECT_URI` و آدرسِ سلامت در
|
||
`deploy.sh` همه از همین دو ساخته میشوند (۲۰۲۶-۰۹-۲۶). فرانت هم دامنه را در کد
|
||
ندارد (README همان ریپو).
|
||
|
||
ترتیبِ کار، از بیرون به داخل:
|
||
|
||
1. **DNS، آروان و گواهی** برای دامنههای تازه، رو به همین سرور، و `server_name`ِ
|
||
nginx — کارِ ادمینِ سرور، نه ما.
|
||
2. **بکاند:** در `.env` سرور `PUBLIC_BASE_URL` و `FRONTEND_URL` را عوض کنید. تا DNS
|
||
کامل جابهجا شود، دامنههای قبلی را هم نگه دارید، مثلاً
|
||
`DJANGO_ALLOWED_HOSTS=<دامنهی قبلیِ API>` و `CORS_ALLOWED_ORIGINS=https://<دامنهی قبلیِ فرانت>`. بعد ریاستارت.
|
||
3. **زرینپال:** دامنهی تازهی API را در پنلِ ترمینال ثبت کنید. خطای `-14` («دامنهی
|
||
callback با دامنهی ثبتشده نمیخواند») دقیقاً نبودنِ همین است.
|
||
**بازار** (اگر استفاده شود): Redirect URI در پنل باید عیناً
|
||
`PUBLIC_BASE_URL/api/payment/bazaar/verify` شود.
|
||
4. **فرانت:** روی سرورِ فرانت در `dist/config.js` بنویسید
|
||
`apiBaseUrl: "https://<دامنهی تازهی API>/api"` (یا `.env.production` را عوض و
|
||
build کنید)، و کشِ آروان را برای `config.js` و `index.html` پاک کنید.
|
||
5. **وارسی:** `curl https://<دامنهی تازهی API>/api/health` (در `clientIp` باید IPِ
|
||
خودتان باشد) و یک بار ورود و باز کردنِ یک درس در اپ.
|
||
|
||
مرحلهی ۳ را فقط یک پرداختِ واقعی ثابت میکند؛ تا آن انجام نشده، پرداخت را
|
||
«کار میکند» ننویسید.
|
||
|
||
## لغو و بازپرداختِ سفارش (از پنلِ ادمین)
|
||
|
||
از ۲۰۲۶-۱۰-۰۲. همه از `/admin/payment/order/` انجام میشود و SSH لازم نیست:
|
||
|
||
1. سفارش را با شمارهاش یا شناسهی کاربر (`user_id`) پیدا کنید. شمارهی فاکتورِ `TD-000123` یعنی سفارشِ ۱۲۳.
|
||
2. تیکش را بزنید و از فهرستِ «Action» یکی را انتخاب کنید:
|
||
- **«لغوِ سفارشِ در انتظار»**: برای سفارشی که هنوز پرداخت نشده. پولی جابهجا نشده است. اگر کاربر
|
||
همان لحظه در درگاه پرداخت کرده باشد، تأییدِ زرینپال سفارش را دوباره «پرداختشده» میکند و او چیزش را میگیرد.
|
||
- **«بازپرداخت»**: برای سفارشِ پرداختشده. اول یک صفحهی تأیید میآید. بعد از تأیید، سفارش
|
||
«بازپرداختشده» میشود و هر چه داده بود تا جایی که هنوز مانده پس گرفته میشود: سوالهای
|
||
خرجنشدهی همان بسته (موجودی منفی نمیشود)، روزهای قلبِ نامحدودِ همان بسته، یا روزهای همان اشتراک.
|
||
پورسانتِ معرف هم برمیگردد. پیامِ بالای صفحه میگوید دقیقاً چه چیزی پس گرفته شد.
|
||
3. **خودِ پول را این دکمه برنمیگرداند.** پول را در پنلِ زرینپال برگردانید. خریدِ کافهبازار هم از
|
||
سمتِ خودِ بازار برگردانده میشود.
|
||
|
||
وضعیتِ سفارش در فرمِ ادمین فقطخواندنی است. اگر وضعیت را با دست از «پرداختشده» به «ناموفق» عوض
|
||
میکردید، اعتبار و اشتراکی که سفارش داده بود سرِ جایش میماند. کاربر بازپرداخت را در صفحهی
|
||
«پرداختها و فاکتورها» میبیند. کد در `payment/services.py` (`refund_order`، `cancel_pending_order`)
|
||
است و تستهایش در `payment/tests_refund.py`.
|
||
|
||
## فروشِ بسته در کافهبازار
|
||
|
||
از ۲۰۲۶-۱۰-۰۳. اپِ اندروید (پوستهی WebViewِ همین سایت، `android/` در ریپوی فرانت) بستهها را با
|
||
پرداختِ درونبرنامهایِ بازار میفروشد؛ در مرورگرِ معمولی همهچیز مثلِ قبل از زرینپال است.
|
||
|
||
1. در پیشخانِ بازار برای هر بسته یک **محصولِ مصرفشدنی** با همان قیمت بسازید.
|
||
2. شناسهی آن (SKU) را در `/admin/subscription/plan/` در فیلدِ «Bazaar sku» همان بسته بنویسید. سرور SKU
|
||
را فقط از همینجا میگیرد، نه از اپ؛ بستهی بیSKU داخلِ اپ «بهزودی در بازار» نشان داده میشود و
|
||
درخواستش ۴۰۰ است. یک SKU روی دو بسته نمینشیند.
|
||
3. کلیدِ RSAِ عمومیِ اپ (از پیشخان) در `android/app/build.gradle.kts` میرود، نه در `.env`.
|
||
|
||
مسیر: `POST /api/subscription/purchase` با `paymentMethod: "bazaar"` (فقط بسته) سفارش میسازد و
|
||
`bazaarSku` برمیگرداند؛ اپ با Poolakey میخرد (`payload` = شمارهی سفارش)؛ `POST /api/payment/bazaar/verify`
|
||
توکن را از خودِ بازار میسنجد و اعتبار میدهد. کدِ تخفیف روی سفارشِ بازار رد میشود (بازار قیمتِ
|
||
ثابتِ محصول را میگیرد). تستها: `payment/tests_bazaar_pack.py`. **هنوز هیچ خریدِ واقعیای از بازار
|
||
انجام نشده.**
|
||
|
||
## ورودِ توسعهدهندهها (بدونِ سرویسِ پیامک)
|
||
|
||
تا وقتی `KAVENEGAR_API_KEY` ست نشده، با `DEBUG=0` هیچکس نمیتواند با شماره
|
||
وارد شود. برای اینکه ورودِ تیم به یک نفرِ خاص وابسته نباشد، چند شمارهی تست با
|
||
کدِ ثابت تعریف کنید:
|
||
|
||
```bash
|
||
TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
|
||
```
|
||
|
||
برای این شمارهها پیامکی ارسال نمیشود و همان کد همیشه معتبر است. هر شمارهی
|
||
دیگری مسیرِ عادیِ OTP را میرود، بیهیچ تغییری.
|
||
|
||
```bash
|
||
curl -X POST https://mathapi.tadrisino2.ir/api/auth/otp/request \
|
||
-H 'Content-Type: application/json' -d '{"phone":"09120000001"}'
|
||
curl -X POST https://mathapi.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`، و پیش از باز شدنِ سرویس روی کاربرانِ واقعی
|
||
خالیاش کنید.
|
||
|
||
### حسابِ نمایشی
|
||
|
||
حسابِ «علی رضایی» (کاربرِ ۱۰) پُر از داده است و با شمارهی مالکِ پروژه وصل میشود؛ بقیهی دموها با `TEST_LOGIN_PHONES` وارد میشوند.
|
||
|
||
⚠️ **این داده دستی روی دیتابیسِ پروداکشن نوشته شده و در گیت نیست.** پیش از باز
|
||
شدنِ سرویس روی کاربرانِ واقعی باید برود، وگرنه اعلانها و لیدربردِ ساختگی را
|
||
کاربرِ واقعی میبیند. `purge_demo_data` اینها را **نمیگیرد** — آن فرمان فقط
|
||
دنبالِ پیشوندِ `[SEED]`/`[DEMO]` روی Grade/Plan/Character/User میگردد. پاک
|
||
کردن یعنی حذفِ دستیِ ردیفهای `Notification` و `LeaderboardEntry` از پنلِ
|
||
ادمین (اول بکاپ، بخشِ بالا).
|
||
|
||
## سرور
|
||
|
||
`mathapi.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`).
|
||
|
||
---
|
||
|
||
> **پیش از آمدنِ کاربرِ واقعی** فهرستِ [`security-before-production.md`](security-before-production.md)
|
||
> را کامل ببندید: کلیدِ امضا، حسابهای دمو، جزئیاتِ عمومیِ `/api/health`، مستنداتِ عمومیِ API،
|
||
> جدولِ رتبهبندیِ عمومی، پنلِ ادمین، پورتِ ۸۰ و دسترسیِ سرور.
|
||
|