Files
MathApp/docs/operations.md
Zareian 4c601087dc deploy.sh: health check through the gunicorn socket, optional DEPLOY_BUNDLE when Gitea is unreachable
From the server the public URL goes through ArvanCloud and returned 504/000 (2026-10-07), so the
final health step failed even for a healthy service. It now asks the gunicorn socket (path read from
the unit) with our Host header first. DEPLOY_BUNDLE=<file> fetches from a git bundle instead of Gitea,
only when set explicitly; without it an unreachable Gitea still stops the deploy.

Tested: probe_health against a throwaway unix-socket server and a dead URL (bash -n clean).
Not tested: the whole script end to end, until the next deploy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:57:50 +03:30

25 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

بدونِ احرازِ هویت و بدونِ SSH می‌گوید: دیتابیس سالم است یا نه، مهاجرتِ اعمال‌نشده مانده یا نه، کش کار می‌کند یا نه، چند پایه/درس/سوال روی آن سرور هست، کدام سرویسِ بیرونی تنظیم شده (فقط بله/خیر)، و چه هشدارهای امنیتی‌ای فعال‌اند. 200 سالم، 503 خرابیِ واقعی. هیچ سکرتی برنمی‌گرداند.

مهم‌ترین بررسی‌اش «مهاجرتِ اعمال‌نشده» است: شایع‌ترین حالتِ دیپلویِ خراب این است که کد رفته ولی migrate اجرا نشده — اپ بالا می‌آید و بعد زیرِ بارِ واقعی با خطای ستونِ ناموجود می‌شکند.

تأییدِ سریعِ اینکه API واقعاً جواب می‌دهد:

curl -i https://mathapi.tadrisino2.ir/api/schema/
curl -i https://mathapi.tadrisino2.ir/api/health

بکاپ و بازیابی

db.sqlite3 در گیت نیست و تنها نسخه‌ی داده‌ی کاربران است. deploy.sh پیش از migrate خودش بکاپ می‌گیرد (.deploy-backups/db-before-deploy-<زمان>.sqlite3، با integrity_check، ۲ تای آخر نگه داشته می‌شود) و اگر بکاپ نشد migrate را اجرا نمی‌کند. این بکاپ روی همان ماشین است؛ نسخه‌ی بیرون از ماشین هنوز نیست. برای بکاپِ دستی (مثلاً پیش از تغییرِ .env یا کارِ ادمین):

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 هشدار می‌دهد
KAVENEGAR_API_KEY POST /api/auth/otp/request خطای صریحِ 503 می‌دهد، نه موفقیتِ ساختگی
ZARINPAL_MERCHANT_ID مسیرِ پرداخت fail-closed می‌ماند (503)
gunicorn.conf.py (در ریپو، نه env) gunicorn خودش از پوشه‌ی کاری می‌خواندش: workerهای gthread با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ هم‌زمانِ دستیار کلِ API را قفل می‌کرد
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. وارسی: 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/ در ریپوی فرانت) بسته‌ها را با پرداختِ درون‌برنامه‌ایِ بازار می‌فروشد؛ در مرورگرِ معمولی همه‌چیز مثلِ قبل از زرین‌پال است.

  1. در پیشخانِ بازار برای هر بسته یک محصولِ مصرف‌شدنی با همان قیمت بسازید.
  2. شناسه‌ی آن (SKU) را در /admin/subscription/plan/ در فیلدِ «Bazaar sku» همان بسته بنویسید. سرور SKU را فقط از همین‌جا می‌گیرد، نه از اپ؛ بسته‌ی بی‌SKU داخلِ اپ «به‌زودی در بازار» نشان داده می‌شود و درخواستش ۴۰۰ است. یک SKU روی دو بسته نمی‌نشیند.
  3. کلیدِ RSAِ عمومیِ اپ (از پیشخان) در android/app/build.gradle.kts می‌رود، نه در .env.

مسیر: POST /api/subscription/purchase با paymentMethod: "bazaar" (فقط بسته) سفارش می‌سازد و bazaarSku برمی‌گرداند؛ اپ با Poolakey می‌خرد (payload = شماره‌ی سفارش)؛ POST /api/payment/bazaar/verify توکن را از خودِ بازار می‌سنجد و اعتبار می‌دهد. کدِ تخفیف روی سفارشِ بازار رد می‌شود (بازار قیمتِ ثابتِ محصول را می‌گیرد). تست‌ها: payment/tests_bazaar_pack.py. هنوز هیچ خریدِ واقعی‌ای از بازار انجام نشده.

ورودِ توسعه‌دهنده‌ها (بدونِ سرویسِ پیامک)

تا وقتی 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            # مالِ ما نیست — اپِ تیمِ دیگر

قواعدی که قابلِ مذاکره نیستند:

  1. هیچ فرمانِ مخربی بیرون از ~/mathapp-v2 اجرا نشود. مسیر را کامل بنویسید، به دایرکتوریِ جاری تکیه نکنید.
  2. git clean آنجا هرگز اجرا نشود. db.sqlite3، .env و .django_secret_key هیچ‌کدام در گیت نیستند و همان‌جا زندگی می‌کنند؛ reset --hard کاری با آن‌ها ندارد ولی clean -fd دیتابیس، پیکربندی و همه‌ی نشست‌های فعال را نابود می‌کند.
  3. پروژه هرگز پاک و «از نو clone» نشود — به همان دلیل. برای برگرداندنِ درخت به حالتِ سالم از git reset --hard origin/main استفاده کنید.
  4. .env فقط از راهِ Django خوانده شود. cat .env نه، و مقادیرش در هیچ چتی paste نشود.
  5. df -h / را قبل از هر کارِ پرنوشت چک کنید. دیسک مدتی‌ست حدودِ ۹۴٪ پر است؛ پر شدنش سرویسِ ایمیل و Gitea را هم با ما پایین می‌کشد.

لاگ‌ها

هر درخواستِ /api/ به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌شود (method/path/status/duration/user — بدونِ بدنه، هدر یا سکرت) توسطِ config.middleware.APIRequestLoggingMiddleware. خروجی روی stdout است و اگر LOG_DIR ست باشد در app.log هم نوشته می‌شود.

هر خطای پیش‌بینی‌نشده همیشه پاسخِ JSON برمی‌گرداند — نه صفحه‌ی HTML پیش‌فرضِ جنگو — و کامل با traceback لاگ می‌شود (config/exceptions.py).


پیش از آمدنِ کاربرِ واقعی فهرستِ security-before-production.md را کامل ببندید: کلیدِ امضا، حساب‌های دمو، جزئیاتِ عمومیِ /api/health، مستنداتِ عمومیِ API، جدولِ رتبه‌بندیِ عمومی، پنلِ ادمین، پورتِ ۸۰ و دسترسیِ سرور.