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 '../config/app_constants.dart'; import '../config/license_copy.dart'; import '../errors/error_reporter.dart'; import '../../providers/connectivity_provider.dart'; import '../../providers/license_provider.dart'; import 'retry_interceptor.dart'; /// Public Dio instance for unauthenticated calls (login / refresh) final _publicDio = _buildPublicDio(); Dio _buildPublicDio() { final dio = Dio(BaseOptions( baseUrl: AppConfig.apiBaseUrl, connectTimeout: AppConstants.publicConnectTimeout, receiveTimeout: AppConstants.publicReceiveTimeout, )); dio.interceptors.add(RetryInterceptor(dio)); return dio; } 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, onTokensRefreshed: (accessToken, refreshToken) { ref .read(authStateProvider.notifier) .updateAccessToken(accessToken, refreshToken); }, onAuthFailed: (reason) { if (ref.read(authStateProvider).isLoggedIn) { if (reason != null) { ref.read(sessionEndedMessageProvider.notifier).state = reason; } ref.read(authStateProvider.notifier).logout(); } }, onConnectionError: () { ref.read(connectivityProvider.notifier).forceCheck(); }, onForbidden: (body) { final map = body is Map ? body : const {}; final code = map['code']; final phase = map['phase']; // 只读角色写被拒:直接提示(无需刷新授权)。 if (code == 'READONLY_USER') { ref.read(apiMessageProvider.notifier).state = LicenseCopy.readonlyUserToast; return; } // 授权过期(readonly/locked)写被拒:刷新授权令横幅/按钮即时降级,并提示。 if (phase == 'readonly' || phase == 'locked') { ref.read(licenseProvider.notifier).refresh().then((_) { final lic = ref.read(licenseProvider).valueOrNull; ref.read(apiMessageProvider.notifier).state = lic != null ? LicenseCopy.writeBlockedToast(lic) : '授权已过期,无法执行写操作'; }); } // 其它 403(管理员/超管权限不足等)不在此统一处理,交由调用方。 }, ); ref.onDispose(client.dispose); return client; }); class ApiClient { late final Dio _dio; bool _disposed = false; /// 当前 refresh token。续期会轮换它,故必须可变并随响应更新—— /// provider 不会因 token 变化重建本实例(只监听 isLoggedIn)。 String? _refreshToken; /// 单飞:并发 401 共享同一次刷新。否则每个 401 各发一次 /auth/refresh, /// 各自轮换 jti,后到的请求重放已被取代的 jti → 触发盗用检测吊销整条会话。 Future? _refreshing; void Function(String accessToken, String refreshToken)? _onTokensRefreshed; void Function(String? reason)? _onAuthFailed; ApiClient({ String? token, String? refreshToken, void Function(String accessToken, String refreshToken)? onTokensRefreshed, void Function(String? reason)? onAuthFailed, void Function()? onConnectionError, void Function(dynamic body)? onForbidden, }) { _refreshToken = refreshToken; _onTokensRefreshed = onTokensRefreshed; _onAuthFailed = onAuthFailed; _dio = Dio(BaseOptions( baseUrl: AppConfig.apiBaseUrl, connectTimeout: AppConstants.publicConnectTimeout, receiveTimeout: AppConstants.publicReceiveTimeout, headers: { if (token != null) 'Authorization': 'Bearer $token', }, )); // 网络层错误自动重试(须在错误处理拦截器之前,先重试再走 401/上报逻辑) _dio.interceptors.add(RetryInterceptor(_dio)); // 网络错误 + 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, ); } // 403:写权限被拒(只读角色 / 授权过期)。交给上层刷新授权 + 提示。 // 后端契约:body 带 code=READONLY_USER 或 phase=readonly|locked。 if (e.response?.statusCode == 403) { if (!_disposed) onForbidden?.call(e.response?.data); return handler.next(e); } // 账号被停用/删除:中间件返回 401 + code=USER_DISABLED。这是终态, // 续期也救不回(refresh 会因 is_active=0 再次失败),直接强制重新登录。 if (e.response?.statusCode == 401 && e.response?.data is Map && e.response?.data['code'] == 'USER_DISABLED') { if (!_disposed) { _onAuthFailed?.call('您的账号已被停用或删除,请重新登录或联系管理员'); } return handler.next(e); } if (e.response?.statusCode == 401 && (_refreshToken ?? '').isNotEmpty) { debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...'); final newToken = await _refreshAccessToken(); if (newToken == null) { return handler.next(e); } try { final opts = e.requestOptions; opts.headers['Authorization'] = 'Bearer $newToken'; final retryResp = await _dio.fetch(opts); return handler.resolve(retryResp); } catch (retryErr) { return handler.next(retryErr is DioException ? retryErr : e); } } else if (e.response?.statusCode == 401) { debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token'); } return handler.next(e); }, ), ); } /// 单飞刷新:并发 401 复用同一次 /auth/refresh,避免多次轮换 jti 触发盗用检测。 /// 成功返回新的 access token;失败(含会话已撤销)返回 null 并通知 onAuthFailed。 Future _refreshAccessToken() { return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null); } Future _doRefresh() async { try { final resp = await _publicDio.post('/auth/refresh', data: { 'refresh_token': _refreshToken, }); final data = resp.data['data'] as Map; final newAccess = data['access_token'] as String; // 后端轮换 refresh token,必须采纳新值(无则沿用旧值,兼容老后端)。 final newRefresh = (data['refresh_token'] as String?) ?? _refreshToken; _refreshToken = newRefresh; _dio.options.headers['Authorization'] = 'Bearer $newAccess'; if (!_disposed && newRefresh != null) { _onTokensRefreshed?.call(newAccess, newRefresh); } return newAccess; } catch (refreshErr) { debugPrint('[ApiClient] refresh failed: $refreshErr'); // 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示 String? reason; if (refreshErr is DioException && refreshErr.response?.data is Map && refreshErr.response?.data['code'] == 'SESSION_REVOKED') { reason = '您的账号已在其他设备登录,或登录已失效,请重新登录'; } if (!_disposed) _onAuthFailed?.call(reason); return null; } } /// 仅供测试:访问内部 Dio,以便挂载 mock adapter 走真实拦截器(401/403 等)。 @visibleForTesting Dio get dioForTest => _dio; /// 取消所有进行中的请求,标记实例为已废弃 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); }