- Android: 添加 network_security_config.xml,默认禁止明文流量 - Android: 仅允许 localhost/127.0.0.1/10.0.2.2 明文(开发调试) - Android: 更新 AndroidManifest 引用网络安全配置 - ApiClient: 默认 URL 改为 https://api.nuanji.app/api/v1 - AppConfig: fromEnvironment 默认值改为 HTTPS 生产地址 - AppConfig: dev 常量保留 localhost(仅用于本地开发) - iOS: ATS 默认已强制 HTTPS,无需修改 审计 ID: 6b-C01
191 lines
5.5 KiB
Dart
191 lines
5.5 KiB
Dart
// 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;
|
||
|
||
/// 创建 API 客户端
|
||
///
|
||
/// [baseUrl] 默认使用 HTTPS 生产地址。
|
||
/// 开发环境可通过构造参数覆盖为 http://localhost:3000/api/v1
|
||
/// (Android 网络安全配置已允许 localhost 明文)。
|
||
ApiClient({this.baseUrl = 'https://api.nuanji.app/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);
|
||
}
|
||
}
|