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:
hailin 2026-01-05 07:20:31 -08:00
parent 6c78e22000
commit e6da0cbb05
1 changed files with 130 additions and 47 deletions

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../storage/secure_storage.dart'; import '../storage/secure_storage.dart';
@ -12,10 +14,18 @@ import '../services/auth_event_service.dart';
/// - Token /// - Token
/// - /// -
/// - / /// - /
/// - Token
class ApiClient { class ApiClient {
final Dio _dio; final Dio _dio;
final SecureStorage _secureStorage; final SecureStorage _secureStorage;
/// Token
/// 401
Completer<bool>? _refreshCompleter;
/// token
bool _hasNotifiedTokenExpired = false;
ApiClient({ ApiClient({
required SecureStorage secureStorage, required SecureStorage secureStorage,
String? baseUrl, String? baseUrl,
@ -98,72 +108,151 @@ class ApiClient {
// auto-login 401 // auto-login 401
final isAutoLogin = error.requestOptions.path.contains('/user/auto-login'); final isAutoLogin = error.requestOptions.path.contains('/user/auto-login');
if (error.response?.statusCode == 401 && !isAutoLogin) { 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 { try {
final refreshed = await _tryRefreshToken(); final refreshed = await _tryRefreshTokenWithLock();
if (refreshed) { if (refreshed) {
debugPrint('Token refresh successful, retrying original request...'); debugPrint(
'[ApiClient] Token refresh successful, retrying: ${error.requestOptions.path}');
// //
final response = await _retryRequest(error.requestOptions); final response = await _retryRequest(error.requestOptions);
return handler.resolve(response); return handler.resolve(response);
} else { } else {
// refresh token // refresh token
debugPrint('Token refresh failed: no refresh token available'); debugPrint(
_notifyTokenExpired(); '[ApiClient] Token refresh failed, no valid refresh token');
_notifyTokenExpiredOnce();
} }
} catch (e) { } catch (e) {
debugPrint('Token refresh exception: $e'); debugPrint('[ApiClient] Token refresh exception: $e');
// refresh token // refresh token
if (e is DioException && e.response?.statusCode == 401) { if (e is DioException && e.response?.statusCode == 401) {
debugPrint('Refresh token expired, notifying token expired event'); debugPrint(
_notifyTokenExpired(); '[ApiClient] Refresh token expired (401), notifying token expired');
_notifyTokenExpiredOnce();
} }
//
} }
} }
handler.next(error); handler.next(error);
} }
/// token /// token
void _notifyTokenExpired() { void _notifyTokenExpiredOnce() {
if (_hasNotifiedTokenExpired) {
debugPrint('[ApiClient] Token expired already notified, skipping');
return;
}
_hasNotifiedTokenExpired = true;
debugPrint('[ApiClient] Emitting token expired event');
AuthEventService().emitTokenExpired(message: '登录已过期,请重新登录'); AuthEventService().emitTokenExpired(message: '登录已过期,请重新登录');
} }
/// Token /// token
Future<bool> _tryRefreshToken() async { void resetTokenExpiredFlag() {
final refreshToken = await _secureStorage.read(key: StorageKeys.refreshToken); _hasNotifiedTokenExpired = false;
if (refreshToken == null) return 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 { try {
final deviceId = await _secureStorage.read(key: StorageKeys.deviceId); final result = await _doRefreshToken();
final response = await _dio.post( _refreshCompleter!.complete(result);
'/user/auto-login', debugPrint('[ApiClient] Token refresh completed with result: $result');
data: { return result;
'refreshToken': refreshToken, } catch (e) {
'deviceId': deviceId, debugPrint('[ApiClient] Token refresh failed with exception: $e');
}, _refreshCompleter!.completeError(e);
options: Options(headers: {'Authorization': ''}), // Token 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) { _hasNotifiedTokenExpired = false;
final responseData = response.data;
// API : { success: true, data: { accessToken, refreshToken, ... } } debugPrint('[ApiClient] Token refreshed successfully, new tokens saved');
final data = responseData['data'] as Map<String, dynamic>; return true;
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');
} }
debugPrint(
'[ApiClient] Unexpected response status: ${response.statusCode}');
return false; return false;
} }
@ -174,12 +263,6 @@ class ApiClient {
return _dio.fetch(options); return _dio.fetch(options);
} }
///
Future<void> _clearAuthData() async {
await _secureStorage.delete(key: StorageKeys.accessToken);
await _secureStorage.delete(key: StorageKeys.refreshToken);
}
/// GET /// GET
Future<Response<T>> get<T>( Future<Response<T>> get<T>(
String path, { String path, {