Files
nj/app/lib/data/remote/api_client.dart
iven a34c9fd176 fix(app): 强制 HTTPS — Android 网络安全配置 + 生产默认 HTTPS
- 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
2026-06-03 10:13:20 +08:00

191 lines
5.5 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;
/// 创建 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);
}
}