diff --git a/.env.example b/.env.example index 5891d45..9aa4620 100644 --- a/.env.example +++ b/.env.example @@ -38,20 +38,22 @@ CACHE_URL= # TADRISINO_SERVICE= # ----------------------------------------------------------------------- -# آدرسِ سرویس — این چهار تا پیش‌فرضِ درست دارند +# آدرسِ سرویس — دو آدرس، بقیه از آن‌ها ساخته می‌شوند # ----------------------------------------------------------------------- -# تا پیش از این هر چهارتا باید دستی روی سرور اضافه می‌شدند، و چون در همین -# فایل نبودند هر بار از نو کشف می‌شدند: نبودِ اولی یعنی «Disallowed Host»، -# دومی یعنی ردِ ورود به پنلِ ادمین، سومی یعنی بلاک شدنِ فرانت در مرورگر، و -# چهارمی یعنی شکستنِ بی‌سروصدای مسیرِ پرداخت. +# ALLOWED_HOSTS، CSRF_TRUSTED_ORIGINS، CORS_ALLOWED_ORIGINS، callbackِ زرین‌پال، +# برگشت از پرداخت و redirectِ بازار همه از این دو ساخته می‌شوند. پیش‌فرضشان +# دامنه‌ی فعلی است و لازم نیست ست شوند. برای جابه‌جایی دامنه فقط همین دو را +# باز کنید (ترتیبِ کار در docs/operations.md، «عوض کردنِ دامنه»): # -# حالا هر چهارتا مقدارِ پیش‌فرضِ دامنه‌ی خودمان را دارند و لازم نیست ست شوند. -# فقط اگر دامنه عوض شد، این‌ها را باز کنید: -# -# DJANGO_ALLOWED_HOSTS=api.math.tadrisino2.ir,math.tadrisino2.ir,localhost,127.0.0.1 -# CSRF_TRUSTED_ORIGINS=https://api.math.tadrisino2.ir -# CORS_ALLOWED_ORIGINS=https://math.tadrisino2.ir # PUBLIC_BASE_URL=https://api.math.tadrisino2.ir +# FRONTEND_URL=https://math.tadrisino2.ir +# +# این سه فقط برای دامنه‌ی *اضافه* لازم‌اند (مثلاً دامنه‌ی قبلی در دورانِ جابه‌جایی)؛ +# مقدارشان به پیش‌فرض‌ها اضافه می‌شود، جایگزینشان نمی‌شود: +# +# DJANGO_ALLOWED_HOSTS=old.example.ir +# CSRF_TRUSTED_ORIGINS=https://old.example.ir +# CORS_ALLOWED_ORIGINS=https://old-app.example.ir # اختیاری — بدونش OTP واقعی ارسال نمی‌شه (فقط خطای صریح ۵۰۳ برمی‌گرده، # طبق رفتارِ موجودِ accounts/services.py) @@ -115,7 +117,7 @@ RAG_FORCE_MOCK= # ----------------------------------------------------------------------- # بدونِ ZARINPAL_MERCHANT_ID، مسیرِ پرداخت fail-closed می‌مونه (خطای صریحِ # ۵۰۳ به‌جایِ پرداختِ ساختگیِ موفق). -# PUBLIC_BASE_URL بالاتر توضیح داده شده؛ پیش‌فرض دارد. +# callbackِ زرین‌پال از PUBLIC_BASE_URL (بالاتر) ساخته می‌شود. ZARINPAL_MERCHANT_ID= # 1 = محیطِ sandbox زرین‌پال (برای تست بدونِ پرداختِ واقعی) ZARINPAL_SANDBOX=1 diff --git a/CLAUDE.md b/CLAUDE.md index 78e1fd3..2d1465d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,48 +119,37 @@ than a coding one, and never assume a step was done just because it was describe ## Frontend ↔ backend (read before wiring the two together) -The app is two repos and two domains: the React frontend lives in -`Tadrisino/MathApp_frontend` (deployed at `math.tadrisino2.ir`, owned by another -developer) and this backend serves `api.math.tadrisino2.ir`. Our typed client for them -is `src/api/` inside their repo, delivered by pull request — we have read-only Gitea -access there, so changes go via a fork + PR, never a direct push. +The app is two repos and two domains: the React frontend `Tadrisino/MathApp_frontend` +(deployed at `math.tadrisino2.ir`) and this backend (`api.math.tadrisino2.ir`). +**Since 2026-09-26 the owner maintains both:** the frontend developer is on leave and +`AliReza_Zareian` has `write` on the frontend repo (branch protection on its `main` +unknown — not visible without admin). Local clone: `~/Desktop/MathApp_frontend`. -**The one trap that keeps biting.** Their `App.tsx` calls relative paths -(`fetch('/api/auth/login')`). Those resolve against the *frontend* origin, where nginx -answers any unknown path with `index.html` — so the response is `200` with an HTML body -and the code dies on `res.json()` with `Unexpected token '<'`. Verify with -`curl https://math.tadrisino2.ir/api/grades`: HTTP 200, HTML. Two fixes exist, both -shipped in PR #3: a `vite` dev proxy (dev works unchanged) and an exported `apiUrl()` -for builds. **The better long-term fix is infrastructure, not code** — proxy `/api` on -the frontend domain to this backend, which makes everything same-origin and removes CORS -entirely. That needs the server admin, not us. +**State on 2026-09-26:** the frontend was rebuilt from the single 3,071-line `App.tsx` +into a typed, modular app (branch `rebuild/v2`; its README is the map). Every item of +the old handoff list (`~/Desktop/کارهای-فرانت-تدریسینو.html`) is implemented, plus +screens for profile, streak, support, parents and invite. Verified by a headless-Chrome +walkthrough against a local backend; real SMS login and a real Zarinpal payment were not +exercised. **The live site still serves the 2026-09-21 build** that calls a relative +`/api` and gets HTML back — nothing reaches users until someone builds and deploys the +frontend, and who deploys `math.tadrisino2.ir` is not known to us yet. -**State on 2026-09-23 (checked against upstream `main`, last commit 2026-09-21):** the -screens are wired to the API (PR #4/#5 upstream), but `App.tsx` builds the client with -`env.VITE_API_URL ?? "/api"` — while their own `.env.example` and `client.ts` name the -variable `VITE_API_BASE_URL`. The deployed bundle has neither, so it calls the relative -`/api` and gets HTML back: **math.tadrisino2.ir cannot reach the backend** until either -that line falls back to `https://api.math.tadrisino2.ir/api` or nginx proxies `/api`. -Our PR #3 fixed the same trap and is still open, now likely conflicting with their -rewrite. Every path their client calls exists and was driven live against the server. -The app also generates a random `deviceId` per browser, so the seeded -`frontend-test-device` account only loads after -`localStorage.setItem("deviceId", "frontend-test-device")`. - -**Order matters when bringing the stack up:** backend first, then frontend. If the -frontend is tested while `api.math.tadrisino2.ir` is down, every screen fails and it -looks like the client is broken. +**Domains are configuration, not code.** Backend: only `PUBLIC_BASE_URL` and +`FRONTEND_URL`; hosts, CSRF, CORS, Zarinpal callback/return, Bazaar redirect and the +`deploy.sh` health URL derive from them (`config/tests_domains.py` guards it). Frontend: +`dist/config.js` at runtime, else `VITE_API_BASE_URL` from `.env.production`. A domain +move is expected (Zarinpal `-14`); the procedure is in `docs/operations.md` +(«عوض کردنِ دامنه»). **Never hand-write the client from memory.** Three of fifteen calls were silently wrong -the first time. Dump the real contract with -`python manage.py spectacular --file schema.yaml --format openapi`, then drive the actual -client module against a running server (`runserver` on a spare port + `npx tsx`) before -sending anything. `content/tests_frontend_contract.py` is the backend-side guard. +the first time, and the rebuilt client found two more the old one had (`POST` instead +of `PATCH` on notifications, a dropped guest refresh token). Read the contract from +`python manage.py spectacular --file schema.yaml --format openapi` and check shapes +against a running server. `content/tests_frontend_contract.py` is the backend-side guard. -**Content the frontend expects but nobody has filled in yet:** `Unit.blurb`, +**Content the frontend shows but nobody has filled in yet:** `Unit.blurb`, `Plan.features`/`badge`, `Lesson.example`/`steps`. All optional, all editable from the -admin panel. `Node.icon`/`color` fall back to a deterministic palette so cards are never -colourless. +admin panel. ## Operations diff --git a/config/settings.py b/config/settings.py index 4cc8c6e..34243dc 100644 --- a/config/settings.py +++ b/config/settings.py @@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ import os import re from pathlib import Path +from urllib.parse import urlsplit from django.core.exceptions import ImproperlyConfigured @@ -89,20 +90,47 @@ def _with_defaults(name: str, defaults: list[str]) -> list[str]: return out -# دامنه‌های شناخته‌شده‌ی خودمان همیشه مجازند تا یک .env قدیمی یا ناقص باعثِ -# DisallowedHost نشود؛ هر چه در محیط باشد به این‌ها اضافه می‌شود. +# --- دامنه‌ها ---------------------------------------------------------------- +# دو آدرسِ عمومی؛ تنها جای کد که دامنه در آن نوشته شده. ALLOWED_HOSTS، CSRF، +# CORS، callbackِ زرین‌پال، برگشت از پرداخت و redirectِ بازار همه از همین دو +# ساخته می‌شوند. برای جابه‌جایی دامنه فقط این دو را در .env عوض کنید؛ جزئیات +# و ترتیبِ کار در docs/operations.md («عوض کردنِ دامنه»). +# +# پیش‌فرض دارند چون خالی بودنشان سرویس را نمی‌خواباند، فقط مسیرِ پرداخت و +# فرانت را بی‌صدا می‌شکند. `.strip() or` لازم است: کلید ممکن است در .env +# باشد ولی خالی، و os.environ.get فقط وقتی کلید *نباشد* پیش‌فرض می‌دهد. +PUBLIC_BASE_URL = ( + os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://api.math.tadrisino2.ir' +).rstrip('/') +# آدرسِ خودِ اپ (فرانت)؛ برگشت از درگاه و CORS از آن ساخته می‌شوند. +FRONTEND_URL = ( + os.environ.get('FRONTEND_URL', '').strip() or 'https://math.tadrisino2.ir' +).rstrip('/') + + +def _origin(url): + parts = urlsplit(url) + return f'{parts.scheme}://{parts.netloc}' if parts.scheme and parts.netloc else '' + + +def _hostname(url): + return urlsplit(url).hostname or '' + + +# دامنه‌های خودمان (از دو آدرسِ بالا) همیشه مجازند تا یک .env قدیمی یا ناقص +# باعثِ DisallowedHost نشود؛ هر چه در محیط باشد به این‌ها اضافه می‌شود. در دورانِ +# جابه‌جاییِ دامنه، دامنه‌ی قبلی را در DJANGO_ALLOWED_HOSTS نگه دارید تا تا +# عوض شدنِ DNS هر دو جواب بدهند. DEFAULT_ALLOWED_HOSTS = [ - 'api.math.tadrisino2.ir', - 'math.tadrisino2.ir', - 'localhost', '127.0.0.1', -] + h for h in (_hostname(PUBLIC_BASE_URL), _hostname(FRONTEND_URL)) if h +] + ['localhost', '127.0.0.1'] ALLOWED_HOSTS = _with_defaults('DJANGO_ALLOWED_HOSTS', DEFAULT_ALLOWED_HOSTS) # لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود # «CSRF verification failed» می‌دهد و کسی نمی‌فهمد چرا. CSRF_TRUSTED_ORIGINS = _with_defaults( 'CSRF_TRUSTED_ORIGINS', - ['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir'], + [o for o in (_origin(PUBLIC_BASE_URL), _origin(FRONTEND_URL)) if o], ) # --- امنیتِ پشتِ پراکسی --------------------------------------------------- @@ -133,9 +161,7 @@ SESSION_COOKIE_HTTPONLY = True # تنها راهش env بود، یک دیپلویِ فراموش‌شده یعنی بک‌اندِ سالم که مرورگر همه‌ی # درخواست‌هایش را بلاک می‌کند — خطایی که در سمتِ سرور هیچ ردی نمی‌گذارد و فقط # در کنسولِ مرورگر دیده می‌شود. -CORS_DEFAULT_ORIGINS = [ - 'https://math.tadrisino2.ir', # فرانتِ مستقر -] +CORS_DEFAULT_ORIGINS = [o for o in (_origin(FRONTEND_URL),) if o] # فرانتِ مستقر CORS_ALLOWED_ORIGINS = _with_defaults('CORS_ALLOWED_ORIGINS', CORS_DEFAULT_ORIGINS) # ⚠️ پورتِ سرورِ توسعه را نباید فهرست کرد. فهرستِ ثابتِ قبلی (۳۰۰۰ و ۵۱۷۳) روزی @@ -733,11 +759,11 @@ BAZAAR_PISHKHAN_API_SECRET = os.environ.get('BAZAAR_PISHKHAN_API_SECRET', '').st BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar.ir/devapi/v2') # Redirect URI که در پنل بازار ثبت شده؛ باید با آن *عیناً* یکی باشد. به # خواستِ مدیرِ حسابِ بازار (۲۰۲۶-۰۹-۲۶) همان آدرسِ verify است: GETِ آن codeِ -# بازار را می‌گیرد و POSTِ آن خریدِ اپ را. PUBLIC_BASE_URL پایین‌تر تعریف -# شده، پس آدرس اینجا کامل نوشته می‌شود. +# بازار را می‌گیرد و POSTِ آن خریدِ اپ را. با عوض شدنِ PUBLIC_BASE_URL این هم +# عوض می‌شود، و آن‌وقت در پنلِ بازار هم باید عوض شود. BAZAAR_REDIRECT_URI = ( os.environ.get('BAZAAR_REDIRECT_URI', '').strip() - or 'https://api.math.tadrisino2.ir/api/payment/bazaar/verify' + or f'{PUBLIC_BASE_URL}/api/payment/bazaar/verify' ) # چند ساعت لینکِ «اجازه»ی بازار باز می‌ماند؛ بعد از اتصالِ موفق خودش بسته می‌شود. BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOURS', '72')) @@ -745,16 +771,8 @@ BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOUR # ----------------------------------------------------------------------- # Zarinpal (درگاهِ اصلیِ پرداختِ ما — Payment API v4) # ----------------------------------------------------------------------- -# آدرسِ عمومیِ بک‌اند: هم paymentUrlی که به کلاینت برمی‌گردد از آن ساخته -# می‌شود، هم callback_urlی که به زرین‌پال می‌رود. -# -# پیش‌فرض دارد چون خالی بودنش سرویس را نمی‌خواباند، فقط مسیرِ پرداخت را -# بی‌صدا می‌شکند و تا اولین پرداختِ واقعی معلوم نمی‌شود. `.strip() or` هم -# لازم است: کلید در .env سرور هست ولی خالی، و os.environ.get فقط وقتی کلید -# *نباشد* پیش‌فرض می‌دهد. -PUBLIC_BASE_URL = ( - os.environ.get('PUBLIC_BASE_URL', '').strip() or 'https://api.math.tadrisino2.ir' -).rstrip('/') +# paymentUrlی که به کلاینت برمی‌گردد و callback_urlی که به زرین‌پال می‌رود +# هر دو از PUBLIC_BASE_URL (بخشِ «دامنه‌ها» در بالای همین فایل) ساخته می‌شوند. ZARINPAL_MERCHANT_ID = os.environ.get('ZARINPAL_MERCHANT_ID', '') ZARINPAL_SANDBOX = os.environ.get('ZARINPAL_SANDBOX') == '1' @@ -768,14 +786,12 @@ ZARINPAL_STARTPAY_BASE = os.environ.get( 'https://sandbox.zarinpal.com/pg/StartPay' if ZARINPAL_SANDBOX else 'https://www.zarinpal.com/pg/StartPay', ) -ZARINPAL_CALLBACK_URL = os.environ.get( - 'ZARINPAL_CALLBACK_URL', - f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback' if PUBLIC_BASE_URL else '', +# `.strip() or` مثلِ بقیه: کلیدِ خالی در .env نباید callback را خالی کند (آن‌وقت +# هر پرداختی بی‌صدا ۵۰۳ می‌گرفت). +ZARINPAL_CALLBACK_URL = ( + os.environ.get('ZARINPAL_CALLBACK_URL', '').strip() + or f'{PUBLIC_BASE_URL}/api/payment/zarinpal/callback' ) -# آدرسِ خودِ اپ (فرانت) برای برگرداندنِ کاربر بعد از پرداخت. -FRONTEND_URL = ( - os.environ.get('FRONTEND_URL', '').strip() or 'https://math.tadrisino2.ir' -).rstrip('/') # بعدِ verify، مرورگر را به این URL با ?orderId=...&status=... برمی‌گردانیم # (برای اپِ موبایل می‌تواند یک deep link باشد). پیش‌فرض خودِ اپِ وب است؛ قبلاً # خالی بود و دانش‌آموز بعد از پرداختِ واقعی روی یک صفحه‌ی JSONِ خام روی دامنه‌ی diff --git a/config/tests_domains.py b/config/tests_domains.py new file mode 100644 index 0000000..16b795f --- /dev/null +++ b/config/tests_domains.py @@ -0,0 +1,62 @@ +""" +دامنه فقط از PUBLIC_BASE_URL و FRONTEND_URL می‌آید. + +تنظیمات در زمانِ import ساخته می‌شوند، پس هر حالت در یک پروسه‌ی جدا با env +خودش بار می‌شود. اگر کسی دوباره دامنه‌ای را مستقیم در settings بنویسد، حالتِ +«دامنه‌ی تازه» اینجا قرمز می‌شود. +""" +import json +import os +import subprocess +import sys +from pathlib import Path + +from django.test import SimpleTestCase + +BASE_DIR = Path(__file__).resolve().parent.parent + +PROBE = """ +import json, os +os.environ['DJANGO_SETTINGS_MODULE'] = 'config.settings' +import django; django.setup() +from django.conf import settings as s +print(json.dumps({ + 'hosts': s.ALLOWED_HOSTS, 'csrf': s.CSRF_TRUSTED_ORIGINS, 'cors': s.CORS_ALLOWED_ORIGINS, + 'callback': s.ZARINPAL_CALLBACK_URL, 'redirect': s.ZARINPAL_REDIRECT_URL, 'bazaar': s.BAZAAR_REDIRECT_URI, +})) +""" + + +def load_settings(**env): + clean = {k: v for k, v in os.environ.items() if not k.startswith(('DJANGO_ALLOWED', 'CSRF_', 'CORS_', 'ZARINPAL_', 'BAZAAR_REDIRECT', 'PUBLIC_BASE', 'FRONTEND_URL'))} + clean.update(env) + # .env محلی نباید مقدارهای آزمون را بپوشاند؛ python-dotenv متغیرِ موجود را عوض نمی‌کند، + # پس کلیدهای خالی را هم صریح می‌گذاریم. + for key in ('DJANGO_ALLOWED_HOSTS', 'CSRF_TRUSTED_ORIGINS', 'CORS_ALLOWED_ORIGINS', 'ZARINPAL_CALLBACK_URL', + 'ZARINPAL_REDIRECT_URL', 'BAZAAR_REDIRECT_URI'): + clean.setdefault(key, '') + out = subprocess.run([sys.executable, '-c', PROBE], cwd=BASE_DIR, env=clean, capture_output=True, text=True, check=True) + return json.loads(out.stdout.strip().splitlines()[-1]) + + +class DomainSettingsTests(SimpleTestCase): + def test_new_domain_moves_everything(self): + s = load_settings(PUBLIC_BASE_URL='https://api.new-domain.ir', FRONTEND_URL='https://app.new-domain.ir/') + self.assertIn('api.new-domain.ir', s['hosts']) + self.assertIn('app.new-domain.ir', s['hosts']) + self.assertIn('https://app.new-domain.ir', s['cors']) + self.assertIn('https://api.new-domain.ir', s['csrf']) + self.assertEqual(s['callback'], 'https://api.new-domain.ir/api/payment/zarinpal/callback') + self.assertEqual(s['redirect'], 'https://app.new-domain.ir/') + self.assertEqual(s['bazaar'], 'https://api.new-domain.ir/api/payment/bazaar/verify') + self.assertNotIn('tadrisino2', json.dumps(s)) + + def test_extra_domain_is_added_not_replacing(self): + s = load_settings( + PUBLIC_BASE_URL='https://api.new-domain.ir', FRONTEND_URL='https://app.new-domain.ir', + DJANGO_ALLOWED_HOSTS='old.example.ir', CORS_ALLOWED_ORIGINS='https://old.example.ir', + ) + self.assertIn('old.example.ir', s['hosts']) + self.assertIn('api.new-domain.ir', s['hosts']) + self.assertIn('https://old.example.ir', s['cors']) + self.assertIn('https://app.new-domain.ir', s['cors']) diff --git a/deploy.sh b/deploy.sh index 76c9a0a..849b819 100755 --- a/deploy.sh +++ b/deploy.sh @@ -27,15 +27,15 @@ fail() { printf '%s✖ %s%s\n' "$C_ERR" "$1" "$C_OFF" >&2; exit 1; } # اگر TADRISINO_HEALTH_URL ست نباشد چند آدرس امتحان می‌شود: پورتِ محلی ممکن # است فرق کند (سوکتِ یونیکس، پورتِ دیگر)، و در آن حالت دیپلویِ موفق هم پیامِ -# خطا می‌گرفت. آدرسِ عمومی نشان می‌دهد سرویس واقعاً از بیرون جواب می‌دهد. +# خطا می‌گرفت. آدرسِ عمومی نشان می‌دهد سرویس واقعاً از بیرون جواب می‌دهد؛ آن +# را پایین‌تر، بعد از فعال شدنِ venv، از خودِ تنظیماتِ جنگو (PUBLIC_BASE_URL) +# می‌خوانیم تا با عوض شدنِ دامنه این فایل دست نخورد. if [ -n "${TADRISINO_HEALTH_URL:-}" ]; then HEALTH_URLS=("$TADRISINO_HEALTH_URL") else HEALTH_URLS=( "http://127.0.0.1:8000/api/health" "http://127.0.0.1:8000/api/health/" - "https://api.math.tadrisino2.ir/api/health" - "https://api.math.tadrisino2.ir/api/health/" ) fi @@ -262,6 +262,12 @@ fi # ─── ۸. آیا واقعاً بالا آمد؟ ─────────────────────────────────────────────── step "بررسیِ سلامت" +if [ -z "${TADRISINO_HEALTH_URL:-}" ]; then + PUBLIC_URL="$(python -c 'import django, os; os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings"); django.setup(); from django.conf import settings; print(settings.PUBLIC_BASE_URL)' 2>/dev/null | tail -n 1)" + if [ -n "$PUBLIC_URL" ]; then + HEALTH_URLS+=("$PUBLIC_URL/api/health" "$PUBLIC_URL/api/health/") + fi +fi CODE=000; BODY="" for attempt in 1 2 3 4 5 6 7 8 9 10; do for url in "${HEALTH_URLS[@]}"; do diff --git a/docs/operations.md b/docs/operations.md index f501447..0815bb5 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -101,13 +101,13 @@ curl https://api.math.tadrisino2.ir/api/health |---|---| | `DJANGO_DEBUG` | خاموش (`0`) — که حالتِ درستِ پروداکشن است | | `DJANGO_SECRET_KEY` | یک کلیدِ تصادفیِ قوی ساخته و در `.django_secret_key` (مجوز `0600`) ذخیره می‌شود | -| `DJANGO_ALLOWED_HOSTS` | دامنه‌های خودمان پیش‌فرض‌اند | -| `CSRF_TRUSTED_ORIGINS` · `CORS_ALLOWED_ORIGINS` · `PUBLIC_BASE_URL` | پیش‌فرضِ درست دارند | +| `PUBLIC_BASE_URL` · `FRONTEND_URL` | دامنه‌ی فعلی (`api.math.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 را قفل می‌کرد | -| `FRONTEND_URL` · `ZARINPAL_REDIRECT_URL` | بعد از پرداخت کاربر به `https://math.tadrisino2.ir/?orderId=…&status=paid\|failed` برمی‌گردد. `ZARINPAL_REDIRECT_URL=-` یعنی همان پاسخِ JSONِ قدیمی | +| `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` | هیچ حسابِ تستی باز نیست (بخشِ بعد) | @@ -145,6 +145,35 @@ pendingِ همان کاربر را دوباره با زرین‌پال می‌س > روشن می‌کند که هر شماره‌ای را به‌عنوانِ ورودِ معتبر می‌پذیرد، و صفحه‌های خطا > کلِ تنظیمات را نشان می‌دهند. جایش فقط `.env` است، نه یونیتِ systemd. +## عوض کردنِ دامنه + +دامنه در کد فقط یک‌جا آمده: پیش‌فرضِ `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.math.tadrisino2.ir,math.tadrisino2.ir` و + `CORS_ALLOWED_ORIGINS=https://math.tadrisino2.ir`. بعد ری‌استارت. +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ِ + خودتان باشد) و یک بار ورود و باز کردنِ یک درس در اپ. + +مرحله‌ی ۳ را فقط یک پرداختِ واقعی ثابت می‌کند؛ تا آن انجام نشده، پرداخت را +«کار می‌کند» ننویسید. + ## ورودِ توسعه‌دهنده‌ها (بدونِ سرویسِ پیامک) تا وقتی `KAVENEGAR_API_KEY` ست نشده، با `DEBUG=0` هیچ‌کس نمی‌تواند با شماره