From ee28fde4146929946bd38b62b17b558324b74639 Mon Sep 17 00:00:00 2001 From: gz <> Date: Tue, 4 Aug 2026 11:10:43 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E6=AC=A1=E5=AE=89=E8=A3=85=E6=97=A0?= =?UTF-8?q?=20token=20=E6=97=B6=EF=BC=8C=E5=A6=82=E6=9E=9C=E8=AF=95?= =?UTF-8?q?=E7=94=A8=E7=99=BB=E5=BD=95=E8=BF=94=E5=9B=9E=20trial-login-fai?= =?UTF-8?q?led=20=E4=B8=94=E9=94=99=E8=AF=AF=E5=83=8F=E7=BD=91=E7=BB=9C/?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E9=97=AE=E9=A2=98=EF=BC=8C=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=20iOS/Android=20=E4=BC=9A=E5=85=88=E6=8C=82=E8=B5=B7?= =?UTF-8?q?=E9=87=8D=E8=AF=95=EF=BC=8C=E4=B8=8D=E7=AB=8B=E5=88=BB=20toast?= =?UTF-8?q?=20=E6=88=96=E8=BF=9B=E7=99=BB=E5=BD=95=E9=A1=B5=EF=BC=9BApp=20?= =?UTF-8?q?=E5=9B=9E=E5=88=B0=20active=20=E5=90=8E=E9=87=8D=E8=B7=91=20res?= =?UTF-8?q?toreSession=EF=BC=8C=E6=88=90=E5=8A=9F=E5=90=8E=E8=BF=9B?= =?UTF-8?q?=E5=85=A5=20Main=E3=80=82=E5=B7=B2=E6=9C=89=20token=E3=80=81?= =?UTF-8?q?=E9=9D=9E=E7=BD=91=E7=BB=9C=E5=A4=B1=E8=B4=A5=E3=80=81PC/Tauri?= =?UTF-8?q?=20=E9=83=BD=E4=B8=8D=E8=B5=B0=E8=BF=99=E4=B8=AA=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E9=80=BB=E8=BE=91=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/screens/SplashScreen.test.tsx | 56 +++++++++- __tests__/setup.ts | 25 +++++ src/screens/SplashScreen.tsx | 129 +++++++++++++++++++++++- 3 files changed, 207 insertions(+), 3 deletions(-) diff --git a/__tests__/screens/SplashScreen.test.tsx b/__tests__/screens/SplashScreen.test.tsx index ef7a859..e08f59d 100644 --- a/__tests__/screens/SplashScreen.test.tsx +++ b/__tests__/screens/SplashScreen.test.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { AppState } from 'react-native'; import { act, create } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -11,16 +12,22 @@ const { syncSessionMock, setLandingReadyMock, aclStartMock, + aclGetFilePathAsyncMock, + aclForceUpdateMock, toastErrorMock, useAppStateMock, + getAuthTokenMock, } = vi.hoisted(() => ({ restoreSessionMock: vi.fn(), bootSplashHideMock: vi.fn(async () => undefined), syncSessionMock: vi.fn(), setLandingReadyMock: vi.fn(), aclStartMock: vi.fn(async () => undefined), + aclGetFilePathAsyncMock: vi.fn(async () => 'acl.dat'), + aclForceUpdateMock: vi.fn(async () => ({ filePath: 'acl.dat' })), toastErrorMock: vi.fn(), useAppStateMock: vi.fn(), + getAuthTokenMock: vi.fn(async () => null as string | null), })); vi.mock('@telescope/boot-splash', () => ({ @@ -42,6 +49,8 @@ vi.mock('@telescope/user-info', () => ({ vi.mock('@telescope/acl-updater', () => ({ useAclUpdaterService: () => ({ start: aclStartMock, + getFilePathAsync: aclGetFilePathAsyncMock, + forceUpdate: aclForceUpdateMock, }), })); @@ -63,7 +72,14 @@ vi.mock('../../src/deep-linking', () => ({ vi.mock('../../src/storage/auth', () => ({ clearAuthStorage: vi.fn(), - getAuthToken: vi.fn(), + getAuthToken: getAuthTokenMock, + setAuthToken: vi.fn(), + setUserInfo: vi.fn(), +})); + +vi.mock('@telescope/user-info/auth', () => ({ + clearAuthStorage: vi.fn(), + getAuthToken: getAuthTokenMock, setAuthToken: vi.fn(), setUserInfo: vi.fn(), })); @@ -96,6 +112,8 @@ describe('screens/SplashScreen', () => { beforeEach(() => { vi.clearAllMocks(); navigation.replace.mockReset(); + getAuthTokenMock.mockResolvedValue(null); + (AppState as any).__reset?.(); useAppStateMock.mockImplementation(() => ({ actions: { syncSession: syncSessionMock, @@ -135,7 +153,7 @@ describe('screens/SplashScreen', () => { restoreSessionMock.mockResolvedValueOnce({ status: 'auth', reason: 'trial-login-failed', - error: new Error('network down'), + error: new Error('trial login rejected'), }); await act(async () => { @@ -149,6 +167,40 @@ describe('screens/SplashScreen', () => { expect(navigation.replace).toHaveBeenCalledWith('Auth'); }); + it('移动端首次试用登录网络失败后会在 App 回到 active 时重试并进入 Main', async () => { + (AppState as any).__emit?.('background'); + restoreSessionMock + .mockResolvedValueOnce({ + status: 'auth', + reason: 'trial-login-failed', + error: new Error('request_timeout'), + }) + .mockResolvedValueOnce({ + status: 'main', + source: 'trial', + reason: 'ok', + user: { id: 6 }, + }); + + await act(async () => { + create(); + await flushPromises(); + }); + + expect(restoreSessionMock).toHaveBeenCalledTimes(1); + expect(toastErrorMock).not.toHaveBeenCalled(); + expect(navigation.replace).not.toHaveBeenCalled(); + + await act(async () => { + (AppState as any).__emit?.('active'); + await flushPromises(); + }); + + expect(restoreSessionMock).toHaveBeenCalledTimes(2); + expect(syncSessionMock).toHaveBeenCalledWith({ id: 6 }); + expect(navigation.replace).toHaveBeenCalledWith('Main'); + }); + it('原生 BootSplash hide 卡住时也会超时继续进入目标页', async () => { vi.useFakeTimers(); restoreSessionMock.mockResolvedValueOnce({ diff --git a/__tests__/setup.ts b/__tests__/setup.ts index 9778aea..09edbba 100644 --- a/__tests__/setup.ts +++ b/__tests__/setup.ts @@ -54,9 +54,34 @@ vi.mock('react-native', () => { const runAfterInteractions = vi.fn((task: () => void) => task()); const StatusBar = createHostComponent('StatusBar') as any; StatusBar.currentHeight = 24; + const appStateListeners = new Set<(state: string) => void>(); + const AppState: any = { + currentState: 'active', + addEventListener: vi.fn((event: string, listener: (state: string) => void) => { + if (event === 'change') { + appStateListeners.add(listener); + } + return { + remove: vi.fn(() => { + appStateListeners.delete(listener); + }), + }; + }), + __emit: (state: string) => { + AppState.currentState = state; + for (const listener of Array.from(appStateListeners)) { + listener(state); + } + }, + __reset: () => { + AppState.currentState = 'active'; + appStateListeners.clear(); + }, + }; return { Alert: { alert }, + AppState, Linking: { canOpenURL, openURL }, NativeModules: {}, AppRegistry: { diff --git a/src/screens/SplashScreen.tsx b/src/screens/SplashScreen.tsx index 64b2bec..a21cb9e 100644 --- a/src/screens/SplashScreen.tsx +++ b/src/screens/SplashScreen.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Image, ImageBackground, Platform, StyleSheet } from 'react-native'; +import { AppState, Image, ImageBackground, Platform, StyleSheet } from 'react-native'; import useLockFn from 'ahooks/es/useLockFn'; import useMemoizedFn from 'ahooks/es/useMemoizedFn'; import useMount from 'ahooks/es/useMount'; @@ -33,6 +33,38 @@ function sleep(ms: number): Promise { }); } +const MOBILE_RESTORE_RETRY_DELAY_MS = 800; +const MOBILE_RESTORE_RETRY_LIMIT = 2; + +function isMobileNativePlatform(): boolean { + return Platform.OS === 'ios' || Platform.OS === 'android'; +} + +function getErrorText(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error ?? ''); +} + +function isLikelyNetworkRestoreError(error: unknown): boolean { + const text = getErrorText(error).toLowerCase(); + return ( + text.includes('network') || + text.includes('timeout') || + text.includes('timed out') || + text.includes('请求超时') || + text.includes('网络') || + text.includes('连接') || + text.includes('offline') || + text.includes('not connected') || + text.includes('nsurlerrordomain') || + text.includes('-1001') || + text.includes('request_timeout') || + text.includes('request_failed') + ); +} + export function SplashScreen({ navigation }: any) { const { actions } = useAppState(); const { setLandingReady } = useLandingReadyState(); @@ -40,6 +72,9 @@ export function SplashScreen({ navigation }: any) { const toast = useToast(); const unmountedRef = useUnmountedRef(); const navigationFinishedRef = React.useRef(false); + const pendingMobileRestoreRetryRef = React.useRef(false); + const mobileRestoreRetryCountRef = React.useRef(0); + const mobileRestoreRetryTimerRef = React.useRef | null>(null); const [fallbackTarget, setFallbackTarget] = useSafeState<'Main' | 'Auth' | null>(null); const finishNavigation = useMemoizedFn((target: 'Main' | 'Auth') => { @@ -113,9 +148,62 @@ export function SplashScreen({ navigation }: any) { } }); + const clearMobileRestoreRetryTimer = useMemoizedFn(() => { + if (mobileRestoreRetryTimerRef.current) { + clearTimeout(mobileRestoreRetryTimerRef.current); + mobileRestoreRetryTimerRef.current = null; + } + }); + + const runPendingMobileRestoreRetry = useMemoizedFn(() => { + if ( + !pendingMobileRestoreRetryRef.current || + unmountedRef.current || + navigationFinishedRef.current + ) { + return; + } + + pendingMobileRestoreRetryRef.current = false; + clearMobileRestoreRetryTimer(); + void bootstrap(); + }); + + const scheduleMobileRestoreRetry = useMemoizedFn(() => { + pendingMobileRestoreRetryRef.current = true; + clearMobileRestoreRetryTimer(); + + if (AppState.currentState === 'active') { + mobileRestoreRetryTimerRef.current = setTimeout( + runPendingMobileRestoreRetry, + MOBILE_RESTORE_RETRY_DELAY_MS, + ); + } + }); + + const shouldRetryMobileRestore = useMemoizedFn(async (args: { + error?: unknown; + hadTokenBeforeRestore: boolean; + reason?: string; + }) => { + if ( + !isMobileNativePlatform() || + args.hadTokenBeforeRestore || + args.reason !== 'trial-login-failed' || + mobileRestoreRetryCountRef.current >= MOBILE_RESTORE_RETRY_LIMIT || + !isLikelyNetworkRestoreError(args.error) + ) { + return false; + } + + const tokenAfterFailure = await getAuthToken().catch(() => null); + return !tokenAfterFailure; + }); + const bootstrap = useLockFn(async () => { try { logger.info('启动恢复会话开始'); + const tokenBeforeRestore = await getAuthToken().catch(() => null); const deviceId = await getDeviceId().catch(() => ''); const isAndroid = Platform.OS === 'android'; const oaid = isAndroid ? deviceId : ''; @@ -149,6 +237,9 @@ export function SplashScreen({ navigation }: any) { }); if (result.status === 'main') { + pendingMobileRestoreRetryRef.current = false; + mobileRestoreRetryCountRef.current = 0; + clearMobileRestoreRetryTimer(); if (result.user) { actions.syncSession(result.user); } @@ -161,6 +252,24 @@ export function SplashScreen({ navigation }: any) { return; } + if ( + await shouldRetryMobileRestore({ + error: result.error, + hadTokenBeforeRestore: Boolean(tokenBeforeRestore), + reason: result.reason, + }) + ) { + mobileRestoreRetryCountRef.current += 1; + logger.warn('移动端首次试用登录失败,等待网络许可后重试', { + retryCount: mobileRestoreRetryCountRef.current, + retryLimit: MOBILE_RESTORE_RETRY_LIMIT, + reason: result.reason, + error: getErrorText(result.error), + }); + scheduleMobileRestoreRetry(); + return; + } + logger.warn('启动恢复会话失败', { reason: result.reason, error: `${result.error}` }); toast.error('网络异常,请稍后重试'); navigateTo('Auth'); @@ -174,6 +283,24 @@ export function SplashScreen({ navigation }: any) { void bootstrap(); }); + React.useEffect(() => { + if (!isMobileNativePlatform()) { + return undefined; + } + + const subscription = AppState.addEventListener('change', (nextState) => { + if (nextState === 'active') { + runPendingMobileRestoreRetry(); + } + }); + + return () => { + pendingMobileRestoreRetryRef.current = false; + clearMobileRestoreRetryTimer(); + subscription.remove(); + }; + }, [clearMobileRestoreRetryTimer, runPendingMobileRestoreRetry]); + return (