412 lines
15 KiB
Dart
412 lines
15 KiB
Dart
import 'dart:convert';
|
||
import 'dart:io';
|
||
|
||
import 'package:common_utils/common_utils.dart';
|
||
import 'package:dio/dio.dart';
|
||
import 'package:dio/io.dart';
|
||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||
import 'package:get/get.dart' hide FormData;
|
||
|
||
import 'package:pj_supermarket_cloud/common/index.dart';
|
||
|
||
/// 无需向操作员重复提示的服务端业务消息。
|
||
const ignoreMsg = ["账号未登录"];
|
||
|
||
/// 应用统一 Dio 管理器,负责鉴权头、租户头、响应解包、错误恢复与安全日志。
|
||
class HttpManage {
|
||
/// 后台心跳、版本检查等旁路请求禁止弹 Toast 时写入 Dio RequestOptions.extra 的稳定键。
|
||
static const String silentRequestKey = 'silentRequest';
|
||
|
||
/// 需要把统一业务非零码作为 Dio 异常交给调用方区分明确失败与超时的请求标记。
|
||
static const String rejectBusinessErrorKey = 'rejectBusinessError';
|
||
|
||
/// HTTP 管理日志标签。
|
||
static const _tag = "http管理";
|
||
|
||
/// 请求开始时间在 Dio extra 中使用的内部键,用于计算请求耗时。
|
||
static const _requestStartAtKey = "_requestStartAt";
|
||
|
||
/// 单段请求或响应日志允许保留的最大字符数,超出部分只记录原长度。
|
||
static const _maxLogLength = 2000;
|
||
|
||
/// 日志输出前必须遮蔽的字段名;字段名会先转小写并移除下划线、短横线。
|
||
static const Set<String> _sensitiveLogKeys = {
|
||
'password',
|
||
'passwd',
|
||
'username',
|
||
'accesstoken',
|
||
'refreshtoken',
|
||
'token',
|
||
'authorization',
|
||
'authcode',
|
||
'paymentcode',
|
||
'paycode',
|
||
'qrcode',
|
||
'servicecode',
|
||
'shortcode',
|
||
'cardid',
|
||
'cardno',
|
||
'cardnumber',
|
||
'phone',
|
||
'mobile',
|
||
'idcard',
|
||
'certno',
|
||
'realname',
|
||
'referenceimageurl',
|
||
'imagehash',
|
||
'localfilepath',
|
||
'evidencefilepath',
|
||
};
|
||
|
||
/// 进程内唯一的 HTTP 管理器实例,确保拦截器和连接池不被重复创建。
|
||
static final HttpManage _instance = HttpManage._internal();
|
||
|
||
/// 返回进程内共享的 HTTP 管理器实例。
|
||
factory HttpManage() => _instance;
|
||
|
||
/// 统一业务 Dio 实例;所有普通 API 请求必须复用该对象。
|
||
late Dio dio;
|
||
|
||
/// 创建 Dio、注册统一拦截器并配置项目既有的 Android HTTP 客户端策略。
|
||
HttpManage._internal() {
|
||
BaseOptions globalOptions = BaseOptions(
|
||
baseUrl: Constants.apiUrl,
|
||
// TCP/SSL 建连超时
|
||
connectTimeout: const Duration(seconds: 10),
|
||
// 请求体发送超时
|
||
sendTimeout: const Duration(seconds: 10),
|
||
// 等待服务端返回数据超时
|
||
receiveTimeout: const Duration(seconds: 10),
|
||
responseType: ResponseType.json,
|
||
);
|
||
dio = Dio(globalOptions);
|
||
// 拦截
|
||
dio.interceptors.add(
|
||
InterceptorsWrapper(
|
||
onRequest: (RequestOptions options, handler) async {
|
||
options.extra[_requestStartAtKey] = DateTime.now();
|
||
options.headers["CLIENT-TOC"] = "Y";
|
||
final userInfo = await AppSettingTool.getUserSetting();
|
||
if (!options.path.contains(Constants.oauth2AppApiUrl)) {
|
||
if (ObjectUtil.isNotEmpty(userInfo.accessToken)) {
|
||
options.headers['Authorization'] =
|
||
"Bearer ${userInfo.accessToken}";
|
||
}
|
||
}
|
||
final tenantId = userInfo.tenantId;
|
||
// 后端会将 TENANT-ID 解析为 Long;旧版本若遗留非数值 code,先回退默认租户,
|
||
// 登录控制器随后会利用缓存租户表迁移为真实数值 id。
|
||
options.headers["TENANT-ID"] =
|
||
tenantId != null && int.tryParse(tenantId) != null ? tenantId : 1;
|
||
logger.i(
|
||
"|$_tag|请求开始 method:${options.method} url:${_formatLogUri(options.uri)} "
|
||
"query:${_formatLogData(options.queryParameters)} "
|
||
"body:${_formatLogData(options.data)}",
|
||
);
|
||
return handler.next(options);
|
||
},
|
||
onResponse: (e, handler) async {
|
||
final silent = e.requestOptions.extra[silentRequestKey] == true;
|
||
// 先判断是不是登录接口
|
||
late ServiceBase serviceData;
|
||
if (e.requestOptions.path.contains(Constants.oauth2AppApiUrl)) {
|
||
serviceData = ServiceBase(msg: "", code: 0, data: e.data);
|
||
} else {
|
||
final parsed = _tryParseServiceBase(e.data);
|
||
if (parsed == null) {
|
||
_logResponse(options: e.requestOptions, response: e.data);
|
||
if (!silent) {
|
||
Toast.error(msg: "服务器返回格式异常,请稍后重试");
|
||
EasyLoading.dismiss();
|
||
}
|
||
handler.reject(
|
||
DioException(
|
||
requestOptions: e.requestOptions,
|
||
response: e,
|
||
type: DioExceptionType.badResponse,
|
||
error: '服务器返回格式不是统一 JSON 对象',
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
serviceData = parsed;
|
||
}
|
||
if (serviceData.code == 401 &&
|
||
ObjectUtil.isNotEmpty(serviceData.msg)) {
|
||
if (!silent && !ignoreMsg.contains(serviceData.msg)) {
|
||
Toast.error(msg: serviceData.msg ?? "服务器未知错误");
|
||
}
|
||
final isLogin = await _autoLogin();
|
||
if (!isLogin) {
|
||
Get.offAndToNamed(RouteNames.login);
|
||
}
|
||
} else if (serviceData.code != 0 && !silent) {
|
||
Toast.error(msg: serviceData.msg ?? "服务器未知错误");
|
||
}
|
||
// 续一下token
|
||
if (!e.realUri.path.contains("refresh-token")) {
|
||
// _refreshToken();
|
||
}
|
||
_logResponse(
|
||
options: e.requestOptions,
|
||
response: serviceData.data,
|
||
);
|
||
if (serviceData.code != 0 &&
|
||
(silent ||
|
||
e.requestOptions.extra[rejectBusinessErrorKey] == true)) {
|
||
handler.reject(
|
||
DioException(
|
||
requestOptions: e.requestOptions,
|
||
response: e,
|
||
type: DioExceptionType.badResponse,
|
||
error: serviceData.msg ?? '后台请求失败',
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
e.data = serviceData.data;
|
||
handler.next(e);
|
||
},
|
||
onError: (e, handler) async {
|
||
final silent = e.requestOptions.extra[silentRequestKey] == true;
|
||
_logError(e);
|
||
final timedOut = e.type == DioExceptionType.connectionTimeout ||
|
||
e.type == DioExceptionType.sendTimeout ||
|
||
e.type == DioExceptionType.receiveTimeout;
|
||
if (!silent && timedOut) {
|
||
Toast.error(msg: "网络请求超时");
|
||
EasyLoading.dismiss();
|
||
}
|
||
final response = e.response;
|
||
final statusCode = response?.statusCode;
|
||
if (statusCode == 424 &&
|
||
!e.requestOptions.path.contains(Constants.oauth2AppApiUrl)) {
|
||
final isLogin = await _autoLogin();
|
||
if (!isLogin) {
|
||
Get.offAndToNamed(RouteNames.login);
|
||
}
|
||
}
|
||
|
||
// 网关、代理或不存在的路由可能返回纯文本/HTML,不能直接强转 Map。
|
||
final serviceError = _tryParseServiceBase(response?.data);
|
||
if (!silent && !timedOut && statusCode != null) {
|
||
final serviceMessage = serviceError?.msg;
|
||
if (ObjectUtil.isNotEmpty(serviceMessage) &&
|
||
!ignoreMsg.contains(serviceMessage)) {
|
||
Toast.error(msg: serviceMessage!);
|
||
} else {
|
||
Toast.error(msg: _httpStatusMessage(statusCode));
|
||
}
|
||
EasyLoading.dismiss();
|
||
}
|
||
return handler.next(e);
|
||
},
|
||
),
|
||
);
|
||
|
||
dio.httpClientAdapter = IOHttpClientAdapter(
|
||
createHttpClient: () {
|
||
final HttpClient client =
|
||
HttpClient(context: SecurityContext(withTrustedRoots: false));
|
||
client.badCertificateCallback = (cert, host, port) => true;
|
||
return client;
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 使用本地保存的账号密码重新获取令牌;失败时清理失效登录信息并返回 false。
|
||
Future<bool> _autoLogin() async {
|
||
final userSetting = await AppSettingTool.getUserSetting();
|
||
final sysSetting = await AppSettingTool.getSysSetting();
|
||
if (ObjectUtil.isEmpty(userSetting.username) ||
|
||
ObjectUtil.isEmpty(userSetting.password) ||
|
||
ObjectUtil.isEmpty(userSetting.accessToken)) {
|
||
return false;
|
||
}
|
||
final pa = <String, dynamic>{
|
||
'username': userSetting.username,
|
||
'password': userSetting.password,
|
||
'sysType': sysSetting.sysType.name,
|
||
'grant_type': 'password',
|
||
'scope': 'server',
|
||
};
|
||
try {
|
||
final data = await UserApi.login(pa);
|
||
if (data is! Map) {
|
||
throw const FormatException('自动登录响应不是 JSON 对象');
|
||
}
|
||
userSetting.userId = data['user_id'];
|
||
userSetting.accessToken = data['access_token'];
|
||
userSetting.refreshToken = data['refresh_token'];
|
||
userSetting.expiresTime = int.tryParse("${data['exp']}");
|
||
await AppSettingTool.setUserSetting(userSetting);
|
||
return true;
|
||
} catch (e) {
|
||
await AppSettingTool.setUserSetting(UserSetting());
|
||
logger.e("|$_tag|自动登录意外的错误error:|$e|");
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// 在令牌接近过期时刷新并持久化新令牌;当前调用点保留但默认未启用。
|
||
// ignore: unused_element
|
||
Future<void> _refreshToken() async {
|
||
final userSetting = await AppSettingTool.getUserSetting();
|
||
if (ObjectUtil.isEmpty(userSetting.refreshToken) ||
|
||
ObjectUtil.isEmpty(userSetting.expiresTime)) {
|
||
return;
|
||
}
|
||
final now = DateTime.now().millisecondsSinceEpoch;
|
||
final remain = userSetting.expiresTime! - now;
|
||
logger.i("|$_tag|token还有${remain / 1000 / 60}分钟${remain / 1000}秒过期");
|
||
if (remain > 1000 * 60 * 20) return;
|
||
final pa = {"refreshToken": userSetting.refreshToken};
|
||
try {
|
||
final res = await UserApi.refreshToken(pa);
|
||
if (res.code != 0) throw Error();
|
||
final data = res.data as ServiceAuth;
|
||
if (ObjectUtil.isNotEmpty(data.userId)) {
|
||
userSetting.userId = data.userId;
|
||
}
|
||
if (ObjectUtil.isNotEmpty(data.accessToken)) {
|
||
userSetting.accessToken = data.accessToken;
|
||
}
|
||
if (ObjectUtil.isNotEmpty(data.refreshToken)) {
|
||
userSetting.refreshToken = data.refreshToken;
|
||
}
|
||
if (ObjectUtil.isNotEmpty(data.expiresTime)) {
|
||
userSetting.expiresTime = data.expiresTime;
|
||
}
|
||
await AppSettingTool.setUserSetting(userSetting);
|
||
} catch (e) {
|
||
logger.e("|$_tag|续存token意外的错误error:|$e|");
|
||
}
|
||
}
|
||
|
||
/// 记录成功响应的请求耗时和脱敏后的响应摘要。
|
||
void _logResponse({
|
||
required RequestOptions options,
|
||
dynamic response,
|
||
}) {
|
||
final cost = _calcRequestCost(options);
|
||
logger.i(
|
||
"|$_tag|请求完成 method:${options.method} url:${_formatLogUri(options.uri)} "
|
||
"cost:${cost}ms response:${_formatLogData(response)}",
|
||
);
|
||
}
|
||
|
||
/// 记录失败请求的耗时、错误类型和脱敏后的请求/响应摘要。
|
||
void _logError(DioException error) {
|
||
final options = error.requestOptions;
|
||
final cost = _calcRequestCost(options);
|
||
logger.e(
|
||
"|$_tag|请求失败 method:${options.method} url:${_formatLogUri(options.uri)} "
|
||
"cost:${cost}ms type:${error.type.name} "
|
||
"query:${_formatLogData(options.queryParameters)} "
|
||
"body:${_formatLogData(options.data)} "
|
||
"response:${_formatLogData(error.response?.data)} "
|
||
"error:${error.message}",
|
||
);
|
||
}
|
||
|
||
/// 根据请求开始时间计算本次请求耗时,缺少记录时返回 0 毫秒。
|
||
int _calcRequestCost(RequestOptions options) {
|
||
final startAt = options.extra[_requestStartAtKey];
|
||
if (startAt is DateTime) {
|
||
return DateTime.now().difference(startAt).inMilliseconds;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/// 将统一业务响应安全解析为 ServiceBase;纯文本、HTML 或畸形 JSON 返回 null。
|
||
ServiceBase? _tryParseServiceBase(dynamic value) {
|
||
if (value == null) return null;
|
||
try {
|
||
dynamic decoded = value;
|
||
if (value is String) {
|
||
decoded = jsonDecode(value);
|
||
}
|
||
if (decoded is! Map) return null;
|
||
return ServiceBase.fromJson(
|
||
decoded.map<String, dynamic>(
|
||
(key, item) => MapEntry(key.toString(), item),
|
||
),
|
||
);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// 将常见 HTTP 状态码转换为可操作的中文提示,不向界面回显服务端 HTML。
|
||
String _httpStatusMessage(int statusCode) {
|
||
return switch (statusCode) {
|
||
400 => '请求参数错误,请检查后重试',
|
||
401 => '账号、密码或登录信息无效',
|
||
403 => '当前账号没有访问权限',
|
||
404 => '请求地址不存在(404),请检查服务配置',
|
||
409 => '请求与当前数据状态冲突,请刷新后重试',
|
||
424 => '登录状态已失效,请重新登录',
|
||
>= 500 => '服务器暂时不可用($statusCode),请稍后重试',
|
||
_ => '服务器请求失败($statusCode)',
|
||
};
|
||
}
|
||
|
||
/// 遮蔽 URL 查询参数中的账号、令牌和业务凭证,避免敏感值出现在原始日志文件。
|
||
String _formatLogUri(Uri uri) {
|
||
if (!uri.hasQuery) return uri.toString();
|
||
final queryParameters = uri.queryParameters.map(
|
||
(key, value) => MapEntry(
|
||
key,
|
||
_isSensitiveLogKey(key) ? '***' : value,
|
||
),
|
||
);
|
||
return uri.replace(queryParameters: queryParameters).toString();
|
||
}
|
||
|
||
/// 递归遮蔽 Map/List 中的敏感字段,保留非敏感结构用于问题定位。
|
||
dynamic _redactLogData(dynamic data) {
|
||
if (data is FormData) {
|
||
return {
|
||
'fields': {
|
||
for (final field in data.fields)
|
||
field.key: _isSensitiveLogKey(field.key) ? '***' : field.value,
|
||
},
|
||
'files': [
|
||
for (final file in data.files)
|
||
{
|
||
'field': file.key,
|
||
'filename': file.value.filename ?? '未命名文件',
|
||
'length': file.value.length,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
if (data is Map) {
|
||
return data.map(
|
||
(key, value) => MapEntry(
|
||
key,
|
||
_isSensitiveLogKey(key.toString()) ? '***' : _redactLogData(value),
|
||
),
|
||
);
|
||
}
|
||
if (data is Iterable) {
|
||
return data.map(_redactLogData).toList(growable: false);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
/// 判断字段名是否属于敏感日志字段,兼容 snake_case、kebab-case 与 camelCase。
|
||
bool _isSensitiveLogKey(String key) {
|
||
final normalized = key.toLowerCase().replaceAll(RegExp(r'[_-]'), '');
|
||
return _sensitiveLogKeys.contains(normalized);
|
||
}
|
||
|
||
/// 将任意请求数据转换为有限长度的脱敏文本,供成功与失败日志统一调用。
|
||
String _formatLogData(dynamic data) {
|
||
if (data == null) return "null";
|
||
final text = sanitizeAppLogContent(_redactLogData(data).toString());
|
||
if (text.length <= _maxLogLength) return text;
|
||
return "${text.substring(0, _maxLogLength)}...(已截断,原长度:${text.length})";
|
||
}
|
||
}
|