import 'dart:developer'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart' show kReleaseMode; import 'local_database.dart'; /// API 基础地址(生产模式)。本地开发可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 const String baseUrl = String.fromEnvironment( 'API_BASE_URL', defaultValue: kReleaseMode ? 'https://erpapi.datalumina.cn/xiaomai' : 'http://192.168.1.34:5000', ); class ApiException implements Exception { final int? code; final String message; const ApiException(this.message, {this.code}); @override String toString() => message; } typedef AuthExpiredListener = void Function(); class AuthExpiredNotifier { final List _listeners = []; AuthExpiredListener addListener(AuthExpiredListener listener) { _listeners.add(listener); return () => _listeners.remove(listener); } void notify() { for (final listener in List.from(_listeners)) { listener(); } } } /// Dio HTTP 客户端封装——带 token 注入、401 自动刷新 class ApiClient { final Dio _dio; final LocalDatabase _db; final AuthExpiredNotifier? _authExpiredNotifier; Future<_TokenRefreshResult>? _refreshInFlight; ApiClient({ required LocalDatabase db, AuthExpiredNotifier? authExpiredNotifier, }) : _db = db, _authExpiredNotifier = authExpiredNotifier, _dio = Dio( BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 60), headers: {'Content-Type': 'application/json'}, ), ) { _dio.interceptors.add(_AuthInterceptor(this)); _dio.interceptors.add( LogInterceptor(requestBody: false, responseBody: false), ); } Dio get dio => _dio; Future get accessToken => _db.read('access_token'); Future get refreshToken => _db.read('refresh_token'); Future saveTokens(String access, String refresh) async { await _db.write('access_token', access); await _db.write('refresh_token', refresh); } Future clearTokens() async { await _db.delete('access_token'); await _db.delete('refresh_token'); } Future clearTokensIfRefreshMatches(String failedRefreshToken) async { if (await refreshToken != failedRefreshToken) return; await clearTokens(); } Future<_TokenRefreshResult> _refreshTokens() { final active = _refreshInFlight; if (active != null) return active; final future = _performTokenRefresh(); _refreshInFlight = future; return future.whenComplete(() { if (identical(_refreshInFlight, future)) _refreshInFlight = null; }); } Future<_TokenRefreshResult> _performTokenRefresh() async { final refresh = await refreshToken; if (refresh == null) return const _TokenRefreshResult.invalid(); try { final response = await Dio( BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 30), ), ).post('/api/auth/refresh', data: {'refreshToken': refresh}); final body = response.data; final data = body is Map ? body['data'] : null; final rawCode = body is Map ? body['code'] : null; final code = rawCode is int ? rawCode : int.tryParse('$rawCode'); if (code != null && code != 0) { return _TokenRefreshResult.invalid(refreshToken: refresh); } if (data is Map && data['accessToken'] is String && data['refreshToken'] is String) { final accessToken = data['accessToken'] as String; final newRefreshToken = data['refreshToken'] as String; // 刷新期间用户可能已经退出或切换账号,旧响应不得覆盖新会话。 if (await refreshToken != refresh) { return const _TokenRefreshResult.transientFailure(); } await saveTokens(accessToken, newRefreshToken); return _TokenRefreshResult.success(accessToken); } return const _TokenRefreshResult.transientFailure(); } on DioException catch (error) { if (error.response?.statusCode == 400 || error.response?.statusCode == 401) { return _TokenRefreshResult.invalid(refreshToken: refresh); } return const _TokenRefreshResult.transientFailure(); } catch (_) { return const _TokenRefreshResult.transientFailure(); } } void notifyAuthExpired() { _authExpiredNotifier?.notify(); } /// 带 token 的 GET 请求 Future get( String path, { Map? queryParameters, }) async { return _request(_dio.get(path, queryParameters: queryParameters)); } /// 带 token 的 POST 请求 Future post(String path, {dynamic data}) async { return _request(_dio.post(path, data: data)); } /// 带 token 的 PUT 请求 Future put(String path, {dynamic data}) async { return _request(_dio.put(path, data: data)); } /// 带 token 的 DELETE 请求 Future delete(String path) async { return _request(_dio.delete(path)); } /// 上传文件(multipart),返回文件 URL Future uploadFile( String path, File file, { String fieldName = 'file', }) async { final formData = FormData.fromMap({ fieldName: await MultipartFile.fromFile( file.path, filename: file.path.split('/').last, ), }); final res = await _request(_dio.post(path, data: formData)); final data = res.data; if (data is Map) { final topUrl = data['url']?.toString(); if (topUrl != null && topUrl.isNotEmpty) return topUrl; final payload = data['data']; if (payload is Map) { final url = payload['url']?.toString(); return url != null && url.isNotEmpty ? url : null; } if (payload is List && payload.isNotEmpty && payload.first is Map) { final first = payload.first as Map; final url = first['url']?.toString(); return url != null && url.isNotEmpty ? url : null; } } return null; } Response _ensureBusinessSuccess(Response response) { final body = response.data; if (body is Map && body.containsKey('code')) { final rawCode = body['code']; final code = rawCode is int ? rawCode : int.tryParse('$rawCode'); if (code != null && code != 0) { throw ApiException( body['message']?.toString().trim().isNotEmpty == true ? body['message'].toString() : '请求失败', code: code, ); } } return response; } Future _request(Future request) async { try { return _ensureBusinessSuccess(await request); } on DioException catch (error) { final body = error.response?.data; final message = body is Map && body['message']?.toString().trim().isNotEmpty == true ? body['message'].toString() : error.type == DioExceptionType.connectionTimeout || error.type == DioExceptionType.receiveTimeout ? '请求超时,请稍后重试' : error.type == DioExceptionType.connectionError ? '无法连接服务器,请检查网络或后端地址' : '请求失败,请稍后重试'; final rawCode = body is Map ? body['code'] : null; throw ApiException( message, code: rawCode is int ? rawCode : int.tryParse('$rawCode') ?? error.response?.statusCode, ); } } } /// 认证拦截器:自动注入 token + 401 刷新 class _AuthInterceptor extends Interceptor { final ApiClient _client; _AuthInterceptor(this._client); @override void onRequest( RequestOptions options, RequestInterceptorHandler handler, ) async { if (!options.path.contains('/auth/')) { final token = await _client.accessToken; if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } } handler.next(options); } @override void onError(DioException err, ErrorInterceptorHandler handler) async { if (err.response?.statusCode != 401 || err.requestOptions.extra['authRetried'] == true) { return handler.next(err); } final result = await _client._refreshTokens(); if (result.accessToken != null) { try { final opts = err.requestOptions; opts.extra['authRetried'] = true; opts.headers['Authorization'] = 'Bearer ${result.accessToken}'; final retryResponse = await _client.dio.fetch(opts); return handler.resolve(retryResponse); } catch (error) { log('[ApiClient] 请求重试失败: $error'); } } else if (result.isInvalid) { final failedRefresh = result.failedRefreshToken; if (failedRefresh != null) { await _client.clearTokensIfRefreshMatches(failedRefresh); } else { await _client.clearTokens(); } _client.notifyAuthExpired(); } handler.next(err); } } class _TokenRefreshResult { final String? accessToken; final bool isInvalid; final String? failedRefreshToken; const _TokenRefreshResult._({ this.accessToken, this.isInvalid = false, this.failedRefreshToken, }); const _TokenRefreshResult.success(String accessToken) : this._(accessToken: accessToken); const _TokenRefreshResult.invalid({String? refreshToken}) : this._(isInvalid: true, failedRefreshToken: refreshToken); const _TokenRefreshResult.transientFailure() : this._(); }