POST /api/auth/session is gone (404). Existing is_guest rows keep refresh and upgrade-by-phone until the demo/guest rows are purged; the code that serves them is listed in docs/security-before-production.md §10. Tests that used the endpoint as a fixture now use accounts/testing.guest_session. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
24 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://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 بکاپ نمیگیرد؛ تا وقتی این خودکار نشده، دستی بگیرید:
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 هم؛ پیشفرض ندارد) |
DEMO_GUEST_DEVICE_IDS |
فقط frontend-test-device با «حذفِ حساب» پاک نمیشود. با پاک شدنِ دادهی دمو، پیشفرض هم از config/settings.py برداشته شود |
اتصال به بازار — روشِ جدید (ترجیحی): در پیشخانِ بازار روی برنامه ← «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) را در
/admin/subscription/plan/در فیلدِ «Bazaar sku» همان بسته بنویسید. سرور SKU را فقط از همینجا میگیرد، نه از اپ؛ بستهی بیSKU داخلِ اپ «بهزودی در بازار» نشان داده میشود و درخواستش ۴۰۰ است. یک SKU روی دو بسته نمینشیند. - کلیدِ 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، و پیش از باز شدنِ سرویس روی کاربرانِ واقعی خالیاش کنید.
حسابِ مهمانِ تستِ فرانت
ورودِ مهمان (POST /api/auth/session) حذف شده و دیگر با frontend-test-device توکن نمیدهد. ردیفِ
کاربرِ شمارهی ۶ («حساب تست فرانت»، is_guest=True) با ۲۴ ردیفِ Notification و ۲۰ ردیفِ
LeaderboardEntry هنوز روی پروداکشن است؛ برای دیدنِ آن صفحهها با حسابِ دموی شمارهدار
(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، جدولِ رتبهبندیِ عمومی، پنلِ ادمین، پورتِ ۸۰ و دسترسیِ سرور.