feat(auth): 添加401未授权统一处理机制

- 在API服务层实现401状态码的统一拦截和处理
- 添加防抖机制,避免短时间内重复处理401错误
- 支持应用层注册自定义的未授权处理器
- 在应用启动时注册401处理器,自动清除登录状态并跳转到登录页
- 同时处理普通请求和流式请求的401响应
This commit is contained in:
richarjiang
2025-11-18 15:59:47 +08:00
parent 21e57634e0
commit 9d424c7bd2
2 changed files with 120 additions and 4 deletions

View File

@@ -1,7 +1,7 @@
import '@/i18n'; import '@/i18n';
import { DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font'; import { useFonts } from 'expo-font';
import { Stack } from 'expo-router'; import { Stack, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import 'react-native-reanimated'; import 'react-native-reanimated';
@@ -19,7 +19,7 @@ import { WaterRecordSource } from '@/services/waterRecords';
import { workoutMonitorService } from '@/services/workoutMonitor'; import { workoutMonitorService } from '@/services/workoutMonitor';
import { store } from '@/store'; import { store } from '@/store';
import { hydrateActiveSchedule, selectActiveFastingSchedule } from '@/store/fastingSlice'; import { hydrateActiveSchedule, selectActiveFastingSchedule } from '@/store/fastingSlice';
import { fetchMyProfile, setPrivacyAgreed } from '@/store/userSlice'; import { fetchMyProfile, logout, setPrivacyAgreed } from '@/store/userSlice';
import { createWaterRecordAction } from '@/store/waterSlice'; import { createWaterRecordAction } from '@/store/waterSlice';
import { loadActiveFastingSchedule } from '@/utils/fasting'; import { loadActiveFastingSchedule } from '@/utils/fasting';
import { initializeHealthPermissions } from '@/utils/health'; import { initializeHealthPermissions } from '@/utils/health';
@@ -31,7 +31,7 @@ import { DialogProvider } from '@/components/ui/DialogProvider';
import { MembershipModalProvider } from '@/contexts/MembershipModalContext'; import { MembershipModalProvider } from '@/contexts/MembershipModalContext';
import { ToastProvider } from '@/contexts/ToastContext'; import { ToastProvider } from '@/contexts/ToastContext';
import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useAuthGuard } from '@/hooks/useAuthGuard';
import { STORAGE_KEYS } from '@/services/api'; import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api';
import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2'; import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2';
import { fetchChallenges } from '@/store/challengesSlice'; import { fetchChallenges } from '@/store/challengesSlice';
import AsyncStorage from '@/utils/kvStore'; import AsyncStorage from '@/utils/kvStore';
@@ -52,6 +52,7 @@ if (__DEV__) {
function Bootstrapper({ children }: { children: React.ReactNode }) { function Bootstrapper({ children }: { children: React.ReactNode }) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const router = useRouter();
const { profile, onboardingCompleted } = useAppSelector((state) => state.user); const { profile, onboardingCompleted } = useAppSelector((state) => state.user);
const activeFastingSchedule = useAppSelector(selectActiveFastingSchedule); const activeFastingSchedule = useAppSelector(selectActiveFastingSchedule);
const [showPrivacyModal, setShowPrivacyModal] = React.useState(false); const [showPrivacyModal, setShowPrivacyModal] = React.useState(false);
@@ -62,6 +63,28 @@ function Bootstrapper({ children }: { children: React.ReactNode }) {
// 初始化快捷动作处理 // 初始化快捷动作处理
useQuickActions(); useQuickActions();
// 注册401未授权处理器应用启动时执行一次
React.useEffect(() => {
const handle401 = async () => {
try {
logger.info('[401处理] 开始处理登录过期');
// 清除Redux状态
await dispatch(logout());
// 跳转到登录页
router.push('/auth/login');
logger.info('[401处理] 登录过期处理完成');
} catch (error) {
logger.error('[401处理] 处理失败:', error);
}
};
setUnauthorizedHandler(handle401);
logger.info('[401处理器] 已注册到API服务');
}, [dispatch, router]);
React.useEffect(() => { React.useEffect(() => {
if (fastingHydrationRequestedRef.current) return; if (fastingHydrationRequestedRef.current) return;
if (activeFastingSchedule) { if (activeFastingSchedule) {

View File

@@ -1,8 +1,87 @@
import { buildApiUrl } from '@/constants/Api'; import { buildApiUrl } from '@/constants/Api';
import AsyncStorage from '@/utils/kvStore'; import AsyncStorage from '@/utils/kvStore';
import { Alert } from 'react-native';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; 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; let inMemoryToken: string | null = null;
export async function setAuthToken(token: string | null): Promise<void> { export async function setAuthToken(token: string | null): Promise<void> {
@@ -52,6 +131,12 @@ async function doFetch<T>(path: string, options: ApiRequestOptions = {}): Promis
console.log('json', json); console.log('json', json);
if (!response.ok) { 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 errorMessage = (json && (json.message || json.error)) || `HTTP ${response.status}`;
const error = new Error(errorMessage); const error = new Error(errorMessage);
// @ts-expect-error augment // @ts-expect-error augment
@@ -181,7 +266,15 @@ export async function postTextStream(path: string, body: any, callbacks: TextStr
try { console.log('[AI_CHAT][stream] done', { status: xhr.status }); } catch { } try { console.log('[AI_CHAT][stream] done', { status: xhr.status }); } catch { }
if (!resolved) { if (!resolved) {
cleanup(); cleanup();
if (xhr.status >= 200 && xhr.status < 300) { 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 { } try { callbacks.onEnd?.(conversationIdFromHeader); } catch { }
} else { } else {
const error = new Error(`HTTP ${xhr.status}`); const error = new Error(`HTTP ${xhr.status}`);