# عملیات دیپلوی، پیکربندی، سلامت و بازیابی. برای شروعِ توسعه‌ی محلی `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 تایم‌اوت می‌دهد)، `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: " 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`ِ ۲۴۷. زیرِ ۳ گیگ فضای آزاد اجرا نمی‌شود. ### کپیِ زنده چطور کار می‌کند 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-.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` دیگر پذیرفته نمی‌شود (برنامه بالا نمی‌آید) | | `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ِ ثبت‌شده در پنلِ بازار `/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/` در ریپوی فرانت) با پرداختِ درون‌برنامه‌ایِ بازار می‌فروشد. در برگه‌ی پرداخت دو کاشی همیشه کنارِ هم‌اند و کاربر هر کدام را بخواهد می‌زند: «کارتِ بانکی» (زرین‌پال، پیش‌فرض) و «کافه‌بازار». در مرورگر، یا وقتی پلن 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 از سوی بازار و نه تمدیدِ واقعی. ## ورودِ توسعه‌دهنده‌ها (بدونِ سرویسِ پیامک) تا وقتی `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، > جدولِ رتبه‌بندیِ عمومی، پنلِ ادمین، پورتِ ۸۰ و دسترسیِ سرور.