diff --git a/app/_layout.tsx b/app/_layout.tsx index c776a2c..e871cd8 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,7 +1,7 @@ import '@/i18n'; import { DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { useFonts } from 'expo-font'; -import { Stack } from 'expo-router'; +import { Stack, useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import 'react-native-reanimated'; @@ -19,7 +19,7 @@ import { WaterRecordSource } from '@/services/waterRecords'; import { workoutMonitorService } from '@/services/workoutMonitor'; import { store } from '@/store'; 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 { loadActiveFastingSchedule } from '@/utils/fasting'; import { initializeHealthPermissions } from '@/utils/health'; @@ -31,7 +31,7 @@ import { DialogProvider } from '@/components/ui/DialogProvider'; import { MembershipModalProvider } from '@/contexts/MembershipModalContext'; import { ToastProvider } from '@/contexts/ToastContext'; import { useAuthGuard } from '@/hooks/useAuthGuard'; -import { STORAGE_KEYS } from '@/services/api'; +import { STORAGE_KEYS, setUnauthorizedHandler } from '@/services/api'; import { BackgroundTaskManager } from '@/services/backgroundTaskManagerV2'; import { fetchChallenges } from '@/store/challengesSlice'; import AsyncStorage from '@/utils/kvStore'; @@ -52,6 +52,7 @@ if (__DEV__) { function Bootstrapper({ children }: { children: React.ReactNode }) { const dispatch = useAppDispatch(); + const router = useRouter(); const { profile, onboardingCompleted } = useAppSelector((state) => state.user); const activeFastingSchedule = useAppSelector(selectActiveFastingSchedule); const [showPrivacyModal, setShowPrivacyModal] = React.useState(false); @@ -62,6 +63,28 @@ function Bootstrapper({ children }: { children: React.ReactNode }) { // 初始化快捷动作处理 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(() => { if (fastingHydrationRequestedRef.current) return; if (activeFastingSchedule) { diff --git a/services/api.ts b/services/api.ts index 74316c7..44bbd02 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1,8 +1,87 @@ 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; +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 { @@ -52,6 +131,12 @@ async function doFetch(path: string, options: ApiRequestOptions = {}): Promis 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 @@ -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 { } if (!resolved) { 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 { } } else { const error = new Error(`HTTP ${xhr.status}`);