import { buildApiUrl } from '@/constants/Api'; import AsyncStorage from '@/utils/kvStore'; import Constants from 'expo-constants'; import { Alert } from 'react-native'; type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; // 401处理的防抖机制 let is401Handling = false; let last401Time = 0; const DEBOUNCE_INTERVAL = 3000; // 3秒内只处理一次401 // 401处理回调函数(由应用层注册) type UnauthorizedHandler = () => void | Promise; let unauthorizedHandler: UnauthorizedHandler | null = null; /** * 注册401未授权处理器 * 应该在应用启动时调用,传入退出登录的函数 */ export function setUnauthorizedHandler(handler: UnauthorizedHandler) { unauthorizedHandler = handler; console.log('[API] 401处理器已注册'); } /** * 统一处理401未授权错误 * 包含防抖机制,避免多次提示 */ async function handle401Unauthorized() { const now = Date.now(); // 防抖:如果正在处理或者距离上次处理不到3秒,直接返回 if (is401Handling || (now - last401Time < DEBOUNCE_INTERVAL)) { console.log('[API] 401处理防抖,跳过重复处理'); return; } is401Handling = true; last401Time = now; try { console.log('[API] 检测到401未授权,开始处理登录过期'); // 清除本地token await AsyncStorage.removeItem(STORAGE_KEYS.authToken); await setAuthToken(null); // 提示用户 Alert.alert( '登录已过期', '您的登录状态已过期,请重新登录', [ { text: '确定', onPress: () => { console.log('[API] 用户确认登录过期提示'); // 调用注册的处理器(如果存在) if (unauthorizedHandler) { try { const result = unauthorizedHandler(); if (result instanceof Promise) { result.catch(err => { console.error('[API] 401处理器执行失败:', err); }); } } catch (err) { console.error('[API] 401处理器执行失败:', err); } } } } ], { cancelable: false } ); } catch (error) { console.error('[API] 处理401错误时发生异常:', error); } finally { // 延迟重置处理标志,确保不会立即再次触发 setTimeout(() => { is401Handling = false; }, 1000); } } // Token 缓存:内存中保存一份,避免每次都读取 AsyncStorage let inMemoryToken: string | null = null; /** * 设置认证 token * 同时更新内存缓存和持久化存储 */ export async function setAuthToken(token: string | null): Promise { inMemoryToken = token; // 同步更新 AsyncStorage if (token) { await AsyncStorage.setItem(STORAGE_KEYS.authToken, token); } else { await AsyncStorage.removeItem(STORAGE_KEYS.authToken); } } /** * 获取认证 token * 优先使用内存缓存,若无则从 AsyncStorage 读取并缓存 */ export async function getAuthToken(): Promise { // 如果内存中有,直接返回 if (inMemoryToken !== null) { return inMemoryToken; } // 否则从 AsyncStorage 读取并缓存到内存 const token = await AsyncStorage.getItem(STORAGE_KEYS.authToken); inMemoryToken = token; return token; } export type ApiRequestOptions = { method?: HttpMethod; headers?: Record; body?: any; signal?: AbortSignal; unsetContentType?: boolean; }; export type ApiResponse = { data: T; }; function getAppVersion(): string | undefined { return Constants.expoConfig?.version || Constants.nativeAppVersion || undefined; } async function doFetch(path: string, options: ApiRequestOptions = {}): Promise { const url = buildApiUrl(path); const headers: Record = { ...(options.headers || {}), }; if (!options.unsetContentType) { headers['Content-Type'] = 'application/json'; } const token = await getAuthToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } const appVersion = getAppVersion(); if (appVersion) { headers['X-App-Version'] = appVersion; } const response = await fetch(url, { method: options.method ?? 'GET', headers, body: options.body != null ? options.unsetContentType ? options.body : JSON.stringify(options.body) : undefined, signal: options.signal, }); const json = await response.json() if (!response.ok) { // 检查是否为401未授权 if (response.status === 401) { console.log('[API] 检测到401状态码,触发登录过期处理'); await handle401Unauthorized(); } const errorMessage = (json && (json.message || json.error)) || `HTTP ${response.status}`; const error = new Error(errorMessage); // @ts-expect-error augment error.status = response.status; throw error; } if (json.code !== undefined && json.code !== 0 && json.code !== 200) { const errorMessage = (json && (json.message || json.error)) || `HTTP ${response.status}`; const error = new Error(errorMessage); // @ts-expect-error augment error.status = response.status; throw error; } // 支持后端返回 { data: ... } 或直接返回对象 return (json && (json.data ?? json)) as T; } export const api = { get: (path: string, options?: ApiRequestOptions) => doFetch(path, { ...options, method: 'GET' }), post: (path: string, body?: any, options?: ApiRequestOptions) => doFetch(path, { ...options, method: 'POST', body }), put: (path: string, body?: any, options?: ApiRequestOptions) => doFetch(path, { ...options, method: 'PUT', body }), patch: (path: string, body?: any, options?: ApiRequestOptions) => doFetch(path, { ...options, method: 'PATCH', body }), delete: (path: string, options?: ApiRequestOptions) => doFetch(path, { ...options, method: 'DELETE' }), }; export const STORAGE_KEYS = { authToken: '@auth_token', userProfile: '@user_profile', privacyAgreed: '@privacy_agreed', onboardingCompleted: '@onboarding_completed', } as const; // 流式文本 POST(基于 XMLHttpRequest),支持增量 onChunk 回调与取消 export type TextStreamCallbacks = { onChunk: (chunkText: string) => void; onEnd?: (conversationId?: string) => void; onError?: (error: any) => void; }; export type TextStreamOptions = { headers?: Record; timeoutMs?: number; signal?: AbortSignal; }; export async function postTextStream(path: string, body: any, callbacks: TextStreamCallbacks, options: TextStreamOptions = {}) { const url = buildApiUrl(path); const token = await getAuthToken(); // 生成请求ID用于追踪和取消 const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const requestHeaders: Record = { 'Content-Type': 'application/json', 'X-Request-Id': requestId, ...(options.headers || {}), }; if (token) { requestHeaders['Authorization'] = `Bearer ${token}`; } const appVersion = getAppVersion(); if (appVersion) { requestHeaders['X-App-Version'] = appVersion; } const xhr = new XMLHttpRequest(); let lastReadIndex = 0; let resolved = false; let conversationIdFromHeader: string | undefined = undefined; const abort = () => { try { xhr.abort(); } catch { } }; const cleanup = () => { resolved = true; }; xhr.open('POST', url, true); // 设置超时(可选) if (typeof options.timeoutMs === 'number') { xhr.timeout = options.timeoutMs; } // 设置请求头 Object.entries(requestHeaders).forEach(([k, v]) => { try { xhr.setRequestHeader(k, v); } catch { } }); // 进度事件:读取新增的响应文本 xhr.onprogress = () => { try { const text = xhr.responseText ?? ''; if (text.length > lastReadIndex) { const nextChunk = text.substring(lastReadIndex); lastReadIndex = text.length; // 首次拿到响应头时尝试解析会话ID if (!conversationIdFromHeader) { try { const rawHeaders = xhr.getAllResponseHeaders?.() || ''; const matched = /^(.*)$/m.test(rawHeaders) ? rawHeaders : rawHeaders; // 保底,避免 TS 报错 const headerLines = String(matched).split('\n'); for (const line of headerLines) { const [hk, ...rest] = line.split(':'); if (hk && hk.toLowerCase() === 'x-conversation-id') { conversationIdFromHeader = rest.join(':').trim(); break; } } } catch { } } try { callbacks.onChunk(nextChunk); } catch (err) { console.warn('[AI_CHAT][stream] onChunk error', err); } try { console.log('[AI_CHAT][stream] chunk', { length: nextChunk.length, preview: nextChunk.slice(0, 50) }); } catch { } } } catch (err) { console.warn('[AI_CHAT][stream] onprogress error', err); } }; xhr.onreadystatechange = () => { if (xhr.readyState === xhr.DONE) { try { console.log('[AI_CHAT][stream] done', { status: xhr.status }); } catch { } if (!resolved) { cleanup(); if (xhr.status === 401) { // 处理401未授权 console.log('[AI_CHAT][stream] 检测到401状态码,触发登录过期处理'); handle401Unauthorized().catch(err => { console.error('[AI_CHAT][stream] 处理401错误时发生异常:', err); }); const error = new Error('登录已过期'); try { callbacks.onError?.(error); } catch { } } else if (xhr.status >= 200 && xhr.status < 300) { try { callbacks.onEnd?.(conversationIdFromHeader); } catch { } } else { const error = new Error(`HTTP ${xhr.status}`); try { callbacks.onError?.(error); } catch { } } } } }; xhr.onerror = (e) => { try { console.warn('[AI_CHAT][stream] xhr error', e); } catch { } if (!resolved) { cleanup(); try { callbacks.onError?.(e); } catch { } } }; xhr.ontimeout = () => { const err = new Error('Request timeout'); try { console.warn('[AI_CHAT][stream] timeout'); } catch { } if (!resolved) { cleanup(); try { callbacks.onError?.(err); } catch { } } }; // AbortSignal 支持 if (options.signal) { const onAbort = () => { try { console.log('[AI_CHAT][stream] aborted'); } catch { } abort(); }; if (options.signal.aborted) onAbort(); else options.signal.addEventListener('abort', onAbort, { once: true }); } try { const payload = body != null ? JSON.stringify(body) : undefined; xhr.send(payload); } catch (err) { try { console.warn('[AI_CHAT][stream] send error', err); } catch { } if (!resolved) { cleanup(); try { callbacks.onError?.(err); } catch { } } } return { abort, requestId }; }