import { refreshAccessToken } from './auth' export interface AuthSession { token: string tokenType?: string refreshToken?: string expiresIn?: number } export interface AuthedRequestOptions { method: 'GET' | 'POST' path: string auth: AuthSession body?: Record onAuthUpdated?: (next: AuthSession) => void } export interface ApiEnvelope { code: number msg: string data: T } interface ApiErrorPayload { code?: number msg?: string message?: string error?: string } export class AuthExpiredError extends Error { constructor(message = '登录状态已过期,请重新登录') { super(message) this.name = 'AuthExpiredError' } } const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').trim().replace(/\/$/, '') function buildUrl(path: string): string { if (/^https?:\/\//.test(path)) { return path } const normalizedPath = path.startsWith('/') ? path : `/${path}` if (!API_BASE_URL) { return normalizedPath } if (API_BASE_URL.startsWith('/')) { const basePath = API_BASE_URL.startsWith('/') ? API_BASE_URL : `/${API_BASE_URL}` if (normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`)) { return normalizedPath } return `${basePath}${normalizedPath}` } try { const baseUrl = new URL(API_BASE_URL) const basePath = baseUrl.pathname.replace(/\/$/, '') if (basePath && normalizedPath.startsWith(`${basePath}/`)) { return `${API_BASE_URL}${normalizedPath.slice(basePath.length)}` } } catch { // API_BASE_URL may be a relative path. } return `${API_BASE_URL}${normalizedPath}` } function createAuthHeader(token: string, tokenType = 'Bearer'): string { const normalizedToken = token.trim() if (/^\S+\s+\S+/.test(normalizedToken)) { return normalizedToken } return `${tokenType || 'Bearer'} ${normalizedToken}` } async function parseEnvelope(response: Response): Promise> { const payload = (await response.json().catch(() => ({}))) as ApiEnvelope & ApiErrorPayload if (!response.ok) { throw new Error(payload.msg ?? payload.message ?? payload.error ?? '请求失败,请稍后再试') } if (typeof payload.code === 'number' && payload.code !== 0) { throw new Error(payload.msg ?? '接口返回失败') } return payload } async function runRequest( options: Omit & { session: AuthSession }, ): Promise<{ response: Response; parsed: ApiEnvelope }> { const response = await fetch(buildUrl(options.path), { method: options.method, headers: { ...(options.body ? { 'Content-Type': 'application/json' } : undefined), Authorization: createAuthHeader(options.session.token, options.session.tokenType), }, body: options.body ? JSON.stringify(options.body) : undefined, }) if (response.status === 401) { return { response, parsed: { code: 401, msg: 'unauthorized', data: {} as T }, } } const parsed = await parseEnvelope(response) return { response, parsed } } export async function authedRequest(options: AuthedRequestOptions): Promise { const first = await runRequest({ ...options, session: options.auth }) if (first.response.status !== 401) { return first.parsed.data } if (!options.auth.refreshToken) { throw new AuthExpiredError() } try { const refreshed = await refreshAccessToken({ token: options.auth.token, tokenType: options.auth.tokenType, refreshToken: options.auth.refreshToken, }) const nextAuth: AuthSession = { token: refreshed.token, tokenType: refreshed.tokenType ?? options.auth.tokenType, refreshToken: refreshed.refreshToken ?? options.auth.refreshToken, expiresIn: refreshed.expiresIn, } options.onAuthUpdated?.(nextAuth) const second = await runRequest({ ...options, session: nextAuth }) if (second.response.status === 401) { throw new AuthExpiredError() } return second.parsed.data } catch (error) { if (error instanceof AuthExpiredError) { throw error } throw new AuthExpiredError() } }