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:
parent
a5cc3fdc5b
commit
8bafb0a8d4
|
|
@ -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 事件
|
||||
// 原因:切换过程中会清除旧 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 跳转到登录页面
|
||||
|
|
|
|||
|
|
@ -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<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 会删除 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;
|
||||
}
|
||||
|
||||
/// 验证账号数据完整性
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue