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:
iven
2026-06-03 10:07:33 +08:00
parent c4b2de8294
commit 45949e3ed0
2 changed files with 71 additions and 5 deletions

View File

@@ -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);

View File

@@ -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 字段