fix(mobile-app): 修复 Token 刷新并发竞态导致的意外过期问题
- 添加 Token 刷新锁,确保多个 401 请求只触发一次刷新 - 添加过期通知去重,避免重复弹出登录过期提示 - 增强 deviceId 校验,缺失时记录日志 - 添加详细调试日志便于排查问题 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6c78e22000
commit
e6da0cbb05
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
|
@ -12,10 +14,18 @@ import '../services/auth_event_service.dart';
|
|||
/// - 自动 Token 注入
|
||||
/// - 错误处理
|
||||
/// - 请求/响应日志
|
||||
/// - Token 自动刷新(带并发锁)
|
||||
class ApiClient {
|
||||
final Dio _dio;
|
||||
final SecureStorage _secureStorage;
|
||||
|
||||
/// Token 刷新锁,防止并发刷新导致的竞态条件
|
||||
/// 当多个请求同时收到 401 时,只有第一个会执行刷新,其他请求等待结果
|
||||
Completer<bool>? _refreshCompleter;
|
||||
|
||||
/// 标记是否已经通知过 token 过期,避免重复通知
|
||||
bool _hasNotifiedTokenExpired = false;
|
||||
|
||||
ApiClient({
|
||||
required SecureStorage secureStorage,
|
||||
String? baseUrl,
|
||||
|
|
@ -98,72 +108,151 @@ class ApiClient {
|
|||
// 排除 auto-login 本身的 401 错误,避免递归
|
||||
final isAutoLogin = error.requestOptions.path.contains('/user/auto-login');
|
||||
if (error.response?.statusCode == 401 && !isAutoLogin) {
|
||||
debugPrint('401 error detected, attempting token refresh...');
|
||||
debugPrint(
|
||||
'[ApiClient] 401 error on ${error.requestOptions.path}, attempting token refresh...');
|
||||
try {
|
||||
final refreshed = await _tryRefreshToken();
|
||||
final refreshed = await _tryRefreshTokenWithLock();
|
||||
if (refreshed) {
|
||||
debugPrint('Token refresh successful, retrying original request...');
|
||||
debugPrint(
|
||||
'[ApiClient] Token refresh successful, retrying: ${error.requestOptions.path}');
|
||||
// 重试原请求
|
||||
final response = await _retryRequest(error.requestOptions);
|
||||
return handler.resolve(response);
|
||||
} else {
|
||||
// refresh token 不存在,需要重新登录
|
||||
debugPrint('Token refresh failed: no refresh token available');
|
||||
_notifyTokenExpired();
|
||||
// refresh token 不存在或刷新失败,需要重新登录
|
||||
debugPrint(
|
||||
'[ApiClient] Token refresh failed, no valid refresh token');
|
||||
_notifyTokenExpiredOnce();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Token refresh exception: $e');
|
||||
debugPrint('[ApiClient] Token refresh exception: $e');
|
||||
// refresh token 刷新失败(可能过期),需要重新登录
|
||||
if (e is DioException && e.response?.statusCode == 401) {
|
||||
debugPrint('Refresh token expired, notifying token expired event');
|
||||
_notifyTokenExpired();
|
||||
debugPrint(
|
||||
'[ApiClient] Refresh token expired (401), notifying token expired');
|
||||
_notifyTokenExpiredOnce();
|
||||
}
|
||||
// 其他异常(网络错误等)不触发重新登录,让请求正常失败
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(error);
|
||||
}
|
||||
|
||||
/// 通知 token 过期事件
|
||||
void _notifyTokenExpired() {
|
||||
/// 通知 token 过期事件(仅通知一次,避免重复弹窗)
|
||||
void _notifyTokenExpiredOnce() {
|
||||
if (_hasNotifiedTokenExpired) {
|
||||
debugPrint('[ApiClient] Token expired already notified, skipping');
|
||||
return;
|
||||
}
|
||||
_hasNotifiedTokenExpired = true;
|
||||
debugPrint('[ApiClient] Emitting token expired event');
|
||||
AuthEventService().emitTokenExpired(message: '登录已过期,请重新登录');
|
||||
}
|
||||
|
||||
/// 尝试刷新 Token
|
||||
Future<bool> _tryRefreshToken() async {
|
||||
final refreshToken = await _secureStorage.read(key: StorageKeys.refreshToken);
|
||||
if (refreshToken == null) return false;
|
||||
/// 重置 token 过期通知标记(在用户重新登录后调用)
|
||||
void resetTokenExpiredFlag() {
|
||||
_hasNotifiedTokenExpired = false;
|
||||
debugPrint('[ApiClient] Token expired flag reset');
|
||||
}
|
||||
|
||||
/// 带锁的 Token 刷新,防止并发刷新导致的竞态条件
|
||||
///
|
||||
/// 问题场景:
|
||||
/// 1. 多个请求同时收到 401
|
||||
/// 2. 都调用 refresh token 接口
|
||||
/// 3. 第一个成功后,后端撤销了旧 refresh token
|
||||
/// 4. 其他请求使用旧 refresh token 失败
|
||||
///
|
||||
/// 解决方案:
|
||||
/// 使用 Completer 作为锁,确保同一时刻只有一个刷新请求
|
||||
/// 其他请求等待第一个刷新完成后复用结果
|
||||
Future<bool> _tryRefreshTokenWithLock() async {
|
||||
// 如果已有刷新在进行中,等待其结果
|
||||
if (_refreshCompleter != null) {
|
||||
debugPrint(
|
||||
'[ApiClient] Token refresh already in progress, waiting for result...');
|
||||
return _refreshCompleter!.future;
|
||||
}
|
||||
|
||||
// 创建新的 Completer 作为锁
|
||||
_refreshCompleter = Completer<bool>();
|
||||
debugPrint('[ApiClient] Starting token refresh (acquired lock)');
|
||||
|
||||
try {
|
||||
final deviceId = await _secureStorage.read(key: StorageKeys.deviceId);
|
||||
final response = await _dio.post(
|
||||
'/user/auto-login',
|
||||
data: {
|
||||
'refreshToken': refreshToken,
|
||||
'deviceId': deviceId,
|
||||
},
|
||||
options: Options(headers: {'Authorization': ''}), // 不带旧 Token
|
||||
final result = await _doRefreshToken();
|
||||
_refreshCompleter!.complete(result);
|
||||
debugPrint('[ApiClient] Token refresh completed with result: $result');
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('[ApiClient] Token refresh failed with exception: $e');
|
||||
_refreshCompleter!.completeError(e);
|
||||
rethrow;
|
||||
} finally {
|
||||
// 释放锁
|
||||
_refreshCompleter = null;
|
||||
debugPrint('[ApiClient] Token refresh lock released');
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行实际的 Token 刷新
|
||||
Future<bool> _doRefreshToken() async {
|
||||
final refreshToken =
|
||||
await _secureStorage.read(key: StorageKeys.refreshToken);
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
debugPrint('[ApiClient] No refresh token available');
|
||||
return false;
|
||||
}
|
||||
|
||||
final deviceId = await _secureStorage.read(key: StorageKeys.deviceId);
|
||||
if (deviceId == null || deviceId.isEmpty) {
|
||||
debugPrint('[ApiClient] No device ID available');
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint('[ApiClient] Calling /user/auto-login with deviceId: $deviceId');
|
||||
|
||||
final response = await _dio.post(
|
||||
'/user/auto-login',
|
||||
data: {
|
||||
'refreshToken': refreshToken,
|
||||
'deviceId': deviceId,
|
||||
},
|
||||
options: Options(headers: {'Authorization': ''}), // 不带旧 Token
|
||||
);
|
||||
|
||||
// auto-login 成功可能返回 200 或 201
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
final responseData = response.data;
|
||||
// API 返回格式: { success: true, data: { accessToken, refreshToken, ... } }
|
||||
final data = responseData['data'] as Map<String, dynamic>;
|
||||
|
||||
final newAccessToken = data['accessToken'] as String?;
|
||||
final newRefreshToken = data['refreshToken'] as String?;
|
||||
|
||||
if (newAccessToken == null || newRefreshToken == null) {
|
||||
debugPrint('[ApiClient] Invalid response: missing tokens');
|
||||
return false;
|
||||
}
|
||||
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.accessToken,
|
||||
value: newAccessToken,
|
||||
);
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.refreshToken,
|
||||
value: newRefreshToken,
|
||||
);
|
||||
|
||||
// auto-login 成功可能返回 200 或 201
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
final responseData = response.data;
|
||||
// API 返回格式: { success: true, data: { accessToken, refreshToken, ... } }
|
||||
final data = responseData['data'] as Map<String, dynamic>;
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.accessToken,
|
||||
value: data['accessToken'],
|
||||
);
|
||||
await _secureStorage.write(
|
||||
key: StorageKeys.refreshToken,
|
||||
value: data['refreshToken'],
|
||||
);
|
||||
debugPrint('Token refreshed successfully, new token saved');
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Token refresh failed: $e');
|
||||
// 刷新成功,重置过期通知标记
|
||||
_hasNotifiedTokenExpired = false;
|
||||
|
||||
debugPrint('[ApiClient] Token refreshed successfully, new tokens saved');
|
||||
return true;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[ApiClient] Unexpected response status: ${response.statusCode}');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -174,12 +263,6 @@ class ApiClient {
|
|||
return _dio.fetch(options);
|
||||
}
|
||||
|
||||
/// 清除认证数据
|
||||
Future<void> _clearAuthData() async {
|
||||
await _secureStorage.delete(key: StorageKeys.accessToken);
|
||||
await _secureStorage.delete(key: StorageKeys.refreshToken);
|
||||
}
|
||||
|
||||
/// GET 请求
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
|
|
|
|||
Loading…
Reference in New Issue