// 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'; import '../models/sync_models.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 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 _isOnline() async { final result = await Connectivity().checkConnectivity(); // connectivity_plus 返回 List return result.any((r) => r != ConnectivityResult.none); } /// 确保网络可用,否则抛出 OfflineException Future _ensureOnline() async { final online = await _isOnline(); if (!online) { throw const OfflineException(); } } // ===== CRUD 方法 ===== /// GET 请求 Future> get( String path, { Map? queryParams, }) async { await _ensureOnline(); return _dio.get(path, queryParameters: queryParams); } /// POST 请求(创建资源) Future> post( String path, { dynamic data, }) async { await _ensureOnline(); return _dio.post(path, data: data); } /// PUT 请求(更新资源) Future> put( String path, { dynamic data, }) async { await _ensureOnline(); return _dio.put(path, data: data); } /// DELETE 请求 Future> delete( String path, { dynamic data, }) async { await _ensureOnline(); return _dio.delete(path, data: data); } /// PATCH 请求(部分更新) Future> patch( String path, { dynamic data, }) async { await _ensureOnline(); return _dio.patch(path, data: data); } /// 文件上传(multipart/form-data) Future> upload( String path, { required String filePath, required String fileName, String fieldName = 'file', Map? extraFields, }) async { await _ensureOnline(); final formData = FormData.fromMap({ fieldName: await MultipartFile.fromFile(filePath, filename: fileName), ...?extraFields, }); return _dio.post(path, data: formData); } // ===== 同步 API ===== /// 批量同步 — POST /diary/sync /// /// 将客户端变更批量提交到服务端,返回服务端变更和冲突信息。 /// 对应 Rust sync_handler::sync_journals 端点。 Future sync(SyncReq req) async { await _ensureOnline(); final response = await _dio.post>( '/diary/sync', data: req.toJson(), ); return SyncResp.fromJson(response.data!); } }