Files
biz_xfj_cloud/lib/common/utils/manage/http_manage.dart
T
dzc d3b818a660 feat:增加预约统计的导出
feat:人脸审核、人脸质量的查询
feat:app购物车严格限制
2026-09-01 14:27:10 +08:00

412 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})";
}
}