| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- 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<bool>((ref) => false);
- /// 版本过期标志 — Dio 拦截器检测到 4099 时置 true,由 HomeScreen 监听并触发更新弹窗
- final versionOutdatedProvider = StateProvider<bool>((ref) => false);
- /// Dio 单例 Provider
- final dioClientProvider = Provider<Dio>((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<String, dynamic>) {
- 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<FlutterSecureStorage>(
- (_) => const FlutterSecureStorage(),
- );
|