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 (