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 客户端,统一配置超时和头信息
|
||||
// - JWT token 自动注入(请求拦截器)
|
||||
// - 401 自动刷新 token + 重试原请求(审计 9a-AUTH-01)
|
||||
// - 离线状态感知(网络不可用时抛出明确异常)
|
||||
// - 为 SyncEngine 提供远程操作能力
|
||||
|
||||
@@ -26,6 +27,15 @@ class ApiClient {
|
||||
/// 基础 URL,默认指向本地开发服务器
|
||||
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'}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
@@ -48,11 +58,37 @@ class ApiClient {
|
||||
},
|
||||
));
|
||||
|
||||
// 响应拦截器:统一错误处理
|
||||
// 响应拦截器:401 自动刷新 token + 重试
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onError: (error, handler) {
|
||||
// 401 时自动清除 token(需要重新登录)
|
||||
onError: (error, handler) async {
|
||||
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;
|
||||
}
|
||||
handler.next(error);
|
||||
|
||||
@@ -47,7 +47,10 @@ class AuthRepository {
|
||||
required ApiClient apiClient,
|
||||
required SecureTokenStore tokenStore,
|
||||
}) : _apiClient = apiClient,
|
||||
_tokenStore = tokenStore;
|
||||
_tokenStore = tokenStore {
|
||||
// 注册 token 自动刷新回调 — 401 时 ApiClient 自动调用
|
||||
_apiClient.onRefreshToken = _handleAutoRefresh;
|
||||
}
|
||||
|
||||
/// 当前用户(可能为 null)
|
||||
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 字段
|
||||
|
||||
Reference in New Issue
Block a user