Files
MathApp/docs/operations.md
Zareian 5492788bf8 make the README an entry point and move procedures into docs/
The README had grown into a 295-line mixture of onboarding, an ops runbook, a
content-authoring workflow, bulk question generation and a Docker log stack. A
reader arriving at the repo could not tell in ten seconds what this service is,
whether it is live, or where to start, and five of its claims no longer matched
the code:

- it said to export .env values by hand; settings.py has loaded .env through
  python-dotenv for a while now
- it listed six variables as "everything the app actually reads"; .env.example
  has carried many more than that for some time
- it called DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS and CACHE_URL required in
  production; all three now have working defaults, and the app boots without
  them on purpose so a partial .env cannot take the service down
- it claimed there is no insecure SECRET_KEY fallback; there is one, under DEBUG
- it required Python 3.12 while the project runs on 3.11 (Docker uses 3.12)

The README now states what the service is, where it runs, how healthy it is and
how to get it running in six commands, then links onward. Procedures moved to
docs/operations.md, docs/content-authoring.md, docs/architecture.md and
docs/docker.md, which is where someone follows a procedure rather than skims it.
Nothing was dropped; the deploy one-liner now includes the separate sudo restart
that deploy.sh cannot perform itself, and backup/restore is written down for the
first time.

Every number in the README was measured rather than quoted: content counts from
the live /api/health and content/seed/curriculum.json, 55 endpoints from the
generated OpenAPI schema, 481 tests from a full run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:12:04 +03:30

12 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 تایم‌اوت می‌دهد).

بررسیِ سلامت

curl https://api.math.tadrisino2.ir/api/health

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

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

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

curl -i https://api.math.tadrisino2.ir/api/schema/
curl -i -X POST https://api.math.tadrisino2.ir/api/auth/session \
  -H 'Content-Type: application/json' \
  -d '{"type":"guest","deviceId":"verify-1"}'

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

db.sqlite3 در گیت نیست و تنها نسخه‌ی داده‌ی کاربران است. deploy.sh پیش از migrate بکاپ نمی‌گیرد؛ تا وقتی این خودکار نشده، دستی بگیرید:

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://api.math.tadrisino2.ir/api/health

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

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

متغیر اگر ست نشود چه می‌شود
DJANGO_DEBUG خاموش (0) — که حالتِ درستِ پروداکشن است
DJANGO_SECRET_KEY یک کلیدِ تصادفیِ قوی ساخته و در .django_secret_key (مجوز 0600) ذخیره می‌شود
DJANGO_ALLOWED_HOSTS دامنه‌های خودمان پیش‌فرض‌اند
CSRF_TRUSTED_ORIGINS · CORS_ALLOWED_ORIGINS · PUBLIC_BASE_URL پیش‌فرضِ درست دارند
CACHE_URL سرویس بالا می‌آید ولی نرخ بینِ workerها مشترک نیست؛ /api/health هشدار می‌دهد
KAVENEGAR_API_KEY POST /api/auth/otp/request خطای صریحِ 503 می‌دهد، نه موفقیتِ ساختگی
ZARINPAL_MERCHANT_ID مسیرِ پرداخت fail-closed می‌ماند (503)
OPENAI_API_KEY · QDRANT_API_KEY POST /api/ai/ask همان پاسخِ stub را می‌دهد
TEST_LOGIN_PHONES هیچ حسابِ تستی باز نیست (بخشِ بعد)

DJANGO_SECRET_KEY توکن‌های ورود را امضا می‌کند. بهترین حالت این است که اصلاً ست نشود تا خودِ تنظیمات یکی بسازد — آن‌وقت کلیدی وجود ندارد که لو برود. هرگز نگذارید سرور روی fallbackِ django-insecure-… کار کند: آن رشته در همین ریپو کامیت شده، پس هر کسی که ریپو را بخواند می‌تواند به‌جای هر کاربری توکن جعل کند. manage.py check --deploy با کدِ security.W009 علامتش می‌زند.

DJANGO_DEBUG هرگز روی سرور 1 نشود. DEV_MASTER_OTP (کد 0000) را روشن می‌کند که هر شماره‌ای را به‌عنوانِ ورودِ معتبر می‌پذیرد، و صفحه‌های خطا کلِ تنظیمات را نشان می‌دهند. جایش فقط .env است، نه یونیتِ systemd.

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

تا وقتی KAVENEGAR_API_KEY ست نشده، با DEBUG=0 هیچ‌کس نمی‌تواند با شماره وارد شود. برای اینکه ورودِ تیم به یک نفرِ خاص وابسته نباشد، چند شماره‌ی تست با کدِ ثابت تعریف کنید:

TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947

برای این شماره‌ها پیامکی ارسال نمی‌شود و همان کد همیشه معتبر است. هر شماره‌ی دیگری مسیرِ عادیِ OTP را می‌رود، بی‌هیچ تغییری.

curl -X POST https://api.math.tadrisino2.ir/api/auth/otp/request \
     -H 'Content-Type: application/json' -d '{"phone":"09120000001"}'
curl -X POST https://api.math.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، و پیش از باز شدنِ سرویس روی کاربرانِ واقعی خالی‌اش کنید.

سرور

api.math.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).