open the API up to the frontend
سینیور خواست زودتر به فرانتاند وصل شویم تا از آنجا هم تست شود. اینها
سمتِ ما لازم بودند:
CORS با allowlist صریح (نه ALLOW_ALL): احرازِ هویتِ ما توکنِ Bearer در هدر
است، پس origin ناشناس نباید از مرورگرِ کاربر به API ما بزند.
CORS_ALLOW_CREDENTIALS عمداً خاموش میماند — کوکیای در کار نیست و روشن
کردنش فقط سطحِ حمله اضافه میکند. CorsMiddleware بالاتر از CommonMiddleware
گذاشته شد وگرنه ریدایرکتِ APPEND_SLASH و خطای ۴۰۰ هدرِ CORS نمیگیرند و در
مرورگر بهشکلِ خطای مبهمِ شبکه دیده میشوند.
PATCH /api/user/profile اضافه شد؛ تا پیش از این هیچ راهی برای ثبتِ نامِ
کاربر نبود. GET /api/home بدونِ پارامتر حالا پایهی ذخیرهشدهی کاربر را
برمیگرداند و POST /api/ai/ask فقط با lessonId کار میکند — هر دو برای
اینکه فرانت لازم نباشد پایه را خودش نگه دارد. questionCount به
lessons/{id}/intro اضافه شد.
docs/frontend/api.ts و README.md کلاینتِ تایپدار و راهنمای اتصالاند، از
روی OpenAPI واقعیِ همین سرویس تولید شدهاند.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,27 @@ class SetGradeRequestSerializer(serializers.Serializer):
|
|||||||
gradeId = serializers.IntegerField()
|
gradeId = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateProfileRequestSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
ورودی PATCH /api/user/profile — همهی فیلدها اختیاریاند و فقط همانهایی
|
||||||
|
که فرستاده شوند تغییر میکنند (partial update واقعی).
|
||||||
|
|
||||||
|
`phone` و `isGuest` عمداً اینجا نیستند: شماره فقط از مسیرِ OTP عوض
|
||||||
|
میشود و مهمانبودن را کاربر نباید بتواند خودش تغییر دهد.
|
||||||
|
"""
|
||||||
|
name = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||||
|
avatar = serializers.CharField(max_length=100, required=False, allow_blank=True)
|
||||||
|
birthYear = serializers.IntegerField(required=False, allow_null=True,
|
||||||
|
min_value=1300, max_value=1500)
|
||||||
|
parentPhone = serializers.CharField(max_length=15, required=False, allow_blank=True)
|
||||||
|
bio = serializers.CharField(max_length=200, required=False, allow_blank=True)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if not attrs:
|
||||||
|
raise serializers.ValidationError('حداقل یکی از فیلدها باید ارسال شود.')
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class RequestOTPRequestSerializer(serializers.Serializer):
|
class RequestOTPRequestSerializer(serializers.Serializer):
|
||||||
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تستپذیری Swagger اضافه شد)."""
|
"""اپ کمکی برای گرفتن OTP (در سند اصلی نبود، برای تستپذیری Swagger اضافه شد)."""
|
||||||
phone = serializers.CharField(max_length=15)
|
phone = serializers.CharField(max_length=15)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from .serializers import (
|
|||||||
RequestOTPRequestSerializer,
|
RequestOTPRequestSerializer,
|
||||||
RequestOTPResponseSerializer,
|
RequestOTPResponseSerializer,
|
||||||
SetGradeRequestSerializer,
|
SetGradeRequestSerializer,
|
||||||
|
UpdateProfileRequestSerializer,
|
||||||
SetGradeResponseSerializer,
|
SetGradeResponseSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -379,9 +380,25 @@ class SetUserGradeView(APIView):
|
|||||||
|
|
||||||
|
|
||||||
class UserProfileView(APIView):
|
class UserProfileView(APIView):
|
||||||
"""GET /api/user/profile — اطلاعات پروفایل کاربر جاری (نیاز به احراز هویت)"""
|
"""
|
||||||
|
GET /api/user/profile — اطلاعات پروفایل کاربر جاری
|
||||||
|
PATCH /api/user/profile — ویرایشِ همان اطلاعات (نیاز به احراز هویت)
|
||||||
|
|
||||||
|
PATCH برای صفحهی «بیا باهم آشنا بشیم» در اپ لازم است: آنجا اسم (و بعداً
|
||||||
|
آواتار) گرفته میشود و تا پیش از این هیچ مسیری برای نوشتنشان وجود نداشت —
|
||||||
|
فقط پایهی تحصیلی endpoint اختصاصیِ خودش را داشت.
|
||||||
|
"""
|
||||||
|
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
# نوشتن است، پس همان throttleِ نوشتنِ پروفایل که SetUserGradeView دارد.
|
||||||
|
throttle_scope = 'user_profile_write'
|
||||||
|
throttle_classes = [GuestAwareAccountThrottle]
|
||||||
|
|
||||||
|
def get_throttles(self):
|
||||||
|
# GET خواندنی است و نباید سهمیهی نوشتن را مصرف کند.
|
||||||
|
if self.request.method == 'GET':
|
||||||
|
return []
|
||||||
|
return super().get_throttles()
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
responses=ProfileResponseSerializer,
|
responses=ProfileResponseSerializer,
|
||||||
@@ -407,3 +424,49 @@ class UserProfileView(APIView):
|
|||||||
'bio': profile.bio,
|
'bio': profile.bio,
|
||||||
}
|
}
|
||||||
return Response(ProfileResponseSerializer(data).data)
|
return Response(ProfileResponseSerializer(data).data)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=UpdateProfileRequestSerializer,
|
||||||
|
responses=ProfileResponseSerializer,
|
||||||
|
tags=['Identity & Profile (accounts)'],
|
||||||
|
)
|
||||||
|
def patch(self, request):
|
||||||
|
serializer = UpdateProfileRequestSerializer(data=request.data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
v = serializer.validated_data
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
profile, _ = Profile.objects.get_or_create(user_id=user.id)
|
||||||
|
|
||||||
|
user_fields = {'name': 'name', 'avatar': 'avatar'}
|
||||||
|
profile_fields = {'birthYear': 'birth_year', 'parentPhone': 'parent_phone', 'bio': 'bio'}
|
||||||
|
|
||||||
|
changed_user = [attr for key, attr in user_fields.items() if key in v]
|
||||||
|
changed_profile = [attr for key, attr in profile_fields.items() if key in v]
|
||||||
|
|
||||||
|
for key, attr in user_fields.items():
|
||||||
|
if key in v:
|
||||||
|
setattr(user, attr, v[key])
|
||||||
|
for key, attr in profile_fields.items():
|
||||||
|
if key in v:
|
||||||
|
setattr(profile, attr, v[key])
|
||||||
|
|
||||||
|
# update_fields: فقط همان ستونهایی که واقعاً عوض شدهاند نوشته شوند تا
|
||||||
|
# PATCH همزمان از یک دستگاهِ دیگر، فیلدهای دستنخورده را بازنویسی نکند.
|
||||||
|
if changed_user:
|
||||||
|
user.save(update_fields=changed_user)
|
||||||
|
if changed_profile:
|
||||||
|
profile.save(update_fields=changed_profile)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'userId': str(user.id),
|
||||||
|
'phone': user.phone,
|
||||||
|
'name': user.name,
|
||||||
|
'avatar': user.avatar,
|
||||||
|
'isGuest': user.is_guest,
|
||||||
|
'gradeId': user.grade_id,
|
||||||
|
'birthYear': profile.birth_year,
|
||||||
|
'parentPhone': profile.parent_phone,
|
||||||
|
'bio': profile.bio,
|
||||||
|
}
|
||||||
|
return Response(ProfileResponseSerializer(data).data)
|
||||||
|
|||||||
@@ -53,6 +53,33 @@ ALLOWED_HOSTS = [h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(','
|
|||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o]
|
CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o]
|
||||||
|
|
||||||
|
# --- CORS -------------------------------------------------------------------
|
||||||
|
# فرانتاند (Vite/React) از یک origin دیگر صدا میزند، پس بدونِ این هدرها مرورگر
|
||||||
|
# هر درخواستِ /api/ را قبل از رسیدن به Django رد میکند.
|
||||||
|
#
|
||||||
|
# allowlist صریح است، نه CORS_ALLOW_ALL_ORIGINS: احرازِ هویتِ ما توکنِ Bearer در
|
||||||
|
# هدر است، پس origin ناشناس نباید بتواند از مرورگرِ کاربر به API ما بزند.
|
||||||
|
# CORS_ALLOW_CREDENTIALS هم عمداً خاموش میماند — کوکیای در کار نیست و روشن
|
||||||
|
# کردنش فقط سطحِ حمله اضافه میکند.
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
o for o in os.environ.get('CORS_ALLOWED_ORIGINS', '').split(',') if o
|
||||||
|
]
|
||||||
|
if DEBUG and not CORS_ALLOWED_ORIGINS:
|
||||||
|
# پورتهای پیشفرضِ Vite/CRA برای توسعهی محلی.
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
'http://localhost:3000', 'http://127.0.0.1:3000',
|
||||||
|
'http://localhost:5173', 'http://127.0.0.1:5173',
|
||||||
|
]
|
||||||
|
CORS_ALLOW_CREDENTIALS = False
|
||||||
|
# فقط مسیرهای API؛ /admin/ و فایلهای استاتیک لازم نیست از origin دیگر خوانده شوند.
|
||||||
|
CORS_URLS_REGEX = r'^/api/.*$'
|
||||||
|
# Idempotency-Key و X-Internal-Key هدرهای خودماناند و در فهرستِ پیشفرض نیستند.
|
||||||
|
CORS_ALLOW_HEADERS = (
|
||||||
|
'accept', 'accept-encoding', 'authorization', 'content-type', 'dnt',
|
||||||
|
'origin', 'user-agent', 'x-csrftoken', 'x-requested-with',
|
||||||
|
'idempotency-key', 'x-internal-key',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
@@ -67,6 +94,7 @@ INSTALLED_APPS = [
|
|||||||
# 3rd party
|
# 3rd party
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'drf_spectacular',
|
'drf_spectacular',
|
||||||
|
'corsheaders',
|
||||||
|
|
||||||
# Tadrisino apps (فاز اول - ۴ از ۱۱ اپ)
|
# Tadrisino apps (فاز اول - ۴ از ۱۱ اپ)
|
||||||
'accounts',
|
'accounts',
|
||||||
@@ -104,6 +132,10 @@ INSTALLED_APPS = [
|
|||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
# ⚠️ CorsMiddleware باید تا حدِ ممکن بالا و حتماً قبل از CommonMiddleware
|
||||||
|
# باشد، وگرنه پاسخهایی که زودتر برمیگردند (ریدایرکتِ APPEND_SLASH، خطای
|
||||||
|
# ۴۰۰) هدرِ CORS نمیگیرند و در مرورگر بهشکلِ خطای مبهمِ شبکه دیده میشوند.
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ class LessonIntroResponseSerializer(serializers.Serializer):
|
|||||||
title = serializers.CharField()
|
title = serializers.CharField()
|
||||||
introText = serializers.CharField()
|
introText = serializers.CharField()
|
||||||
mascotAnimation = serializers.CharField()
|
mascotAnimation = serializers.CharField()
|
||||||
|
# تعدادِ سوالهای آمادهی این درس. صفر یعنی محتوای آموزشی هست ولی آزمونش
|
||||||
|
# هنوز ساخته نشده؛ اپ باید «بهزودی» نشان دهد نه یک کویزِ خالی. بدونِ این
|
||||||
|
# فیلد فرانت مجبور بود اول questions را بگیرد تا بفهمد چیزی هست یا نه، و
|
||||||
|
# `complete` هم totalQuestions=0 را رد میکند (درست است، ولی گیجکننده).
|
||||||
|
questionCount = serializers.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
# ---- Lesson questions ----
|
# ---- Lesson questions ----
|
||||||
|
|||||||
@@ -153,17 +153,31 @@ class HomeView(APIView):
|
|||||||
tags=['Curriculum (content)'],
|
tags=['Curriculum (content)'],
|
||||||
)
|
)
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
grade_id = request.query_params.get('grade')
|
|
||||||
user_id = request.user.id
|
user_id = request.user.id
|
||||||
|
|
||||||
|
# ⚠️ پیشتر فقط پارامترِ صریحِ ?grade= خوانده میشد و پایهی ذخیرهشدهی
|
||||||
|
# خودِ کاربر نادیده میماند؛ نتیجه این بود که اپ در «آشنایی» پایه را ثبت
|
||||||
|
# میکرد و بعد صفحهی خانه خالی برمیگشت. حالا پارامتر اولویت دارد (برای
|
||||||
|
# مرورِ پایهی دیگر) و در نبودش پایهی خودِ کاربر ملاک است.
|
||||||
|
grade_param = request.query_params.get('grade')
|
||||||
|
if grade_param:
|
||||||
|
try:
|
||||||
|
grade_id = int(grade_param)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise Http404
|
||||||
|
else:
|
||||||
|
grade_id = request.user.grade_id
|
||||||
|
|
||||||
tree = []
|
tree = []
|
||||||
if grade_id:
|
if grade_id:
|
||||||
try:
|
tree = _grade_tree(grade_id)
|
||||||
tree = _grade_tree(int(grade_id))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
raise Http404
|
|
||||||
if tree is None:
|
if tree is None:
|
||||||
raise Http404
|
# پایهای که کاربر ذخیره کرده ممکن است بعداً حذف شده باشد؛ آن
|
||||||
|
# حالت خطا نیست، فقط نقشهی خالی است. ولی ?grade=ی که کاربر
|
||||||
|
# صریحاً خواسته و وجود ندارد، ۴۰۴ است.
|
||||||
|
if grade_param:
|
||||||
|
raise Http404
|
||||||
|
tree = []
|
||||||
|
|
||||||
# همهی lesson_idهای کاملشدهی این کاربر در همین گرید — یک کوئری،
|
# همهی lesson_idهای کاملشدهی این کاربر در همین گرید — یک کوئری،
|
||||||
# مستقل از تعدادِ node (رفعِ N+1).
|
# مستقل از تعدادِ node (رفعِ N+1).
|
||||||
@@ -214,6 +228,7 @@ class LessonIntroView(APIView):
|
|||||||
'title': lesson.title,
|
'title': lesson.title,
|
||||||
'introText': lesson.intro_text,
|
'introText': lesson.intro_text,
|
||||||
'mascotAnimation': lesson.mascot_animation,
|
'mascotAnimation': lesson.mascot_animation,
|
||||||
|
'questionCount': lesson.questions.count(),
|
||||||
}
|
}
|
||||||
return Response(LessonIntroResponseSerializer(data).data)
|
return Response(LessonIntroResponseSerializer(data).data)
|
||||||
|
|
||||||
|
|||||||
111
docs/frontend/README.md
Normal file
111
docs/frontend/README.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# راهنمای اتصالِ فرانتاند به این بکاند
|
||||||
|
|
||||||
|
این پوشه برای تیمِ فرانت (`MathApp_frontend`) است. `api.ts` یک کلاینتِ
|
||||||
|
تایپدار است که از روی OpenAPI واقعیِ همین سرور نوشته شده — نامِ فیلدها حدس
|
||||||
|
نیست.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp docs/frontend/api.ts <پروژهی-فرانت>/src/lib/api.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
```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` دارد، پس
|
||||||
|
برای تست لازم نیست پیامک واقعی برسد.
|
||||||
|
|
||||||
|
**۲. فهرستِ پایهها تا ششم است.** `GRADES` در `App.tsx` هاردکدِ ۱ تا ۶ است، ولی
|
||||||
|
جهتِ محصول از ششم به بالا رفته (ترانزیشنی: ۶، ۷، ۹، ۱۰). از `api.grades()`
|
||||||
|
بخوانید تا هر پایهای که اضافه شد خودبهخود بیاید.
|
||||||
|
|
||||||
|
**۳. صفحهی درس، ویدیو دارد.** `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
|
||||||
|
تفکیکش کنید («بر اساسِ کتابِ درسی» در برابر «پاسخِ عمومی»).
|
||||||
|
|
||||||
|
## اجرای محلی
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# بکاند
|
||||||
|
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/`
|
||||||
442
docs/frontend/api.ts
Normal file
442
docs/frontend/api.ts
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
/**
|
||||||
|
* کلاینتِ API تدریسینو — برای فرانتِ React/TypeScript (MathApp_frontend).
|
||||||
|
*
|
||||||
|
* از روی OpenAPI واقعیِ همین بکاند نوشته شده، نه از روی حدس. نامِ فیلدها
|
||||||
|
* دقیقاً همان چیزیست که سرور برمیگرداند (camelCase).
|
||||||
|
*
|
||||||
|
* نکتههای مهمِ اتصال:
|
||||||
|
* - احرازِ هویت توکنِ Bearer است، نه کوکی. پس `credentials: 'include'` لازم
|
||||||
|
* نیست و CORS سمتِ سرور هم عمداً بدونِ credentials تنظیم شده.
|
||||||
|
* - توکنِ دسترسی امضاشده و کوتاهعمر است؛ با ۴۰۱ باید `refresh` صدا زد.
|
||||||
|
* این کلاینت خودش یکبار refresh میکند و درخواست را تکرار.
|
||||||
|
* - برای پرسش از دستیار کافیست `lessonId` بدهید؛ سرور خودش میفهمد آن درس
|
||||||
|
* مالِ کدام پایه و فصل است و پاسخ را به کتاب مستند میکند.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Json = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface Tokens {
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── انواعِ پاسخ ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed';
|
||||||
|
|
||||||
|
export interface HomeNode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: NodeStatus;
|
||||||
|
lessonIds: number[];
|
||||||
|
}
|
||||||
|
export interface HomeUnit {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
nodes: HomeNode[];
|
||||||
|
}
|
||||||
|
export interface HomeResponse {
|
||||||
|
units: HomeUnit[];
|
||||||
|
stats: { progressPercent: number; xp: number; coins: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Grade {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserSummary {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
gradeId: number | null;
|
||||||
|
xp: number;
|
||||||
|
coins: number;
|
||||||
|
streak: { current: number; longest: number };
|
||||||
|
subscription: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
userId: string;
|
||||||
|
phone: string | null;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
isGuest: boolean;
|
||||||
|
gradeId: number | null;
|
||||||
|
birthYear: number | null;
|
||||||
|
parentPhone: string;
|
||||||
|
bio: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonIntro {
|
||||||
|
lessonId: string;
|
||||||
|
title: string;
|
||||||
|
introText: string;
|
||||||
|
/** برای انتخابِ حالتِ ماسکوت در صفحهی درس */
|
||||||
|
mascotAnimation: string;
|
||||||
|
/**
|
||||||
|
* صفر یعنی متنِ درس هست ولی آزمونش هنوز ساخته نشده — «بهزودی» نشان دهید،
|
||||||
|
* نه کویزِ خالی. `complete` هم `totalQuestions: 0` را نمیپذیرد.
|
||||||
|
*/
|
||||||
|
questionCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuestionOption {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
export interface Question {
|
||||||
|
id: string;
|
||||||
|
type: 'multiple_choice' | 'count_objects';
|
||||||
|
prompt: string;
|
||||||
|
options: QuestionOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnswerResult {
|
||||||
|
correct: boolean;
|
||||||
|
correctOptionId: string;
|
||||||
|
xpEarned: number;
|
||||||
|
heartsRemaining: number;
|
||||||
|
/** حالتِ ماسکوت: درست → happy، غلط → encourage */
|
||||||
|
mascotMood: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonComplete {
|
||||||
|
stars: number;
|
||||||
|
xpEarned: number;
|
||||||
|
coinsEarned: number;
|
||||||
|
stats: { progressPercent: number; xp: number; coins: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hearts {
|
||||||
|
currentHearts: number;
|
||||||
|
maxHearts: number;
|
||||||
|
nextRefillAt: string | null;
|
||||||
|
isUnlimited: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskResponse {
|
||||||
|
conversationId: string;
|
||||||
|
messageId: string;
|
||||||
|
answer: string;
|
||||||
|
suggestedFollowUps: string[];
|
||||||
|
remainingQuota: number;
|
||||||
|
/** تکههای کتاب که پاسخ رویشان بنا شده؛ خالی یعنی پاسخِ عمومی */
|
||||||
|
usedChunks: { title: string; summary: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
price: number;
|
||||||
|
durationDays: number;
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── خطا ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly detail: string,
|
||||||
|
readonly body: Json,
|
||||||
|
) {
|
||||||
|
super(detail);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ۴۲۹: throttle یا اتمامِ سهمیهی روزانهی دستیار */
|
||||||
|
get isRateLimited() {
|
||||||
|
return this.status === 429;
|
||||||
|
}
|
||||||
|
/** ۵۰۳: سرویسِ هوش مصنوعی/پیامک موقتاً در دسترس نیست */
|
||||||
|
get isProviderDown() {
|
||||||
|
return this.status === 503;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── کلاینت ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export interface ClientOptions {
|
||||||
|
baseUrl?: string;
|
||||||
|
/** خواندن/نوشتنِ توکن؛ پیشفرض localStorage */
|
||||||
|
storage?: {
|
||||||
|
get(): Tokens | null;
|
||||||
|
set(t: Tokens | null): void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'tadrisino.tokens';
|
||||||
|
|
||||||
|
function defaultStorage() {
|
||||||
|
return {
|
||||||
|
get(): Tokens | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as Tokens) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set(t: Tokens | null) {
|
||||||
|
try {
|
||||||
|
if (t) localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
|
||||||
|
else localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* حالتِ خصوصیِ مرورگر — نادیده */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createClient(options: ClientOptions = {}) {
|
||||||
|
const baseUrl = (options.baseUrl ?? '/api').replace(/\/$/, '');
|
||||||
|
const storage = options.storage ?? defaultStorage();
|
||||||
|
|
||||||
|
async function raw<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
extraHeaders: Record<string, string> = {},
|
||||||
|
retryOn401 = true,
|
||||||
|
): Promise<T> {
|
||||||
|
const tokens = storage.get();
|
||||||
|
const headers: Record<string, string> = { ...extraHeaders };
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
|
if (tokens?.token) headers['Authorization'] = `Bearer ${tokens.token}`;
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401 && retryOn401 && tokens?.refreshToken) {
|
||||||
|
const refreshed = await refresh(tokens.refreshToken).catch(() => null);
|
||||||
|
if (refreshed) return raw<T>(method, path, body, extraHeaders, false);
|
||||||
|
storage.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const parsed: Json = text ? (JSON.parse(text) as Json) : {};
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail =
|
||||||
|
(parsed.detail as string) ??
|
||||||
|
(parsed.error as string) ??
|
||||||
|
`خطای ${response.status}`;
|
||||||
|
throw new ApiError(response.status, detail, parsed);
|
||||||
|
}
|
||||||
|
return parsed as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(refreshToken: string) {
|
||||||
|
const response = await fetch(`${baseUrl}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = (await response.json()) as Tokens;
|
||||||
|
storage.set({ token: data.token, refreshToken: data.refreshToken });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
/* ── ورود (صفحهای که فعلاً در فرانت نیست و باید ساخته شود) ────────── */
|
||||||
|
auth: {
|
||||||
|
/** مهمان: بدونِ شماره، فقط با شناسهی دستگاه. برای «شروع سریع». */
|
||||||
|
async guest(deviceId: string) {
|
||||||
|
const data = await raw<{ userId: string; token: string; isNewUser: boolean; isGuest: boolean }>(
|
||||||
|
'POST', '/auth/session', { type: 'guest', deviceId },
|
||||||
|
);
|
||||||
|
storage.set({ token: data.token, refreshToken: '' });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
/** گامِ ۱: ارسالِ پیامکِ کد. در حالتِ توسعه `devOtp` هم برمیگردد. */
|
||||||
|
requestOtp(phone: string) {
|
||||||
|
return raw<{ success: boolean; devOtp?: string; note?: string }>(
|
||||||
|
'POST', '/auth/otp/request', { phone },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
/** گامِ ۲: تأییدِ کد و گرفتنِ توکن. نامِ فیلد در سرور `otp` است. */
|
||||||
|
async login(phone: string, otp: string) {
|
||||||
|
const data = await raw<{ userId: string } & Tokens>(
|
||||||
|
'POST', '/auth/login', { phone, otp },
|
||||||
|
);
|
||||||
|
storage.set({ token: data.token, refreshToken: data.refreshToken });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
logout() {
|
||||||
|
storage.set(null);
|
||||||
|
},
|
||||||
|
isSignedIn() {
|
||||||
|
return Boolean(storage.get()?.token);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── صفحهی «بیا باهم آشنا بشیم» ──────────────────────────────────── */
|
||||||
|
profile: {
|
||||||
|
get: () => raw<UserProfile>('GET', '/user/profile'),
|
||||||
|
/** اسم/آواتار/… . فقط فیلدهایی که میفرستید عوض میشوند. */
|
||||||
|
update: (patch: Partial<Pick<UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
|
||||||
|
raw<UserProfile>('PATCH', '/user/profile', patch),
|
||||||
|
setGrade: (gradeId: number) =>
|
||||||
|
raw<{ success: boolean; gradeId: number }>('POST', '/user/grade', { gradeId }),
|
||||||
|
},
|
||||||
|
|
||||||
|
/** فهرستِ پایهها را از سرور بگیرید، هاردکد نکنید — پایهها اضافه میشوند. */
|
||||||
|
grades: () => raw<{ grades: Grade[] }>('GET', '/grades').then((r) => r.grades),
|
||||||
|
|
||||||
|
/* ── صفحهی خانه و نقشهی مهارت ────────────────────────────────────── */
|
||||||
|
/** بدونِ gradeId، پایهی ذخیرهشدهی خودِ کاربر ملاک است. */
|
||||||
|
home: (gradeId?: number) =>
|
||||||
|
raw<HomeResponse>('GET', gradeId ? `/home?grade=${gradeId}` : '/home'),
|
||||||
|
summary: () => raw<UserSummary>('GET', '/user/summary'),
|
||||||
|
hearts: () => raw<Hearts>('GET', '/user/hearts'),
|
||||||
|
streakCalendar: () =>
|
||||||
|
raw<{ currentStreak: number; longestStreak: number; activeDays: string[] }>(
|
||||||
|
'GET', '/user/streak/calendar',
|
||||||
|
),
|
||||||
|
leaderboard: (period: 'weekly' | 'monthly' = 'weekly') =>
|
||||||
|
raw<{ period: string; entries: unknown[] }>('GET', `/leaderboard?period=${period}`),
|
||||||
|
|
||||||
|
/* ── صفحهی درس و سوال ─────────────────────────────────────────────── */
|
||||||
|
lesson: {
|
||||||
|
intro: (lessonId: number) => raw<LessonIntro>('GET', `/lessons/${lessonId}/intro`),
|
||||||
|
questions: (lessonId: number) =>
|
||||||
|
raw<{ questions: Question[] }>('GET', `/lessons/${lessonId}/questions`).then((r) => r.questions),
|
||||||
|
/**
|
||||||
|
* `idempotencyKey` را بدهید تا اگر شبکه قطع شد و کاربر دوباره زد،
|
||||||
|
* امتیاز/قلب دوبار حساب نشود.
|
||||||
|
*/
|
||||||
|
answer: (questionId: number, selectedOptionId: number, idempotencyKey?: string) =>
|
||||||
|
raw<AnswerResult>(
|
||||||
|
'POST', `/questions/${questionId}/answer`, { selectedOptionId },
|
||||||
|
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
|
||||||
|
),
|
||||||
|
/**
|
||||||
|
* پایانِ درس. `correctCount` را سرور با تعدادِ واقعیِ سوالها سقف میزند.
|
||||||
|
* مجموعِ XPِ کاربر **اینجا** بهروز میشود، نه سرِ هر پاسخ.
|
||||||
|
*/
|
||||||
|
complete: (
|
||||||
|
lessonId: number,
|
||||||
|
result: { correctCount: number; totalQuestions: number },
|
||||||
|
idempotencyKey?: string,
|
||||||
|
) =>
|
||||||
|
raw<LessonComplete>(
|
||||||
|
'POST', `/lessons/${lessonId}/complete`, result,
|
||||||
|
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── صفحهی «از رُبو بپرس» ─────────────────────────────────────────── */
|
||||||
|
ai: {
|
||||||
|
/**
|
||||||
|
* `lessonId` را بدهید تا پاسخ به کتابِ همان درس مستند شود؛ لازم نیست
|
||||||
|
* subject/grade/chapter را خودتان حساب کنید.
|
||||||
|
* `conversationId` را از پاسخِ قبلی پاس بدهید تا گفتگو ادامه پیدا کند.
|
||||||
|
*/
|
||||||
|
ask: (input: {
|
||||||
|
userQuestion: string;
|
||||||
|
lessonId?: number | string;
|
||||||
|
conversationId?: string;
|
||||||
|
questionId?: number | string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}) => {
|
||||||
|
const { idempotencyKey, ...body } = input;
|
||||||
|
return raw<AskResponse>(
|
||||||
|
'POST', '/ai/ask', body,
|
||||||
|
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* همان ask ولی توکنبهتوکن (SSE). اولین کلمه ~۳.۵ ثانیه بهجای ~۱۰.
|
||||||
|
* برای حبابِ چتِ «در حالِ تایپ» همین را استفاده کنید.
|
||||||
|
*/
|
||||||
|
async askStream(
|
||||||
|
input: { userQuestion: string; lessonId?: number | string; conversationId?: string },
|
||||||
|
onDelta: (text: string) => void,
|
||||||
|
): Promise<{ conversationId?: string; messageId?: string }> {
|
||||||
|
const tokens = storage.get();
|
||||||
|
const response = await fetch(`${baseUrl}/ai/ask`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(tokens?.token ? { Authorization: `Bearer ${tokens.token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ...input, stream: true }),
|
||||||
|
});
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new ApiError(response.status, 'استریم شروع نشد', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let done: { conversationId?: string; messageId?: string } = {};
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { value, done: finished } = await reader.read();
|
||||||
|
if (finished) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// رویدادها با یک خطِ خالی از هم جدا میشوند.
|
||||||
|
const parts = buffer.split('\n\n');
|
||||||
|
buffer = parts.pop() ?? '';
|
||||||
|
for (const part of parts) {
|
||||||
|
const event = /^event: (.+)$/m.exec(part)?.[1];
|
||||||
|
const dataLine = /^data: (.+)$/m.exec(part)?.[1];
|
||||||
|
if (!event || !dataLine) continue;
|
||||||
|
const payload = JSON.parse(dataLine) as Json;
|
||||||
|
if (event === 'chunk') onDelta(String(payload.delta ?? ''));
|
||||||
|
else if (event === 'done') done = payload as typeof done;
|
||||||
|
else if (event === 'error') {
|
||||||
|
throw new ApiError(503, String(payload.detail ?? 'خطای دستیار'), payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return done;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 👍/👎 روی یک پاسخ. `reason` اختیاری ولی خیلی مفید است. */
|
||||||
|
feedback: (messageId: string | number, isHelpful: boolean, reason?: string) =>
|
||||||
|
raw<{ success: boolean }>('POST', `/ai/messages/${messageId}/feedback`, {
|
||||||
|
isHelpful,
|
||||||
|
...(reason ? { reason } : {}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
history: (conversationId: string | number) =>
|
||||||
|
raw<{ conversationId: string; lessonId: string | null; messages: unknown[] }>(
|
||||||
|
'GET', `/ai/conversations/${conversationId}/messages`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ── صفحهی اشتراک ─────────────────────────────────────────────────── */
|
||||||
|
subscription: {
|
||||||
|
plans: () => raw<{ plans: Plan[] }>('GET', '/subscription/plans').then((r) => r.plans),
|
||||||
|
status: () =>
|
||||||
|
raw<{ active: boolean; planId: string | null; expiresAt: string | null }>(
|
||||||
|
'GET', '/subscription/status',
|
||||||
|
),
|
||||||
|
/**
|
||||||
|
* `paymentUrl` را در مرورگر باز کنید. آن آدرس خودش سراغِ زرینپال
|
||||||
|
* میرود؛ اپ نباید مستقیم با درگاه حرف بزند.
|
||||||
|
*/
|
||||||
|
purchase: (planId: string, idempotencyKey?: string) =>
|
||||||
|
raw<{ orderId: string; paymentUrl: string }>(
|
||||||
|
'POST', '/subscription/purchase', { planId },
|
||||||
|
idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
notifications: {
|
||||||
|
list: () => raw<{ items: unknown[]; unreadCount: number }>('GET', '/notifications'),
|
||||||
|
markRead: (id: number) => raw<Json>('POST', `/notifications/${id}/read`),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TadrisinoClient = ReturnType<typeof createClient>;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
Django==5.2.17
|
Django==5.2.17
|
||||||
djangorestframework==3.18.0
|
djangorestframework==3.18.0
|
||||||
drf-spectacular==0.30.0
|
drf-spectacular==0.30.0
|
||||||
|
django-cors-headers==4.9.0
|
||||||
requests==2.33.1
|
requests==2.33.1
|
||||||
redis==5.2.1
|
redis==5.2.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user