Found in a deeper pass over what the frontend's flow actually touches:
- Nothing in the app ever wrote StreakRecord: "days in a row" was 0 forever
for every real user. Answers and lesson/island completions now record the
day (Tehran time); a broken chain shows 0. A streak error never fails the
answer itself.
- /api/leaderboard only changed when someone ran rebuild_leaderboard by
hand. It is now computed live (60s cache); weekly counts island XP too.
- gunicorn ran 3 sync workers with a 30s timeout, so every "ask Pi" stream
pinned a whole worker (~10s, GapGPT buffers the answer) and three at once
queued the entire API. gunicorn.conf.py (read automatically from the
working directory) switches to gthread, 8 threads, 120s timeout.
- Every /api/ response now says Cache-Control: private, no-store, so turning
on caching in the ArvanCloud panel can never serve one student another's data.
- /api/user/summary named no active plan ever ("subscription app not
implemented yet" had long stopped being true).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 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 تایماوت میدهد).
بررسیِ سلامت
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) |
gunicorn.conf.py (در ریپو، نه env) |
gunicorn خودش از پوشهی کاری میخواندش: workerهای gthread با ۸ نخ و timeoutِ ۱۲۰. بدونِ آن، سه استریمِ همزمانِ دستیار کلِ API را قفل میکرد |
FRONTEND_URL · ZARINPAL_REDIRECT_URL |
بعد از پرداخت کاربر به https://math.tadrisino2.ir/?orderId=…&status=paid|failed برمیگردد. ZARINPAL_REDIRECT_URL=- یعنی همان پاسخِ JSONِ قدیمی |
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، و پیش از باز شدنِ سرویس روی کاربرانِ واقعی خالیاش کنید.
حسابِ مهمانِ تستِ فرانت
مسیرِ مهمان (POST /api/auth/session با یک deviceId) اصلاً به پیامک کار
ندارد و همیشه کار میکند — هر رشتهای بهعنوانِ deviceId یک حسابِ تازه
میسازد. از ۲۰۲۶-۰۹-۲۴ پاسخش refreshToken هم دارد (مثلِ ورود با شماره)، پس
مهمان بعد از انقضای توکنِ یکساعته با POST /api/auth/refresh ادامه میدهد.
یک deviceId مشخص روی پروداکشن از قبل داده دارد:
curl -X POST https://api.math.tadrisino2.ir/api/auth/session \
-H 'Content-Type: application/json' \
-d '{"type":"guest","deviceId":"frontend-test-device"}'
کاربرِ شمارهی ۶، «حساب تست فرانت». رویش ۲۴ ردیفِ Notification و ۲۰ ردیفِ
LeaderboardEntry (هفتگی و کل، با ترتیبِ عمداً متفاوت) نشسته تا تیمِ فرانت
بتواند آن صفحهها را قبل از وجودِ ترافیکِ واقعی ببیند.
⚠️ این داده دستی روی دیتابیسِ پروداکشن نوشته شده و در گیت نیست. پیش از باز
شدنِ سرویس روی کاربرانِ واقعی باید برود، وگرنه اعلانها و لیدربردِ ساختگی را
کاربرِ واقعی میبیند. purge_demo_data اینها را نمیگیرد — آن فرمان فقط
دنبالِ پیشوندِ [SEED]/[DEMO] روی Grade/Plan/Character/User میگردد. پاک
کردن یعنی حذفِ دستیِ ردیفهای Notification و LeaderboardEntry از پنلِ
ادمین (اول بکاپ، بخشِ بالا).
سرور
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 # مالِ ما نیست — اپِ تیمِ دیگر
قواعدی که قابلِ مذاکره نیستند:
- هیچ فرمانِ مخربی بیرون از
~/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).