import { config } from '@/lib/config' import { AppError, ErrorType } from '@/lib/errors' import type { ApiResponse, RequestOptions } from '@/types' import { clearRefreshPromise, clearSession, endRefreshing, getAccessToken, getRefreshPromise, isAccessTokenExpired, isRefreshing, setAccessToken, setRefreshPromise, startRefreshing, } from './auth-session' import { clearRefreshToken, getRefreshToken, setRefreshToken } from '../storage' import { CSRF_PROTECTED_METHODS, getCSRFHeaders } from './csrf' import type { TokenBundle } from '@/types' const DEFAULT_TIMEOUT = 30_000 let inFlightRefreshBundle: Promise | null = null function isFormDataBody(body: unknown): body is FormData { return typeof FormData !== 'undefined' && body instanceof FormData } function serializeBody(body: unknown): BodyInit | undefined { if (body === undefined || body === null) { return undefined } if (isFormDataBody(body)) { return body } return JSON.stringify(body) } function resolveApiBaseUrl(): URL { const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost' const rawBaseUrl = /^https?:\/\//i.test(config.apiBaseUrl) ? config.apiBaseUrl : config.apiBaseUrl.startsWith('/') ? config.apiBaseUrl : `/${config.apiBaseUrl}` const baseUrl = new URL(rawBaseUrl, origin) if (!baseUrl.pathname.endsWith('/')) { baseUrl.pathname = `${baseUrl.pathname}/` } return baseUrl } function buildUrl(path: string, params?: Record): string { const url = new URL(path.replace(/^\/+/, ''), resolveApiBaseUrl()) if (params) { for (const [key, value] of Object.entries(params)) { if (value !== undefined) { url.searchParams.append(key, String(value)) } } } return url.toString() } function cleanupSessionOnAuthFailure(): never { clearRefreshToken() clearSession() throw AppError.auth('会话已过期,请重新登录') } function createTimeoutSignal(signal?: AbortSignal): { signal: AbortSignal; cleanup: () => void } { const controller = new AbortController() const timeoutId = window.setTimeout(() => controller.abort(), DEFAULT_TIMEOUT) if (signal) { signal.addEventListener('abort', () => controller.abort(), { once: true }) } return { signal: controller.signal, cleanup: () => window.clearTimeout(timeoutId), } } async function parseJsonResponse(response: Response): Promise> { const raw = await response.json() // 运行时验证响应结构 if (!isApiResponse(raw)) { throw new Error('Invalid API response structure: missing required fields') } return raw as ApiResponse } /** * 运行时验证 API 响应结构 * 防止后端返回异常格式时导致运行时错误 */ function isApiResponse(obj: unknown): obj is ApiResponse { if (typeof obj !== 'object' || obj === null) { return false } const r = obj as Record // 必须有 code 字段且为数字 if (typeof r.code !== 'number') { return false } // 必须有 message 字段且为字符串 if (typeof r.message !== 'string') { return false } // 如果有 data 字段,应该存在 // (data 可以是 undefined/null/任何类型,但我们允许这些值) return true } async function refreshAccessToken(): Promise { const refreshToken = getRefreshToken() const body = refreshToken ? JSON.stringify({ refresh_token: refreshToken }) : undefined const response = await fetch(buildUrl('/auth/refresh'), { method: 'POST', credentials: 'include', headers: body ? { 'Content-Type': 'application/json' } : undefined, body, }) if (!response.ok) { return cleanupSessionOnAuthFailure() } const result = await parseJsonResponse(response) if (result.code !== 0) { return cleanupSessionOnAuthFailure() } return result.data as TokenBundle } async function performTokenRefresh(): Promise { if (inFlightRefreshBundle) { return inFlightRefreshBundle } startRefreshing() const promise = (async () => { try { const tokenBundle = await refreshAccessToken() setAccessToken(tokenBundle.access_token, tokenBundle.expires_in) setRefreshToken(tokenBundle.refresh_token) return tokenBundle } finally { endRefreshing() clearRefreshPromise() inFlightRefreshBundle = null } })() inFlightRefreshBundle = promise setRefreshPromise( promise.then( () => undefined, () => undefined, ), ) return promise } export async function refreshSessionBundle(): Promise { return await performTokenRefresh() } async function performRefresh(): Promise { if (isRefreshing()) { const promise = getRefreshPromise() if (promise) { await promise } const token = getAccessToken() if (!token) { return cleanupSessionOnAuthFailure() } return token } const tokenBundle = await performTokenRefresh() return tokenBundle.access_token } async function resolveAuthorizationHeader(auth: boolean): Promise { if (!auth) { return null } let token = getAccessToken() if (isRefreshing()) { const promise = getRefreshPromise() if (promise) { await promise token = getAccessToken() } } if (token && isAccessTokenExpired()) { token = await performRefresh() } return token } async function request(path: string, options: RequestOptions = {}): Promise { const { method = 'GET', headers = {}, body, params, auth = true, credentials = 'include', signal, } = options const url = buildUrl(path, params) const requestHeaders: Record = { ...headers } if (body !== undefined && body !== null && !isFormDataBody(body) && !requestHeaders['Content-Type']) { requestHeaders['Content-Type'] = 'application/json' } if (CSRF_PROTECTED_METHODS.includes(method)) { Object.assign(requestHeaders, getCSRFHeaders()) } const authToken = await resolveAuthorizationHeader(auth) if (authToken) { requestHeaders.Authorization = `Bearer ${authToken}` } const timeout = createTimeoutSignal(signal) try { let response = await fetch(url, { method, headers: requestHeaders, body: serializeBody(body), credentials, signal: timeout.signal, }) if (response.status === 401 && auth) { const refreshedToken = await performRefresh() requestHeaders.Authorization = `Bearer ${refreshedToken}` response = await fetch(url, { method, headers: requestHeaders, body: serializeBody(body), credentials, signal: timeout.signal, }) } if (response.status === 401) { return cleanupSessionOnAuthFailure() } if (!response.ok) { if (response.status === 403) { throw AppError.forbidden() } if (response.status === 404) { throw new AppError(404, '请求的资源不存在', { status: 404, type: ErrorType.NOT_FOUND, }) } throw AppError.network(`请求失败: ${response.status}`) } const result = await parseJsonResponse(response) if (result.code !== 0) { throw AppError.fromResponse(result, response.status) } return result.data! } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { throw AppError.network('请求超时,请稍后重试') } throw error } finally { timeout.cleanup() } } export function get( path: string, params?: Record, options?: Omit, ): Promise { return request(path, { ...options, method: 'GET', params }) } export function post( path: string, body?: unknown, options?: Omit, ): Promise { return request(path, { ...options, method: 'POST', body }) } export function put( path: string, body?: unknown, options?: Omit, ): Promise { return request(path, { ...options, method: 'PUT', body }) } export function del( path: string, options?: Omit, ): Promise { return request(path, { ...options, method: 'DELETE' }) } async function resolveAuthorizedHeaders(options?: Omit): Promise> { const headers: Record = { ...(options?.headers ?? {}) } if (options?.auth !== false) { const token = await resolveAuthorizationHeader(true) if (token) { headers.Authorization = `Bearer ${token}` } } return headers } export async function download( path: string, params?: Record, options?: Omit, ): Promise { const url = buildUrl(path, params) const headers = await resolveAuthorizedHeaders(options) const timeout = createTimeoutSignal(options?.signal) try { let response = await fetch(url, { headers, credentials: options?.credentials ?? 'include', signal: timeout.signal, }) if (response.status === 401 && options?.auth !== false) { const refreshedToken = await performRefresh() headers.Authorization = `Bearer ${refreshedToken}` response = await fetch(url, { headers, credentials: options?.credentials ?? 'include', signal: timeout.signal, }) } if (response.status === 401) { return cleanupSessionOnAuthFailure() } if (!response.ok) { throw AppError.network(`下载失败: ${response.status}`) } return response.blob() } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { throw AppError.network('下载超时,请稍后重试') } throw error } finally { timeout.cleanup() } } export async function upload( path: string, file: File, fieldName: string = 'file', additionalData?: Record, options?: Omit, ): Promise { const formData = new FormData() formData.append(fieldName, file) if (additionalData) { for (const [key, value] of Object.entries(additionalData)) { formData.append(key, value) } } return request(path, { ...options, method: 'POST', body: formData, }) } export { request }