453 lines
14 KiB
Dart
453 lines
14 KiB
Dart
// api.dart
|
||
import 'dart:convert';
|
||
import 'package:dio/dio.dart';
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:yumi/app/constants/sc_global_config.dart';
|
||
import 'package:yumi/ui_kit/components/sc_tts.dart';
|
||
import 'package:yumi/shared/tools/sc_room_utils.dart';
|
||
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
|
||
import 'package:yumi/modules/auth/login_route.dart';
|
||
import 'package:yumi/app/routes/sc_fluro_navigator.dart';
|
||
import 'package:yumi/shared/tools/sc_loading_manager.dart';
|
||
import 'package:yumi/main.dart';
|
||
import 'package:yumi/shared/data_sources/sources/remote/net/network_client.dart';
|
||
|
||
import '../../../models/enum/sc_erro_code.dart';
|
||
|
||
export 'package:dio/dio.dart';
|
||
|
||
_parseAndDecode(String response) => jsonDecode(response);
|
||
|
||
parseJson(String text) => compute(_parseAndDecode, text);
|
||
|
||
String _normalizeAuthorizationHeader(Object? authorization) {
|
||
if (authorization == null) {
|
||
return "";
|
||
}
|
||
if (authorization is Iterable) {
|
||
return authorization.map((value) => value.toString()).join(",");
|
||
}
|
||
return authorization.toString().trim();
|
||
}
|
||
|
||
bool _shouldLogoutForUnauthorized(DioException e) {
|
||
final currentToken = AccountStorage().getToken();
|
||
if (currentToken.isEmpty) {
|
||
return false;
|
||
}
|
||
final requestAuthorization = _normalizeAuthorizationHeader(
|
||
e.requestOptions.headers["Authorization"],
|
||
);
|
||
if (requestAuthorization.isEmpty) {
|
||
return false;
|
||
}
|
||
return requestAuthorization == "Bearer $currentToken";
|
||
}
|
||
|
||
Future<bool> _isCurrentSessionStillValid(DioException e) async {
|
||
if (!_shouldLogoutForUnauthorized(e)) {
|
||
return true;
|
||
}
|
||
|
||
final verificationHeaders =
|
||
Map<String, dynamic>.from(e.requestOptions.headers)
|
||
..remove("Content-Length")
|
||
..remove("content-length");
|
||
|
||
final verificationClient = Dio(
|
||
BaseOptions(
|
||
baseUrl:
|
||
e.requestOptions.baseUrl.isNotEmpty
|
||
? e.requestOptions.baseUrl
|
||
: SCGlobalConfig.apiHost,
|
||
connectTimeout: const Duration(seconds: 5),
|
||
receiveTimeout: const Duration(seconds: 5),
|
||
sendTimeout: const Duration(seconds: 5),
|
||
responseType: ResponseType.json,
|
||
validateStatus: (status) => status != null && status < 500,
|
||
),
|
||
);
|
||
|
||
try {
|
||
final verificationResponse = await verificationClient.get(
|
||
"/app/h5/identity",
|
||
options: Options(headers: verificationHeaders),
|
||
);
|
||
final responseData = verificationResponse.data;
|
||
|
||
if (verificationResponse.statusCode == 401) {
|
||
return false;
|
||
}
|
||
|
||
if (responseData is Map<String, dynamic>) {
|
||
if (responseData["status"] == true) {
|
||
return true;
|
||
}
|
||
if (responseData["errorCode"] == SCErroCode.authUnauthorized.code) {
|
||
return false;
|
||
}
|
||
}
|
||
} on DioException catch (verificationError) {
|
||
final responseData = verificationError.response?.data;
|
||
if (verificationError.response?.statusCode == 401) {
|
||
return false;
|
||
}
|
||
if (responseData is Map<String, dynamic> &&
|
||
responseData["errorCode"] == SCErroCode.authUnauthorized.code) {
|
||
return false;
|
||
}
|
||
} catch (_) {}
|
||
|
||
// 校验结果不明确时不要主动登出,避免单条接口抖动把整次登录踢掉。
|
||
return true;
|
||
}
|
||
|
||
class BaseNetworkClient {
|
||
final Dio dio;
|
||
static const String silentErrorToastKey = 'silentErrorToast';
|
||
static const String suppressAuthLogoutKey = 'suppressAuthLogout';
|
||
static const String baseUrlOverrideKey = 'baseUrlOverride';
|
||
|
||
BaseNetworkClient() : dio = Dio() {
|
||
_confD();
|
||
init();
|
||
}
|
||
|
||
void _confD() {
|
||
dio.transformer = BackgroundTransformer()..jsonDecodeCallback = parseJson;
|
||
dio.options = BaseOptions(
|
||
connectTimeout: const Duration(seconds: 12),
|
||
receiveTimeout: const Duration(seconds: 30),
|
||
contentType: 'application/json; charset=UTF-8',
|
||
);
|
||
}
|
||
|
||
void init() {}
|
||
|
||
// 通用 GET 请求
|
||
Future<T> get<T>(
|
||
String path, {
|
||
Map<String, dynamic>? queryParams,
|
||
Map<String, dynamic>? extra,
|
||
bool allowNullBody = false,
|
||
required T Function(dynamic) fromJson,
|
||
CancelToken? cancelToken,
|
||
ProgressCallback? onSendProgress,
|
||
ProgressCallback? onReceiveProgress,
|
||
}) async {
|
||
return _req<T>(
|
||
path,
|
||
method: 'GET',
|
||
queryParams: queryParams,
|
||
extra: extra,
|
||
allowNullBody: allowNullBody,
|
||
fromJson: fromJson,
|
||
cancelToken: cancelToken,
|
||
onSendProgress: onSendProgress,
|
||
onReceiveProgress: onReceiveProgress,
|
||
);
|
||
}
|
||
|
||
// 通用 put 请求
|
||
Future<T> put<T>(
|
||
String path, {
|
||
dynamic data,
|
||
Map<String, dynamic>? queryParams,
|
||
Map<String, dynamic>? extra,
|
||
bool allowNullBody = false,
|
||
required T Function(dynamic) fromJson,
|
||
CancelToken? cancelToken,
|
||
ProgressCallback? onSendProgress,
|
||
ProgressCallback? onReceiveProgress,
|
||
}) async {
|
||
return _req<T>(
|
||
path,
|
||
method: 'PUT',
|
||
data: data,
|
||
queryParams: queryParams,
|
||
extra: extra,
|
||
allowNullBody: allowNullBody,
|
||
fromJson: fromJson,
|
||
cancelToken: cancelToken,
|
||
onSendProgress: onSendProgress,
|
||
onReceiveProgress: onReceiveProgress,
|
||
);
|
||
}
|
||
|
||
// 通用 POST 请求
|
||
Future<T> post<T>(
|
||
String path, {
|
||
dynamic data,
|
||
Map<String, dynamic>? queryParams,
|
||
Map<String, dynamic>? extra,
|
||
bool allowNullBody = false,
|
||
required T Function(dynamic) fromJson,
|
||
CancelToken? cancelToken,
|
||
ProgressCallback? onSendProgress,
|
||
ProgressCallback? onReceiveProgress,
|
||
}) async {
|
||
return _req<T>(
|
||
path,
|
||
method: 'POST',
|
||
data: data,
|
||
queryParams: queryParams,
|
||
extra: extra,
|
||
allowNullBody: allowNullBody,
|
||
fromJson: fromJson,
|
||
cancelToken: cancelToken,
|
||
onSendProgress: onSendProgress,
|
||
onReceiveProgress: onReceiveProgress,
|
||
);
|
||
}
|
||
|
||
// 通用 POST 请求
|
||
Future<T> delete<T>(
|
||
String path, {
|
||
dynamic data,
|
||
Map<String, dynamic>? queryParams,
|
||
Map<String, dynamic>? extra,
|
||
bool allowNullBody = false,
|
||
required T Function(dynamic) fromJson,
|
||
CancelToken? cancelToken,
|
||
ProgressCallback? onSendProgress,
|
||
ProgressCallback? onReceiveProgress,
|
||
}) async {
|
||
return _req<T>(
|
||
path,
|
||
method: 'DELETE',
|
||
data: data,
|
||
queryParams: queryParams,
|
||
extra: extra,
|
||
allowNullBody: allowNullBody,
|
||
fromJson: fromJson,
|
||
cancelToken: cancelToken,
|
||
onSendProgress: onSendProgress,
|
||
onReceiveProgress: onReceiveProgress,
|
||
);
|
||
}
|
||
|
||
// 核心请求方法
|
||
Future<T> _req<T>(
|
||
String path, {
|
||
required String method,
|
||
dynamic data,
|
||
Map<String, dynamic>? queryParams,
|
||
Map<String, dynamic>? extra,
|
||
bool allowNullBody = false,
|
||
CancelToken? cancelToken,
|
||
ProgressCallback? onSendProgress,
|
||
ProgressCallback? onReceiveProgress,
|
||
required T Function(dynamic) fromJson,
|
||
}) async {
|
||
try {
|
||
final response = await dio.request(
|
||
path,
|
||
data: data,
|
||
queryParameters: queryParams,
|
||
options: Options(method: method, extra: extra),
|
||
cancelToken: cancelToken,
|
||
onSendProgress: onSendProgress,
|
||
onReceiveProgress: onReceiveProgress,
|
||
);
|
||
|
||
// 处理基础响应
|
||
final responseData = response.data;
|
||
if (responseData is! Map) {
|
||
throw DioException(
|
||
requestOptions: response.requestOptions,
|
||
response: response,
|
||
type: DioExceptionType.badResponse,
|
||
error: '响应格式错误: ${responseData.runtimeType}',
|
||
);
|
||
}
|
||
|
||
final ResponseData<T> baseResponse;
|
||
try {
|
||
baseResponse = ResponseData.fromJson(
|
||
Map<String, dynamic>.from(responseData),
|
||
fromJsonT: fromJson,
|
||
);
|
||
} catch (error) {
|
||
throw DioException(
|
||
requestOptions: response.requestOptions,
|
||
response: response,
|
||
type: DioExceptionType.badResponse,
|
||
error: '响应解析失败: $error',
|
||
);
|
||
}
|
||
// 业务逻辑成功判断
|
||
if (baseResponse.success) {
|
||
if (baseResponse.body != null) {
|
||
return baseResponse.body!;
|
||
} else if (allowNullBody && null is T) {
|
||
return null as T;
|
||
} else {
|
||
if (T == bool) return false as T;
|
||
if (T == int) return 0 as T;
|
||
if (T == String) return "" as T;
|
||
SCLoadingManager.hide();
|
||
throw Exception('Response data is null');
|
||
}
|
||
} else {
|
||
// 业务逻辑错误
|
||
SCLoadingManager.hide();
|
||
throw DioException(
|
||
requestOptions: response.requestOptions,
|
||
response: response,
|
||
error: '业务错误: ${baseResponse.errorMsg}',
|
||
);
|
||
}
|
||
} on DioException catch (e) {
|
||
// 网络错误处理
|
||
throw await _hdlErr(e);
|
||
} catch (e) {
|
||
SCLoadingManager.hide();
|
||
throw Exception('未知错误: $e');
|
||
}
|
||
}
|
||
|
||
// 错误处理
|
||
Future<DioException> _hdlErr(DioException e) async {
|
||
SCLoadingManager.hide();
|
||
final bool silentErrorToast =
|
||
e.requestOptions.extra[BaseNetworkClient.silentErrorToastKey] == true;
|
||
final handledBusinessError = await _handleBusinessErrorResponse(
|
||
e,
|
||
silentErrorToast: silentErrorToast,
|
||
);
|
||
if (handledBusinessError != null) {
|
||
return handledBusinessError;
|
||
}
|
||
switch (e.type) {
|
||
case DioExceptionType.connectionTimeout:
|
||
return DioException(requestOptions: e.requestOptions, error: '连接超时');
|
||
case DioExceptionType.sendTimeout:
|
||
return DioException(requestOptions: e.requestOptions, error: '发送超时');
|
||
case DioExceptionType.receiveTimeout:
|
||
return DioException(requestOptions: e.requestOptions, error: '接收超时');
|
||
case DioExceptionType.badResponse:
|
||
return DioException(
|
||
requestOptions: e.requestOptions,
|
||
response: e.response,
|
||
error: e.error ?? e.message ?? 'Server fail',
|
||
);
|
||
case DioExceptionType.cancel:
|
||
return DioException(
|
||
requestOptions: e.requestOptions,
|
||
response: e.response,
|
||
type: DioExceptionType.cancel,
|
||
error: e.error ?? 'Cancel',
|
||
);
|
||
default:
|
||
return DioException(
|
||
requestOptions: e.requestOptions,
|
||
response: e.response,
|
||
type: e.type,
|
||
error: e.error ?? e.message ?? 'Net fail',
|
||
message: e.message,
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<DioException?> _handleBusinessErrorResponse(
|
||
DioException e, {
|
||
required bool silentErrorToast,
|
||
}) async {
|
||
final responseData = e.response?.data;
|
||
if (responseData == null) {
|
||
return null;
|
||
}
|
||
|
||
final errorCode = _readErrorCode(responseData);
|
||
final errorMsg = _readErrorMessage(responseData);
|
||
if (errorCode == null && errorMsg == null) {
|
||
return null;
|
||
}
|
||
|
||
if (errorCode == SCErroCode.userNotRegistered.code) {
|
||
// 用户还没有注册:Google/Apple 登录后需要继续补充资料。
|
||
SCTts.show("Please register an account first.");
|
||
final BuildContext? context = navigatorKey.currentContext;
|
||
if (context != null && context.mounted) {
|
||
SCNavigatorUtils.push(context, LoginRouter.editProfile, replace: false);
|
||
}
|
||
} else if (errorCode == SCErroCode.authUnauthorized.code) {
|
||
// token过期
|
||
if (e.requestOptions.extra[BaseNetworkClient.suppressAuthLogoutKey] ==
|
||
true) {
|
||
return DioException(
|
||
requestOptions: e.requestOptions,
|
||
response: e.response,
|
||
type: e.type,
|
||
error: '服务器错误: ${errorCode ?? ""} ${errorMsg ?? ""}'.trim(),
|
||
message: e.message,
|
||
);
|
||
}
|
||
final shouldLogout =
|
||
!SCNavigatorUtils.inLoginPage &&
|
||
!await _isCurrentSessionStillValid(e);
|
||
final BuildContext? context = navigatorKey.currentContext;
|
||
if (context != null && context.mounted && shouldLogout) {
|
||
AccountStorage().logout(context);
|
||
SCNavigatorUtils.inLoginPage = true;
|
||
}
|
||
} else {
|
||
final message = errorMsg ?? "";
|
||
if (message.endsWith("balance not made")) {
|
||
final BuildContext? context = navigatorKey.currentContext;
|
||
if (context != null && context.mounted) {
|
||
SCRoomUtils.goRecharge(context);
|
||
}
|
||
} else if (!silentErrorToast && message.isNotEmpty) {
|
||
SCTts.show(message);
|
||
}
|
||
}
|
||
|
||
return DioException(
|
||
requestOptions: e.requestOptions,
|
||
response: e.response,
|
||
type: e.type,
|
||
error: '服务器错误: ${errorCode ?? ""} ${errorMsg ?? ""}'.trim(),
|
||
message: e.message,
|
||
);
|
||
}
|
||
|
||
int? _readErrorCode(dynamic responseData) {
|
||
if (responseData is! Map) {
|
||
return null;
|
||
}
|
||
final value = responseData["errorCode"] ?? responseData["code"];
|
||
if (value is int) {
|
||
return value;
|
||
}
|
||
if (value is num) {
|
||
return value.toInt();
|
||
}
|
||
if (value is String) {
|
||
return int.tryParse(value);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String? _readErrorMessage(dynamic responseData) {
|
||
if (responseData is Map) {
|
||
return responseData["errorMsg"]?.toString() ??
|
||
responseData["message"]?.toString();
|
||
}
|
||
final message = responseData?.toString().trim();
|
||
return message == null || message.isEmpty ? null : message;
|
||
}
|
||
}
|
||
|
||
class NotSuccessException implements Exception {
|
||
final String message;
|
||
|
||
NotSuccessException(this.message);
|
||
|
||
factory NotSuccessException.fromRespData(ResponseData respData) {
|
||
return NotSuccessException(respData.errorMsg ?? "操作失败");
|
||
}
|
||
}
|