کدِ auth داخلِ App.tsx مسیرهای نسبی صدا میزند (`/api/auth/login` و مانندش).
این مسیرها به دامنهی خودِ فرانت میروند، nginx چون آن مسیر را نمیشناسد
index.html برمیگرداند، و نتیجه پاسخِ ۲۰۰ با بدنهی HTML است — یعنی res.ok
درست به نظر میرسد و بعد res.json() با «Unexpected token '<'» میشکند.
همین الان روی math.tadrisino2.ir قابلِ بازتولید است.
در حالتِ توسعه پروکسیِ vite حلش میکند: هر /api به بکاند فرستاده میشود و
کدِ موجود بدونِ هیچ تغییری کار میکند. مقصد با VITE_API_PROXY_TARGET قابلِ
عوض کردن است، پیشفرض بکاندِ مشترک.
برای بیلدِ نهایی apiUrl() اضافه شد: fetch(apiUrl('/auth/login')) بهجای
fetch('/api/auth/login'). یا اگر روی سرور مسیرِ /api به بکاند پروکسی شود،
مسیرهای نسبی همانطور که هستند درست کار میکنند — آن راه حتی بهتر است چون
same-origin میشود و CORS از میان برمیدارد.
App.tsx دست نخورده؛ تصمیمِ اینکه کدام راه را بروند با خودشان است.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
8.7 KiB
TypeScript
178 lines
8.7 KiB
TypeScript
/**
|
||
* API تدریسینو — تنها جایی که فرانت باید برای صدا زدنِ بکاند به آن نگاه کند.
|
||
*
|
||
* import { api } from '@/api';
|
||
* const home = await api.home();
|
||
*
|
||
* هیچجای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ
|
||
* درستِ خطا همه اینجا یکجا جمعاند.
|
||
*
|
||
* هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شده. جاهایی که
|
||
* غافلگیرکنندهاند کامنت خوردهاند، چون اشتباهشان خطای ۴۰۰/۴۰۴ میدهد که
|
||
* از پیامش علت معلوم نیست.
|
||
*/
|
||
import { ApiError, createHttpClient, localTokenStorage } from './client';
|
||
import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client';
|
||
import type {
|
||
AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, NodeLessons, Plan,
|
||
Question, ReportReason, ReportResponse, UserProfile,
|
||
} from './types';
|
||
|
||
export { ApiError, localTokenStorage, createHttpClient };
|
||
export type { Tokens, HttpClient, ClientOptions, RequestOptions };
|
||
export * from './types';
|
||
|
||
/**
|
||
* آدرسِ کاملِ یک مسیرِ API.
|
||
*
|
||
* fetch(apiUrl('/auth/login'), …)
|
||
*
|
||
* برای کدی که هنوز مستقیم fetch میزند. مسیرِ نسبی (`/api/auth/login`) در
|
||
* بیلدِ نهایی به دامنهی خودِ فرانت میرود، nginx چون مسیر را نمیشناسد
|
||
* index.html برمیگرداند، و نتیجه پاسخِ ۲۰۰ با بدنهی HTML است که موقعِ
|
||
* res.json() با «Unexpected token '<'» میشکند.
|
||
*/
|
||
export function apiUrl(path: string): string {
|
||
const base = createHttpClient().baseUrl;
|
||
return `${base}${path.startsWith('/') ? path : `/${path}`}`;
|
||
}
|
||
|
||
/** پرسش از دستیار کند است (تولیدِ متن)؛ ۱۵ ثانیهی پیشفرض کم است. */
|
||
const ASK_TIMEOUT_MS = 45_000;
|
||
|
||
export function createApi(options: ClientOptions = {}) {
|
||
const http = createHttpClient(options);
|
||
|
||
return {
|
||
http,
|
||
|
||
/* ── ورود ─────────────────────────────────────────────────────────── */
|
||
auth: {
|
||
/** «شروع سریع» بدونِ شماره. توکنِ مهمان refreshToken ندارد. */
|
||
async guest(deviceId: string) {
|
||
const data = await http.post<{
|
||
userId: string; token: string; isNewUser: boolean; isGuest: boolean;
|
||
}>('/auth/session', { type: 'guest', deviceId });
|
||
http.storage.set({ token: data.token, refreshToken: '' });
|
||
return data;
|
||
},
|
||
/** گامِ ۱ — در حالتِ توسعه `devOtp` هم برمیگردد. */
|
||
requestOtp(phone: string) {
|
||
return http.post<{ success: boolean; devOtp?: string }>(
|
||
'/auth/otp/request', { phone },
|
||
);
|
||
},
|
||
/** گامِ ۲ — نامِ فیلد در سرور `otp` است، نه `code`. */
|
||
async login(phone: string, otp: string) {
|
||
const data = await http.post<{ userId: string } & Tokens>(
|
||
'/auth/login', { phone, otp },
|
||
);
|
||
http.storage.set({ token: data.token, refreshToken: data.refreshToken });
|
||
return data;
|
||
},
|
||
logout() {
|
||
http.storage.set(null);
|
||
},
|
||
isSignedIn() {
|
||
return Boolean(http.storage.get()?.token);
|
||
},
|
||
},
|
||
|
||
/* ── کاربر ────────────────────────────────────────────────────────── */
|
||
profile: {
|
||
get: (opts?: RequestOptions) => http.get<UserProfile>('/user/profile', opts),
|
||
/**
|
||
* فقط فیلدهایی که میفرستید عوض میشوند. صفحهی ثبتنام سن میپرسد، پس
|
||
* `age` هم پذیرفته میشود و سرور خودش به سالِ تولدِ شمسی تبدیلش میکند —
|
||
* `age` و `birthYear` را با هم نفرستید.
|
||
*/
|
||
update: (patch: Partial<Pick<UserProfile,
|
||
'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>> & { age?: number }) =>
|
||
http.patch<UserProfile>('/user/profile', patch),
|
||
setGrade: (gradeId: number) =>
|
||
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
|
||
},
|
||
|
||
/** xp/سکه/استریک اینجاست، نه در profile. */
|
||
summary: (opts?: RequestOptions) =>
|
||
http.get<Record<string, unknown>>('/user/summary', opts),
|
||
|
||
/** پایهها را هاردکد نکنید — اضافه میشوند. */
|
||
grades: (opts?: RequestOptions) =>
|
||
http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades),
|
||
|
||
/** بدونِ gradeId، پایهی ذخیرهشدهی خودِ کاربر استفاده میشود. */
|
||
home: (gradeId?: number, opts?: RequestOptions) =>
|
||
http.get<HomeResponse>(gradeId ? `/home?gradeId=${gradeId}` : '/home', opts),
|
||
|
||
/* ── درس و آزمون ──────────────────────────────────────────────────── */
|
||
lessons: {
|
||
intro: (lessonId: number | string, opts?: RequestOptions) =>
|
||
http.get<LessonIntro>(`/lessons/${lessonId}/intro`, opts),
|
||
questions: (lessonId: number | string, opts?: RequestOptions) =>
|
||
http.get<{ questions: Question[] }>(`/lessons/${lessonId}/questions`, opts)
|
||
.then((r) => r.questions),
|
||
complete: (lessonId: number | string) =>
|
||
http.create<Record<string, unknown>>(`/lessons/${lessonId}/complete`, {}),
|
||
},
|
||
|
||
nodes: {
|
||
/**
|
||
* قدمهای یک مبحث برای نقشهی مهارت — عنوان، نوع، زیرنویس و «انجام شد».
|
||
* `home()` فقط شناسهی درسها را میدهد؛ بدونِ این، برای هر کارت یک
|
||
* درخواستِ intro جدا لازم بود.
|
||
*/
|
||
lessons: (nodeId: number | string, opts?: RequestOptions) =>
|
||
http.get<NodeLessons>(`/nodes/${nodeId}/lessons`, opts),
|
||
},
|
||
|
||
questions: {
|
||
/**
|
||
* ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد
|
||
* `selectedOptionId` است. کلیدِ یکتا میگیرد تا دابلکلیکِ کاربر یا
|
||
* تکرارِ خودکارِ شبکه دو بار XP ندهد و دو بار «قلب» کم نکند.
|
||
*/
|
||
answer: (questionId: number | string, selectedOptionId: number | string) =>
|
||
http.create<AnswerResponse>(`/questions/${questionId}/answer`, { selectedOptionId }),
|
||
|
||
/** «این سوال ایراد دارد» — گزارشِ تکراریِ همان کاربر ردیفِ تازه نمیسازد. */
|
||
report: (questionId: number | string, reason: ReportReason, note = '') =>
|
||
http.post<ReportResponse>(`/questions/${questionId}/report`, { reason, note }),
|
||
},
|
||
|
||
/* ── دستیار ───────────────────────────────────────────────────────── */
|
||
ai: {
|
||
/**
|
||
* فقط `lessonId` کافیست: سرور خودش میفهمد این درس مالِ کدام پایه و
|
||
* فصل است و پاسخ را به کتاب مستند میکند.
|
||
*
|
||
* ⚠️ نامِ فیلدِ پرسش `userQuestion` است، نه `question`.
|
||
*/
|
||
ask: (
|
||
userQuestion: string,
|
||
ctx: { lessonId?: number | string; conversationId?: string; gradeId?: number } = {},
|
||
opts?: RequestOptions,
|
||
) =>
|
||
http.post<AskResponse>('/ai/ask', { userQuestion, ...ctx }, {
|
||
timeoutMs: ASK_TIMEOUT_MS,
|
||
...opts,
|
||
}),
|
||
|
||
/** ⚠️ `isHelpful` بولین است، نه رشتهی up/down. `reason` اختیاری. */
|
||
feedback: (messageId: number | string, isHelpful: boolean, reason?: string) =>
|
||
http.post<{ success: boolean }>(
|
||
`/ai/messages/${messageId}/feedback`, { isHelpful, reason },
|
||
),
|
||
},
|
||
|
||
/* ── اشتراک ───────────────────────────────────────────────────────── */
|
||
plans: (opts?: RequestOptions) =>
|
||
http.get<{ plans: Plan[] }>('/subscription/plans', opts).then((r) => r.plans),
|
||
};
|
||
}
|
||
|
||
export type Api = ReturnType<typeof createApi>;
|
||
|
||
/** نمونهی پیشفرض — برای حالتِ عادی همین کافیست. */
|
||
export const api = createApi();
|