import 'dart:developer' as developer; import 'dart:io' show Platform, SocketException; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import '../../providers/app_provider.dart' show backendLangFromLocale, localeProvider; import '../../providers/node_provider.dart'; import '../config/app_config.dart'; import 'api_response.dart'; /// 解析统一响应里的业务 [code](部分接口会返回字符串格式的 code) int _parseApiCode(dynamic value) { if (value == null) { return 0; } if (value is int) { return value; } if (value is String) { return int.tryParse(value) ?? 0; } if (value is num) { return value.toInt(); } return 0; } /// Token 存储 Key(与 auth_repository.dart 保持一致) const _kAccessToken = 'auth_token'; /// 故障转移重试计数 key,挂在 RequestOptions.extra 上随请求流转。 /// 每次 failover 重试 +1,达到当前节点池大小后停止,避免无限循环。 const _kRetryCountKey = '_nodeRetryCount'; /// Token 内存缓存 — 避免每次 HTTP 请求都读 iOS Keychain /// 由 auth_repository 的 _saveSession/_clearSession 维护一致性 String? _cachedToken; /// 供 auth_repository 在登录/登出时更新缓存 void updateCachedToken(String? token) => _cachedToken = token; /// 会话过期标志 — Dio 拦截器检测到 4000 时置 true,由路由监听并触发登出 final sessionExpiredProvider = StateProvider((ref) => false); /// 版本过期标志 — Dio 拦截器检测到 4099 时置 true,由 HomeScreen 监听并触发更新弹窗 final versionOutdatedProvider = StateProvider((ref) => false); /// Dio 单例 Provider final dioClientProvider = Provider((ref) { // 从 NodeProvider 获取当前 API Host(可能为空,回退到编译时默认值) final nodeState = ref.read(nodeProvider); // Debug/Profile:始终以 env_debug + AppConfig 为准,避免本地节点缓存(旧 apitest 等)覆盖编译时环境 final initialBaseUrl = AppConfig.isDebug ? AppConfig.effectiveApiBaseUrl : (nodeState.currentNode?.normalizedApiHost ?? AppConfig.effectiveApiBaseUrl); final dio = Dio( BaseOptions( baseUrl: initialBaseUrl, connectTimeout: const Duration(milliseconds: AppConfig.connectTimeoutMs), receiveTimeout: const Duration(milliseconds: AppConfig.receiveTimeoutMs), headers: {'Content-Type': 'application/json'}, ), ); // Release:节点切换时更新 baseUrl;Debug 固定走 env_debug,不跟节点池 if (!AppConfig.isDebug) { ref.listen(nodeProvider, (prev, next) { final newHost = next.currentNode?.normalizedApiHost; if (newHost != null && dio.options.baseUrl != newHost) { developer.log( 'Dio baseUrl switched: ${dio.options.baseUrl} → $newHost', name: 'Node', ); dio.options.baseUrl = newHost; } }); } // ── 拦截器:Token 注入 ────────────────────────────── dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) async { // 优先用内存缓存;无缓存时才读 Keychain(仅首次或缓存失效时) String? token = _cachedToken; if (token == null) { token = await const FlutterSecureStorage().read(key: _kAccessToken); _cachedToken = token; // 写入缓存,后续请求直接用 } if (token != null && token.isNotEmpty) { options.headers['x-auth-token'] = token; } options.headers['x-app-version'] = AppConfig.appVersion; options.headers['x-app-platform'] = Platform.isIOS ? '1' : '0'; options.headers['lang'] = _resolveLang(ref); handler.next(options); }, onResponse: (response, handler) async { // 打印接口返回的原始内容 developer.log( '${response.requestOptions.method} ${response.requestOptions.uri}\n' 'Response: ${response.data}', name: 'API', ); // 解包统一响应格式 { code, data, message } final body = response.data; if (body is Map) { final code = _parseApiCode(body['code']); if (code != 0) { // 4000 = 登录已过期:只触发一次,避免并发请求重复弹窗 // 先检查 token 是否还在:若已被清除说明用户主动退出,跳过过期弹窗 if (code == 4000 && !ref.read(sessionExpiredProvider)) { final existingToken = _cachedToken; if (existingToken != null && existingToken.isNotEmpty) { _cachedToken = null; // 清除内存缓存 await const FlutterSecureStorage().delete(key: _kAccessToken); ref.read(sessionExpiredProvider.notifier).state = true; } } // 4099 = 版本过低:触发更新弹窗,只触发一次 if (code == 4099 && !ref.read(versionOutdatedProvider)) { ref.read(versionOutdatedProvider.notifier).state = true; } handler.reject( DioException( requestOptions: response.requestOptions, error: ApiException( code: code, message: body['message']?.toString() ?? body['msg']?.toString() ?? 'Unknown error', ), type: DioExceptionType.badResponse, response: response, ), ); return; } } // 请求成功 → 重置失败计数 ref.read(nodeProvider.notifier).reportSuccess(); handler.next(response); }, onError: (error, handler) async { // 打印错误响应内容 developer.log( '${error.requestOptions.method} ${error.requestOptions.uri}\n' 'Error [${error.response?.statusCode}]: ${error.response?.data}', name: 'API', ); // 401 = HTTP 未授权 → 清除 Token 并触发登出(防重复) final isHttp401 = error.response?.statusCode == 401; if (isHttp401 && !ref.read(sessionExpiredProvider)) { _cachedToken = null; // 清除内存缓存 await const FlutterSecureStorage().delete(key: _kAccessToken); ref.read(sessionExpiredProvider.notifier).state = true; handler.next(error); return; } // ── 故障转移:网络级错误触发,硬错误立即切节点 ───── if (_isNetworkError(error)) { final retryCount = (error.requestOptions.extra[_kRetryCountKey] as int?) ?? 0; // 上限 = 当前节点池大小,最多挨个试一遍后放弃 final maxRetries = ref.read(nodeProvider).nodes.length; if (retryCount < maxRetries) { // DNS / connect / timeout = 硬网络错误,第一次就切;5xx 仍走累积阈值 final isHard = _isHardNetworkError(error); final switched = ref .read(nodeProvider.notifier) .reportFailure(immediate: isHard); if (switched) { try { final opts = error.requestOptions; opts.baseUrl = dio.options.baseUrl; // 已被 listen 更新 opts.extra[_kRetryCountKey] = retryCount + 1; final response = await dio.fetch(opts); handler.resolve(response); return; } catch (_) { // 重试链路最终失败,透传原始错误(递归 onError 已尝试更多节点) } } } } // ── 错误归一化:所有恢复尝试都失败后,把原始 SocketException / // "Failed host lookup" 之类的技术错误替换成多语言友好文案, // 避免 UI 直接 toast 出底层 dart 错误。 if (_isNetworkError(error) && error.error is! ApiException) { handler.next(_normalizeNetworkError(ref, error)); return; } handler.next(error); }, ), ); // ── 拦截器:日志(仅 Debug)────────────────────────── if (AppConfig.isDebug) { dio.interceptors.add( PrettyDioLogger( requestHeader: true, requestBody: true, responseBody: false, error: true, compact: true, ), ); } return dio; }); /// 判断是否为网络级错误(值得故障转移重试的) bool _isNetworkError(DioException error) { switch (error.type) { case DioExceptionType.connectionTimeout: case DioExceptionType.sendTimeout: case DioExceptionType.receiveTimeout: case DioExceptionType.connectionError: return true; case DioExceptionType.badResponse: // 5xx 服务端错误也视为网络级故障 final code = error.response?.statusCode ?? 0; return code >= 500; default: // unknown 里通常是 SocketException("Failed host lookup") 等 DNS 失败 final inner = error.error; if (inner is SocketException) return true; return false; } } /// 判断是否为"硬"网络错误:DNS 失败、连不上、超时类。 /// 这类错误几乎可以判定当前节点不可用,应当立即 failover, /// 而不是累积到 failoverThreshold 才切(首屏会等满 N 次超时)。 bool _isHardNetworkError(DioException error) { switch (error.type) { case DioExceptionType.connectionTimeout: case DioExceptionType.sendTimeout: case DioExceptionType.receiveTimeout: case DioExceptionType.connectionError: return true; case DioExceptionType.unknown: return error.error is SocketException; default: return false; } } /// 网络错误归一化错误码(与后端 4xxx 业务码隔离,避开 401/4000 等已用值) const int _kErrCodeNetwork = -1001; const int _kErrCodeTimeout = -1002; const int _kErrCodeServer5xx = -1500; /// 把底层 DioException 包装成带本地化文案的 ApiException, /// UI 直接 toast `e.message` 即可,不再暴露 "Failed host lookup" 之类的技术细节。 DioException _normalizeNetworkError(Ref ref, DioException error) { final lang = _resolveLang(ref); final int code; final String message; switch (error.type) { case DioExceptionType.connectionTimeout: case DioExceptionType.sendTimeout: case DioExceptionType.receiveTimeout: code = _kErrCodeTimeout; message = _localizedTimeout(lang); case DioExceptionType.badResponse: code = _kErrCodeServer5xx; message = _localizedServerError(lang); default: code = _kErrCodeNetwork; message = _localizedNetworkError(lang); } return DioException( requestOptions: error.requestOptions, error: ApiException(code: code, message: message), type: error.type, response: error.response, stackTrace: error.stackTrace, ); } /// 多语言文案:与 ARB 中 errNetworkError / errTimeout / errServiceUnavailable 对齐 String _localizedNetworkError(String lang) { switch (lang) { case 'en_US': return 'Network error, please check your connection and retry'; case 'ja_JP': return 'ネットワークエラーです。接続を確認して再試行してください'; case 'ko_KR': return '네트워크 오류, 연결을 확인 후 다시 시도하세요'; case 'zh_HK': return '網絡連接異常,請檢查網絡設置後重試'; default: return '网络连接异常,请检查网络设置后重试'; } } String _localizedTimeout(String lang) { switch (lang) { case 'en_US': return 'Request timed out, please retry'; case 'ja_JP': return 'リクエストがタイムアウトしました。再試行してください'; case 'ko_KR': return '요청 시간 초과, 다시 시도하세요'; case 'zh_HK': return '請求超時,請重試'; default: return '请求超时,请重试'; } } String _localizedServerError(String lang) { switch (lang) { case 'en_US': return 'Service temporarily unavailable, please retry later'; case 'ja_JP': return 'サービスが一時的に利用できません。しばらくしてから再試行してください'; case 'ko_KR': return '서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도하세요'; case 'zh_HK': return '服務暫時不可用,請稍後重試'; default: return '服务暂不可用,请稍后重试'; } } /// 与当前 `localeProvider` 一致的后端 lang(避免仅读 prefs 与 MaterialApp.locale 不同步) String _resolveLang(Ref ref) { return backendLangFromLocale(ref.read(localeProvider)); } /// 安全存储 Provider(供其他模块使用) final secureStorageProvider = Provider( (_) => const FlutterSecureStorage(), );