diff --git a/frontend/mobile-app/lib/app.dart b/frontend/mobile-app/lib/app.dart index a0f09eb5..b944dd2f 100644 --- a/frontend/mobile-app/lib/app.dart +++ b/frontend/mobile-app/lib/app.dart @@ -52,13 +52,23 @@ class _AppState extends ConsumerState { /// 处理 token 过期 Future _handleTokenExpired(String? message) async { - debugPrint('[App] !!!! Token expired 事件触发,即将跳转登录页面 !!!!'); + debugPrint('[App] !!!! Token expired 事件触发 !!!!'); debugPrint('[App] Token expired message: $message'); + + // ★ 切换账号期间忽略 tokenExpired 事件 + // 原因:切换过程中会清除旧 token,in-flight 的 API 请求收到 401 后触发此事件, + // 如果不拦截,logoutCurrentAccount() 会把正在恢复或已恢复的新账号数据全部清掉 + final multiAccountService = ref.read(multiAccountServiceProvider); + if (multiAccountService.isSwitchingAccount) { + debugPrint('[App] ★★★ 正在切换账号,忽略 tokenExpired 事件 ★★★'); + return; + } + + debugPrint('[App] 即将跳转登录页面'); // 打印调用栈,方便排查是谁触发了 token 过期事件 debugPrint('[App] Token expired stack: ${StackTrace.current}'); // 清除当前账号状态(但保留账号列表和向导页标识) - final multiAccountService = ref.read(multiAccountServiceProvider); await multiAccountService.logoutCurrentAccount(); // 使用全局 Navigator Key 跳转到登录页面 diff --git a/frontend/mobile-app/lib/core/services/multi_account_service.dart b/frontend/mobile-app/lib/core/services/multi_account_service.dart index a29a341c..c0eada42 100644 --- a/frontend/mobile-app/lib/core/services/multi_account_service.dart +++ b/frontend/mobile-app/lib/core/services/multi_account_service.dart @@ -60,6 +60,14 @@ class MultiAccountService { this._telemetryStorage, ); + /// 切换中标志位:在整个切换流程期间为 true + /// 用于防止切换过程中 in-flight API 请求收到 401 后触发 tokenExpired → logoutCurrentAccount() + /// 把刚恢复的新账号数据全部清掉 + bool _isSwitchingAccount = false; + + /// 是否正在切换账号(供外部读取,如 app.dart 的 _handleTokenExpired) + bool get isSwitchingAccount => _isSwitchingAccount; + // ===== 账号数据键列表(统一定义,确保一致性) ===== /// 需要按账号隔离保存的 SecureStorage 键 @@ -215,76 +223,91 @@ class MultiAccountService { /// 切换到指定账号 /// 返回 true 表示切换成功 /// - /// [onBeforeRestore] 在保存当前账号数据之后、清除存储之前调用, - /// 用于停止所有定时器和 Provider 轮询,确保在 token 被清除前没有 in-flight 的 API 请求, - /// 防止请求收到 401 后触发 tokenExpired → 自动退出登录。 + /// 整个切换过程中 [isSwitchingAccount] 为 true, + /// app.dart 的 _handleTokenExpired 会检查此标志,忽略切换期间的 tokenExpired 事件, + /// 防止 in-flight API 请求收到 401 后触发 logoutCurrentAccount() 擦除新账号数据。 + /// + /// 调用方应在调用本方法之前先停止所有定时器和轮询(如 walletStatus、pendingAction、 + /// notificationBadge 等),确保 save 阶段不会有新的 API 请求发出。 + /// + /// [onBeforeRestore] 可选回调,在 clear 之前调用(已弃用,建议在调用方提前停止定时器)。 /// /// 切换流程: + /// 0. 设置 isSwitchingAccount = true /// 1. 验证目标账号存在 /// 2. 验证目标账号数据完整性 /// 3. 保存当前账号数据到账号专用存储 - /// 3.5 调用 onBeforeRestore 停止定时器(必须在 clear 之前,防止 401 触发自动退出) + /// 3.5 调用 onBeforeRestore(如果提供) /// 4. 清除当前存储空间(确保干净环境) /// 5. 从账号专用存储恢复目标账号数据 /// 6. 设置当前账号标记 /// 7. 更新遥测和错误追踪服务的用户信息 + /// 8. 设置 isSwitchingAccount = false Future switchToAccount(String userSerialNum, {Future Function()? onBeforeRestore}) async { debugPrint('$_tag switchToAccount() - 切换到账号: $userSerialNum'); - // 1. 验证账号存在于列表中 - final accounts = await getAccountList(); - final account = accounts.where((a) => a.userSerialNum == userSerialNum).firstOrNull; + // 立即设置切换中标志,阻止 _handleTokenExpired 在切换期间触发 logoutCurrentAccount + _isSwitchingAccount = true; + debugPrint('$_tag switchToAccount() - ★ isSwitchingAccount = true'); - if (account == null) { - debugPrint('$_tag switchToAccount() - 账号不在列表中'); - return false; + try { + // 1. 验证账号存在于列表中 + final accounts = await getAccountList(); + final account = accounts.where((a) => a.userSerialNum == userSerialNum).firstOrNull; + + if (account == null) { + debugPrint('$_tag switchToAccount() - 账号不在列表中'); + return false; + } + + // 2. 验证目标账号数据完整性(至少要有 accessToken 或 mnemonic) + final hasValidData = await _validateAccountData(userSerialNum); + if (!hasValidData) { + debugPrint('$_tag switchToAccount() - 账号数据不完整,无法切换'); + return false; + } + + // 3. 保存当前账号数据(如果有) + final currentId = await getCurrentAccountId(); + if (currentId != null && currentId != userSerialNum) { + await saveCurrentAccountData(); + debugPrint('$_tag switchToAccount() - 已保存当前账号数据: $currentId'); + } + + // 3.5 停止所有定时器(如果有回调;建议调用方在外部提前停止) + if (onBeforeRestore != null) { + await onBeforeRestore(); + debugPrint('$_tag switchToAccount() - 已执行 onBeforeRestore 回调'); + } + + // 4. 清除当前存储空间(确保干净环境,避免数据残留) + await _clearCurrentAccountData(); + + // 5. 从账号专用存储恢复目标账号数据 + await _restoreAccountData(userSerialNum); + + // 6. 设置当前账号标记 + await setCurrentAccountId(userSerialNum); + + // 7. 更新遥测服务的用户ID + if (TelemetryService().isInitialized) { + TelemetryService().setUserId(userSerialNum); + debugPrint('$_tag switchToAccount() - 设置TelemetryService userId: $userSerialNum'); + } + + // 8. 更新 Sentry 用户信息 + if (SentryService().isInitialized) { + SentryService().setUser(userId: userSerialNum); + debugPrint('$_tag switchToAccount() - 设置SentryService userId: $userSerialNum'); + } + + debugPrint('$_tag switchToAccount() - 切换成功'); + return true; + } finally { + // 无论成功或失败,都必须清除标志位,否则后续真正的 tokenExpired 也会被忽略 + _isSwitchingAccount = false; + debugPrint('$_tag switchToAccount() - ★ isSwitchingAccount = false'); } - - // 2. 验证目标账号数据完整性(至少要有 accessToken 或 mnemonic) - final hasValidData = await _validateAccountData(userSerialNum); - if (!hasValidData) { - debugPrint('$_tag switchToAccount() - 账号数据不完整,无法切换'); - return false; - } - - // 3. 保存当前账号数据(如果有) - final currentId = await getCurrentAccountId(); - if (currentId != null && currentId != userSerialNum) { - await saveCurrentAccountData(); - debugPrint('$_tag switchToAccount() - 已保存当前账号数据: $currentId'); - } - - // 3.5 停止所有定时器(必须在 clear 之前!) - // 原因:clear 会删除 token,如果定时器还在跑,in-flight 的 API 请求收到 401 - // → 触发 _handleTokenExpired → logoutCurrentAccount() 把恢复中的数据全部清掉 - if (onBeforeRestore != null) { - await onBeforeRestore(); - debugPrint('$_tag switchToAccount() - 已停止定时器和重置内存状态'); - } - - // 4. 清除当前存储空间(确保干净环境,避免数据残留) - await _clearCurrentAccountData(); - - // 5. 从账号专用存储恢复目标账号数据 - await _restoreAccountData(userSerialNum); - - // 6. 设置当前账号标记 - await setCurrentAccountId(userSerialNum); - - // 7. 更新遥测服务的用户ID - if (TelemetryService().isInitialized) { - TelemetryService().setUserId(userSerialNum); - debugPrint('$_tag switchToAccount() - 设置TelemetryService userId: $userSerialNum'); - } - - // 8. 更新 Sentry 用户信息 - if (SentryService().isInitialized) { - SentryService().setUser(userId: userSerialNum); - debugPrint('$_tag switchToAccount() - 设置SentryService userId: $userSerialNum'); - } - - debugPrint('$_tag switchToAccount() - 切换成功'); - return true; } /// 验证账号数据完整性 diff --git a/frontend/mobile-app/lib/features/account/presentation/pages/account_switch_page.dart b/frontend/mobile-app/lib/features/account/presentation/pages/account_switch_page.dart index 7b9673ad..7b6242ff 100644 --- a/frontend/mobile-app/lib/features/account/presentation/pages/account_switch_page.dart +++ b/frontend/mobile-app/lib/features/account/presentation/pages/account_switch_page.dart @@ -72,33 +72,30 @@ class _AccountSwitchPageState extends ConsumerState { try { final multiAccountService = ref.read(multiAccountServiceProvider); - // 切换到新账号 - // switchToAccount() 内部会先调用 saveCurrentAccountData(),无需在此重复调用 - // onBeforeRestore: storage 已清空、新数据尚未恢复 → 只停定时器,禁止发起任何 API 请求 - // Provider invalidate 必须在 switchToAccount 返回后做(此时 storage 已恢复新账号数据) - debugPrint('$_tag [1/6] 调用 switchToAccount()...'); + // ===== [1/6] 停止所有用户相关的定时任务(必须在 switchToAccount 之前!)===== + // 原因:switchToAccount 内部 saveCurrentAccountData() 需要 ~7 秒, + // 如果定时器还在跑,save 期间发出的 API 请求在 clear 阶段 token 被删除后收到 401, + // 触发 tokenExpired → logoutCurrentAccount() 把恢复中的数据清掉。 + // 在这里提前停止,确保 save 期间不会有新的 API 请求发出。 + debugPrint('$_tag [1/6] 停止全部定时器(在 switchToAccount 之前)...'); + ref.read(walletStatusProvider.notifier).stopPolling(); + debugPrint('$_tag ✓ walletStatusProvider 轮询已停止'); + ref.read(pendingActionPollingServiceProvider).stop(); + debugPrint('$_tag ✓ pendingActionPollingService 已停止'); + ref.read(notificationBadgeProvider.notifier).stopAutoRefresh(); + debugPrint('$_tag ✓ notificationBadgeProvider 自动刷新已停止'); + if (TelemetryService().isInitialized) { + await TelemetryService().pauseForLogout(); + debugPrint('$_tag ✓ TelemetryService 已暂停'); + } + debugPrint('$_tag [1/6] 定时器全部停止'); + + // ===== [2/6] 调用 switchToAccount() ===== + // 内部流程:save → clear → restore → 设置当前账号 + // isSwitchingAccount 标志位会在整个过程中为 true,阻止 tokenExpired 事件 + debugPrint('$_tag [2/6] 调用 switchToAccount()...'); final success = await multiAccountService.switchToAccount( account.userSerialNum, - onBeforeRestore: () async { - // ===== 停止所有用户相关的定时任务 ===== - // 必须在此阶段停止,防止定时器在 storage 空窗期(clear 完成、restore 未完成) - // 触发混账号请求(旧账号 userSerialNum + 新账号 token) - debugPrint('$_tag [2/6] onBeforeRestore - 停止全部定时器...'); - ref.read(walletStatusProvider.notifier).stopPolling(); - debugPrint('$_tag ✓ walletStatusProvider 轮询已停止'); - ref.read(pendingActionPollingServiceProvider).stop(); - debugPrint('$_tag ✓ pendingActionPollingService 已停止'); - // NotificationBadge 30s 定时器:必须停止,否则在 restore 过程中触发, - // 读取 authProvider 内存中的旧 userSerialNum + 已恢复的新 token,产生混账号请求 - ref.read(notificationBadgeProvider.notifier).stopAutoRefresh(); - debugPrint('$_tag ✓ notificationBadgeProvider 自动刷新已停止'); - // 遥测:清空旧账号事件队列、停止上传 - if (TelemetryService().isInitialized) { - await TelemetryService().pauseForLogout(); - debugPrint('$_tag ✓ TelemetryService 已暂停'); - } - debugPrint('$_tag [2/6] onBeforeRestore - 定时器全部停止'); - }, ); if (success && mounted) {