# عملیات دیپلوی، پیکربندی، سلامت و بازیابی. برای شروعِ توسعه‌ی محلی `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`ِ ۲۴۷. زیرِ ۳ گیگ فضای آزاد اجرا نمی‌شود. ### لاگِ برنامه (`app.log`): برای همیشه روی ۲۴۷ با `LOG_FILE_ROTATION=external` در `.env`ِ ۲۴۹ برنامه لاگ را پاک نمی‌کند (`WatchedFileHandler`). هر شب ۰۰:۰۵ `zareian-applog-archive.timer` (۲۴۹) با `ops/applogs/archive-app-log.sh` فایل را به `logs/archive/app-.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ِ ثبت‌شده در پنلِ بازار `/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` است. فهرست را از در 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 ` | | بکاپ | **رمزنگاری روی همین سرور، ۲۴۷ فقط فایلِ رمزشده می‌بیند** (۲۴۷ مشترک است و هر کس `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ِ ورودی باز شود. ۲) روی ۲۴۹ `zareian-mathapp` متوقف (پیامِ نگه‌داری در ArvanCloud)، `pg_dump -Fc`، بازیابی روی سرورِ جدید در دیتابیسِ خالی‌شده، مقایسه‌ی تعدادِ ردیف‌ها. ۳) originِ هر دو دامنه در ArvanCloud به `78.38.35.216`. ۴) health و یک ورود و یک آزمون از بیرون. ۵) `tadrisino-pgbackrest-full` یک بار دستی (بعد از بازیابیِ دیتابیسِ نهایی)؛ روی ۲۴۷ یونیت‌های بکاپِ ۲۴۹ (`zareian-pg-*`) متوقف و، با تأییدِ صاحبِ محصول، بکاپ‌های رمزنگاری‌نشده‌ی ۲۴۹ (`~/zareian-pgbackup`) پاک؛ `pull-app-logs.sh` به آدرسِ جدید؛ در کدِ health، `_slot_state` و `OFFSITE_WAL_SLOT` حذف. ۶) CLAUDE.md و این سند. **باز، بعد از سوئیچ:** ورود با کلیدِ SSH برای `zareian` (صاحبِ محصول روی لپ‌تاپ `ssh-keygen -t ed25519` و `ssh-copy-id`)، بعد `PasswordAuthentication no` با `Match Address 192.168.200.0/24` برای ورودِ با رمز از شبکه‌ی داخلی؛ private کردنِ مخزن در Gitea (الان بی‌رمز از اینترنت خواندنی است) و توکنِ فقط‌خواندنی برای `deploy.sh`. تا سوئیچ، `deploy.sh` روی هر دو سرور اجرا شود تا کدِ دو طرف یکی بماند. ### 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) > را کامل ببندید (پنلِ ادمینِ عمومی، کدِ ورودِ بی‌هش، سقفِ پیامک، دسترسیِ مشترکِ سرور، …).