import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../auth/auth_state.dart'; import '../config/app_config.dart'; import '../errors/error_reporter.dart'; import '../../providers/connectivity_provider.dart'; /// Public Dio instance for unauthenticated calls (login / refresh) final _publicDio = Dio(BaseOptions( baseUrl: AppConfig.apiBaseUrl, connectTimeout: const Duration(seconds: 5), receiveTimeout: const Duration(seconds: 15), )); final apiClientProvider = Provider((ref) { // 只监听登录/登出,不监听 token 内容变化。 // token refresh 只改 accessToken,isLoggedIn 不变,provider 不重建, // 避免旧 Dio 被 close 后还在执行 fetch 导致 "adapter was closed"。 ref.watch(authStateProvider.select((s) => s.isLoggedIn)); final user = ref.read(authStateProvider).user; final client = ApiClient( token: user?.accessToken, refreshToken: user?.refreshToken, onTokenRefreshed: (newToken) { ref.read(authStateProvider.notifier).updateAccessToken(newToken); }, onAuthFailed: () { if (ref.read(authStateProvider).isLoggedIn) { ref.read(authStateProvider.notifier).logout(); } }, onConnectionError: () { ref.read(connectivityProvider.notifier).forceCheck(); }, ); ref.onDispose(client.dispose); return client; }); class ApiClient { late final Dio _dio; bool _disposed = false; ApiClient({ String? token, String? refreshToken, void Function(String newToken)? onTokenRefreshed, void Function()? onAuthFailed, void Function()? onConnectionError, }) { _dio = Dio(BaseOptions( baseUrl: AppConfig.apiBaseUrl, connectTimeout: const Duration(seconds: 5), receiveTimeout: const Duration(seconds: 15), headers: { if (token != null) 'Authorization': 'Bearer $token', }, )); // 网络错误 + 401 拦截器 _dio.interceptors.add( InterceptorsWrapper( onError: (DioException e, ErrorInterceptorHandler handler) async { if (_disposed) return handler.next(e); // 网络层错误(连接拒绝、超时等)→ 立即触发连通性检测 final isNetworkError = e.type == DioExceptionType.connectionError || e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.receiveTimeout || e.type == DioExceptionType.sendTimeout; if (isNetworkError) { onConnectionError?.call(); return handler.next(e); } // 服务端 5xx 错误上报(4xx 是业务/权限错误,不上报) final statusCode = e.response?.statusCode ?? 0; if (statusCode >= 500) { reportError( '[${e.requestOptions.method}] ${e.requestOptions.path} → $statusCode', e.stackTrace, errorType: ErrorType.apiError, ); } if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) { debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...'); try { final resp = await _publicDio.post('/auth/refresh', data: { 'refresh_token': refreshToken, }); final newToken = resp.data['data']['access_token'] as String; _dio.options.headers['Authorization'] = 'Bearer $newToken'; if (!_disposed) onTokenRefreshed?.call(newToken); final opts = e.requestOptions; opts.headers['Authorization'] = 'Bearer $newToken'; final retryResp = await _dio.fetch(opts); return handler.resolve(retryResp); } catch (refreshErr) { debugPrint('[ApiClient] refresh failed: $refreshErr'); if (!_disposed) onAuthFailed?.call(); } } else if (e.response?.statusCode == 401) { debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token'); } return handler.next(e); }, ), ); } /// 取消所有进行中的请求,标记实例为已废弃 void dispose() { _disposed = true; _dio.close(force: true); } Future get(String path, {Map? params}) => _dio.get(path, queryParameters: params); Future> getBytes(String path) async { final resp = await _dio.get>(path, options: Options(responseType: ResponseType.bytes)); return resp.data ?? []; } Future post(String path, {dynamic data}) => _dio.post(path, data: data); Future put(String path, {dynamic data}) => _dio.put(path, data: data); Future patch(String path, {dynamic data}) => _dio.patch(path, data: data); Future delete(String path) => _dio.delete(path); } /// Unauthenticated client for login/refresh class PublicApiClient { static Future post(String path, {dynamic data}) => _publicDio.post(path, data: data); }