fix(app): Token 自动刷新拦截器 — 401 时自动刷新 + 重试原请求
- ApiClient: 添加 onRefreshToken 回调,401 时自动调用刷新 - ApiClient: 并发保护(_isRefreshing),防止多个 401 触发多次刷新 - ApiClient: 跳过 /auth/refresh 自身的 401(避免无限循环) - ApiClient: 刷新成功后自动重试原始请求 - AuthRepository: 注册 _handleAutoRefresh 回调 - 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖 审计 ID: 9a-AUTH-01
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
// API 客户端 — Dio 封装 + JWT 注入 + 离线感知
|
// API 客户端 — Dio 封装 + JWT 注入 + Token 自动刷新 + 离线感知
|
||||||
//
|
//
|
||||||
// 核心职责:
|
// 核心职责:
|
||||||
// - 封装 Dio HTTP 客户端,统一配置超时和头信息
|
// - 封装 Dio HTTP 客户端,统一配置超时和头信息
|
||||||
// - JWT token 自动注入(请求拦截器)
|
// - JWT token 自动注入(请求拦截器)
|
||||||
|
// - 401 自动刷新 token + 重试原请求(审计 9a-AUTH-01)
|
||||||
// - 离线状态感知(网络不可用时抛出明确异常)
|
// - 离线状态感知(网络不可用时抛出明确异常)
|
||||||
// - 为 SyncEngine 提供远程操作能力
|
// - 为 SyncEngine 提供远程操作能力
|
||||||
|
|
||||||
@@ -26,6 +27,15 @@ class ApiClient {
|
|||||||
/// 基础 URL,默认指向本地开发服务器
|
/// 基础 URL,默认指向本地开发服务器
|
||||||
final String baseUrl;
|
final String baseUrl;
|
||||||
|
|
||||||
|
/// Token 刷新回调 — 由 AuthRepository 在构造后注册
|
||||||
|
///
|
||||||
|
/// 返回新的 access token,失败返回 null。
|
||||||
|
/// 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖。
|
||||||
|
Future<String?> Function()? onRefreshToken;
|
||||||
|
|
||||||
|
/// 是否正在刷新 token(防止并发 401 触发多次刷新)
|
||||||
|
bool _isRefreshing = false;
|
||||||
|
|
||||||
ApiClient({this.baseUrl = 'http://localhost:3000/api/v1'}) {
|
ApiClient({this.baseUrl = 'http://localhost:3000/api/v1'}) {
|
||||||
_dio = Dio(BaseOptions(
|
_dio = Dio(BaseOptions(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
@@ -48,11 +58,37 @@ class ApiClient {
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
// 响应拦截器:统一错误处理
|
// 响应拦截器:401 自动刷新 token + 重试
|
||||||
_dio.interceptors.add(InterceptorsWrapper(
|
_dio.interceptors.add(InterceptorsWrapper(
|
||||||
onError: (error, handler) {
|
onError: (error, handler) async {
|
||||||
// 401 时自动清除 token(需要重新登录)
|
|
||||||
if (error.response?.statusCode == 401) {
|
if (error.response?.statusCode == 401) {
|
||||||
|
// 不对刷新端点本身重试(避免无限循环)
|
||||||
|
final isRefreshRequest =
|
||||||
|
error.requestOptions.path.endsWith('/auth/refresh');
|
||||||
|
|
||||||
|
if (!isRefreshRequest &&
|
||||||
|
onRefreshToken != null &&
|
||||||
|
!_isRefreshing) {
|
||||||
|
_isRefreshing = true;
|
||||||
|
try {
|
||||||
|
final newToken = await onRefreshToken!();
|
||||||
|
if (newToken != null) {
|
||||||
|
_token = newToken;
|
||||||
|
// 用新 token 重试原始请求
|
||||||
|
error.requestOptions.headers['Authorization'] =
|
||||||
|
'Bearer $newToken';
|
||||||
|
_isRefreshing = false;
|
||||||
|
return handler.resolve(
|
||||||
|
await _dio.fetch(error.requestOptions),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 刷新失败,继续走 401 逻辑
|
||||||
|
}
|
||||||
|
_isRefreshing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新失败或无刷新回调 → 清除 token
|
||||||
_token = null;
|
_token = null;
|
||||||
}
|
}
|
||||||
handler.next(error);
|
handler.next(error);
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ class AuthRepository {
|
|||||||
required ApiClient apiClient,
|
required ApiClient apiClient,
|
||||||
required SecureTokenStore tokenStore,
|
required SecureTokenStore tokenStore,
|
||||||
}) : _apiClient = apiClient,
|
}) : _apiClient = apiClient,
|
||||||
_tokenStore = tokenStore;
|
_tokenStore = tokenStore {
|
||||||
|
// 注册 token 自动刷新回调 — 401 时 ApiClient 自动调用
|
||||||
|
_apiClient.onRefreshToken = _handleAutoRefresh;
|
||||||
|
}
|
||||||
|
|
||||||
/// 当前用户(可能为 null)
|
/// 当前用户(可能为 null)
|
||||||
User? get currentUser => _currentUser;
|
User? get currentUser => _currentUser;
|
||||||
@@ -215,6 +218,33 @@ class AuthRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Token 自动刷新 =====
|
||||||
|
|
||||||
|
/// ApiClient 401 拦截器调用的自动刷新处理
|
||||||
|
///
|
||||||
|
/// 使用 refresh_token 获取新 access_token,更新 ApiClient 的 token,
|
||||||
|
/// 返回新 access_token(失败返回 null)。
|
||||||
|
Future<String?> _handleAutoRefresh() async {
|
||||||
|
if (_currentToken == null) return null;
|
||||||
|
|
||||||
|
_logger.d('自动刷新令牌(401 触发)');
|
||||||
|
try {
|
||||||
|
final response = await _apiClient.post('/auth/refresh', data: {
|
||||||
|
'refresh_token': _currentToken!.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
final data = _extractData(response.data);
|
||||||
|
final token = AuthToken.fromJson(data);
|
||||||
|
|
||||||
|
await _saveToken(token);
|
||||||
|
_logger.i('自动刷新令牌成功');
|
||||||
|
return token.accessToken;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.w('自动刷新令牌失败: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 私有方法 =====
|
// ===== 私有方法 =====
|
||||||
|
|
||||||
/// 从 API 响应中提取 data 字段
|
/// 从 API 响应中提取 data 字段
|
||||||
|
|||||||
Reference in New Issue
Block a user