首次安装无 token 时,如果试用登录返回 trial-login-failed 且错误像网络/超时问题,移动端 iOS/Android 会先挂起重试,不立刻 toast 或进登录页;App 回到 active 后重跑 restoreSession,成功后进入 Main。已有 token、非网络失败、PC/Tauri 都不走这个重试逻辑。
This commit is contained in:
parent
1b6494cfd1
commit
ee28fde414
|
|
@ -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(<SplashScreen navigation={navigation} />);
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
});
|
||||
}
|
||||
|
||||
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<ReturnType<typeof setTimeout> | 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 (
|
||||
<ImageBackground
|
||||
resizeMode="cover"
|
||||
|
|
|
|||
Loading…
Reference in New Issue