221 lines
5.5 KiB
TypeScript
221 lines
5.5 KiB
TypeScript
/**
|
||
* 統一的API客戶端
|
||
* 提供一致的請求處理、錯誤處理和認證邏輯
|
||
*/
|
||
|
||
import {
|
||
createApiErrorFromResponse,
|
||
createApiErrorFromException,
|
||
createSuccessResponse,
|
||
createErrorResponse,
|
||
StandardApiResponse,
|
||
type ApiError
|
||
} from './errorHandler'
|
||
|
||
export interface RequestConfig extends RequestInit {
|
||
timeout?: number
|
||
retries?: number
|
||
context?: string
|
||
}
|
||
|
||
export class ApiClient {
|
||
private baseURL: string
|
||
private defaultTimeout: number = 10000 // 10秒
|
||
private defaultRetries: number = 3
|
||
|
||
constructor(baseURL: string) {
|
||
this.baseURL = baseURL
|
||
}
|
||
|
||
/**
|
||
* 統一的HTTP請求方法
|
||
*/
|
||
async request<T>(
|
||
endpoint: string,
|
||
config: RequestConfig = {}
|
||
): Promise<StandardApiResponse<T>> {
|
||
const {
|
||
timeout = this.defaultTimeout,
|
||
retries = this.defaultRetries,
|
||
context,
|
||
...fetchOptions
|
||
} = config
|
||
|
||
const url = `${this.baseURL}${endpoint}`
|
||
let lastError: ApiError
|
||
|
||
// 重試邏輯
|
||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||
try {
|
||
const controller = new AbortController()
|
||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||
|
||
const response = await fetch(url, {
|
||
...fetchOptions,
|
||
signal: controller.signal,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...this.getDefaultHeaders(),
|
||
...fetchOptions.headers,
|
||
},
|
||
})
|
||
|
||
clearTimeout(timeoutId)
|
||
|
||
if (!response.ok) {
|
||
const apiError = await createApiErrorFromResponse(response, context)
|
||
lastError = apiError
|
||
|
||
// 處理認證錯誤 - 自動清除過期token
|
||
if (response.status === 401) {
|
||
localStorage.removeItem('auth_token')
|
||
console.log('🔒 認證過期,已清除token')
|
||
}
|
||
|
||
// 如果不可重試,直接返回錯誤
|
||
if (attempt === retries || !this.isRetryableStatus(response.status)) {
|
||
return createErrorResponse(apiError)
|
||
}
|
||
|
||
// 等待後重試
|
||
await this.delay(Math.pow(2, attempt) * 1000) // 指數退避
|
||
continue
|
||
}
|
||
|
||
// 成功情況
|
||
const data = await response.json()
|
||
return createSuccessResponse(data)
|
||
|
||
} catch (error) {
|
||
const apiError = createApiErrorFromException(error, context)
|
||
lastError = apiError
|
||
|
||
// 超時或網路錯誤才重試
|
||
if (attempt === retries || !this.isRetryableError(apiError)) {
|
||
return createErrorResponse(apiError)
|
||
}
|
||
|
||
await this.delay(Math.pow(2, attempt) * 1000)
|
||
}
|
||
}
|
||
|
||
return createErrorResponse(lastError!)
|
||
}
|
||
|
||
/**
|
||
* GET請求
|
||
*/
|
||
async get<T>(endpoint: string, config?: RequestConfig): Promise<StandardApiResponse<T>> {
|
||
return this.request<T>(endpoint, { ...config, method: 'GET' })
|
||
}
|
||
|
||
/**
|
||
* POST請求
|
||
*/
|
||
async post<T>(endpoint: string, data?: any, config?: RequestConfig): Promise<StandardApiResponse<T>> {
|
||
return this.request<T>(endpoint, {
|
||
...config,
|
||
method: 'POST',
|
||
body: data ? JSON.stringify(data) : undefined,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* PUT請求
|
||
*/
|
||
async put<T>(endpoint: string, data?: any, config?: RequestConfig): Promise<StandardApiResponse<T>> {
|
||
return this.request<T>(endpoint, {
|
||
...config,
|
||
method: 'PUT',
|
||
body: data ? JSON.stringify(data) : undefined,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* DELETE請求
|
||
*/
|
||
async delete<T>(endpoint: string, config?: RequestConfig): Promise<StandardApiResponse<T>> {
|
||
return this.request<T>(endpoint, { ...config, method: 'DELETE' })
|
||
}
|
||
|
||
/**
|
||
* 獲取預設Headers
|
||
*/
|
||
private getDefaultHeaders(): Record<string, string> {
|
||
const headers: Record<string, string> = {}
|
||
|
||
// 添加認證Token(如果存在)
|
||
const token = localStorage.getItem('auth_token')
|
||
if (token) {
|
||
headers.Authorization = `Bearer ${token}`
|
||
}
|
||
|
||
return headers
|
||
}
|
||
|
||
/**
|
||
* 判斷HTTP狀態碼是否可重試
|
||
*/
|
||
private isRetryableStatus(status: number): boolean {
|
||
return [
|
||
408, // Request Timeout
|
||
429, // Too Many Requests
|
||
500, // Internal Server Error
|
||
502, // Bad Gateway
|
||
503, // Service Unavailable
|
||
504, // Gateway Timeout
|
||
].includes(status)
|
||
}
|
||
|
||
/**
|
||
* 判斷錯誤是否可重試
|
||
*/
|
||
private isRetryableError(error: ApiError): boolean {
|
||
return [
|
||
'NETWORK_ERROR',
|
||
'TIMEOUT_ERROR',
|
||
'SERVER_ERROR'
|
||
].includes(error.type)
|
||
}
|
||
|
||
/**
|
||
* 延遲函數
|
||
*/
|
||
private delay(ms: number): Promise<void> {
|
||
return new Promise(resolve => setTimeout(resolve, ms))
|
||
}
|
||
|
||
/**
|
||
* 設置基礎URL
|
||
*/
|
||
setBaseURL(url: string): void {
|
||
this.baseURL = url
|
||
}
|
||
|
||
/**
|
||
* 設置預設超時時間
|
||
*/
|
||
setTimeout(timeout: number): void {
|
||
this.defaultTimeout = timeout
|
||
}
|
||
|
||
/**
|
||
* 設置預設重試次數
|
||
*/
|
||
setRetries(retries: number): void {
|
||
this.defaultRetries = retries
|
||
}
|
||
}
|
||
|
||
// 統一import
|
||
import { BASE_URL, API_URLS } from '@/lib/config/api'
|
||
|
||
// 建立預設的API客戶端實例
|
||
export const apiClient = new ApiClient(BASE_URL)
|
||
|
||
// 建立特定服務的API客戶端
|
||
|
||
export const authApiClient = new ApiClient(API_URLS.auth())
|
||
export const flashcardsApiClient = new ApiClient(API_URLS.flashcards())
|
||
export const reviewApiClient = new ApiClient(API_URLS.review())
|
||
export const imageApiClient = new ApiClient(API_URLS.image()) |