Files
MathApp/docs/frontend
Zareian 7307bfcadb Remove silent fallbacks found in the 2026-10-03 audit
- /api/ai/ask: the stub answer is reachable only under DEBUG or
  RAG_FORCE_MOCK. Without RAG keys on the server it used to return 200 with
  "this is a stub, set OPENAI_API_KEY" and spend the student's quota; now 503.
- /api/home: the `grade` alias is gone and sending it is a 400 instead of a
  silent fallback to the user's own grade. No client sent it.
- activate_subscription logs ERROR when the paid order's plan no longer exists
  (it silently became 30 days).
- resolve_grade_title logs instead of `except: pass`; curriculum_io no longer
  reads an `intro_text` key no file contains.
- CLAUDE.md: "No silent fallbacks" rules for future agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:38:42 +03:30
..

راهنمای اتصالِ فرانت‌اند به این بک‌اند

این پوشه برای تیمِ فرانت (MathApp_frontend) است. api.ts یک کلاینتِ تایپ‌دار است که از روی OpenAPI واقعیِ همین سرور نوشته شده — نامِ فیلدها حدس نیست.

cp docs/frontend/api.ts <پروژه‌ی-فرانت>/src/lib/api.ts
import { createClient } from './lib/api';
export const api = createClient({ baseUrl: import.meta.env.VITE_API_URL ?? '/api' });

نگاشتِ صفحاتِ فعلیِ فرانت به API

فرانت الان (src/app/App.tsx) خروجیِ Figma Make است و هیچ فراخوانیِ APIای ندارد؛ همه‌ی داده‌ها هاردکد است. این جدول می‌گوید هر صفحه به چه چیزی وصل شود.

صفحه‌ی فرانت فراخوانی نکته
SplashScreen api.auth.isSignedIn() «قبلاً حساب دارم» → صفحه‌ی ورود
— (وجود ندارد، باید ساخته شود) auth.requestOtp → auth.login پایین ببینید
OnboardingScreen — ایستا
ProfileSetupScreen profile.update({name}) + profile.setGrade(id) GRADES هاردکد را با api.grades() عوض کنید
HomeScreen api.home() + api.summary() بدونِ آرگومان، پایه‌ی خودِ کاربر ملاک است
SkillMapScreen api.home() → units[].nodes[] status = locked / unlocked / in_progress / completed
LessonScreen lesson.intro(id) + lesson.questions(id) پایین ببینید
QuestionScreen lesson.answer(qid, optId) → lesson.complete(id) mascotMood حالتِ رُبو را می‌دهد
AskAIScreen ai.askStream({userQuestion, lessonId}, onDelta) فقط lessonId کافی‌ست
SubscriptionScreen subscription.plans() + subscription.purchase(planId) قیمت‌های هاردکد را بردارید

چهار چیزی که الان با فرانت جور نیست

۱. صفحه‌ی ورود وجود ندارد. فرانت فقط اسم و پایه می‌پرسد، ولی احرازِ هویتِ ما OTP با شماره‌ی موبایل است. دو راه:

  • مهمان (سریع‌ترین): api.auth.guest(deviceId) — بدونِ شماره، کاربر بلافاصله وارد می‌شود و بعداً می‌تواند شماره‌اش را وصل کند.
  • کامل: یک صفحه‌ی «شماره + کد» بین Splash و ProfileSetup.

در حالتِ توسعه (DJANGO_DEBUG=1) پاسخِ requestOtp فیلدِ devOtp دارد، پس برای تست لازم نیست پیامک واقعی برسد.

برای تستِ مسیرِ کامل روی سرورِ واقعی، شماره‌ی تست بگیرید. روی mathapi.tadrisino2.ir سرویسِ پیامک هنوز تنظیم نشده، پس requestOtp برای یک شماره‌ی معمولی خطای ۵۰۳ می‌دهد. بک‌اند چند شماره‌ی تست با کدِ ثابت دارد (TEST_LOGIN_PHONES در .env سرور): برای آن شماره‌ها پیامکی ارسال نمی‌شود و همان کد همیشه جواب می‌دهد، دقیقاً مثل مسیرِ واقعی. یک جفت «شماره + کد» برای خودتان بخواهید — ساختنش کارِ یک خط ویرایشِ .env و یک ری‌استارت است.

کدِ شماره‌ی تست در پاسخِ API برنمی‌گردد. اگر devOtp خالی آمد، یعنی همین مسیر فعال است، نه اینکه چیزی خراب باشد.

یک حسابِ تستِ آماده هم روی پروداکشن هست که از قبل داده دارد، تا برای دیدنِ صفحه‌های اعلان و لیدربرد مجبور نباشید منتظرِ تولیدِ داده‌ی واقعی بمانید:

curl -X POST https://mathapi.tadrisino2.ir/api/auth/session \
     -H 'Content-Type: application/json' \
     -d '{"type":"guest","deviceId":"frontend-test-device"}'
  • کاربرِ شماره‌ی ۶، با نامِ «حساب تست فرانت». هیچ پیامکی و هیچ توکنی که دست به دست شود در کار نیست — همان deviceId کافی‌ست.
  • GET /api/notifications روی این حساب چند اعلانِ واقعی دارد. پاسخ در کلیدِ items است، نه results.
  • GET /api/leaderboard?period=weekly و ?period=all_time هر کدام ۶ ردیف می‌دهند، با ترتیبِ عمداً متفاوت بینِ دو بازه، تا بشود دید پارامترِ period واقعاً اثر می‌کند.
  • این داده دستی روی دیتابیسِ پروداکشن ساخته شده و در گیت نیست؛ پیش از باز شدنِ سرویس روی کاربرانِ واقعی باید پاک شود (docs/operations.md).

