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: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, {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue