Files
digital-pilates/services/api.ts
richarjiang 9d424c7bd2 feat(auth): 添加401未授权统一处理机制
- 在API服务层实现401状态码的统一拦截和处理
- 添加防抖机制,避免短时间内重复处理401错误
- 支持应用层注册自定义的未授权处理器
- 在应用启动时注册401处理器,自动清除登录状态并跳转到登录页
- 同时处理普通请求和流式请求的401响应
2025-11-18 15:59:47 +08:00

328 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { buildApiUrl } from '@/constants/Api';
import AsyncStorage from '@/utils/kvStore';
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<void>;
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);
}
}
let inMemoryToken: string | null = null;
export async function setAuthToken(token: string | null): Promise<void> {
inMemoryToken = token;
}
export function getAuthToken(): Promise<string | null> {
return AsyncStorage.getItem(STORAGE_KEYS.authToken);
}
export type ApiRequestOptions = {
method?: HttpMethod;
headers?: Record<string, string>;
body?: any;
signal?: AbortSignal;
unsetContentType?: boolean;
};
export type ApiResponse<T> = {
data: T;
};
async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const url = buildApiUrl(path);
const headers: Record<string, string> = {
...(options.headers || {}),
};
if (!options.unsetContentType) {
headers['Content-Type'] = 'application/json';
}
const token = await getAuthToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
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()
console.log('json', 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) {
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: <T>(path: string, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'GET' }),
post: <T>(path: string, body?: any, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'POST', body }),
put: <T>(path: string, body?: any, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'PUT', body }),
patch: <T>(path: string, body?: any, options?: ApiRequestOptions) => doFetch<T>(path, { ...options, method: 'PATCH', body }),
delete: <T>(path: string, options?: ApiRequestOptions) => doFetch<T>(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<string, string>;
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<string, string> = {
'Content-Type': 'application/json',
'X-Request-Id': requestId,
...(options.headers || {}),
};
if (token) {
requestHeaders['Authorization'] = `Bearer ${token}`;
}
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 };
}