⚠️ این حساب مشترک است. هر کسی که همین deviceId را بفرستد وارد همان حساب می‌شود، پس رویش چیزی که مالِ خودتان باشد نگذارید. برای کارِ روزمره یک deviceId مخصوصِ خودتان بسازید — هر رشته‌ای قبول است.

۲. فهرستِ پایه‌ها از api.grades() می‌آید (انجام شده). ترتیب همان ترتیبِ کلاس‌هاست و id هر پایه برابرِ شماره‌اش است: gradeId: 8 یعنی پایه‌ی هشتم. فیلدِ number هم همین عدد را صریح می‌دهد. پیش از ۲۰۲۶-۰۹-۲۳ idها ترتیبِ واردشدنِ کتاب‌ها بودند (هشتم=۵)؛ اپ چون همیشه gradeId را از سرور می‌گیرد، تغییری لازم نداشت.

۳. صفحه‌ی درس، ویدیو دارد. LessonScreen فهرستی از {type: "video" | "quiz"} نشان می‌دهد. بک‌اند هنوز مفهومِ ویدیو ندارد — lessons/{id}/intro فقط متن و حالتِ ماسکوت می‌دهد. فعلاً فقط quiz را نشان دهید؛ اگر ویدیو واقعاً لازم است بگویید تا در مدل اضافه کنیم.

۴. «سطح» در آمار. HomeScreen سه عدد نشان می‌دهد: روز پیاپی، امتیاز، سطح. دوتای اول را داریم (streak.current، xp)؛ «سطح» را نداریم. یا از روی xp سمتِ فرانت حساب کنید، یا بگویید تا در /api/user/summary اضافه کنیم.

چیزهایی که سمتِ بک‌اند برایتان درست شد

  • CORS اضافه شد. origin خود را در CORS_ALLOWED_ORIGINS بدهید (در حالتِ توسعه پورت‌های ۳۰۰۰ و ۵۱۷۳ پیش‌فرض بازند).
  • PATCH /api/user/profile اضافه شد — قبلاً پروفایل فقط خواندنی بود و هیچ راهی برای ثبتِ اسم نبود.
  • GET /api/home بدونِ پارامتر حالا پایه‌ی ذخیره‌شده‌ی کاربر را می‌خواند. قبلاً اگر ?grade= نمی‌فرستادید نقشه خالی برمی‌گشت.
  • POST /api/ai/ask با فقط lessonId حالا خودش می‌فهمد آن درس مالِ کدام پایه و فصلِ کتاب است. قبلاً باید subject + grade + lessonNumber را خودتان می‌فرستادید وگرنه پاسخ بدونِ استناد به کتاب تولید می‌شد.

چند قاعده که وقت‌تان را می‌خرد

Idempotency-Key بفرستید روی هر چیزی که پول/امتیاز/قلب جابه‌جا می‌کند (answer, complete, purchase). یک UUID که تا موفق شدنِ درخواست ثابت بماند. بدونِ آن، قطعیِ شبکه + دکمه‌ی دوباره = دوبار کم شدنِ قلب.

۴۰۱ یعنی توکن منقضی شده، نه اینکه کاربر بیرون است. کلاینت خودش یک‌بار refresh می‌زند و درخواست را تکرار می‌کند؛ فقط اگر آن هم شکست خورد کاربر را به صفحه‌ی ورود ببرید.

۴۲۹ و ۵۰۳ را جدا نشان دهید. ۴۲۹ یعنی سقفِ روزانه‌ی سوال از دستیار پر شده (الان ۱۵ تا) یا throttle؛ پیامش «فردا برگرد» است. ۵۰۳ یعنی سرویسِ AI موقتاً قطع است؛ پیامش «چند لحظه دیگر دوباره». ApiError.isRateLimited و .isProviderDown همین را می‌دهند.

پرداخت را در مرورگر باز کنید. purchase یک paymentUrl می‌دهد که به سرورِ خودمان اشاره می‌کند، نه به زرین‌پال. اپ نباید مستقیم با درگاه حرف بزند.

امتیاز موقعِ اتمامِ درس در مجموع می‌نشیند، نه سرِ هر سوال. پاسخِ answer فیلدِ xpEarned دارد ولی آن ارزشِ همان سوال است؛ user/summary تا وقتی complete صدا نخورد تکان نمی‌خورد. در UI می‌توانید خوش‌بینانه جمع بزنید، ولی عددِ معتبر بعد از complete می‌آید.

complete دو فیلدِ اجباری دارد: correctCount و totalQuestions. سرور correctCount را با تعدادِ واقعیِ سوال‌های آن درس سقف می‌زند، پس عددِ متورم امتیاز نمی‌سازد.

usedChunks خالی یعنی پاسخ به کتاب مستند نیست. اگر خواستید، در UI تفکیکش کنید («بر اساسِ کتابِ درسی» در برابر «پاسخِ عمومی»).

اجرای محلی

# بک‌اند
DJANGO_DEBUG=1 python manage.py runserver          # 127.0.0.1:8000

# فرانت
VITE_API_URL=http://127.0.0.1:8000/api npm run dev

مستنداتِ زنده‌ی همه‌ی endpointها: http://127.0.0.1:8000/api/docs/