39 KiB
عملیات
دیپلوی، پیکربندی، سلامت و بازیابی. برای شروعِ توسعهی محلی 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 همان ریپو).
ترتیبِ کار، از بیرون به داخل:
- DNS، آروان و گواهی برای دامنههای تازه، رو به همین سرور، و
server_nameِ nginx — کارِ ادمینِ سرور، نه ما. - بکاند: در
.envسرورPUBLIC_BASE_URLوFRONTEND_URLرا عوض کنید. تا DNS کامل جابهجا شود، دامنههای قبلی را هم نگه دارید، مثلاًDJANGO_ALLOWED_HOSTS=<دامنهی قبلیِ API>وCORS_ALLOWED_ORIGINS=https://<دامنهی قبلیِ فرانت>. بعد ریاستارت. - زرینپال: دامنهی تازهی API را در پنلِ ترمینال ثبت کنید. خطای
-14(«دامنهی callback با دامنهی ثبتشده نمیخواند») دقیقاً نبودنِ همین است. بازار (اگر استفاده شود): Redirect URI در پنل باید عیناًPUBLIC_BASE_URL/api/payment/bazaar/verifyشود. - فرانت: روی سرورِ فرانت در
dist/config.jsبنویسیدapiBaseUrl: "https://<دامنهی تازهی API>/api"(یا.env.productionرا عوض و build کنید)، و کشِ آروان را برایconfig.jsوindex.htmlپاک کنید. - وارسی:
curl https://<دامنهی تازهی API>/api/health(درclientIpباید IPِ خودتان باشد) و یک بار ورود و باز کردنِ یک درس در اپ.
مرحلهی ۳ را فقط یک پرداختِ واقعی ثابت میکند؛ تا آن انجام نشده، پرداخت را «کار میکند» ننویسید.
لغو و بازپرداختِ سفارش (از پنلِ ادمین)
از ۲۰۲۶-۱۰-۰۲. همه از /admin/payment/order/ انجام میشود و SSH لازم نیست:
- سفارش را با شمارهاش یا شناسهی کاربر (
user_id) پیدا کنید. شمارهی فاکتورِTD-000123یعنی سفارشِ ۱۲۳. - تیکش را بزنید و از فهرستِ «Action» یکی را انتخاب کنید:
- «لغوِ سفارشِ در انتظار»: برای سفارشی که هنوز پرداخت نشده. پولی جابهجا نشده است. اگر کاربر همان لحظه در درگاه پرداخت کرده باشد، تأییدِ زرینپال سفارش را دوباره «پرداختشده» میکند و او چیزش را میگیرد.
- «بازپرداخت»: برای سفارشِ پرداختشده. اول یک صفحهی تأیید میآید. بعد از تأیید، سفارش «بازپرداختشده» میشود و روزهای اشتراکِ همان سفارش از پایانِ اشتراک کم میشود (اگر چیزی نماند، اشتراک «لغوشده» میشود). پورسانتِ معرف هم برمیگردد. پیامِ بالای صفحه میگوید دقیقاً چه چیزی پس گرفته شد.
- خودِ پول را این دکمه برنمیگرداند. پول را در پنلِ زرینپال برگردانید. خریدِ کافهبازار هم از سمتِ خودِ بازار برگردانده میشود.
وضعیتِ سفارش در فرمِ ادمین فقطخواندنی است. اگر وضعیت را با دست از «پرداختشده» به «ناموفق» عوض
میکردید، اشتراکی که سفارش داده بود سرِ جایش میماند. کاربر بازپرداخت را در صفحهی
«پرداختها و فاکتورها» میبیند. کد در 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 |
گذاشته شد |
مسیر:
POST /api/subscription/purchaseباpaymentMethod: "bazaar"→ سفارش وbazaarSku.- (اختیاری)
POST /api/discount/redeemروی همان سفارش. فقط با کلیدِ «تخفیف پویا»، وگرنه ۴۰۰. POST /api/payment/bazaar/orders/{id}/start→ SKU، و اگر تخفیف خوردهdynamicPriceToken(JWTِ HS256 با مبلغ به ریال). بیتخفیف توکن نمیرود چون بازارِ قدیمیتر از ۱۳.۳ با آن خطا میدهد. از این لحظه کدِ تخفیف نمینشیند.- اپ با Poolakey اشتراک میخرد (
subscribeProduct،payload= شمارهی سفارش). POST /api/payment/bazaar/verifyتوکن را از خودِ بازار میسنجد و اشتراک را فعال میکند. اشتراک consume نمیشود.
تمدیدِ خودکار: بازار به ما خبر نمیدهد. وقتی دورهی پرداختشدهی کاربر (Order.bazaar_valid_until) کمتر از یک روز
مانده یا گذشته، با باز شدنِ وضعیتِ اشتراک (GET /api/subscription/status) همان توکن از بازار پرسیده میشود (هر توکن
ساعتی یک بار)، و همینطور وقتی کاربرِ بیاشتراکِ فعال خانه یا فصلِ پولی را باز میکند (has_subscription_access). اگر validUntil جلو رفته، برای هر دوره یک سفارشِ «پرداختشده» ساخته میشود و روزها اضافه میشوند
(payment.services.sync_bazaar_renewals). مبلغِ آن سفارش قیمتِ فعلیِ ادمین است، چون بازار مبلغِ تمدید را نمیگوید.
اگر بازار در دسترس نباشد، کاربرِ تمدیدشده تا تلاشِ ساعتِ بعد پشتِ دیوار میماند. لغوِ تمدید از خودِ بازار است.
تستها: payment/tests_bazaar_subscription.py، payment/tests_bazaar_oauth.py.
هیچ خریدِ واقعیای از بازار انجام نشده؛ نه پذیرفتنِ JWT از سوی بازار و نه تمدیدِ واقعی.
ورودِ توسعهدهندهها (بدونِ سرویسِ پیامک)
تا وقتی KAVENEGAR_API_KEY ست نشده، با DEBUG=0 هیچکس نمیتواند با شماره
وارد شود. برای اینکه ورودِ تیم به یک نفرِ خاص وابسته نباشد، چند شمارهی تست با
کدِ ثابت تعریف کنید:
TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
برای این شمارهها پیامکی ارسال نمیشود و همان کد همیشه معتبر است. هر شمارهی دیگری مسیرِ عادیِ OTP را میرود، بیهیچ تغییری.
curl -X POST https://mathapi.tadrisino2.ir/api/auth/otp/request \
-H 'Content-Type: application/json' -d '{"phone":"09120000001"}'
curl -X POST https://mathapi.tadrisino2.ir/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"phone":"09120000001","otp":"834512"}'
این همان الگوییست که Firebase Auth (با نامِ fictional phone numbers) و Apple (حسابِ دمو برای بررسیِ App Store) برای همین مسئله دارند.
- با
DEV_MASTER_OTPیکی نیست. آن کدِ0000هر شمارهای را میپذیرد و فقط باDEBUG=1روشن میشود. این فقط همین چند شماره را، هرکدام با کدِ خودش. - کد در پاسخِ API برنمیگردد، پس دانستنِ شماره برای ورود کافی نیست. سقفِ تلاشِ ورودِ هر شماره روی اینها هم اعمال میشود، پس حدسِ کد بسته است.
- مقدارِ نامعتبر سرویس را بالا نمیآورد و پیامِ صریح میدهد — بیصدا رد
نمیشود. کد باید ۴ تا ۶ رقم باشد (
LoginRequestSerializerسقفِ ۶ کاراکتر دارد). /api/healthتا وقتی پر باشد هشدار میدهد — فقط تعداد، نه شماره و نه کد.- شماره را دقیقاً همانطور بنویسید که کلاینت میفرستد؛ این کدبیس شماره را
normalize نمیکند، پس
09121111111و+989121111111دو کاربرِ جدا هستند. - کدِ تصادفی بگذارید، نه
1234، و پیش از باز شدنِ سرویس روی کاربرانِ واقعی خالیاش کنید.
حسابِ نمایشی
حسابِ «علی رضایی» (کاربرِ ۱۰) پُر از داده است و با شمارهی مالکِ پروژه وصل میشود؛ بقیهی دموها با TEST_LOGIN_PHONES وارد میشوند.
⚠️ این داده دستی روی دیتابیسِ پروداکشن نوشته شده و در گیت نیست. پیش از باز
شدنِ سرویس روی کاربرانِ واقعی باید برود، وگرنه اعلانها و لیدربردِ ساختگی را
کاربرِ واقعی میبیند. purge_demo_data اینها را نمیگیرد — آن فرمان فقط
دنبالِ پیشوندِ [SEED]/[DEMO] روی Grade/Plan/Character/User میگردد. پاک
کردن یعنی حذفِ دستیِ ردیفهای Notification و LeaderboardEntry از پنلِ
ادمین (اول بکاپ، بخشِ بالا).
سرور
mathapi.tadrisino2.ir روی یک ماشینِ مشترکِ شرکت اجرا میشود
(administrator@78.38.35.249)، نه سروری که مالِ این پروژه باشد. روی همان ماشین
Gitea، سرویسِ ایمیل، DNS، چند دیتابیس و اپهای تیمهای دیگر در حالِ کارند.
مالِ ما دقیقاً یک دایرکتوری است: /home/administrator/mathapp-v2، که با
gunicorn-mathapp.service سرو میشود. همان یونیت با نامِ zareian-mathapp هم
پاسخ میدهد و همیشه همین نام را بنویسید:
sudo systemctl restart zareian-mathapp # مالِ ما
sudo systemctl restart gunicorn # مالِ ما نیست — اپِ تیمِ دیگر
قواعدی که قابلِ مذاکره نیستند:
- هیچ فرمانِ مخربی بیرون از
~/mathapp-v2اجرا نشود. مسیر را کامل بنویسید، به دایرکتوریِ جاری تکیه نکنید. git cleanآنجا هرگز اجرا نشود.db.sqlite3،.envو.django_secret_keyهیچکدام در گیت نیستند و همانجا زندگی میکنند؛reset --hardکاری با آنها ندارد ولیclean -fdدیتابیس، پیکربندی و همهی نشستهای فعال را نابود میکند.- پروژه هرگز پاک و «از نو clone» نشود — به همان دلیل. برای برگرداندنِ
درخت به حالتِ سالم از
git reset --hard origin/mainاستفاده کنید. .envفقط از راهِ Django خوانده شود.cat .envنه، و مقادیرش در هیچ چتی paste نشود.df -h /را قبل از هر کارِ پرنوشت چک کنید. دیسک مدتیست حدودِ ۹۴٪ پر است؛ پر شدنش سرویسِ ایمیل و Gitea را هم با ما پایین میکشد.
لاگها
هر درخواستِ /api/ بهصورتِ یک شیءِ JSON در هر خط لاگ میشود
(method/path/status/duration/user — بدونِ بدنه، هدر یا سکرت) توسطِ
config.middleware.APIRequestLoggingMiddleware. خروجی روی stdout است و اگر
LOG_DIR ست باشد در app.log هم نوشته میشود.
هر خطای پیشبینینشده همیشه پاسخِ JSON برمیگرداند — نه صفحهی HTML پیشفرضِ
جنگو — و کامل با traceback لاگ میشود (config/exceptions.py).
پیش از آمدنِ کاربرِ واقعی فهرستِ
security-before-production.mdرا کامل ببندید: کلیدِ امضا، حسابهای دمو، جزئیاتِ عمومیِ/api/health، مستنداتِ عمومیِ API، جدولِ رتبهبندیِ عمومی، پنلِ ادمین، پورتِ ۸۰ و دسترسیِ سرور.