首次安装无 token 时,如果试用登录返回 trial-login-failed 且错误像网络/超时问题,移动端 iOS/Android 会先挂起重试,不立刻 toast 或进登录页;App 回到 active 后重跑 restoreSession,成功后进入 Main。已有 token、非网络失败、PC/Tauri 都不走这个重试逻辑。

This commit is contained in:
gz 2026-08-04 11:10:43 +08:00
parent 1b6494cfd1
commit ee28fde414
3 changed files with 207 additions and 3 deletions

View File

@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { AppState } from 'react-native';
import { act, create } from 'react-test-renderer'; import { act, create } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
@ -11,16 +12,22 @@ const {
syncSessionMock, syncSessionMock,
setLandingReadyMock, setLandingReadyMock,
aclStartMock, aclStartMock,
aclGetFilePathAsyncMock,
aclForceUpdateMock,
toastErrorMock, toastErrorMock,
useAppStateMock, useAppStateMock,
getAuthTokenMock,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
restoreSessionMock: vi.fn(), restoreSessionMock: vi.fn(),
bootSplashHideMock: vi.fn(async () => undefined), bootSplashHideMock: vi.fn(async () => undefined),
syncSessionMock: vi.fn(), syncSessionMock: vi.fn(),
setLandingReadyMock: vi.fn(), setLandingReadyMock: vi.fn(),
aclStartMock: vi.fn(async () => undefined), aclStartMock: vi.fn(async () => undefined),
aclGetFilePathAsyncMock: vi.fn(async () => 'acl.dat'),
aclForceUpdateMock: vi.fn(async () => ({ filePath: 'acl.dat' })),
toastErrorMock: vi.fn(), toastErrorMock: vi.fn(),
useAppStateMock: vi.fn(), useAppStateMock: vi.fn(),
getAuthTokenMock: vi.fn(async () => null as string | null),
})); }));
vi.mock('@telescope/boot-splash', () => ({ vi.mock('@telescope/boot-splash', () => ({
@ -42,6 +49,8 @@ vi.mock('@telescope/user-info', () => ({
vi.mock('@telescope/acl-updater', () => ({ vi.mock('@telescope/acl-updater', () => ({
useAclUpdaterService: () => ({ useAclUpdaterService: () => ({
start: aclStartMock, start: aclStartMock,
getFilePathAsync: aclGetFilePathAsyncMock,
forceUpdate: aclForceUpdateMock,
}), }),
})); }));
@ -63,7 +72,14 @@ vi.mock('../../src/deep-linking', () => ({
vi.mock('../../src/storage/auth', () => ({ vi.mock('../../src/storage/auth', () => ({
clearAuthStorage: vi.fn(), 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(), setAuthToken: vi.fn(),
setUserInfo: vi.fn(), setUserInfo: vi.fn(),
})); }));
@ -96,6 +112,8 @@ describe('screens/SplashScreen', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
navigation.replace.mockReset(); navigation.replace.mockReset();
getAuthTokenMock.mockResolvedValue(null);
(AppState as any).__reset?.();
useAppStateMock.mockImplementation(() => ({ useAppStateMock.mockImplementation(() => ({
actions: { actions: {
syncSession: syncSessionMock, syncSession: syncSessionMock,
@ -135,7 +153,7 @@ describe('screens/SplashScreen', () => {
restoreSessionMock.mockResolvedValueOnce({ restoreSessionMock.mockResolvedValueOnce({
status: 'auth', status: 'auth',
reason: 'trial-login-failed', reason: 'trial-login-failed',
error: new Error('network down'), error: new Error('trial login rejected'),
}); });
await act(async () => { await act(async () => {
@ -149,6 +167,40 @@ describe('screens/SplashScreen', () => {
expect(navigation.replace).toHaveBeenCalledWith('Auth'); 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 () => { it('原生 BootSplash hide 卡住时也会超时继续进入目标页', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
restoreSessionMock.mockResolvedValueOnce({ restoreSessionMock.mockResolvedValueOnce({

View File

@ -54,9 +54,34 @@ vi.mock('react-native', () => {
const runAfterInteractions = vi.fn((task: () => void) => task()); const runAfterInteractions = vi.fn((task: () => void) => task());
const StatusBar = createHostComponent('StatusBar') as any; const StatusBar = createHostComponent('StatusBar') as any;
StatusBar.currentHeight = 24; 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 { return {
Alert: { alert }, Alert: { alert },
AppState,
Linking: { canOpenURL, openURL }, Linking: { canOpenURL, openURL },
NativeModules: {}, NativeModules: {},
AppRegistry: { AppRegistry: {

View File

@ -1,5 +1,5 @@
import React from 'react'; 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 useLockFn from 'ahooks/es/useLockFn';
import useMemoizedFn from 'ahooks/es/useMemoizedFn'; import useMemoizedFn from 'ahooks/es/useMemoizedFn';
import useMount from 'ahooks/es/useMount'; 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) { export function SplashScreen({ navigation }: any) {
const { actions } = useAppState(); const { actions } = useAppState();
const { setLandingReady } = useLandingReadyState(); const { setLandingReady } = useLandingReadyState();
@ -40,6 +72,9 @@ export function SplashScreen({ navigation }: any) {
const toast = useToast(); const toast = useToast();
const unmountedRef = useUnmountedRef(); const unmountedRef = useUnmountedRef();
const navigationFinishedRef = React.useRef(false); 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 [fallbackTarget, setFallbackTarget] = useSafeState<'Main' | 'Auth' | null>(null);
const finishNavigation = useMemoizedFn((target: 'Main' | 'Auth') => { 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 () => { const bootstrap = useLockFn(async () => {
try { try {
logger.info('启动恢复会话开始'); logger.info('启动恢复会话开始');
const tokenBeforeRestore = await getAuthToken().catch(() => null);
const deviceId = await getDeviceId().catch(() => ''); const deviceId = await getDeviceId().catch(() => '');
const isAndroid = Platform.OS === 'android'; const isAndroid = Platform.OS === 'android';
const oaid = isAndroid ? deviceId : ''; const oaid = isAndroid ? deviceId : '';
@ -149,6 +237,9 @@ export function SplashScreen({ navigation }: any) {
}); });
if (result.status === 'main') { if (result.status === 'main') {
pendingMobileRestoreRetryRef.current = false;
mobileRestoreRetryCountRef.current = 0;
clearMobileRestoreRetryTimer();
if (result.user) { if (result.user) {
actions.syncSession(result.user); actions.syncSession(result.user);
} }
@ -161,6 +252,24 @@ export function SplashScreen({ navigation }: any) {
return; 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}` }); logger.warn('启动恢复会话失败', { reason: result.reason, error: `${result.error}` });
toast.error('网络异常,请稍后重试'); toast.error('网络异常,请稍后重试');
navigateTo('Auth'); navigateTo('Auth');
@ -174,6 +283,24 @@ export function SplashScreen({ navigation }: any) {
void bootstrap(); 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 ( return (
<ImageBackground <ImageBackground
resizeMode="cover" resizeMode="cover"