Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown, with rag/services/mathtext.py converting whatever still slips through; full step-by-step solutions with a check before the final answer; three tones by grade; the last 6 messages of the conversation go to the model and follow-ups are no longer served from another user's cache; book solutions the checker disputes are kept out of the context; a questionId gives the tutor the practice question and the student's wrong answer. /api/health reports when the AI credit runs out. rag_model_benchmark compares chat models on verified book problems (run on the server; credit ran out mid-run). Map: only lessons that have questions count toward finishing an island, lessonIds[0] / nextLessonId is always the next lesson to play, and a finished island stays finished. The Seyar characters (sigma/delta/pi) are seeded. Audit fixes, each reproduced before fixing: - the plan is stored on the order, so paying a monthly order after starting a yearly checkout no longer grants a year - opening checkout no longer demotes an active subscription; renewals extend from the current expiry; expired subscriptions are not reported active - one discount code per order and only before the gateway; a 100% code completes the purchase; lost Zarinpal confirmations are re-verified - the unused /api/payment/webhook "mark paid" endpoint is removed - XP counts only answers recorded on the server - 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout - a guest who signs up keeps their account and progress (test phones excluded) - affiliate commission on the first real payment; guests excluded - parents with several children no longer get a 500 - SQLite runs transactions IMMEDIATE, since it ignores select_for_update - admin login is rate-limited; display names drop phone numbers and links; userQuestion is capped at 2000 characters - server-side heart enforcement exists behind HEARTS_ENFORCED (off until the frontend handles 403 no_hearts) Not done on purpose: rotating the server's secret key and moving to Postgres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 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 |
هیچ حسابِ تستی باز نیست (بخشِ بعد) |
HEARTS_ENFORCED |
خاموش: با صفر قلب هم میشود جواب داد. 1 وقتی فرانت برای ۴۰۳ِ no_hearts صفحهی «قلبها تمام شد» دارد |
INTERNAL_SERVICE_KEY |
endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بستهاند |
پرداختهای تأییدنشده: اپ هر بار که وضعیتِ اشتراک را میخواند، سفارشهای
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.
ورودِ توسعهدهندهها (بدونِ سرویسِ پیامک)
تا وقتی 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).