Files
MathApp/docs/operations.md

52 KiB
Raw Permalink Blame History

عملیات

دیپلوی، پیکربندی، سلامت و بازیابی. برای شروعِ توسعه‌ی محلی README.md را ببینید.

دیپلوی

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 دیپلو را می‌ایستاند.

بررسیِ سلامت

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 واقعاً جواب می‌دهد:

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-*.

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 mathapp، IPِ داخلی 192.168.200.163 (۲۴۹ = .164)، IPِ خروجی 78.38.35.228
ورود دو حساب با sudo: zareian (صاحبِ محصول) و mathapp-admin (مشترکِ سینیور و صاحبِ شرکت؛ رمزش در فایلِ رمز زیرِ AdminUser). temp حذف شد (۲۰۲۶-۱۰-۱۷؛ authorized_keysش در /home/zareian/backups/). ورودِ root بسته، AllowUsers zareian mathapp-admin، 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 حذف و mathapp-admin ساخته شد)؛ ۳) عوض کردنِ 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 را کامل ببندید (پنلِ ادمینِ عمومی، کدِ ورودِ بی‌هش، سقفِ پیامک، دسترسیِ مشترکِ سرور، …).