561 lines
15 KiB
Dart
561 lines
15 KiB
Dart
import 'dart:io';
|
||
import 'package:dio/dio.dart';
|
||
import 'package:novyronst/common/api/api_string.dart';
|
||
import 'package:novyronst/common/api/ny_network_intercept.dart';
|
||
import 'package:novyronst/common/help/help_token.dart';
|
||
import 'package:novyronst/tools/widgets/app_toast_util.dart';
|
||
|
||
|
||
/// HTTP 请求工具类(单例)
|
||
class AppService {
|
||
static AppService? _instance;
|
||
late Dio _dio;
|
||
|
||
static AppService get instance {
|
||
_instance ??= AppService._internal();
|
||
return _instance!;
|
||
}
|
||
|
||
AppService._internal() {
|
||
_initDio();
|
||
}
|
||
|
||
Dio get dio => _dio;
|
||
|
||
/// 初始化 Dio 配置
|
||
void _initDio() {
|
||
_dio = Dio(BaseOptions(
|
||
baseUrl: ApiString.baseUrl,
|
||
connectTimeout: const Duration(seconds: 15),
|
||
receiveTimeout: const Duration(seconds: 15),
|
||
sendTimeout: const Duration(seconds: 15),
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json',
|
||
},
|
||
responseType: ResponseType.json,
|
||
));
|
||
|
||
// 添加自定义拦截器
|
||
_dio.interceptors.add(NyNetworkIntercept());
|
||
|
||
}
|
||
|
||
/// 更新配置
|
||
void updateBaseOptions(BaseOptions options) {
|
||
_dio.options = options;
|
||
}
|
||
}
|
||
|
||
/// ============ 请求方法封装 ============
|
||
|
||
class ApiRequest {
|
||
static final Dio _dio = AppService.instance.dio;
|
||
|
||
/// GET 请求(带重试)
|
||
static Future<HttpResult<T>> get<T>(
|
||
String path, {
|
||
Map<String, dynamic>? queryParameters,
|
||
Options? options,
|
||
CancelToken? cancelToken,
|
||
int? retry,
|
||
}) async {
|
||
try {
|
||
final response = await _executeRequest(
|
||
() => _dio.get(
|
||
path,
|
||
queryParameters: queryParameters,
|
||
options: options,
|
||
cancelToken: cancelToken,
|
||
),
|
||
);
|
||
return HttpResult<T>.fromResponse(response);
|
||
} on DioException catch (e) {
|
||
return _handleError<T>(e);
|
||
} catch (e) {
|
||
return HttpResult<T>.error(e.toString());
|
||
}
|
||
}
|
||
|
||
/// POST 请求(带重试)
|
||
static Future<HttpResult<T>> post<T>(
|
||
String path, {
|
||
dynamic data,
|
||
Map<String, dynamic>? queryParameters,
|
||
Options? options,
|
||
CancelToken? cancelToken,
|
||
int? retry,
|
||
}) async {
|
||
try {
|
||
final response = await _executeRequest(
|
||
() => _dio.post(
|
||
path,
|
||
data: data,
|
||
queryParameters: queryParameters,
|
||
options: options,
|
||
cancelToken: cancelToken,
|
||
),
|
||
);
|
||
return HttpResult<T>.fromResponse(response);
|
||
} on DioException catch (e) {
|
||
return _handleError<T>(e);
|
||
} catch (e) {
|
||
return HttpResult<T>.error(e.toString());
|
||
}
|
||
}
|
||
|
||
|
||
/// 文件上传(带重试)
|
||
static Future<HttpResult<T>> fileUpload<T>(
|
||
String path, {
|
||
required File file,
|
||
String fieldName = 'file',
|
||
Map<String, dynamic>? queryParameters,
|
||
Options? options,
|
||
CancelToken? cancelToken,
|
||
ProgressCallback? onSendProgress,
|
||
int? retry,
|
||
}) async {
|
||
try {
|
||
final formData = FormData.fromMap({
|
||
fieldName: await MultipartFile.fromFile(file.path),
|
||
});
|
||
|
||
final response = await _executeRequest(
|
||
() => _dio.post(
|
||
path,
|
||
data: formData,
|
||
queryParameters: queryParameters,
|
||
options: options ?? Options(contentType: 'multipart/form-data'),
|
||
cancelToken: cancelToken,
|
||
onSendProgress: onSendProgress,
|
||
),
|
||
);
|
||
return HttpResult<T>.fromResponse(response);
|
||
} on DioException catch (e) {
|
||
return _handleError<T>(e);
|
||
} catch (e) {
|
||
return HttpResult<T>.error(e.toString());
|
||
}
|
||
}
|
||
|
||
/// ============ 分页请求(原项目的 fetchPage) ============
|
||
static Future<List<T>> fetchPage<T>(
|
||
String url, {
|
||
int? page,
|
||
int? pageSize,
|
||
Map<String, dynamic>? extraParams,
|
||
required T Function(dynamic json) fromJson,
|
||
bool error = true,
|
||
String method = 'get',
|
||
int? retry,
|
||
}) async {
|
||
try {
|
||
final params = {
|
||
'current_page': page?.toString() ?? '1',
|
||
'page_size': pageSize?.toString() ?? '20',
|
||
...?extraParams,
|
||
};
|
||
|
||
Response response;
|
||
if (method.toLowerCase() == 'get') {
|
||
response = await _executeRequest(
|
||
() => _dio.get(url, queryParameters: params),
|
||
);
|
||
} else {
|
||
response = await _executeRequest(
|
||
() => _dio.post(url, data: params),
|
||
);
|
||
}
|
||
|
||
if (response.statusCode != 200) {
|
||
throw Exception("HTTP ${response.statusCode}");
|
||
}
|
||
|
||
final data = response.data;
|
||
if (data is Map<String, dynamic>) {
|
||
final base = BasePageResponse.fromJson(data);
|
||
if (!base.success) {
|
||
if (error) {
|
||
// 这里可以触发 Toast
|
||
AppToastUtil.showError(base.msg);
|
||
}
|
||
throw Exception(base.msg);
|
||
}
|
||
|
||
final result = base.data;
|
||
if (result is List) {
|
||
return result.map<T>((e) => fromJson(e)).toList();
|
||
} else if (result is Map && result['list'] is List) {
|
||
return (result['list'] as List).map<T>((e) => fromJson(e)).toList();
|
||
} else {
|
||
throw Exception("data error");
|
||
}
|
||
} else {
|
||
throw Exception("format error");
|
||
}
|
||
} catch (e) {
|
||
// _handleError();
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// 直接执行请求,不重试
|
||
static Future<Response> _executeRequest(
|
||
Future<Response> Function() requestFn,
|
||
) async {
|
||
try {
|
||
final response = await requestFn();
|
||
|
||
// 如果状态码不是 200,直接抛出异常
|
||
if (response.statusCode != 200) {
|
||
throw DioException(
|
||
requestOptions: response.requestOptions,
|
||
response: response,
|
||
type: DioExceptionType.badResponse,
|
||
error: 'HTTP ${response.statusCode}',
|
||
);
|
||
}
|
||
|
||
return response;
|
||
} catch (e) {
|
||
// 直接重新抛出异常,不重试
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
// ============ 重试机制 ============
|
||
static Future<Response> _retryRequest(
|
||
Future<Response> Function() requestFn, {
|
||
int? retries,
|
||
}) async {
|
||
int attempt = 0;
|
||
final maxRetries = retries ?? 2;
|
||
|
||
while (attempt <= maxRetries) {
|
||
try {
|
||
final response = await requestFn();
|
||
if (response.statusCode == 200) {
|
||
return response;
|
||
}
|
||
// 401/402 特殊处理,由拦截器处理
|
||
if (response.statusCode == 401 || response.statusCode == 402) {
|
||
if (attempt == maxRetries) {
|
||
throw DioException(
|
||
requestOptions: response.requestOptions,
|
||
response: response,
|
||
type: DioExceptionType.badResponse,
|
||
error: 'errorCode:${response.statusCode}',
|
||
);
|
||
}
|
||
}
|
||
if (attempt == maxRetries) {
|
||
throw DioException(
|
||
requestOptions: response.requestOptions,
|
||
response: response,
|
||
type: DioExceptionType.badResponse,
|
||
error: 'HTTP ${response.statusCode}',
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (attempt == maxRetries) {
|
||
rethrow;
|
||
}
|
||
}
|
||
attempt++;
|
||
await Future.delayed(const Duration(milliseconds: 300));
|
||
}
|
||
throw Exception("retry failed (unreachable)");
|
||
}
|
||
|
||
|
||
static HttpResult<T> _handleError<T>(DioException e) {
|
||
final Response? resp = e.response;
|
||
|
||
// 尝试从响应中解析错误信息
|
||
if (resp != null && resp.data != null) {
|
||
try {
|
||
final parsed = HttpResult<T>.fromResponse(resp);
|
||
if (!parsed.success) {
|
||
// 401/402 错误,触发重新登录
|
||
if (resp.statusCode == 401 || resp.statusCode == 402) {
|
||
HelpToken().update();
|
||
if (resp.statusCode == 402) {
|
||
AppToastUtil.showError('账号已在其他设备登录,请重新登录');
|
||
}
|
||
}
|
||
return parsed;
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
String message = _getErrorMessage(e);
|
||
return HttpResult<T>.error(message, httpStatus: resp?.statusCode);
|
||
}
|
||
|
||
|
||
static String _getErrorMessage(DioException e) {
|
||
switch (e.type) {
|
||
case DioExceptionType.connectionTimeout:
|
||
return '连接超时,请稍后重试';
|
||
case DioExceptionType.sendTimeout:
|
||
return '发送超时,请稍后重试';
|
||
case DioExceptionType.receiveTimeout:
|
||
return '接收超时,请稍后重试';
|
||
case DioExceptionType.badResponse:
|
||
final statusCode = e.response?.statusCode;
|
||
if (statusCode == 401) {
|
||
HelpToken().update();
|
||
return '未授权,请重新登录';
|
||
}
|
||
if (statusCode == 402) {
|
||
HelpToken().update();
|
||
return '账号已在其他设备登录,请重新登录';
|
||
}
|
||
return _handleStatusCode(statusCode);
|
||
case DioExceptionType.cancel:
|
||
return '请求已取消';
|
||
case DioExceptionType.connectionError:
|
||
return '网络连接失败,请检查网络设置';
|
||
default:
|
||
return '网络错误,请稍后重试';
|
||
}
|
||
}
|
||
|
||
static String _handleStatusCode(int? statusCode) {
|
||
switch (statusCode) {
|
||
case 400:
|
||
return '请求错误';
|
||
case 403:
|
||
return '拒绝访问';
|
||
case 404:
|
||
return '请求资源不存在';
|
||
case 500:
|
||
return '服务器内部错误';
|
||
case 502:
|
||
return '网关错误';
|
||
case 503:
|
||
return '服务不可用';
|
||
default:
|
||
return '请求失败';
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/// 统一响应:业务成功见 [isSuccess]
|
||
class HttpResult<T> {
|
||
final int? status;
|
||
final bool success;
|
||
final String? message;
|
||
final T? data;
|
||
final int? errorCode;
|
||
|
||
const HttpResult({
|
||
this.status,
|
||
this.success = false,
|
||
this.message,
|
||
this.data,
|
||
this.errorCode,
|
||
});
|
||
|
||
bool get isSuccess => success;
|
||
|
||
factory HttpResult.fromResponse(Response response) {
|
||
try {
|
||
final int httpStatus = response.statusCode ?? 0;
|
||
final dynamic raw = response.data;
|
||
final Map<String, dynamic>? root = _jsonMap(raw);
|
||
|
||
// 明确失败包:{ "success": false, "error": { ... } }
|
||
if (root != null && root['success'] == false) {
|
||
final payload = ApiErrorPayload.tryParse(root);
|
||
return HttpResult<T>(
|
||
status: httpStatus,
|
||
success: false,
|
||
message: payload?.message ?? root['message']?.toString() ?? '请求失败',
|
||
errorCode: payload?.code,
|
||
data: null,
|
||
);
|
||
}
|
||
|
||
// HTTP 2xx:body 即为业务数据
|
||
if (httpStatus >= 200 && httpStatus < 300) {
|
||
return HttpResult<T>(
|
||
status: httpStatus,
|
||
success: true,
|
||
message: null,
|
||
data: raw as T?,
|
||
errorCode: null,
|
||
);
|
||
}
|
||
|
||
// 非 2xx:尽量从 body 里拆 error
|
||
if (root != null) {
|
||
final payload = ApiErrorPayload.tryParse(root);
|
||
if (payload != null) {
|
||
return HttpResult<T>(
|
||
status: httpStatus,
|
||
success: false,
|
||
message: payload.message,
|
||
errorCode: payload.code,
|
||
data: null,
|
||
);
|
||
}
|
||
}
|
||
|
||
return HttpResult<T>(
|
||
status: httpStatus,
|
||
success: false,
|
||
message: _handleStatusCode(httpStatus),
|
||
data: null,
|
||
);
|
||
} catch (e) {
|
||
return HttpResult<T>(
|
||
status: response.statusCode,
|
||
success: false,
|
||
message: '数据解析失败: $e',
|
||
data: null,
|
||
);
|
||
}
|
||
}
|
||
|
||
factory HttpResult.error(String message, {int? httpStatus}) {
|
||
return HttpResult<T>(
|
||
success: false,
|
||
status: httpStatus,
|
||
message: message,
|
||
);
|
||
}
|
||
|
||
factory HttpResult.networkError() {
|
||
return HttpResult<T>(
|
||
success: false,
|
||
status: -1,
|
||
message: '网络连接失败,请检查网络后重试',
|
||
);
|
||
}
|
||
|
||
// ============ 私有方法 ============
|
||
static Map<String, dynamic>? _jsonMap(dynamic value) {
|
||
if (value is Map<String, dynamic>) return value;
|
||
if (value is Map) {
|
||
try {
|
||
return value.cast<String, dynamic>();
|
||
} catch (_) {
|
||
return Map<String, dynamic>.from(value as Map);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static String _handleStatusCode(int? statusCode) {
|
||
switch (statusCode) {
|
||
case 400:
|
||
return '请求错误';
|
||
case 401:
|
||
return '未授权,请重新登录';
|
||
case 403:
|
||
return '拒绝访问';
|
||
case 404:
|
||
return '请求资源不存在';
|
||
case 500:
|
||
return '服务器内部错误';
|
||
case 502:
|
||
return '网关错误';
|
||
case 503:
|
||
return '服务不可用';
|
||
default:
|
||
return '请求失败';
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 后台错误体:`{ "error": { "code", "details", "message" }, "success": false }`
|
||
class ApiErrorPayload {
|
||
final int? code;
|
||
final dynamic details;
|
||
final String? message;
|
||
|
||
const ApiErrorPayload({this.code, this.details, this.message});
|
||
|
||
static ApiErrorPayload? tryParse(Map<String, dynamic>? root) {
|
||
if (root == null) return null;
|
||
final err = root['error'];
|
||
if (err is! Map) return null;
|
||
final m = _jsonMap(err);
|
||
if (m == null) return null;
|
||
return ApiErrorPayload(
|
||
code: _parseInt(m['code']),
|
||
details: m['details'],
|
||
message: m['message']?.toString(),
|
||
);
|
||
}
|
||
|
||
static Map<String, dynamic>? _jsonMap(dynamic value) {
|
||
if (value is Map<String, dynamic>) return value;
|
||
if (value is Map) {
|
||
try {
|
||
return value.cast<String, dynamic>();
|
||
} catch (_) {
|
||
return Map<String, dynamic>.from(value as Map);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
static int? _parseInt(dynamic v) {
|
||
if (v == null) return null;
|
||
if (v is int) return v;
|
||
if (v is num) return v.toInt();
|
||
return int.tryParse(v.toString());
|
||
}
|
||
}
|
||
|
||
/// 分页响应模型(原项目的 GlimzoGetxBaseResponse 改造)
|
||
class BasePageResponse<T> {
|
||
final int code;
|
||
final String msg;
|
||
final T? data;
|
||
|
||
BasePageResponse({
|
||
required this.code,
|
||
required this.msg,
|
||
this.data,
|
||
});
|
||
|
||
bool get success => code == 200;
|
||
|
||
factory BasePageResponse.fromJson(Map<String, dynamic> json) {
|
||
return BasePageResponse(
|
||
code: json['code'] ?? -1,
|
||
msg: json['msg'] ?? '',
|
||
data: json['data'],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Map 数据提取工具(原项目的 GlimzoMapRes 改造)
|
||
class MapRes {
|
||
static List<T> getList<T>({
|
||
required Map<dynamic, dynamic> mapData,
|
||
required String alias,
|
||
required T Function(Map<String, dynamic>) fromJson,
|
||
}) {
|
||
final rawList = mapData[alias];
|
||
if (rawList is List) {
|
||
return rawList
|
||
.whereType<Map<String, dynamic>>()
|
||
.map(fromJson)
|
||
.toList();
|
||
}
|
||
return <T>[];
|
||
}
|
||
|
||
static T toModel<T>({
|
||
required Map<String, dynamic> json,
|
||
required T Function(Map<String, dynamic>) fromJson,
|
||
}) {
|
||
return fromJson(json);
|
||
}
|
||
} |