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 过期
|
/// 处理 token 过期
|
||||||
Future<void> _handleTokenExpired(String? message) async {
|
Future<void> _handleTokenExpired(String? message) async {
|
||||||
debugPrint('[App] !!!! Token expired 事件触发,即将跳转登录页面 !!!!');
|
debugPrint('[App] !!!! Token expired 事件触发 !!!!');
|
||||||
debugPrint('[App] Token expired message: $message');
|
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 过期事件
|
// 打印调用栈,方便排查是谁触发了 token 过期事件
|
||||||
debugPrint('[App] Token expired stack: ${StackTrace.current}');
|
debugPrint('[App] Token expired stack: ${StackTrace.current}');
|
||||||
|
|
||||||
// 清除当前账号状态(但保留账号列表和向导页标识)
|
// 清除当前账号状态(但保留账号列表和向导页标识)
|
||||||
final multiAccountService = ref.read(multiAccountServiceProvider);
|
|
||||||
await multiAccountService.logoutCurrentAccount();
|
await multiAccountService.logoutCurrentAccount();
|
||||||
|
|
||||||
// 使用全局 Navigator Key 跳转到登录页面
|
// 使用全局 Navigator Key 跳转到登录页面
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,14 @@ class MultiAccountService {
|
||||||
this._telemetryStorage,
|
this._telemetryStorage,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// 切换中标志位:在整个切换流程期间为 true
|
||||||
|
/// 用于防止切换过程中 in-flight API 请求收到 401 后触发 tokenExpired → logoutCurrentAccount()
|
||||||
|
/// 把刚恢复的新账号数据全部清掉
|
||||||
|
bool _isSwitchingAccount = false;
|
||||||
|
|
||||||
|
/// 是否正在切换账号(供外部读取,如 app.dart 的 _handleTokenExpired)
|
||||||
|
bool get isSwitchingAccount => _isSwitchingAccount;
|
||||||
|
|
||||||
// ===== 账号数据键列表(统一定义,确保一致性) =====
|
// ===== 账号数据键列表(统一定义,确保一致性) =====
|
||||||
|
|
||||||
/// 需要按账号隔离保存的 SecureStorage 键
|
/// 需要按账号隔离保存的 SecureStorage 键
|
||||||
|
|
@ -215,76 +223,91 @@ class MultiAccountService {
|
||||||
/// 切换到指定账号
|
/// 切换到指定账号
|
||||||
/// 返回 true 表示切换成功
|
/// 返回 true 表示切换成功
|
||||||
///
|
///
|
||||||
/// [onBeforeRestore] 在保存当前账号数据之后、清除存储之前调用,
|
/// 整个切换过程中 [isSwitchingAccount] 为 true,
|
||||||
/// 用于停止所有定时器和 Provider 轮询,确保在 token 被清除前没有 in-flight 的 API 请求,
|
/// app.dart 的 _handleTokenExpired 会检查此标志,忽略切换期间的 tokenExpired 事件,
|
||||||
/// 防止请求收到 401 后触发 tokenExpired → 自动退出登录。
|
/// 防止 in-flight API 请求收到 401 后触发 logoutCurrentAccount() 擦除新账号数据。
|
||||||
|
///
|
||||||
|
/// 调用方应在调用本方法之前先停止所有定时器和轮询(如 walletStatus、pendingAction、
|
||||||
|
/// notificationBadge 等),确保 save 阶段不会有新的 API 请求发出。
|
||||||
|
///
|
||||||
|
/// [onBeforeRestore] 可选回调,在 clear 之前调用(已弃用,建议在调用方提前停止定时器)。
|
||||||
///
|
///
|
||||||
/// 切换流程:
|
/// 切换流程:
|
||||||
|
/// 0. 设置 isSwitchingAccount = true
|
||||||
/// 1. 验证目标账号存在
|
/// 1. 验证目标账号存在
|
||||||
/// 2. 验证目标账号数据完整性
|
/// 2. 验证目标账号数据完整性
|
||||||
/// 3. 保存当前账号数据到账号专用存储
|
/// 3. 保存当前账号数据到账号专用存储
|
||||||
/// 3.5 调用 onBeforeRestore 停止定时器(必须在 clear 之前,防止 401 触发自动退出)
|
/// 3.5 调用 onBeforeRestore(如果提供)
|
||||||
/// 4. 清除当前存储空间(确保干净环境)
|
/// 4. 清除当前存储空间(确保干净环境)
|
||||||
/// 5. 从账号专用存储恢复目标账号数据
|
/// 5. 从账号专用存储恢复目标账号数据
|
||||||
/// 6. 设置当前账号标记
|
/// 6. 设置当前账号标记
|
||||||
/// 7. 更新遥测和错误追踪服务的用户信息
|
/// 7. 更新遥测和错误追踪服务的用户信息
|
||||||
|
/// 8. 设置 isSwitchingAccount = false
|
||||||
Future<bool> switchToAccount(String userSerialNum, {Future<void> Function()? onBeforeRestore}) async {
|
Future<bool> switchToAccount(String userSerialNum, {Future<void> Function()? onBeforeRestore}) async {
|
||||||
debugPrint('$_tag switchToAccount() - 切换到账号: $userSerialNum');
|
debugPrint('$_tag switchToAccount() - 切换到账号: $userSerialNum');
|
||||||
|
|
||||||
// 1. 验证账号存在于列表中
|
// 立即设置切换中标志,阻止 _handleTokenExpired 在切换期间触发 logoutCurrentAccount
|
||||||
final accounts = await getAccountList();
|
_isSwitchingAccount = true;
|
||||||
final account = accounts.where((a) => a.userSerialNum == userSerialNum).firstOrNull;
|
debugPrint('$_tag switchToAccount() - ★ isSwitchingAccount = true');
|
||||||
|
|
||||||
if (account == null) {
|
try {
|
||||||
debugPrint('$_tag switchToAccount() - 账号不在列表中');
|
// 1. 验证账号存在于列表中
|
||||||
return false;
|
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 {
|
try {
|
||||||
final multiAccountService = ref.read(multiAccountServiceProvider);
|
final multiAccountService = ref.read(multiAccountServiceProvider);
|
||||||
|
|
||||||
// 切换到新账号
|
// ===== [1/6] 停止所有用户相关的定时任务(必须在 switchToAccount 之前!)=====
|
||||||
// switchToAccount() 内部会先调用 saveCurrentAccountData(),无需在此重复调用
|
// 原因:switchToAccount 内部 saveCurrentAccountData() 需要 ~7 秒,
|
||||||
// onBeforeRestore: storage 已清空、新数据尚未恢复 → 只停定时器,禁止发起任何 API 请求
|
// 如果定时器还在跑,save 期间发出的 API 请求在 clear 阶段 token 被删除后收到 401,
|
||||||
// Provider invalidate 必须在 switchToAccount 返回后做(此时 storage 已恢复新账号数据)
|
// 触发 tokenExpired → logoutCurrentAccount() 把恢复中的数据清掉。
|
||||||
debugPrint('$_tag [1/6] 调用 switchToAccount()...');
|
// 在这里提前停止,确保 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(
|
final success = await multiAccountService.switchToAccount(
|
||||||
account.userSerialNum,
|
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) {
|
if (success && mounted) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue