Files
nj/app/lib/data/remote/api_client.dart
iven 45949e3ed0 fix(app): Token 自动刷新拦截器 — 401 时自动刷新 + 重试原请求
- ApiClient: 添加 onRefreshToken 回调,401 时自动调用刷新
- ApiClient: 并发保护(_isRefreshing),防止多个 401 触发多次刷新
- ApiClient: 跳过 /auth/refresh 自身的 401(避免无限循环)
- ApiClient: 刷新成功后自动重试原始请求
- AuthRepository: 注册 _handleAutoRefresh 回调
- 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖

审计 ID: 9a-AUTH-01
2026-06-03 10:07:33 +08:00

186 lines
5.3 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// API 客户端 — Dio 封装 + JWT 注入 + Token 自动刷新 + 离线感知
//
// 核心职责:
// - 封装 Dio HTTP 客户端,统一配置超时和头信息
// - JWT token 自动注入(请求拦截器)
// - 401 自动刷新 token + 重试原请求(审计 9a-AUTH-01
// - 离线状态感知(网络不可用时抛出明确异常)
// - 为 SyncEngine 提供远程操作能力
import 'package:dio/dio.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
/// 网络离线异常 — 网络不可用时由 ApiClient 抛出
class OfflineException implements Exception {
final String message;
const OfflineException([this.message = '网络不可用,请检查网络连接']);
@override
String toString() => 'OfflineException: $message';
}
/// API 客户端 — 所有远程请求的统一入口
class ApiClient {
late final Dio _dio;
String? _token;
/// 基础 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,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
));
// 请求拦截器:注入 JWT token
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
if (_token != null) {
options.headers['Authorization'] = 'Bearer $_token';
}
handler.next(options);
},
));
// 响应拦截器401 自动刷新 token + 重试
_dio.interceptors.add(InterceptorsWrapper(
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);
},
));
}
/// 设置 JWT token登录成功后调用
void setToken(String token) => _token = token;
/// 清除 JWT token退出登录时调用
void clearToken() => _token = null;
/// 当前是否已登录
bool get isAuthenticated => _token != null;
/// 检查网络是否可用
Future<bool> _isOnline() async {
final result = await Connectivity().checkConnectivity();
// connectivity_plus 返回 List<ConnectivityResult>
return result.any((r) => r != ConnectivityResult.none);
}
/// 确保网络可用,否则抛出 OfflineException
Future<void> _ensureOnline() async {
final online = await _isOnline();
if (!online) {
throw const OfflineException();
}
}
// ===== CRUD 方法 =====
/// GET 请求
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParams,
}) async {
await _ensureOnline();
return _dio.get<T>(path, queryParameters: queryParams);
}
/// POST 请求(创建资源)
Future<Response<T>> post<T>(
String path, {
dynamic data,
}) async {
await _ensureOnline();
return _dio.post<T>(path, data: data);
}
/// PUT 请求(更新资源)
Future<Response<T>> put<T>(
String path, {
dynamic data,
}) async {
await _ensureOnline();
return _dio.put<T>(path, data: data);
}
/// DELETE 请求
Future<Response<T>> delete<T>(
String path, {
dynamic data,
}) async {
await _ensureOnline();
return _dio.delete<T>(path, data: data);
}
/// PATCH 请求(部分更新)
Future<Response<T>> patch<T>(
String path, {
dynamic data,
}) async {
await _ensureOnline();
return _dio.patch<T>(path, data: data);
}
/// 文件上传multipart/form-data
Future<Response<T>> upload<T>(
String path, {
required String filePath,
required String fileName,
String fieldName = 'file',
Map<String, dynamic>? extraFields,
}) async {
await _ensureOnline();
final formData = FormData.fromMap({
fieldName: await MultipartFile.fromFile(filePath, filename: fileName),
...?extraFields,
});
return _dio.post<T>(path, data: formData);
}
}