dio_client.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import 'dart:developer' as developer;
  2. import 'dart:io' show Platform, SocketException;
  3. import 'package:dio/dio.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import 'package:flutter_secure_storage/flutter_secure_storage.dart';
  6. import 'package:pretty_dio_logger/pretty_dio_logger.dart';
  7. import '../../providers/app_provider.dart'
  8. show backendLangFromLocale, localeProvider;
  9. import '../../providers/node_provider.dart';
  10. import '../config/app_config.dart';
  11. import 'api_response.dart';
  12. /// 解析统一响应里的业务 [code](部分接口会返回字符串格式的 code)
  13. int _parseApiCode(dynamic value) {
  14. if (value == null) {
  15. return 0;
  16. }
  17. if (value is int) {
  18. return value;
  19. }
  20. if (value is String) {
  21. return int.tryParse(value) ?? 0;
  22. }
  23. if (value is num) {
  24. return value.toInt();
  25. }
  26. return 0;
  27. }
  28. /// Token 存储 Key(与 auth_repository.dart 保持一致)
  29. const _kAccessToken = 'auth_token';
  30. /// 故障转移重试计数 key,挂在 RequestOptions.extra 上随请求流转。
  31. /// 每次 failover 重试 +1,达到当前节点池大小后停止,避免无限循环。
  32. const _kRetryCountKey = '_nodeRetryCount';
  33. /// Token 内存缓存 — 避免每次 HTTP 请求都读 iOS Keychain
  34. /// 由 auth_repository 的 _saveSession/_clearSession 维护一致性
  35. String? _cachedToken;
  36. /// 供 auth_repository 在登录/登出时更新缓存
  37. void updateCachedToken(String? token) => _cachedToken = token;
  38. /// 会话过期标志 — Dio 拦截器检测到 4000 时置 true,由路由监听并触发登出
  39. final sessionExpiredProvider = StateProvider<bool>((ref) => false);
  40. /// 版本过期标志 — Dio 拦截器检测到 4099 时置 true,由 HomeScreen 监听并触发更新弹窗
  41. final versionOutdatedProvider = StateProvider<bool>((ref) => false);
  42. /// Dio 单例 Provider
  43. final dioClientProvider = Provider<Dio>((ref) {
  44. // 从 NodeProvider 获取当前 API Host(可能为空,回退到编译时默认值)
  45. final nodeState = ref.read(nodeProvider);
  46. // Debug/Profile:始终以 env_debug + AppConfig 为准,避免本地节点缓存(旧 apitest 等)覆盖编译时环境
  47. final initialBaseUrl = AppConfig.isDebug
  48. ? AppConfig.effectiveApiBaseUrl
  49. : (nodeState.currentNode?.normalizedApiHost ??
  50. AppConfig.effectiveApiBaseUrl);
  51. final dio = Dio(
  52. BaseOptions(
  53. baseUrl: initialBaseUrl,
  54. connectTimeout: const Duration(milliseconds: AppConfig.connectTimeoutMs),
  55. receiveTimeout: const Duration(milliseconds: AppConfig.receiveTimeoutMs),
  56. headers: {'Content-Type': 'application/json'},
  57. ),
  58. );
  59. // Release:节点切换时更新 baseUrl;Debug 固定走 env_debug,不跟节点池
  60. if (!AppConfig.isDebug) {
  61. ref.listen(nodeProvider, (prev, next) {
  62. final newHost = next.currentNode?.normalizedApiHost;
  63. if (newHost != null && dio.options.baseUrl != newHost) {
  64. developer.log(
  65. 'Dio baseUrl switched: ${dio.options.baseUrl} → $newHost',
  66. name: 'Node',
  67. );
  68. dio.options.baseUrl = newHost;
  69. }
  70. });
  71. }
  72. // ── 拦截器:Token 注入 ──────────────────────────────
  73. dio.interceptors.add(
  74. InterceptorsWrapper(
  75. onRequest: (options, handler) async {
  76. // 优先用内存缓存;无缓存时才读 Keychain(仅首次或缓存失效时)
  77. String? token = _cachedToken;
  78. if (token == null) {
  79. token = await const FlutterSecureStorage().read(key: _kAccessToken);
  80. _cachedToken = token; // 写入缓存,后续请求直接用
  81. }
  82. if (token != null && token.isNotEmpty) {
  83. options.headers['x-auth-token'] = token;
  84. }
  85. options.headers['x-app-version'] = AppConfig.appVersion;
  86. options.headers['x-app-platform'] = Platform.isIOS ? '1' : '0';
  87. options.headers['lang'] = _resolveLang(ref);
  88. handler.next(options);
  89. },
  90. onResponse: (response, handler) async {
  91. // 打印接口返回的原始内容
  92. developer.log(
  93. '${response.requestOptions.method} ${response.requestOptions.uri}\n'
  94. 'Response: ${response.data}',
  95. name: 'API',
  96. );
  97. // 解包统一响应格式 { code, data, message }
  98. final body = response.data;
  99. if (body is Map<String, dynamic>) {
  100. final code = _parseApiCode(body['code']);
  101. if (code != 0) {
  102. // 4000 = 登录已过期:只触发一次,避免并发请求重复弹窗
  103. // 先检查 token 是否还在:若已被清除说明用户主动退出,跳过过期弹窗
  104. if (code == 4000 && !ref.read(sessionExpiredProvider)) {
  105. final existingToken = _cachedToken;
  106. if (existingToken != null && existingToken.isNotEmpty) {
  107. _cachedToken = null; // 清除内存缓存
  108. await const FlutterSecureStorage().delete(key: _kAccessToken);
  109. ref.read(sessionExpiredProvider.notifier).state = true;
  110. }
  111. }
  112. // 4099 = 版本过低:触发更新弹窗,只触发一次
  113. if (code == 4099 && !ref.read(versionOutdatedProvider)) {
  114. ref.read(versionOutdatedProvider.notifier).state = true;
  115. }
  116. handler.reject(
  117. DioException(
  118. requestOptions: response.requestOptions,
  119. error: ApiException(
  120. code: code,
  121. message: body['message']?.toString() ??
  122. body['msg']?.toString() ??
  123. 'Unknown error',
  124. ),
  125. type: DioExceptionType.badResponse,
  126. response: response,
  127. ),
  128. );
  129. return;
  130. }
  131. }
  132. // 请求成功 → 重置失败计数
  133. ref.read(nodeProvider.notifier).reportSuccess();
  134. handler.next(response);
  135. },
  136. onError: (error, handler) async {
  137. // 打印错误响应内容
  138. developer.log(
  139. '${error.requestOptions.method} ${error.requestOptions.uri}\n'
  140. 'Error [${error.response?.statusCode}]: ${error.response?.data}',
  141. name: 'API',
  142. );
  143. // 401 = HTTP 未授权 → 清除 Token 并触发登出(防重复)
  144. final isHttp401 = error.response?.statusCode == 401;
  145. if (isHttp401 && !ref.read(sessionExpiredProvider)) {
  146. _cachedToken = null; // 清除内存缓存
  147. await const FlutterSecureStorage().delete(key: _kAccessToken);
  148. ref.read(sessionExpiredProvider.notifier).state = true;
  149. handler.next(error);
  150. return;
  151. }
  152. // ── 故障转移:网络级错误触发,硬错误立即切节点 ─────
  153. if (_isNetworkError(error)) {
  154. final retryCount =
  155. (error.requestOptions.extra[_kRetryCountKey] as int?) ?? 0;
  156. // 上限 = 当前节点池大小,最多挨个试一遍后放弃
  157. final maxRetries = ref.read(nodeProvider).nodes.length;
  158. if (retryCount < maxRetries) {
  159. // DNS / connect / timeout = 硬网络错误,第一次就切;5xx 仍走累积阈值
  160. final isHard = _isHardNetworkError(error);
  161. final switched = ref
  162. .read(nodeProvider.notifier)
  163. .reportFailure(immediate: isHard);
  164. if (switched) {
  165. try {
  166. final opts = error.requestOptions;
  167. opts.baseUrl = dio.options.baseUrl; // 已被 listen 更新
  168. opts.extra[_kRetryCountKey] = retryCount + 1;
  169. final response = await dio.fetch(opts);
  170. handler.resolve(response);
  171. return;
  172. } catch (_) {
  173. // 重试链路最终失败,透传原始错误(递归 onError 已尝试更多节点)
  174. }
  175. }
  176. }
  177. }
  178. // ── 错误归一化:所有恢复尝试都失败后,把原始 SocketException /
  179. // "Failed host lookup" 之类的技术错误替换成多语言友好文案,
  180. // 避免 UI 直接 toast 出底层 dart 错误。
  181. if (_isNetworkError(error) && error.error is! ApiException) {
  182. handler.next(_normalizeNetworkError(ref, error));
  183. return;
  184. }
  185. handler.next(error);
  186. },
  187. ),
  188. );
  189. // ── 拦截器:日志(仅 Debug)──────────────────────────
  190. if (AppConfig.isDebug) {
  191. dio.interceptors.add(
  192. PrettyDioLogger(
  193. requestHeader: true,
  194. requestBody: true,
  195. responseBody: false,
  196. error: true,
  197. compact: true,
  198. ),
  199. );
  200. }
  201. return dio;
  202. });
  203. /// 判断是否为网络级错误(值得故障转移重试的)
  204. bool _isNetworkError(DioException error) {
  205. switch (error.type) {
  206. case DioExceptionType.connectionTimeout:
  207. case DioExceptionType.sendTimeout:
  208. case DioExceptionType.receiveTimeout:
  209. case DioExceptionType.connectionError:
  210. return true;
  211. case DioExceptionType.badResponse:
  212. // 5xx 服务端错误也视为网络级故障
  213. final code = error.response?.statusCode ?? 0;
  214. return code >= 500;
  215. default:
  216. // unknown 里通常是 SocketException("Failed host lookup") 等 DNS 失败
  217. final inner = error.error;
  218. if (inner is SocketException) return true;
  219. return false;
  220. }
  221. }
  222. /// 判断是否为"硬"网络错误:DNS 失败、连不上、超时类。
  223. /// 这类错误几乎可以判定当前节点不可用,应当立即 failover,
  224. /// 而不是累积到 failoverThreshold 才切(首屏会等满 N 次超时)。
  225. bool _isHardNetworkError(DioException error) {
  226. switch (error.type) {
  227. case DioExceptionType.connectionTimeout:
  228. case DioExceptionType.sendTimeout:
  229. case DioExceptionType.receiveTimeout:
  230. case DioExceptionType.connectionError:
  231. return true;
  232. case DioExceptionType.unknown:
  233. return error.error is SocketException;
  234. default:
  235. return false;
  236. }
  237. }
  238. /// 网络错误归一化错误码(与后端 4xxx 业务码隔离,避开 401/4000 等已用值)
  239. const int _kErrCodeNetwork = -1001;
  240. const int _kErrCodeTimeout = -1002;
  241. const int _kErrCodeServer5xx = -1500;
  242. /// 把底层 DioException 包装成带本地化文案的 ApiException,
  243. /// UI 直接 toast `e.message` 即可,不再暴露 "Failed host lookup" 之类的技术细节。
  244. DioException _normalizeNetworkError(Ref ref, DioException error) {
  245. final lang = _resolveLang(ref);
  246. final int code;
  247. final String message;
  248. switch (error.type) {
  249. case DioExceptionType.connectionTimeout:
  250. case DioExceptionType.sendTimeout:
  251. case DioExceptionType.receiveTimeout:
  252. code = _kErrCodeTimeout;
  253. message = _localizedTimeout(lang);
  254. case DioExceptionType.badResponse:
  255. code = _kErrCodeServer5xx;
  256. message = _localizedServerError(lang);
  257. default:
  258. code = _kErrCodeNetwork;
  259. message = _localizedNetworkError(lang);
  260. }
  261. return DioException(
  262. requestOptions: error.requestOptions,
  263. error: ApiException(code: code, message: message),
  264. type: error.type,
  265. response: error.response,
  266. stackTrace: error.stackTrace,
  267. );
  268. }
  269. /// 多语言文案:与 ARB 中 errNetworkError / errTimeout / errServiceUnavailable 对齐
  270. String _localizedNetworkError(String lang) {
  271. switch (lang) {
  272. case 'en_US':
  273. return 'Network error, please check your connection and retry';
  274. case 'ja_JP':
  275. return 'ネットワークエラーです。接続を確認して再試行してください';
  276. case 'ko_KR':
  277. return '네트워크 오류, 연결을 확인 후 다시 시도하세요';
  278. case 'zh_HK':
  279. return '網絡連接異常,請檢查網絡設置後重試';
  280. default:
  281. return '网络连接异常,请检查网络设置后重试';
  282. }
  283. }
  284. String _localizedTimeout(String lang) {
  285. switch (lang) {
  286. case 'en_US':
  287. return 'Request timed out, please retry';
  288. case 'ja_JP':
  289. return 'リクエストがタイムアウトしました。再試行してください';
  290. case 'ko_KR':
  291. return '요청 시간 초과, 다시 시도하세요';
  292. case 'zh_HK':
  293. return '請求超時,請重試';
  294. default:
  295. return '请求超时,请重试';
  296. }
  297. }
  298. String _localizedServerError(String lang) {
  299. switch (lang) {
  300. case 'en_US':
  301. return 'Service temporarily unavailable, please retry later';
  302. case 'ja_JP':
  303. return 'サービスが一時的に利用できません。しばらくしてから再試行してください';
  304. case 'ko_KR':
  305. return '서비스를 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도하세요';
  306. case 'zh_HK':
  307. return '服務暫時不可用,請稍後重試';
  308. default:
  309. return '服务暂不可用,请稍后重试';
  310. }
  311. }
  312. /// 与当前 `localeProvider` 一致的后端 lang(避免仅读 prefs 与 MaterialApp.locale 不同步)
  313. String _resolveLang(Ref ref) {
  314. return backendLangFromLocale(ref.read(localeProvider));
  315. }
  316. /// 安全存储 Provider(供其他模块使用)
  317. final secureStorageProvider = Provider<FlutterSecureStorage>(
  318. (_) => const FlutterSecureStorage(),
  319. );