498 lines
52 KiB
Markdown
498 lines
52 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) حذف شد: کپیِ زنده همان کار را بهتر میکند و ایمیجِ روزانه را ادمین دارد.
|
||
|
||
### دفترِ خرید (`PaymentEvent`) روی هر دو سرور
|
||
|
||
هر قدمِ مسیرِ خرید یک ردیف در جدولِ `payment_paymentevent` مینویسد (`payment/events.py`): `order_created`،
|
||
`discount_applied`، `gateway_redirect`، `gateway_request_failed`، `callback_received`، `callback_unknown_authority`،
|
||
`payment_result` (همهی مسیرها: کالبک، بازسنجی، بازار، تمدیدِ بازار، کدِ ۱۰۰٪؛ با `ignored` برای تکراری/جعلی)،
|
||
`bazaar_start`، `admin_cancel`، `admin_refund`. توکنِ خریدِ بازار در آن نمیرود. ادمین: «Payment events» (فقطخواندنی).
|
||
|
||
چون جدول در همان Postgres است با WAL لحظهای به ۲۴۷ میرسد. روی ۲۴۷، `zareian-pg-export-events.timer`
|
||
(۰۱:۱۵، ۰۷:۱۵، ۱۳:۱۵، ۱۹:۱۵) با `bin/export-payment-events.sh` از روی همان بکاپ (نمونهی موقت روی سوکتِ داخلِ پوشه،
|
||
بیاتصال به ۲۴۹ یا Postgresِ دیگران) سه CSVِ تجمعی در `~/zareian-pgbackup/payment-log/` مینویسد:
|
||
`payment-events-*`، `orders-*`، `transactions-*`؛ ۱۴ تای آخرِ هر نوع میماند. شکستش در
|
||
`journalctl -u zareian-pg-export-events`ِ ۲۴۷. زیرِ ۳ گیگ فضای آزاد اجرا نمیشود.
|
||
|
||
### لاگِ برنامه (`app.log`): برای همیشه روی ۲۴۷
|
||
|
||
با `LOG_FILE_ROTATION=external` در `.env`ِ ۲۴۹ برنامه لاگ را پاک نمیکند (`WatchedFileHandler`). هر شب ۰۰:۰۵
|
||
`zareian-applog-archive.timer` (۲۴۹) با `ops/applogs/archive-app-log.sh` فایل را به `logs/archive/app-<UTC>.log.gz`
|
||
میبرد و فشرده میکند؛ هر ساعت `zareian-applog-pull.timer` (۲۴۷) با `bin/pull-app-logs.sh` فایلهای تازه را به
|
||
`~/zareian-pgbackup/app-logs/` میآورد (`gzip -t` پیش از نگهداشتن) و **هرگز پاک نمیکند** (تصمیمِ مالک ۲۰۲۶-۱۰-۱۳).
|
||
روی ۲۴۹ فقط ۳۰ روز میماند و فقط فایلی پاک میشود که ۲۴۷ گرفتنش را تأیید کرده (`.shipped`).
|
||
|
||
دسترسی: کلیدِ `~/.ssh/zareian_applogs`ِ ۲۴۷ در `authorized_keys`ِ ۲۴۹ با
|
||
`restrict,command="…/ops/applogs/serve-archive.sh"` است: فقط `list`، `get NAME`، `ack NAME`؛ شِل و پورت ندارد
|
||
(سنجیده: `id` و `cat /etc/passwd` → `bad request`). بکاپِ `authorized_keys` پیش از افزودن کنارش است.
|
||
|
||
حجم (برآورد، نه سنجش): لاگِ JSON حدودِ ۱۵ برابر فشرده میشود (۷۰۰ک → ۴۰ک در اولین اجرا)؛ با ۶۰۰ کاربرِ فعال
|
||
حدودِ ۳۰ مگِ خام و ۲ تا ۳ مگِ فشرده در روز، یعنی حدودِ ۱ گیگ در سال روی ۲۴۷. اگر آرشیوِ شبانه بخوابد و
|
||
`app.log` از ۲۵۶ مگ بگذرد، `/api/health` هشدار میدهد. خواندن: `zcat app-*.log.gz | grep user_id=26`.
|
||
|
||
### کپیِ زنده چطور کار میکند
|
||
|
||
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 روی ماشینِ تازه، و سفارشهای چند ثانیهی آخر را با پنلِ زرینپال/بازار
|
||
تطبیق دهید.
|
||
|
||
## متغیرهای محیطی
|
||
|
||
`.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` دیگر پذیرفته نمیشود (برنامه بالا نمیآید) |
|
||
| `AI_MAX_CONCURRENT_PER_WORKER` | ۱۲: حداکثر جوابِ همزمانِ دستیار در هر worker (۴ × ۱۲ = ۴۸). هر جواب یک نخ را تا ۴۰ ثانیه نگه میدارد؛ سقف نمیگذارد رگبارِ سوال همهی ۳۲ نخ را بگیرد و نقشه و آزمون بخوابند. بیشتر از آن ۵۰۳ «دستیار شلوغه» با `Retry-After` میگیرد، پیش از رزروِ سهمیه. `/api/health` → `aiConcurrency` |
|
||
| `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 روی `mathapi.tadrisino2.ir` و فرانت روی `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` همه از همین دو ساخته میشوند (`config/tests_domains.py`). فرانت هم دامنه را در کد
|
||
ندارد (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. **nginx و CSP:** در `ops/nginx/zareian-front-headers.conf` دامنهی تازهی API را در `connect-src` بگذارید،
|
||
روی سرور در `/etc/nginx/snippets/` نصب کنید (بخشِ «nginx»)؛ وگرنه مرورگر هر درخواستِ API را میبندد.
|
||
6. **وارسی:** `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/` در ریپوی فرانت) با پرداختِ درونبرنامهایِ بازار میفروشد. در
|
||
برگهی پرداخت دو کاشی همیشه کنارِ هماند و کاربر هر کدام را بخواهد میزند: «کارتِ بانکی» (زرینپال، پیشفرض) و
|
||
«کافهبازار». در مرورگر، یا وقتی پلن SKU ندارد، کاشیِ بازار غیرفعال است و دلیلش را میگوید. فقط اشتراک فروخته میشود
|
||
(بستهی سوال از ریشه حذف شد، `subscription.0017`/`0018`). داخلِ اپ
|
||
زیرِ «کافهبازار» قیمتی میآید که خودِ بازار برای آن SKU دارد (Poolakey روی گوشی؛ API سرور قیمت نمیدهد).
|
||
|
||
تفاوتِ روشها در یک جاست: `payment/providers.py` (هر روش یک کلاس، `provider_for`/`provider_for_method` انتخابش
|
||
میکنند). روشِ تازه = یک زیرکلاس و یک خط در `PROVIDERS`.
|
||
|
||
| چه | کجا | الان |
|
||
|---|---|---|
|
||
| محصول در پیشخانِ بازار | محصولِ «اشتراکی» (بازار خودکار تمدید میکند) | یکماهه `10342`، سهماهه `10343`، ششماهه `10344` (تأییدِ سینیور) |
|
||
| SKU روی پلن | `/admin/subscription/plan/` ← «Bazaar sku» (سرور SKU را فقط از اینجا میگیرد) | سه اشتراکِ بالا (`subscription.0014`، `0015`) |
|
||
| قیمت | بازار قیمتِ پیشخان را میگیرد؛ ادمین باید با آن یکی باشد | ۵۹۸/۸۹۸/۱٬۳۹۸ هزار تومان در هر دو (`subscription.0016`، به گفتهی سینیور)؛ داخلِ اپ قیمتِ بازار روی کاشی دیده میشود |
|
||
| کلیدِ «تخفیف پویا» | `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور | با آن کدِ تخفیف روی خریدِ بازار هم کار میکند |
|
||
| کلیدِ RSAِ عمومی | `android/app/build.gradle.kts` | گذاشته شد |
|
||
|
||
مسیر:
|
||
|
||
1. `POST /api/subscription/purchase` با `paymentMethod: "bazaar"` → سفارش و `bazaarSku`.
|
||
2. (اختیاری) `POST /api/discount/redeem` روی همان سفارش. فقط با کلیدِ «تخفیف پویا»، وگرنه ۴۰۰.
|
||
3. `POST /api/payment/bazaar/orders/{id}/start` → SKU، و اگر تخفیف خورده `dynamicPriceToken` (JWTِ HS256 با مبلغ
|
||
به ریال). بیتخفیف توکن نمیرود چون بازارِ قدیمیتر از ۱۳.۳ با آن خطا میدهد. از این لحظه کدِ تخفیف نمینشیند.
|
||
4. اپ با Poolakey اشتراک میخرد (`subscribeProduct`، `payload` = شمارهی سفارش).
|
||
5. `POST /api/payment/bazaar/verify` توکن را از خودِ بازار میسنجد و اشتراک را فعال میکند. اشتراک consume نمیشود.
|
||
|
||
**تمدیدِ خودکار:** بازار به ما خبر نمیدهد. وقتی دورهی پرداختشدهی کاربر (`Order.bazaar_valid_until`) کمتر از یک روز
|
||
مانده یا گذشته، با باز شدنِ وضعیتِ اشتراک (`GET /api/subscription/status`) همان توکن از بازار پرسیده میشود (هر توکن
|
||
ساعتی یک بار)، و همینطور وقتی کاربرِ بیاشتراکِ فعال خانه یا فصلِ پولی را باز میکند (`has_subscription_access`). اگر `validUntil` جلو رفته، برای هر دوره یک سفارشِ «پرداختشده» ساخته میشود و روزها اضافه میشوند
|
||
(`payment.services.sync_bazaar_renewals`). مبلغِ آن سفارش قیمتِ فعلیِ ادمین است، چون بازار مبلغِ تمدید را نمیگوید.
|
||
اگر بازار در دسترس نباشد، کاربرِ تمدیدشده تا تلاشِ ساعتِ بعد پشتِ دیوار میماند. لغوِ تمدید از خودِ بازار است.
|
||
|
||
تستها: `payment/tests_bazaar_subscription.py`، `payment/tests_bazaar_oauth.py`.
|
||
**هیچ خریدِ واقعیای از بازار انجام نشده**؛ نه پذیرفتنِ JWT از سوی بازار و نه تمدیدِ واقعی.
|
||
|
||
## ورود بیپیامک (آزمون)
|
||
|
||
**روی سرور هیچ شمارهای با کدِ ثابت نیست** و نباید باشد: کدِ ثابت هر کس آن را در ارائه یا اسکرینشات
|
||
دیده باشد را وارد میکند. `TEST_LOGIN_PHONES` در `.env` سرور خالی است و `/api/health` اگر پر شود هشدار
|
||
میدهد. حسابهای دمو (کاربرهای ۱۱ تا ۱۵ و ۲۵) و همهی دادهشان با `delete_users` پاک شدند (بکاپِ پیش از آن:
|
||
`.deploy-backups/db-before-demo-removal-20261013-190632.pgdump`). حسابِ ۱۰ به شمارهی مالکِ پروژه وصل است و ماند.
|
||
|
||
راهِ آزمون بیپیامک: یک ابرکاربر در `/admin/accounts/otpcode/add/` برای شمارهی خودش یک کدِ تصادفی با انقضای
|
||
چند دقیقه میسازد (یکبارمصرف، با همان سقفِ ۵ حدس). کارمندِ عادی این صفحه را ندارد
|
||
(`StaffCannotMintLoginsTests`). بعد از آزمون `POST /api/auth/logout`.
|
||
|
||
روی لپتاپ (`DEBUG=1`) کدِ `devOtp` در پاسخ میآید و `TEST_LOGIN_PHONES=09120000001:834512` هم کار میکند؛
|
||
هیچکدام برای سرور نیست.
|
||
|
||
## سرور
|
||
|
||
`mathapi.tadrisino2.ir` روی یک **ماشینِ مشترکِ شرکت** اجرا میشود (`administrator@78.38.35.249`)، نه سروری که مالِ این
|
||
پروژه باشد. روی همان ماشین Gitea، ایمیل، DNS، چند دیتابیس و اپهای تیمهای دیگر در حالِ کارند، و حسابِ ما `sudo`ِ
|
||
کامل دارد؛ تنها مانع میانِ یک فرمانِ بیدقت و دادهی کلِ شرکت، خودِ اجراکننده است. سرورِ بکاپ (`78.38.35.247`) هم
|
||
مشترک است.
|
||
|
||
مالِ ما روی ۲۴۹: پوشههای `~/mathapp-v2` (بکاند) و `~/mathapp_frontend_new` (فرانت)، سرویسِ `gunicorn-mathapp` با
|
||
نامِ دومِ **`zareian-mathapp`** (همیشه همین نام را بنویسید)، `zareian-redis`، clusterِ Postgresِ `14/zareian`، تایمرِ
|
||
`zareian-applog-archive`، و دو سایت و دو snippetِ nginx (بخشِ بعد). روی ۲۴۷ فقط `~/zareian-pgbackup/` و یونیتهای
|
||
`zareian-*`.
|
||
|
||
```bash
|
||
sudo systemctl restart zareian-mathapp # مالِ ما
|
||
sudo systemctl restart gunicorn # مالِ ما نیست — اپِ تیمِ دیگر
|
||
```
|
||
|
||
قواعدی که قابلِ مذاکره نیستند (نسخهی کامل، با فهرستِ آنچه مالِ ما نیست و رویهی دیپلو، در `CLAUDE.md` «Working on
|
||
the servers»):
|
||
|
||
1. **اول فقط نگاه کنید**، بعد تغییر بدهید؛ هیچ فرمانِ مخربی بیرون از پوشههای ما، و مسیرها را کامل بنویسید.
|
||
2. **`git clean` هرگز، و پروژه هرگز پاک و «از نو clone» نشود.** `.env`، `.django_secret_key`، لاگها و بکاپها در گیت
|
||
نیستند و همانجا زندگی میکنند؛ راهِ برگشت `git reset --hard origin/main` است.
|
||
3. **فقط `zareian-mathapp` ریاستارت شود**؛ nginx مشترک است: `nginx -t` و بعد `reload`، هرگز `restart`.
|
||
4. **پیش از ویرایشِ هر چیز زیرِ `/etc` یا `.env` بکاپِ تاریخدار** در `~/mathapp-v2/.deploy-backups/`.
|
||
5. **`.env` فقط از راهِ Django خوانده شود**؛ `cat .env` نه، و مقادیرش در هیچ چتی paste نشود.
|
||
6. **`df -h /` پیش از هر کارِ پرنوشت.** پر شدنِ دیسک ایمیل و Gitea را هم پایین میکشد.
|
||
7. **از منابعِ تیمهای دیگر استفاده نشود** (Redis، Postgres، صف، سایتِ nginx)؛ نیاز به آنها درخواست از ادمینِ سرور است.
|
||
|
||
## nginx (دو سایتِ ما)
|
||
|
||
نسخهی اصلی در ریپو است: `ops/nginx/`. روی ۲۴۹: `sites-enabled/mathapp` (API، فایلِ معمولی)،
|
||
`sites-available/mathapp-front` (فرانت)، و دو snippet در `/etc/nginx/snippets/`:
|
||
|
||
| فایل | چه میکند |
|
||
|---|---|
|
||
| `zareian-arvan-only.conf` | فقط IPهای ArvanCloud و خودِ ماشین؛ درخواستِ مستقیم به IPِ سرور ۴۰۳. همان فهرستِ `TRUSTED_PROXY_NETWORKS` |
|
||
| `zareian-front-headers.conf` | CSP، `X-Frame-Options`، `nosniff`، `Referrer-Policy`، `Permissions-Policy`، HSTS برای فرانت |
|
||
| `mathapp` | سقفِ بدنه ۲۰ مگابایت، HSTS |
|
||
|
||
تغییر: فایلِ ریپو را ویرایش و کامیت کنید؛ روی سرور فایلِ فعلی را در `.deploy-backups/` کپی کنید، `sudo install -m 644`،
|
||
`sudo nginx -t`، و فقط اگر موفق بود `sudo systemctl reload nginx` (نه restart: سایتهای دیگران روی همین nginxاند).
|
||
بعد از بیرون: هدرها با `curl -sI`، و درخواستِ مستقیم به IP باید ۴۰۳ بدهد.
|
||
|
||
**وقتی دیگران به اینها بخورند:**
|
||
- آروان رنجِ IPِ تازه گرفته: کاربرانِ پشتِ آن edge ۴۰۳ میگیرند و peerشان در `/var/log/nginx/access.log` است. فهرست را
|
||
از <https://www.arvancloud.ir/fa/ips.txt> در snippet و در `config/settings.py` با هم تازه کنید.
|
||
- اسکریپتِ درونخطیِ `index.html`ِ فرانت عوض شده: CSP آن را میبندد (کنسول: «Refused to execute inline script»). هشِ تازه را در
|
||
snippet بگذارید (روش در خودِ فایل).
|
||
- سنجیده (۲۰۲۶-۱۰-۱۳): اپ در Chrome، بیورود و با ورود، همهی تبها و آزمونِ یک جزیره، بی هیچ خطای CSP. **نسنجیده:** اپِ اندروید
|
||
(WebView همان CSP را میگیرد) و صفحهی `pay.html` تا درگاه.
|
||
|
||
## سرورِ اختصاصیِ جدید (`78.38.35.216`): آماده، هنوز سوئیچ نشده
|
||
|
||
اپ به یک ماشینِ مجازیِ اختصاصی منتقل میشود. کاربران هنوز از ۲۴۹ سرو میشوند؛ ArvanCloud هنوز به ۲۴۹ اشاره میکند.
|
||
|
||
| | |
|
||
|---|---|
|
||
| ماشین | VMware، Ubuntu 24.04، ۸ هسته، ۱۲ گیگ رم، دیسکِ ۹۷ گیگ؛ hostname `tadrisino`، IPِ داخلی `192.168.200.163` (۲۴۹ = `.164`)، IPِ خروجی `78.38.35.228` |
|
||
| ورود | `ssh zareian@78.38.35.216` (sudo). `temp` حسابِ موقتِ ادمینِ شبکه است و حذف میشود. ورودِ root بسته، `AllowUsers zareian temp`، forwarding بسته (`10-tadrisino.conf`) |
|
||
| فایلها | نسخهی اصلیِ همهی اسکریپتها و تنظیمهای این سرور در `ops/server216/` (بیکلید). تغییر: فایلِ ریپو، بعد کپی روی سرور |
|
||
| ضدِ ربات | ۱) `tadrisino-blocklist.timer`: هر روز FireHOL level1 (Spamhaus، DShield، …) در ipsetِ `tadrisino-block`، همهی پورتها؛ شبکهی داخلی، `78.38.35.0/24` و رنجهای آروان هرگز در آن نیستند. ۲) `ufw limit 22`: بیش از ۶ اتصال در ۳۰ ثانیه رد. ۳) fail2ban پلهای: ۶ اشتباه در ۱۰ دقیقه → ۱۰ دقیقه، هر تکرار دو برابر تا یک هفته؛ `recidive`: ۵ بار در روز → یک هفته همهی پورتها؛ `192.168.200.0/24` هرگز. آزاد کردن: `sudo fail2ban-client set sshd unbanip <IP>` |
|
||
| بکاپ | **رمزنگاری روی همین سرور، ۲۴۷ فقط فایلِ رمزشده میبیند** (۲۴۷ مشترک است و هر کس `administrator` را دارد root است). pgBackRest با `repo1-cipher-type=aes-256-cbc`: آرشیوِ پیوستهی WAL (`archive_timeout=60`، یعنی حداکثر یک دقیقه تغییرِ آرشیونشده)، `tadrisino-pgbackrest-full` یکشنبهها، `-diff` هر روز، دو نسخهی کامل نگه داشته میشود. CSVهای پرداخت هر ۶ ساعت با age رمز میشوند (`tadrisino-export-payment-events`). `tadrisino-offsite-sync` هر دقیقه `/var/lib/pgbackrest` و `payment-log` را به `~/zareian-pgbackrest-216/` روی ۲۴۷ میفرستد؛ کلیدِ postgres در `authorized_keys`ِ ۲۴۷ با `from=192.168.200.163,restrict,command="rrsync -wo …"` فقط میتواند در همان پوشه بنویسد (نه بخواند، نه شِل). `/api/health` → `offsiteBackup` از `pg_stat_archiver` و `OFFSITE_SYNC_STATUS_FILE` |
|
||
| کلیدهای بازیابی | `/etc/tadrisino-backup/RECOVERY-KEYS` (root، 600): کلیدِ pgBackRest و کلیدِ age. **بیآنها بکاپِ ۲۴۷ خواندنی نیست**؛ یک نسخه بیرون از سرورها نزدِ صاحبِ محصول لازم است: `ssh -t zareian@78.38.35.216 sudo cat /etc/tadrisino-backup/RECOVERY-KEYS` در ترمینالِ خودش (نه در چت) |
|
||
| آزمونِ بازیابی | `sudo -u postgres tadrisino-restore-check` (ماهی یک بار): بازیابی در پوشهی موقت و مقایسهی شمارشها با دیتابیسِ زنده. بازیابیِ واقعی از ۲۴۷ (اگر ۲۱۶ از دست رفت): پوشهی `repo/` را از ۲۴۷ کپی، `pgbackrest.conf` با کلید از RECOVERY-KEYS، `pgbackrest --stanza=zareian restore` |
|
||
| اپ | کاربرِ بیsudoِ `mathapp`؛ `/home/mathapp/mathapp-v2` و `/home/mathapp/mathapp_frontend_new` |
|
||
| سرویسها | `zareian-mathapp` (= `gunicorn-mathapp`، با `RuntimeDirectory`)، `zareian-redis` (6390)، `postgresql@16-zareian` (5440)، `nginx`، `zareian-applog-archive.timer` |
|
||
| رمزها | رمزِ Postgres و Redis روی این سرور تازه ساخته شده (`.pg_password`، `.redis_password` کنارِ `.env`)، نه کپیِ ۲۴۹ |
|
||
| فایروال | ufw فقط 22/80/443. فایروالِ شبکهی شرکت 443ِ ورودی را هنوز میبندد (80 باز است) |
|
||
| بستهها | apt از `mirror.arvancloud.ir`، pip از `mirror-pypi.runflare.com` (`~mathapp/.config/pip/pip.conf`)، npm از `mirror-npm.runflare.com`؛ `files.pythonhosted.org` و `registry.npmjs.org` از این سرور بستهاند |
|
||
|
||
سنجیده: `/api/health` با همهی جزئیات ok، آزمونِ بازیابی از بکاپِ رمزشده با همان شمارشها، فایلهای ۲۴۷ رمزشده (`Salted__`،
|
||
`age-encryption.org/v1`)، کلیدِ ۲۱۶ روی ۲۴۷ نه میخواند نه شِل میگیرد، کپیِ ۲۴۷ با `rsync --checksum` عینِ مخزنِ محلی، Qdrant از این سرور وصل، فرانت ۲۰۰، ریبوت و
|
||
بالا آمدنِ خودکارِ همهی سرویسها، درخواستِ مستقیم به IP ۴۰۳ (nginx IPِ واقعی را میبیند)، بازیابیِ کامل از نسخهی رمزشدهی
|
||
خودِ ۲۴۷ (دیتابیس و CSV؛ عقبماندگیِ طراحیشده حداکثر ~۲ دقیقه). سفرِ کاملِ کاربر از nginx+TLS روی خودِ سرور (۲۹ مورد): فرانت و
|
||
هدرهای امنیتی، پیامکِ واقعیِ کاوهنگار و ورود، نقشه، آزمونِ جزیره و پایانش، پیِ زنده، سفارش و redirect به StartPayِ زرینپال (بیپرداخت)،
|
||
خروج و باطل شدنِ توکن. کاوهنگار و GapGPT از IPِ خروجیِ این سرور پذیرفتهاند. نسنجیده: هیچ درخواستی از مسیرِ ArvanCloud
|
||
(۴۴۳ بسته است)، پرداختِ کامل، خریدِ بازار.
|
||
|
||
### چه چیزی منتظرِ دیگران است
|
||
|
||
درخواست از سینیور فرستاده شده (۲۰۲۶-۱۰-۱۷): ۱) 443ِ ورودی روی فایروالِ شرکت؛ ۲) حذفِ `temp`؛ ۳) عوض کردنِ originِ
|
||
`math.tadrisino2.ir` و `mathapi.tadrisino2.ir` در پنلِ ArvanCloud به `78.38.35.216` (ما به پنل دسترسی نداریم؛ همزمان با قدمِ ۴ پایین)؛
|
||
۴) private کردنِ مخزن در Gitea؛ ۵) ۱۲ هسته و رمِ بیشتر (اختیاری؛ سرور ریبوت را تحمل میکند، تست شده). زرینپال و بازار به
|
||
دامنه بستهاند نه IP، پس در پنلِ آنها چیزی عوض نمیشود.
|
||
|
||
### سوئیچ، قدمبهقدم (وقتی سینیور گفت آماده است)
|
||
|
||
1. **پیششرط:** از لپتاپ `curl -sk -o /dev/null -w '%{http_code}' https://78.38.35.216/` باید ۴۰۳ بدهد (نه ۰۰۰: یعنی 443 باز است و
|
||
nginx فقط آروان را راه میدهد). کامیتها push شده باشند و `deploy.sh` روی هر دو سرور اجرا شده باشد (کدِ دو طرف یکی).
|
||
2. **۲۴۹ را بخوابان:** `sudo systemctl stop zareian-mathapp` (از این لحظه کاربران خطا میبینند تا قدمِ ۵؛ کلِ کار ~۱۵ دقیقه).
|
||
3. **دیتابیسِ نهایی:** `sudo -u postgres pg_dump -p 5440 -Fc zareian_mathapp` روی ۲۴۹، با pipe از لپتاپ به
|
||
`/home/zareian/stage/` روی ۲۱۶ (مسیرِ داخلیِ ۲۴۹→۲۱۶ کلید ندارد).
|
||
4. **روی ۲۱۶:** `systemctl stop zareian-mathapp`؛ `dropdb -p 5440 zareian_mathapp && createdb -p 5440 -O zareian_app zareian_mathapp` (با
|
||
`sudo -u postgres`)؛ `pg_restore -p 5440 -d zareian_mathapp --no-owner --role=zareian_app --exit-on-error`؛ شمارشِ `accounts_user`،
|
||
`content_question`، `payment_order`، `payment_paymentevent`، `content_useranswer` با همان پرسش روی ۲۴۹ یکی باشد؛ `systemctl start
|
||
zareian-mathapp`؛ health با `X-Internal-Key` بیهشدار. دادههای تستِ ۲۰۲۶-۱۰-۱۷ روی ۲۱۶ با همین قدم پاک میشوند.
|
||
5. **سینیور origin را در آروان عوض میکند.** بعد از بیرون: `https://mathapi.tadrisino2.ir/api/health` ok؛ در
|
||
`/var/log/nginx/access.log`ِ ۲۱۶ IPهای آروان دیده شوند؛ ورود در Chrome (روشِ OTPِ ادمین، بخشِ «ورود بیپیامک») و یک آزمون؛ کاربرِ
|
||
اندروید (WebView و `pay.html`) هم یک بار.
|
||
6. **بکاپِ تازه:** `sudo systemctl start tadrisino-pgbackrest-full` و بعد `tadrisino-offsite-sync`؛ `sudo -u postgres tadrisino-restore-check`.
|
||
7. **۲۴۹ را کنار بگذار (پاک نکن):** `sudo systemctl disable zareian-mathapp` روی ۲۴۹؛ اسلات را بردار تا WAL روی دیسکِ مشترک جمع نشود:
|
||
`sudo -u postgres psql -p 5440 -c "select pg_drop_replication_slot('zareian_247')"`؛ روی ۲۴۷ `systemctl disable --now zareian-pg-tunnel
|
||
zareian-pg-wal zareian-pg-basebackup.timer zareian-pg-export-events.timer zareian-applog-pull.timer`. دیتابیسِ ۲۴۹ یک هفته برای برگشت میماند.
|
||
8. **برگشت (اگر قدمِ ۵ خراب شد):** origin در آروان دوباره ۲۴۹، و روی ۲۴۹ `systemctl start zareian-mathapp`. فقط تا وقتی درست است که ۲۱۶
|
||
نوشتنِ واقعی (پرداخت، ثبتنام) نگرفته؛ بعد از آن اول dumpِ ۲۱۶ به ۲۴۹.
|
||
9. **اسناد:** CLAUDE.md (سرورِ زنده = ۲۱۶؛ رویهی دیپلو با `zareian@78.38.35.216` و `sudo -u mathapp ./deploy.sh`)، این بخش، و
|
||
`ops/nginx/` با مسیرهای `/home/mathapp` (۲۱۶ نسخهی sedشده را دارد).
|
||
|
||
### بعد از سوئیچ
|
||
|
||
- یک هفته بعد، با تأییدِ صاحبِ محصول: پاک کردنِ بکاپهای رمزنگارینشدهی ۲۴۹ روی ۲۴۷ (`~/zareian-pgbackup`، یونیتهای `zareian-pg-*`)
|
||
و حذفِ `_slot_state`/`OFFSITE_WAL_SLOT` از `config/health.py` و `settings.py`.
|
||
- لاگِ اپ: آرشیوِ شبانه روی ۲۱۶ هست (`zareian-applog-archive.timer`) ولی هنوز به ۲۴۷ نمیرود؛ راهِ پیشنهادی: افزودنِ
|
||
`logs/archive` به `tadrisino-offsite-sync` (همان rrsync، پوشهی `app-logs/`).
|
||
- ورود با کلیدِ SSH برای `zareian` (صاحبِ محصول خواسته فعلاً بماند؛ روی لپتاپ `ssh-keygen -t ed25519` و `ssh-copy-id`)، بعد
|
||
`PasswordAuthentication no` با `Match Address 192.168.200.0/24` برای ورودِ با رمز از شبکهی داخلی.
|
||
- توکنِ فقطخواندنیِ Gitea برای `deploy.sh` روی ۲۱۶ (بعد از private شدنِ مخزن، `git fetch` بیرمز دیگر کار نمیکند).
|
||
- `CORS_ALLOWED_ORIGINS` در `.env`ِ سرور هنوز `localhost:5174` دارد؛ حذفش.
|
||
- برنامهی صاحبِ محصول: مرورِ کاملِ یک فهرستِ امنیتیِ استاندارد (OWASP Top 10 / API Top 10 برای اپ، CIS Benchmark برای سرور، همراهِ
|
||
`docs/security-before-production.md`)، بعد لودتست روی ۲۱۶ (`tools/loadtest/README.md`؛ با هستهی بیشتر: ۸ worker و `DB_POOL_MAX_SIZE` 6).
|
||
|
||
### Qdrant
|
||
|
||
**وضعِ فعلی Qdrant:** بردارهای RAG روی `https://qdrant.tadrisino2.ir` هستند (پشت ArvanCloud؛ سرورِ اصلی از بیرون پیدا
|
||
نیست و در فهرستِ سرورهای ما نیست. `rag_catalog` آن را سرورِ تیمِ دیگر میداند). نسخهٔ 1.17.0، ۳۴ collection، حدود
|
||
۱۰۸۸۰ نقطه، همه با بُعد ۱۵۳۶ و Cosine.
|
||
|
||
**هدف:** همهٔ کتابهای ریاضیای که در Qdrant هست (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته؛ پایههای سوم تا دوازدهم) روی یک
|
||
Qdrant اختصاصی ببریم و اپ را به آن وصل کنیم، بدون ingest دوباره، چون ingest هزینهٔ GapGPT دارد.
|
||
|
||
**کدام collectionها (۱۵ تا، حدود ۵۶۸۰ نقطه):**
|
||
- ریاضی سوم، چهارم، پنجم، ششم، هفتم، هشتم، نهم، دهم.
|
||
- حسابان یازدهم، دوازدهم.
|
||
- هندسه دهم، یازدهم، دوازدهم.
|
||
- آمار و احتمال یازدهم.
|
||
- گسسته دوازدهم.
|
||
|
||
ریاضیِ سوم، ششم و هفتم را تیمِ دیگر ingest کرده، و صاحبِ محصول خواسته هر سه منتقل شوند. بقیهٔ collectionها (علوم، فارسی،
|
||
مطالعات اجتماعی، هدیهٔ آسمان، پیامهای آسمان) در این برنامه نیستند.
|
||
|
||
**اصولِ انتقال:**
|
||
- از هر collection یک snapshot بگیرید، فایلِ آن را منتقل و روی Qdrant جدید بارگذاری کنید (بدون فراخوانیِ OpenAI).
|
||
- بعد از انتقال: تعدادِ نقاط، بُعد و Cosine یکی باشند؛ یک بردارِ ذخیرهشده را بهعنوانِ query بدهید و همان نقطه بالا بیاید.
|
||
- `/api/rag/ingest` هنوز روی Qdrant فعلی مینویسد. در بازهٔ کپی ingest متوقف شود، یا بعد از سوئیچ نقاطِ تازه دوباره همگام شوند.
|
||
- `QDRANT_URL` و `QDRANT_API_KEY` را فقط **بعد** از تأییدِ کپی عوض کنید؛ زودتر عوض شود، تیوتور جوابِ ناقص میدهد.
|
||
|
||
**باز:** محلِ سرورِ اصلیِ فعلیِ Qdrant (ادعای «روی سرورِ تیمِ دیگر» هنوز از روی سرور تأیید نشده). سرورِ جدید فعلاً به همان
|
||
`qdrant.tadrisino2.ir` وصل است.
|
||
|
||
## لاگها
|
||
|
||
هر درخواستِ `/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)
|
||
> را کامل ببندید (پنلِ ادمینِ عمومی، کدِ ورودِ بیهش، سقفِ پیامک، دسترسیِ مشترکِ سرور، …).
|
||
|