Files
MathApp/docs/operations.md
Zareian ba4ecff29e Server hardening: nginx origin locked to ArvanCloud + CSP, demo logins removed, docs
- ops/nginx/: the two nginx sites as they now run on 249. Only ArvanCloud
  edges reach the app (direct hits on the IP get 403, so Arvan's DDoS/WAF
  can't be skipped); the frontend gets CSP (own sources only, the one inline
  script by hash), X-Frame-Options, nosniff, Referrer-Policy,
  Permissions-Policy, HSTS; API body limit 100M -> 20M plus HSTS. Checked from
  outside and in Chrome (all tabs and an island quiz, logged in): no CSP errors.
- Demo accounts (users 11-15, 25) and TEST_LOGIN_PHONES removed from the server;
  the health warning for fixed-code logins now has a test and says how to
  test instead (an OTPCode made in the admin).
- SMS daily cap on the server is 2000 (launch of ~600 users).
- CLAUDE.md: never run tests with --parallel on macOS, always --noinput
  (both hang forever without an error).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:17:11 +03:30

39 KiB
Raw 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 روی ماشینِ تازه، و سفارش‌های چند ثانیه‌ی آخر را با پنلِ زرین‌پال/بازار تطبیق دهید.

بکاپِ دستیِ SQLite (توسعه‌ی محلی):

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؛ کپیِ ساده وسطِ یک نوشتن، فایلِ نیمه‌کاره می‌دهد.

بازیابی: سرویس را بخوابانید، فایل را برگردانید، دوباره بالا بیاورید.

sudo systemctl stop zareian-mathapp
cp .deploy-backups/db-<timestamp>.sqlite3 db.sqlite3
sudo systemctl start zareian-mathapp
curl https://mathapi.tadrisino2.ir/api/health

متغیرهای محیطی

.env خودکار خوانده می‌شود (python-dotenv) و مقادیرش با پیش‌فرض‌های کد ادغام می‌شوند، نه جایگزینشان. پس یک .env ناقص روی سرور دیگر باعثِ بالا نیامدن نمی‌شود. .env.example توضیحِ کاملِ هر کدام را دارد.

متغیر اگر ست نشود چه می‌شود
DJANGO_DEBUG خاموش (0) — که حالتِ درستِ پروداکشن است
DJANGO_SECRET_KEY یک کلیدِ تصادفیِ قوی ساخته و در .django_secret_key (مجوز 0600) ذخیره می‌شود
PUBLIC_BASE_URL · FRONTEND_URL دامنه‌ی فعلی (mathapi.tadrisino2.ir / math.tadrisino2.ir). بقیه‌ی تنظیم‌های دامنه از همین دو ساخته می‌شوند؛ بخشِ «عوض کردنِ دامنه»
DJANGO_ALLOWED_HOSTS · CSRF_TRUSTED_ORIGINS · CORS_ALLOWED_ORIGINS از دو آدرسِ بالا ساخته می‌شوند؛ مقدارِ env فقط دامنه‌ی اضافه است
CACHE_URL سرویس بالا می‌آید ولی نرخ بینِ workerها مشترک نیست؛ /api/health هشدار می‌دهد. روی سرور به Redisِ اختصاصیِ خودمان (zareian-redis، پورت ۶۳۹۰) اشاره می‌کند، نه Redisِ مشترکِ ماشین. با آن، قطعیِ Redis API را ۵۰۰ نمی‌کند ولی تا برگشتنش سقف‌های مبتنی بر کش اعمال نمی‌شوند و health ۵۰۳ می‌دهد (config/cache.py)
KAVENEGAR_API_KEY POST /api/auth/otp/request خطای صریحِ 503 می‌دهد، نه موفقیتِ ساختگی
ZARINPAL_MERCHANT_ID مسیرِ پرداخت fail-closed می‌ماند (503)
gunicorn.conf.py (در ریپو، نه env) gunicorn خودش از پوشه‌ی کاری می‌خواندش: ۴ workerِ gthread با ۳۲ نخ و timeoutِ ۱۲۰. تعدادِ workerها همین‌جاست؛ یونیتِ systemd دیگر --workers ندارد (اگر داشته باشد بر این فایل مقدم است)
DB_POOL_MAX_SIZE · DB_POOL_MIN_SIZE · DB_POOL_TIMEOUT ۸ · ۲ · ۱۰ ثانیه: هر worker حداکثر ۸ اتصالِ Postgres دارد و نخ‌ها آن را فقط تا پایانِ درخواست قرض می‌گیرند. اگر تا ۱۰ ثانیه اتصالی آزاد نشد، درخواست ۵۰۳ با Retry-After می‌گیرد (نه ۵۰۰). workers × DB_POOL_MAX_SIZE + 6 باید زیرِ max_connections (روی سرور ۶۰ − ۳) بماند؛ /api/health → dbPool آن را می‌سنجد و هشدار می‌دهد. DB_CONN_MAX_AGE دیگر پذیرفته نمی‌شود (برنامه بالا نمی‌آید)
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 از 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. 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، چند دیتابیس و اپ‌های تیم‌های دیگر در حالِ کارند.

مالِ ما دقیقاً یک دایرکتوری است: /home/administrator/mathapp-v2، که با gunicorn-mathapp.service سرو می‌شود. همان یونیت با نامِ zareian-mathapp هم پاسخ می‌دهد و همیشه همین نام را بنویسید:

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 را هم با ما پایین می‌کشد.

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 تا درگاه.

لاگ‌ها

هر درخواستِ /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 را کامل ببندید: کلیدِ امضا، حساب‌های دمو، جزئیاتِ عمومیِ /api/health، مستنداتِ عمومیِ API، جدولِ رتبه‌بندیِ عمومی، پنلِ ادمین، پورتِ ۸۰ و دسترسیِ سرور.