make the domain configuration, not code: two env vars move everything

A domain change is coming (the Zarinpal terminal rejects the current callback
domain). ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, CORS defaults, the Bazaar
redirect URI and deploy.sh's public health URL each spelled the domain out.
They now derive from PUBLIC_BASE_URL and FRONTEND_URL, which keep today's
domain as their default; env values for the three lists are still added on
top, so the old domain can stay allowed while DNS moves.

ZARINPAL_CALLBACK_URL used os.environ.get(key, default), so an empty key in
.env produced an empty callback and every payment would fail closed. It now
uses the same `.strip() or` fallback as its neighbours; the new test caught it.

config/tests_domains.py loads settings in a subprocess with a new domain and
fails if any derived value still mentions the old one. docs/operations.md
gains the step-by-step domain move (DNS, .env, Zarinpal/Bazaar panels,
frontend config.js, verification). CLAUDE.md records that the frontend is
now ours and where the rebuild stands. 624 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 23:46:25 +03:30
parent 2f8ed50b86
commit 4b246e0c70
6 changed files with 188 additions and 84 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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ِ خام روی دامنه‌ی

62
config/tests_domains.py Normal file
View File

@@ -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'])

View File

@@ -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

View File

@@ -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` هیچ‌کس نمی‌تواند با شماره