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: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, {