chore: initialize Guangya Flutter project
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import 'interceptors/auth_interceptor.dart';
|
||||
import 'interceptors/response_interceptor.dart';
|
||||
|
||||
class DioClient {
|
||||
static late Dio dio;
|
||||
static late Dio accountDio;
|
||||
static AuthInterceptor? _authInterceptor;
|
||||
|
||||
/// 初始化 DIO 客户端
|
||||
static void init({OnLogout? onLogout}) {
|
||||
_authInterceptor = AuthInterceptor(onLogout: onLogout);
|
||||
|
||||
dio = _createDio(
|
||||
baseUrl: AppConfig.apiBase,
|
||||
interceptors: [
|
||||
_authInterceptor!,
|
||||
ResponseInterceptor(),
|
||||
],
|
||||
);
|
||||
|
||||
accountDio = _createDio(
|
||||
baseUrl: AppConfig.accountBase,
|
||||
interceptors: [
|
||||
ResponseInterceptor(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 更新 API baseUrl 并同步到 DioClient
|
||||
static void updateBaseUrl(String apiBase, {String? accountBase}) {
|
||||
dio.options.baseUrl = apiBase;
|
||||
if (accountBase != null) {
|
||||
accountDio.options.baseUrl = accountBase;
|
||||
}
|
||||
}
|
||||
|
||||
/// 重新设置 onLogout 回调(例如切换 provider 时)
|
||||
static void setOnLogout(OnLogout? onLogout) {
|
||||
if (_authInterceptor != null) {
|
||||
_authInterceptor = AuthInterceptor(onLogout: onLogout);
|
||||
dio.interceptors.removeWhere((i) => i is AuthInterceptor);
|
||||
dio.interceptors.insert(0, _authInterceptor!);
|
||||
}
|
||||
}
|
||||
|
||||
static Dio _createDio({
|
||||
required String baseUrl,
|
||||
required List<Interceptor> interceptors,
|
||||
}) {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
sendTimeout: const Duration(seconds: 30),
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'https://www.guangyapan.com',
|
||||
'Referer': 'https://www.guangyapan.com/',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Windows 下优化连接池
|
||||
if (Platform.isWindows) {
|
||||
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
|
||||
final client = HttpClient();
|
||||
client.maxConnectionsPerHost = 10;
|
||||
client.idleTimeout = const Duration(seconds: 60);
|
||||
return client;
|
||||
};
|
||||
}
|
||||
|
||||
dio.interceptors.addAll(interceptors);
|
||||
return dio;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../storage/storage_manager.dart';
|
||||
import 'dio_client.dart';
|
||||
import 'http_error.dart';
|
||||
import 'interceptors/response_interceptor.dart';
|
||||
|
||||
/// 统一 HTTP 封装 — 光鸭云盘
|
||||
class Http {
|
||||
/// 通用底层请求方法
|
||||
static Future<T> request<T>(
|
||||
String path, {
|
||||
String method = 'POST',
|
||||
Map<String, dynamic>? queryParameters,
|
||||
dynamic data,
|
||||
Map<String, dynamic>? headers,
|
||||
CancelToken? cancelToken,
|
||||
Options? options,
|
||||
bool useAccountDio = false,
|
||||
bool allowAnyCode = false,
|
||||
}) async {
|
||||
final client = useAccountDio ? DioClient.accountDio : DioClient.dio;
|
||||
|
||||
final mergedOptions = (options ?? Options()).copyWith(
|
||||
method: method,
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final endpoint = useAccountDio ? 'account' : 'api';
|
||||
log('[HTTP:$endpoint] -> $method $path');
|
||||
|
||||
try {
|
||||
final res = await client.request(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
cancelToken: cancelToken,
|
||||
options: mergedOptions,
|
||||
);
|
||||
stopwatch.stop();
|
||||
|
||||
log(
|
||||
'[HTTP:$endpoint] <- $method $path status=${res.statusCode} '
|
||||
'elapsed=${stopwatch.elapsedMilliseconds}ms',
|
||||
);
|
||||
|
||||
return res.data as T;
|
||||
} on DioException catch (e) {
|
||||
stopwatch.stop();
|
||||
final status = e.response?.statusCode;
|
||||
log(
|
||||
'[HTTP:$endpoint] !! $method $path status=${status ?? '-'} '
|
||||
'type=${e.type.name} elapsed=${stopwatch.elapsedMilliseconds}ms',
|
||||
);
|
||||
|
||||
// 将业务错误信息包装为 ApiException
|
||||
final errorMsg = e.error is String ? (e.error as String) : '';
|
||||
if (errorMsg.isNotEmpty) {
|
||||
throw ApiException(status: status, message: errorMsg);
|
||||
}
|
||||
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
stopwatch.stop();
|
||||
log(
|
||||
'[HTTP:$endpoint] !! $method $path elapsed=${stopwatch.elapsedMilliseconds}ms',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 光鸭 API 请求(带 token,code==200 为成功)
|
||||
static Future<Map<String, dynamic>> apiRequest(
|
||||
String path, {
|
||||
String method = 'POST',
|
||||
dynamic body,
|
||||
Map<String, dynamic>? headers,
|
||||
CancelToken? cancelToken,
|
||||
List<int> allowedCodes = const [],
|
||||
}) async {
|
||||
// 过期自动刷新
|
||||
_checkTokenExpiry();
|
||||
|
||||
final mergedHeaders = <String, dynamic>{
|
||||
'traceparent': _generateTraceparent(),
|
||||
...?headers,
|
||||
};
|
||||
|
||||
final data = await request<Map<String, dynamic>>(
|
||||
path,
|
||||
method: method,
|
||||
data: body,
|
||||
headers: mergedHeaders,
|
||||
cancelToken: cancelToken,
|
||||
options: Options(extra: {ResponseInterceptor.skipCodeCheckKey: true}),
|
||||
);
|
||||
|
||||
final code = _businessCode(data['code']);
|
||||
if (code == null ||
|
||||
code == 0 ||
|
||||
code == 200 ||
|
||||
allowedCodes.contains(code)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
throw ApiException(
|
||||
status: code,
|
||||
message: extractHttpMessage(data) ?? '请求失败 ($code)',
|
||||
);
|
||||
}
|
||||
|
||||
/// 光鸭账号请求(account API 响应格式无 code 字段,跳过业务 code 检查)
|
||||
static Future<Map<String, dynamic>> accountRequest(
|
||||
String path, {
|
||||
String method = 'POST',
|
||||
Map<String, dynamic>? body,
|
||||
Map<String, dynamic>? extraHeaders,
|
||||
bool authenticated = false,
|
||||
}) async {
|
||||
final headers = <String, dynamic>{
|
||||
'did': _getDeviceID(),
|
||||
'dt': '4',
|
||||
...?extraHeaders,
|
||||
};
|
||||
|
||||
if (authenticated) {
|
||||
final token = StorageManager.get<String>(StorageKeys.accessToken);
|
||||
if (token != null && token.isNotEmpty) {
|
||||
headers['authorization'] = 'Bearer $token';
|
||||
}
|
||||
}
|
||||
|
||||
final data = await request<Map<String, dynamic>>(
|
||||
path,
|
||||
method: method,
|
||||
data: body,
|
||||
headers: headers,
|
||||
useAccountDio: true,
|
||||
options: Options(extra: {ResponseInterceptor.skipCodeCheckKey: true}),
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/// 公开请求(无 token,用于分享预览等)
|
||||
static Future<Map<String, dynamic>> publicRequest(
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
}) async {
|
||||
return request<Map<String, dynamic>>(
|
||||
path,
|
||||
method: 'POST',
|
||||
data: body,
|
||||
headers: {'traceparent': _generateTraceparent()},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Token 过期检测 ─────────────────────────────────────────────
|
||||
|
||||
static void _checkTokenExpiry() {
|
||||
final expiresAt = StorageManager.get<int>(StorageKeys.tokenExpiresAt);
|
||||
if (expiresAt == null) return;
|
||||
if (DateTime.now().millisecondsSinceEpoch > expiresAt) {
|
||||
log('[HTTP] token expired, will refresh on next 401');
|
||||
}
|
||||
}
|
||||
|
||||
static String _getDeviceID() {
|
||||
return StorageManager.get<String>(StorageKeys.deviceID) ??
|
||||
'flutter-${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
static String _generateTraceparent() {
|
||||
final now = DateTime.now().microsecondsSinceEpoch;
|
||||
return '00-${now.toRadixString(16).padLeft(32, '0')}-${now.toRadixString(16).padLeft(16, '0')}-01';
|
||||
}
|
||||
|
||||
static int? _businessCode(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
return int.tryParse(value.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
const String noToastHeader = 'NoToast';
|
||||
const String suppressErrorToastExtra = 'suppressErrorToast';
|
||||
|
||||
bool suppressErrorToast(RequestOptions options) {
|
||||
return options.extra[suppressErrorToastExtra] == true;
|
||||
}
|
||||
|
||||
void applyNoToastHeader(RequestOptions options) {
|
||||
String? matchedKey;
|
||||
Object? matchedValue;
|
||||
for (final entry in options.headers.entries) {
|
||||
if (entry.key.toLowerCase() == noToastHeader.toLowerCase()) {
|
||||
matchedKey = entry.key;
|
||||
matchedValue = entry.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedKey == null) return;
|
||||
options.headers.remove(matchedKey);
|
||||
if (_isNoToastValue(matchedValue)) {
|
||||
options.extra[suppressErrorToastExtra] = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isNoToastValue(Object? value) {
|
||||
if (value == null) return true;
|
||||
final text = value.toString().trim().toLowerCase();
|
||||
return text.isEmpty || text == '1' || text == 'true' || text == 'yes';
|
||||
}
|
||||
|
||||
/// Extracts a human-readable message from various response shapes
|
||||
String? extractHttpMessage(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is String) return value.trim().isEmpty ? null : value.trim();
|
||||
if (value is Map) {
|
||||
for (final key in const ['message', 'msg', 'info', 'detail', 'error']) {
|
||||
final message = extractHttpMessage(value[key]);
|
||||
if (message != null) return message;
|
||||
}
|
||||
return extractHttpMessage(value['data']);
|
||||
}
|
||||
if (value is Iterable) {
|
||||
final messages = value
|
||||
.map(extractHttpMessage)
|
||||
.whereType<String>()
|
||||
.where((message) => message.trim().isNotEmpty)
|
||||
.toList();
|
||||
return messages.isEmpty ? null : messages.join('\n');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Build a user-friendly toast message from request context + detail
|
||||
String requestToastMessage(RequestOptions options, String detail) {
|
||||
final endpoint = _describePath(options.path);
|
||||
final trimmed = detail.trim();
|
||||
if (trimmed.isEmpty) return endpoint;
|
||||
return '$endpoint:$trimmed';
|
||||
}
|
||||
|
||||
String _describePath(String path) {
|
||||
if (path.contains('/auth')) return '认证接口';
|
||||
if (path.contains('/token')) return '令牌接口';
|
||||
if (path.contains('/file')) return '文件接口';
|
||||
if (path.contains('/share')) return '分享接口';
|
||||
if (path.contains('/res')) return '资源接口';
|
||||
if (path.contains('/task') || path.contains('/cloud')) return '云下载接口';
|
||||
return '接口请求';
|
||||
}
|
||||
|
||||
/// Custom exception for API errors
|
||||
class ApiException implements Exception {
|
||||
final int? status;
|
||||
final String message;
|
||||
|
||||
ApiException({this.status, required this.message});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class AuthTokens {
|
||||
final String accessToken;
|
||||
final String? refreshToken;
|
||||
final double? expiresIn;
|
||||
|
||||
AuthTokens({
|
||||
required this.accessToken,
|
||||
this.refreshToken,
|
||||
this.expiresIn,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
import '../../storage/storage_manager.dart';
|
||||
import '../http_error.dart';
|
||||
import '../dio_client.dart';
|
||||
|
||||
/// 光鸭 OAuth token 刷新回调
|
||||
typedef OnLogout = Future<void> Function();
|
||||
|
||||
class AuthInterceptor extends Interceptor {
|
||||
final OnLogout? onLogout;
|
||||
bool _isRefreshing = false;
|
||||
bool _logoutScheduled = false;
|
||||
final List<Completer<void>> _waitQueue = [];
|
||||
|
||||
AuthInterceptor({this.onLogout});
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
applyNoToastHeader(options);
|
||||
|
||||
// 免认证路径:登录、刷新等
|
||||
if (_isAuthExemptPath(options.path)) {
|
||||
return handler.next(options);
|
||||
}
|
||||
|
||||
final token = StorageManager.get<String>(StorageKeys.accessToken);
|
||||
if (token != null && token.isNotEmpty) {
|
||||
_logoutScheduled = false;
|
||||
options.headers['authorization'] = 'Bearer $token';
|
||||
return handler.next(options);
|
||||
}
|
||||
|
||||
// 无 token → 静默取消
|
||||
return handler.reject(_silentCancel(options));
|
||||
}
|
||||
|
||||
bool _isAuthExemptPath(String path) {
|
||||
return path.contains('/auth/signin') ||
|
||||
path.contains('/auth/verification') ||
|
||||
path.contains('/auth/device/code') ||
|
||||
path.contains('/auth/token');
|
||||
}
|
||||
|
||||
bool _isSilentCancel(DioException err) {
|
||||
return err.type == DioExceptionType.cancel &&
|
||||
err.error?.toString() == 'silent_auth_cancel';
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (_isSilentCancel(err)) {
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
final status = err.response?.statusCode;
|
||||
final alreadyLoggedOut = status == 401 && _isAlreadyLoggedOut;
|
||||
|
||||
if (!alreadyLoggedOut) {
|
||||
log('[Auth] error path=${err.requestOptions.path} status=$status');
|
||||
}
|
||||
|
||||
// 免认证路径的错误直接放行
|
||||
if (_isAuthExemptPath(err.requestOptions.path)) {
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
// 网络错误
|
||||
if (err.type == DioExceptionType.connectionError ||
|
||||
err.type == DioExceptionType.connectionTimeout ||
|
||||
err.type == DioExceptionType.sendTimeout ||
|
||||
err.type == DioExceptionType.receiveTimeout) {
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
// 非 401 → 直接传递
|
||||
if (status != 401) {
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
// 以下全部是 401 处理
|
||||
if (alreadyLoggedOut) {
|
||||
return handler.reject(_silentCancel(err.requestOptions));
|
||||
}
|
||||
|
||||
// 刷新接口本身 401 → refresh token 也失效了
|
||||
if (err.requestOptions.path.contains('/auth/token') &&
|
||||
err.requestOptions.method == 'POST') {
|
||||
await _logout();
|
||||
return handler.reject(_silentCancel(err.requestOptions));
|
||||
}
|
||||
|
||||
// 获取 refreshToken
|
||||
final refreshToken = StorageManager.get<String>(StorageKeys.refreshToken);
|
||||
|
||||
// 没有 refreshToken → 静默登出
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
await _logout();
|
||||
return handler.reject(_silentCancel(err.requestOptions));
|
||||
}
|
||||
|
||||
// 已经在刷新中 → 排队等待
|
||||
if (_isRefreshing) {
|
||||
final completer = Completer<void>();
|
||||
_waitQueue.add(completer);
|
||||
await completer.future;
|
||||
|
||||
final newToken = StorageManager.get<String>(StorageKeys.accessToken);
|
||||
if (newToken == null || newToken.isEmpty) {
|
||||
return handler.reject(_silentCancel(err.requestOptions));
|
||||
}
|
||||
|
||||
final request = err.requestOptions;
|
||||
request.headers['authorization'] = 'Bearer $newToken';
|
||||
|
||||
try {
|
||||
final response = await DioClient.dio.fetch(request);
|
||||
return handler.resolve(response);
|
||||
} catch (e) {
|
||||
return handler.reject(_silentCancel(err.requestOptions));
|
||||
}
|
||||
}
|
||||
|
||||
// 开始刷新
|
||||
_isRefreshing = true;
|
||||
log('[Auth] refreshing access token');
|
||||
|
||||
try {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.accountBase,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
),
|
||||
);
|
||||
|
||||
final res = await dio.post(
|
||||
'/v1/auth/token',
|
||||
data: {
|
||||
'client_id': AppConfig.clientID,
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refreshToken,
|
||||
},
|
||||
options: Options(
|
||||
headers: {
|
||||
'Accept': 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'https://www.guangyapan.com',
|
||||
'Referer': 'https://www.guangyapan.com/',
|
||||
'did': StorageManager.get<String>(StorageKeys.deviceID) ?? '',
|
||||
'dt': '4',
|
||||
'x-action': '401',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
log('[Auth] access token refreshed');
|
||||
|
||||
final data = res.data;
|
||||
final newAccess = _findStringDeep(data, ['access_token', 'accessToken']);
|
||||
final newRefresh = _findStringDeep(data, [
|
||||
'refresh_token',
|
||||
'refreshToken',
|
||||
]);
|
||||
|
||||
if (newAccess == null) {
|
||||
throw Exception('刷新令牌返回数据无效');
|
||||
}
|
||||
|
||||
// 保存新 token
|
||||
await StorageManager.set(StorageKeys.accessToken, newAccess);
|
||||
if (newRefresh != null) {
|
||||
await StorageManager.set(StorageKeys.refreshToken, newRefresh);
|
||||
}
|
||||
|
||||
// 保存过期时间
|
||||
final expiresIn = data is Map ? data['expires_in'] : null;
|
||||
if (expiresIn != null) {
|
||||
final expiresAt =
|
||||
DateTime.now().millisecondsSinceEpoch +
|
||||
(expiresIn is int
|
||||
? expiresIn
|
||||
: int.tryParse(expiresIn.toString()) ?? 3600) *
|
||||
1000;
|
||||
await StorageManager.set(StorageKeys.tokenExpiresAt, expiresAt);
|
||||
}
|
||||
|
||||
// 唤醒排队的请求
|
||||
for (var c in _waitQueue) {
|
||||
c.complete();
|
||||
}
|
||||
_waitQueue.clear();
|
||||
|
||||
// 用新 token 重试当前请求
|
||||
final request = err.requestOptions;
|
||||
request.headers['authorization'] = 'Bearer $newAccess';
|
||||
|
||||
final response = await DioClient.dio.fetch(request);
|
||||
return handler.resolve(response);
|
||||
} catch (e) {
|
||||
log('[Auth] token refresh failed: $e');
|
||||
|
||||
for (var c in _waitQueue) {
|
||||
c.complete();
|
||||
}
|
||||
_waitQueue.clear();
|
||||
|
||||
await _logout();
|
||||
return handler.reject(_silentCancel(err.requestOptions));
|
||||
} finally {
|
||||
_isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
DioException _silentCancel(RequestOptions options) {
|
||||
return DioException(
|
||||
requestOptions: options,
|
||||
type: DioExceptionType.cancel,
|
||||
error: 'silent_auth_cancel',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _logout() async {
|
||||
if (_logoutScheduled) return;
|
||||
_logoutScheduled = true;
|
||||
|
||||
log('[Auth] logout scheduled');
|
||||
await StorageManager.delete(StorageKeys.accessToken);
|
||||
await StorageManager.delete(StorageKeys.refreshToken);
|
||||
await StorageManager.delete(StorageKeys.tokenExpiresAt);
|
||||
|
||||
final logout = onLogout;
|
||||
if (logout != null) {
|
||||
await logout();
|
||||
}
|
||||
_logoutScheduled = false;
|
||||
}
|
||||
|
||||
bool get _isAlreadyLoggedOut {
|
||||
return StorageManager.get<String>(StorageKeys.accessToken) == null &&
|
||||
StorageManager.get<String>(StorageKeys.refreshToken) == null;
|
||||
}
|
||||
|
||||
static String? _findStringDeep(dynamic value, List<String> keys) {
|
||||
if (value is Map) {
|
||||
for (final key in keys) {
|
||||
final v = value[key];
|
||||
if (v != null && v.toString().isNotEmpty) return v.toString();
|
||||
}
|
||||
for (final entry in value.entries) {
|
||||
if (entry.value is Map) {
|
||||
final found = _findStringDeep(entry.value, keys);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../http_error.dart';
|
||||
|
||||
class ResponseInterceptor extends Interceptor {
|
||||
/// Extra key: true = account API (skip business code check)
|
||||
static const String skipCodeCheckKey = 'skipBusinessCodeCheck';
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
final data = response.data;
|
||||
|
||||
// 流式响应直接放行
|
||||
if (data is ResponseBody) {
|
||||
return handler.next(response);
|
||||
}
|
||||
|
||||
// 尝试解析 JSON
|
||||
if (data is String) {
|
||||
try {
|
||||
response.data = jsonDecode(data);
|
||||
} catch (_) {
|
||||
// non-JSON response, pass through
|
||||
}
|
||||
}
|
||||
|
||||
final body = response.data;
|
||||
|
||||
if (body is! Map) {
|
||||
return handler.next(response);
|
||||
}
|
||||
|
||||
// Account API 跳过业务 code 检查(响应格式是 {data: {...}},没有 code 字段)
|
||||
final skipCodeCheck =
|
||||
response.requestOptions.extra[skipCodeCheckKey] == true ||
|
||||
response.requestOptions.headers['x-skip-code-check'] == true;
|
||||
|
||||
if (skipCodeCheck) {
|
||||
return handler.next(response);
|
||||
}
|
||||
|
||||
// 业务 API:检查 code 字段
|
||||
final code = _businessCode(body['code']);
|
||||
final message = extractHttpMessage(body);
|
||||
|
||||
// code == 0/200 视为成功。不同网关返回的业务成功码不完全一致。
|
||||
if (code == null || code == 0 || code == 200) {
|
||||
if (message != null && message.isNotEmpty) {
|
||||
response.extra['success_message'] = message;
|
||||
}
|
||||
return handler.next(response);
|
||||
}
|
||||
|
||||
// 其他 code 视为业务错误
|
||||
final errorMsg = message ?? _defaultErrorMessage(code);
|
||||
return handler.reject(
|
||||
DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
type: DioExceptionType.badResponse,
|
||||
error: errorMsg,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (err.type == DioExceptionType.cancel) {
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
// 网络错误
|
||||
if (err.type == DioExceptionType.connectionError ||
|
||||
err.type == DioExceptionType.connectionTimeout ||
|
||||
err.type == DioExceptionType.sendTimeout ||
|
||||
err.type == DioExceptionType.receiveTimeout) {
|
||||
if (!suppressErrorToast(err.requestOptions)) {
|
||||
log(
|
||||
'[HTTP] ${requestToastMessage(err.requestOptions, '请求超时或连接失败,请检查网络')}',
|
||||
);
|
||||
}
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
if (!suppressErrorToast(err.requestOptions)) {
|
||||
final msg = err.response?.data is Map
|
||||
? extractHttpMessage(err.response?.data) ?? '请求失败'
|
||||
: '请求失败';
|
||||
log('[HTTP] ${requestToastMessage(err.requestOptions, msg)}');
|
||||
}
|
||||
|
||||
return handler.next(err);
|
||||
}
|
||||
|
||||
static String? _defaultErrorMessage(dynamic code) {
|
||||
if (code == 400) return '请求参数错误';
|
||||
if (code == 401) return '未授权,请重新登录';
|
||||
if (code == 403) return '没有权限执行此操作';
|
||||
if (code == 404) return '接口不存在';
|
||||
if (code == 500) return '服务器内部错误';
|
||||
return '请求失败 ($code)';
|
||||
}
|
||||
|
||||
static int? _businessCode(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
return int.tryParse(value.toString());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user