/** * API 客户端 * 使用 fetch API 进行 HTTP 请求 * 支持请求/响应拦截器 * 支持主动刷新 token(快过期时自动刷新) */ import AsyncStorage from '@react-native-async-storage/async-storage'; import { CommonActions } from '@react-navigation/native'; import Constants from 'expo-constants'; import { Platform } from 'react-native'; import { hrefVerificationGuide } from '../navigation/hrefs'; // 生产地址 https://bbs.littlelan.cn const getBaseUrl = () => { const configuredBaseUrl = Constants.expoConfig?.extra?.apiBaseUrl; if (typeof configuredBaseUrl === 'string' && configuredBaseUrl.trim().length > 0) { return configuredBaseUrl; } return 'https://bbs.littlelan.cn/api/v1'; }; const BASE_URL = getBaseUrl(); const WS_URL = `${BASE_URL.replace(/^https?:\/\//, 'wss://').replace(/\/api\/v1$/, '/api/v1')}/realtime/ws`; // Token 存储键 const TOKEN_KEY = 'auth_token'; const REFRESH_TOKEN_KEY = 'refresh_token'; // Token 提前刷新阈值(秒):提前 5 分钟刷新 const TOKEN_REFRESH_THRESHOLD_SECONDS = 5 * 60; // API 响应格式 export interface ApiResponse { code: number; message: string; data: T; } // 分页响应格式 export interface PaginatedData { list: T[]; total: number; page: number; page_size: number; total_pages: number; } // 错误类型 export class ApiError extends Error { code: number; message: string; errorCode?: string; constructor(code: number, message: string, errorCode?: string) { super(message); this.code = code; this.message = message; this.errorCode = errorCode; this.name = 'ApiError'; } } // JWT Payload 结构 interface JwtPayload { user_id: string; username: string; exp?: number; iat?: number; iss?: string; } // API 客户端类 class ApiClient { private baseUrl: string; private navigation: any = null; private expoRouter: any = null; private verificationModalShown = false; // 刷新锁:防止并发刷新 private refreshLock: Promise | null = null; constructor(baseUrl: string) { this.baseUrl = baseUrl; } setExpoRouter(router: any) { this.expoRouter = router; } private handleVerificationRequired() { if (this.verificationModalShown) return; this.verificationModalShown = true; if (this.expoRouter) { this.expoRouter.push(hrefVerificationGuide()); } setTimeout(() => { this.verificationModalShown = false; }, 3000); } // 设置导航对象(用于处理401跳转) setNavigation(navigation: any) { this.navigation = navigation; } // 解析 JWT token 获取 payload private parseJwt(token: string): JwtPayload | null { try { const parts = token.split('.'); if (parts.length !== 3) { return null; } // React Native/Expo 环境使用 atob const payload = JSON.parse(atob(parts[1])); return payload as JwtPayload; } catch { return null; } } // 检查 token 是否快过期(返回 true 表示需要刷新) private isTokenExpiringSoon(token: string): boolean { const payload = this.parseJwt(token); if (!payload || !payload.exp) { return false; } const now = Math.floor(Date.now() / 1000); const remaining = payload.exp - now; // 剩余时间小于阈值时需要刷新 return remaining < TOKEN_REFRESH_THRESHOLD_SECONDS && remaining > 0; } // 获取 token async getToken(): Promise { try { return await AsyncStorage.getItem(TOKEN_KEY); } catch (error) { console.error('获取token失败:', error); return null; } } // 保存 token async setToken(token: string): Promise { try { await AsyncStorage.setItem(TOKEN_KEY, token); } catch (error) { console.error('保存token失败:', error); } } // 获取刷新 token async getRefreshToken(): Promise { try { return await AsyncStorage.getItem(REFRESH_TOKEN_KEY); } catch (error) { console.error('获取刷新token失败:', error); return null; } } // 保存刷新 token async setRefreshToken(token: string): Promise { try { await AsyncStorage.setItem(REFRESH_TOKEN_KEY, token); } catch (error) { console.error('保存刷新token失败:', error); } } // 清除 token async clearToken(): Promise { try { await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]); } catch (error) { console.error('清除token失败:', error); } } // 统一请求方法 private async request( method: string, path: string, params?: Record, body?: any ): Promise> { const url = new URL(`${this.baseUrl}${path}`); // 添加查询参数 if (params) { Object.keys(params).forEach(key => { if (params[key] !== undefined && params[key] !== null) { url.searchParams.append(key, String(params[key])); } }); } // 构建请求头 const headers: HeadersInit = { 'Content-Type': 'application/json', }; // 添加认证 token(主动刷新快过期的 token) const token = await this.getToken(); if (token) { // 检查 token 是否快过期,如果是则主动刷新 if (this.isTokenExpiringSoon(token)) { const refreshed = await this.refreshToken(); if (!refreshed) { // 刷新失败,清除 token 并跳转登录 await this.clearToken(); if (this.navigation) { this.navigation.dispatch( CommonActions.reset({ index: 0, routes: [{ name: 'Login' }], }) ); } throw new ApiError(401, '登录已过期,请重新登录'); } // 刷新成功,使用新 token const newToken = await this.getToken(); if (newToken) { headers['Authorization'] = `Bearer ${newToken}`; } } else { headers['Authorization'] = `Bearer ${token}`; } } // 构建请求配置 const config: RequestInit = { method, headers, }; // 添加请求体 if (body && method !== 'GET') { config.body = JSON.stringify(body); } try { const response = await fetch(url.toString(), config); // 处理 401 未授权 if (response.status === 401) { // 尝试刷新 token const refreshed = await this.refreshToken(); if (!refreshed) { // 刷新失败,清除 token 并跳转登录 await this.clearToken(); if (this.navigation) { this.navigation.dispatch( CommonActions.reset({ index: 0, routes: [{ name: 'Login' }], }) ); } throw new ApiError(401, '登录已过期,请重新登录'); } // 刷新成功后重试请求 return this.request(method, path, params, body); } // 解析响应(兼容非 JSON 返回,避免 SyntaxError 被误判为网络错误) const contentType = response.headers.get('content-type') || ''; let parsedBody: any = null; let rawText = ''; if (contentType.includes('application/json')) { parsedBody = await response.json(); } else { rawText = await response.text(); if (rawText) { try { parsedBody = JSON.parse(rawText); } catch { parsedBody = null; } } } // 优先处理标准 API 结构 if (parsedBody && typeof parsedBody === 'object' && 'code' in parsedBody) { const data = parsedBody as ApiResponse; if (data.code !== 0) { if (parsedBody.error_code === 'VERIFICATION_REQUIRED') { this.handleVerificationRequired(); } throw new ApiError(data.code, data.message || '请求失败', parsedBody.error_code); } return data; } // 非标准结构:先按 HTTP 状态处理失败 if (!response.ok) { const fallbackMessage = rawText || response.statusText || `请求失败(${response.status})`; throw new ApiError(response.status, fallbackMessage); } // 非标准结构但 HTTP 成功:兜底为成功响应(兼容部分纯文本成功接口) return { code: 0, message: 'success', data: (parsedBody as T) ?? (undefined as T), }; } catch (error) { // 如果是 ApiError,直接抛出 if (error instanceof ApiError) { throw error; } // 网络错误 console.error('API请求失败:', error); throw new ApiError(500, '网络请求失败,请检查网络连接'); } } // 刷新 token(带锁,防止并发刷新) private async refreshToken(): Promise { // 如果已有刷新操作正在进行,等待其完成 if (this.refreshLock) { return this.refreshLock; } // 创建刷新锁 this.refreshLock = this.doRefreshToken(); try { const result = await this.refreshLock; return result; } finally { // 释放刷新锁 this.refreshLock = null; } } // 实际执行刷新 token private async doRefreshToken(): Promise { try { const refreshToken = await this.getRefreshToken(); if (!refreshToken) { return false; } const response = await fetch(`${this.baseUrl}/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ refresh_token: refreshToken, }), }); if (!response.ok) { return false; } const data = await response.json(); if (data.code === 0 && data.data?.token) { await this.setToken(data.data.token); // 后端返回refresh_token (snake_case),兼容两种命名 if (data.data.refresh_token || data.data.refreshToken) { await this.setRefreshToken(data.data.refresh_token || data.data.refreshToken); } return true; } return false; } catch (error) { console.error('刷新token失败:', error); return false; } } // GET 请求 async get(path: string, params?: Record): Promise> { return this.request('GET', path, params); } // POST 请求 async post(path: string, body?: any): Promise> { return this.request('POST', path, undefined, body); } // PUT 请求 async put(path: string, body?: any): Promise> { return this.request('PUT', path, undefined, body); } // DELETE 请求 async delete(path: string, body?: any): Promise> { return this.request('DELETE', path, undefined, body); } // 上传文件 async upload( path: string, file: { uri: string; name: string; type: string }, additionalData?: Record ): Promise> { const formData = new FormData(); // 添加文件(使用 image 字段名) // Web 端需要 append Blob/File;原生端可以直接传 { uri, name, type }。 if (Platform.OS === 'web') { const fileResponse = await fetch(file.uri); const blob = await fileResponse.blob(); const uploadFile = new File([blob], file.name, { type: file.type || blob.type || 'application/octet-stream' }); formData.append('image', uploadFile); } else { formData.append('image', { uri: file.uri, name: file.name, type: file.type, } as any); } // 添加额外数据 if (additionalData) { Object.keys(additionalData).forEach(key => { formData.append(key, additionalData[key]); }); } // 获取 token 并检查是否需要刷新 const token = await this.getToken(); const headers: HeadersInit = {}; if (token) { // 检查 token 是否快过期 if (this.isTokenExpiringSoon(token)) { const refreshed = await this.refreshToken(); if (refreshed) { const newToken = await this.getToken(); if (newToken) { headers['Authorization'] = `Bearer ${newToken}`; } } } else { headers['Authorization'] = `Bearer ${token}`; } } const response = await fetch(`${this.baseUrl}${path}`, { method: 'POST', headers, body: formData, }); const data: ApiResponse = await response.json(); if (data.code !== 0) { throw new ApiError(data.code, data.message); } return data; } } // 导出 API 客户端实例 export const api = new ApiClient(BASE_URL); export { WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY };