fix(mobile-app): 增加切换账号全局防护,彻底解决切换期间自动退出登录

根因:切换账号时 saveCurrentAccountData() 耗时 ~7 秒,期间定时器仍在发 API 请求,
clear 阶段 token 被删除后 in-flight 请求收到 401 → 触发 tokenExpired →
logoutCurrentAccount() 把刚恢复的新账号数据全部擦除。

修复(两层防护):
1. 全局锁 isSwitchingAccount:MultiAccountService 在 switchToAccount 整个过程中
   设为 true,app.dart _handleTokenExpired 检测到该标志直接 return,不执行 logout
2. 定时器提前停止:将定时器停止从 onBeforeRestore(save 之后)移到 switchToAccount
   调用之前,确保 save 期间无新 API 请求
3. try/finally 保证标志位必定清除,异常情况不会锁死后续 tokenExpired 事件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-26 12:41:45 -08:00
parent a5cc3fdc5b
commit 8bafb0a8d4
3 changed files with 113 additions and 83 deletions

View File

@ -52,13 +52,23 @@ class _AppState extends ConsumerState<App> {
/// token
Future<void> _handleTokenExpired(String? message) async {
debugPrint('[App] !!!! Token expired 事件触发,即将跳转登录页面 !!!!');
debugPrint('[App] !!!! Token expired 事件触发 !!!!');
debugPrint('[App] Token expired message: $message');
// tokenExpired
// tokenin-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

View File

@ -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()
///
/// walletStatuspendingAction
/// 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<bool> switchToAccount(String userSerialNum, {Future<void> 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 tokenin-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;
}
///

View File

@ -72,33 +72,30 @@ class _AccountSwitchPageState extends ConsumerState<AccountSwitchPage> {
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) {