初始化
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:encrypt/encrypt.dart';
|
||||
|
||||
/// 新版本解密(项目自定义协议:12 字节 nonce + sha256 派生 key/iv + AES-CBC)
|
||||
String aesDecryptEx(String cipher, String key) {
|
||||
// final t1 = DateTime.now();
|
||||
const nonceLen = 12;
|
||||
final cipherBytes = base64Decode(cipher);
|
||||
final nonce = cipherBytes.sublist(0, nonceLen);
|
||||
final largeShaRaw = [...utf8.encode(key), ...nonce];
|
||||
final largeShaRawMid = largeShaRaw.length ~/ 2;
|
||||
final msgKeyLarge = sha256.convert(largeShaRaw).bytes;
|
||||
final msgKey = msgKeyLarge.sublist(8, 24);
|
||||
|
||||
final shaRawA = [...msgKey, ...largeShaRaw.sublist(0, largeShaRawMid)];
|
||||
final sha256a = sha256.convert(shaRawA).bytes;
|
||||
|
||||
final shaRawB = [...largeShaRaw.sublist(largeShaRawMid), ...msgKey];
|
||||
final sha256b = sha256.convert(shaRawB).bytes;
|
||||
|
||||
final aesKey = [...sha256a.sublist(0, 8), ...sha256b.sublist(8, 24), ...sha256a.sublist(24)];
|
||||
|
||||
final aesIV = [...sha256b.sublist(0, 4), ...sha256a.sublist(12, 20), ...sha256b.sublist(28)];
|
||||
|
||||
final encrypter = Encrypter(AES(Key(Uint8List.fromList(aesKey)), mode: AESMode.cbc));
|
||||
final decrypted = encrypter.decryptBytes(Encrypted(cipherBytes.sublist(nonceLen)), iv: IV(Uint8List.fromList(aesIV)));
|
||||
final text = const Utf8Decoder().convert(decrypted);
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/// future 扔出的异常
|
||||
class ApiException implements Exception {
|
||||
int? code = -200;
|
||||
dynamic message;
|
||||
ApiException([this.code = -200, this.message]);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (message == null) return "ApiException:code:$code";
|
||||
return "ApiException:code:$code message:$message";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/// 基础网络请求结构
|
||||
/// 本来是业务层包装的一层东西,但是这个项目好像没用,用状态码代替了业务码
|
||||
class BaseRespBean<T> {
|
||||
int? code;
|
||||
T? data;
|
||||
|
||||
// 打印的data
|
||||
dynamic printData;
|
||||
|
||||
/// 后台提示
|
||||
String? tip;
|
||||
String? action;
|
||||
|
||||
/// 是否加密
|
||||
bool? hash;
|
||||
|
||||
/// 一般是code不为200的后端错误信息
|
||||
String? msg;
|
||||
|
||||
/// 服务器时间,一切vip时间计算以服务器时间为准
|
||||
String? time;
|
||||
|
||||
// String get avalibleMsg => TextUtil.isNotEmpty(tip) ? tip : msg;
|
||||
String get toast {
|
||||
if (tip?.isNotEmpty ?? false) return tip!;
|
||||
return msg ?? ''; // tip 为空时回退到 msg,避免错误信息弹不出来
|
||||
}
|
||||
|
||||
BaseRespBean(this.code, {this.data, this.msg, this.tip, this.hash = false, this.time, this.printData});
|
||||
|
||||
BaseRespBean.fromJson(Map<String, dynamic>? json) {
|
||||
json ??= {};
|
||||
code = json['code'];
|
||||
data = json['data'];
|
||||
tip = json['tip'];
|
||||
action = json['action'];
|
||||
msg = json['msg'];
|
||||
time = json['time'];
|
||||
hash = json['hash'];
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return toJson().toString();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = {};
|
||||
data['code'] = code;
|
||||
data['data'] = printData;
|
||||
data['tip'] = tip;
|
||||
data['action'] = action;
|
||||
data['msg'] = msg;
|
||||
data['time'] = time;
|
||||
data['hash'] = hash;
|
||||
return data;
|
||||
}
|
||||
|
||||
bool get isSuccess => code == 200;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class CurlUtil {
|
||||
static String generateCurl(RequestOptions options) {
|
||||
const String curl = 'curl -X ';
|
||||
final String method = options.method;
|
||||
String url = options.baseUrl + options.path;
|
||||
String query = '';
|
||||
|
||||
if (options.data != null && options.data is String) {
|
||||
query = options.data;
|
||||
} else {
|
||||
late Map<String, dynamic> map;
|
||||
|
||||
if (options.queryParameters.isNotEmpty) {
|
||||
map = options.queryParameters;
|
||||
} else if (options.data is Map) {
|
||||
map = options.data;
|
||||
} else if (options.data is String) {
|
||||
} else if (options.data is FormData) {
|
||||
map = {};
|
||||
map.addEntries((options.data as FormData).fields);
|
||||
} else {
|
||||
map = {};
|
||||
}
|
||||
query = Transformer.urlEncodeMap(map);
|
||||
}
|
||||
String curlUrl;
|
||||
if (method.toLowerCase() == 'get') {
|
||||
if (query.isNotEmpty) {
|
||||
url += (url.contains('?') ? '&' : '?') + query;
|
||||
}
|
||||
String header = '';
|
||||
options.headers.forEach((key, value) {
|
||||
if (key != 'content-length') {
|
||||
header += ' -H ' '\"$key:$value\" ';
|
||||
}
|
||||
});
|
||||
|
||||
curlUrl = '$curl$method $header \"$url\"';
|
||||
} else {
|
||||
String header = '';
|
||||
options.headers.forEach((key, value) {
|
||||
if (key != 'content-length') {
|
||||
header += ' -H ' '\"$key:$value\" ';
|
||||
}
|
||||
});
|
||||
final param = json.encode(options.data).replaceAll('"', '\\"');
|
||||
header += " -d \"$param\"";
|
||||
curlUrl = '$curl$method $header \"$url\"';
|
||||
}
|
||||
|
||||
return curlUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/hj_utils/text_util.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:hgdj/tools_base/net/http_response_interceptor.dart';
|
||||
import 'package:hgdj/tools_base/net/net_code.dart';
|
||||
import 'package:hgdj/tools_base/net/net_manager.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../track_event_manager/device_service.dart';
|
||||
import '../../track_event_manager/track_session.dart';
|
||||
|
||||
final httpManager = _HttpManagerImp();
|
||||
|
||||
enum HttpMethod {
|
||||
get('GET'),
|
||||
post('POST'),
|
||||
delete('DELETE');
|
||||
|
||||
final String method;
|
||||
const HttpMethod(this.method);
|
||||
}
|
||||
|
||||
abstract class HttpManger {
|
||||
final dio = Dio();
|
||||
String _baseUrl = '';
|
||||
|
||||
String get baseUrl => _baseUrl;
|
||||
|
||||
/// 服务器时间校准
|
||||
int _diffTimeInSeconds = 0;
|
||||
DateTime? _serverTime;
|
||||
|
||||
initDefault() {
|
||||
// 选线发生在 resetBaseUrl 之前,这里必须先给 dio.options 配 connectTimeout,
|
||||
// 否则线路握手挂起时永不超时,导致启动页"选线中..."一直转(iOS release 高发)
|
||||
dio.options.connectTimeout = const Duration(seconds: 15);
|
||||
dio.options.sendTimeout = const Duration(seconds: 15);
|
||||
dio.options.receiveTimeout = const Duration(seconds: 15);
|
||||
_addDioIns();
|
||||
}
|
||||
|
||||
init(String baseUrl) {
|
||||
resetBaseUrl(baseUrl);
|
||||
}
|
||||
|
||||
// 同步服务器时间
|
||||
setServerTime(String? serverTimeS) {
|
||||
if (TextUtil.isNotEmpty(serverTimeS)) {
|
||||
_serverTime = DateTime.parse(serverTimeS!);
|
||||
_diffTimeInSeconds = DateTime.now().difference(_serverTime!).inSeconds;
|
||||
debugLog(
|
||||
"============>server diff from local in seconds:$_diffTimeInSeconds");
|
||||
}
|
||||
}
|
||||
|
||||
/// get
|
||||
Future<BaseRespBean> fetchResponseByGET(
|
||||
String url, {
|
||||
Map<String, dynamic>? param,
|
||||
Options? options,
|
||||
Function(Map<String, dynamic> json)? jsonTransformation,
|
||||
});
|
||||
|
||||
/// post
|
||||
Future<BaseRespBean> fetchResponseByPOST(
|
||||
String url, {
|
||||
Map<String, dynamic>? param,
|
||||
Options? options,
|
||||
Function(Map<String, dynamic> json)? jsonTransformation,
|
||||
});
|
||||
|
||||
/// post
|
||||
|
||||
Future<BaseRespBean> fetchResponseByDELETE(
|
||||
String url, {
|
||||
Map<String, dynamic>? param,
|
||||
Options? options,
|
||||
Function(Map<String, dynamic> json)? jsonTransformation,
|
||||
});
|
||||
|
||||
Future<BaseRespBean> _requestByUrl(
|
||||
String url, {
|
||||
Map<String, dynamic>? data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
required Options options,
|
||||
CancelToken? cancelToken,
|
||||
Function(Map<String, dynamic> json)? jsonTransformation,
|
||||
}) async {
|
||||
// 给默认值兜底:若 response 非 null 但 data 不是 Map/BaseRespBean(null/String/List),
|
||||
// 下面 resultData 不会被赋值,late 变量访问会抛 LateInitializationError
|
||||
BaseRespBean resultData =
|
||||
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
|
||||
Response? response;
|
||||
try {
|
||||
response = await dio.request(url,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
data: data,
|
||||
cancelToken: cancelToken);
|
||||
} on DioException catch (e) {
|
||||
debugLog('DioException $e');
|
||||
if (e.type == DioExceptionType.cancel) {
|
||||
resultData = BaseRespBean(Code.LOCAL_CANCEL_REQUEST,
|
||||
msg: '请求已经取消~,请重试', data: null);
|
||||
} else if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout ||
|
||||
e.type == DioExceptionType.sendTimeout) {
|
||||
resultData =
|
||||
BaseRespBean(Code.NETWORK_TIMEOUT, msg: '网络连接超时~', data: null);
|
||||
}
|
||||
final connectivityResult = await Connectivity().checkConnectivity();
|
||||
if (connectivityResult == ConnectivityResult.none) {
|
||||
//没有网络
|
||||
resultData = BaseRespBean(Code.LOCAL_NO_NETWORK,
|
||||
msg: '暂无网络,请检查网络设置', data: null);
|
||||
} else {
|
||||
resultData =
|
||||
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常,请重新试试~', data: null);
|
||||
}
|
||||
} on SocketException catch (e) {
|
||||
resultData =
|
||||
BaseRespBean(Code.NETWORK_TIMEOUT, msg: '网络连接超时~', data: null);
|
||||
debugLog('SocketException $e');
|
||||
} on HttpException catch (e) {
|
||||
resultData =
|
||||
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
|
||||
debugLog('HttpException $e');
|
||||
} on FormatException catch (e) {
|
||||
debugLog('FormatException $e');
|
||||
resultData =
|
||||
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
|
||||
} catch (e) {
|
||||
resultData =
|
||||
BaseRespBean(Code.NETWORK_ERROR, msg: '网络异常请稍后再试~', data: null);
|
||||
debugLog(e);
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
return resultData;
|
||||
} else if (response.data is BaseRespBean) {
|
||||
resultData = response.data;
|
||||
} else if (response.data is Map<String, dynamic>) {
|
||||
resultData = BaseRespBean.fromJson(response.data);
|
||||
}
|
||||
if (resultData.isSuccess) {
|
||||
if (jsonTransformation != null) {
|
||||
final data_ = resultData.data;
|
||||
if (data_ is Map) {
|
||||
try {
|
||||
resultData.data =
|
||||
jsonTransformation.call(Map<String, dynamic>.from(data_));
|
||||
} catch (e) {
|
||||
print('jsonTransformation error $e');
|
||||
resultData.data = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (resultData.data is String) {
|
||||
// 非成功响应 data 仍是未解密的密文串;service 层若 `return result.data`(返回类型是 Model?)
|
||||
// 会把 String 强转模型,抛 'String is not a subtype of FutureOr<Model?>'。统一置空,降级返回 null。
|
||||
resultData.data = null;
|
||||
}
|
||||
return resultData;
|
||||
}
|
||||
|
||||
// dio 添加拦截
|
||||
_addDioIns() {
|
||||
dio.interceptors.add(HttpResponseInterceptor());
|
||||
}
|
||||
|
||||
// 重制地址
|
||||
resetBaseUrl(String baseUrl) {
|
||||
_baseUrl = baseUrl;
|
||||
final options = BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
validateStatus: (int? status) => (status ?? 600) < 600,
|
||||
baseUrl: baseUrl,
|
||||
);
|
||||
|
||||
options.headers[HttpHeaders.acceptEncodingHeader] = "*";
|
||||
|
||||
dio.options = options;
|
||||
|
||||
var adapter = DefaultHttpClientAdapter();
|
||||
|
||||
adapter.onHttpClientCreate = (client) {
|
||||
client.badCertificateCallback =
|
||||
(X509Certificate cert, String host, int port) => true;
|
||||
return client;
|
||||
};
|
||||
dio.httpClientAdapter = adapter;
|
||||
}
|
||||
|
||||
// 获取统一的请求头
|
||||
Future<Options> generateRequestOption(String apiUrl,
|
||||
{Options? options, required HttpMethod method}) async {
|
||||
//调用方可以自带 options(比如 X-Request-ID 这种单接口的头),公共头往上加,method 一律以本次请求为准
|
||||
options ??= Options();
|
||||
options.method = method.method;
|
||||
options.headers ??= {};
|
||||
final token = await netManager.getToken();
|
||||
if (token.isNotEmpty == true) {
|
||||
options.headers?["Authorization"] = token;
|
||||
}
|
||||
if (options.method == "GET") {
|
||||
//options.headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||
} else if (options.method == "POST") {
|
||||
options.headers?["Content-Type"] = "application/json;charset=UTF-8";
|
||||
}
|
||||
options.headers?["User-Agent"] = await netManager.userAgent();
|
||||
options.headers?["api_version"] = "1.0.0";
|
||||
options.headers?["device"] = Platform.operatingSystem;
|
||||
Uri? baseUri = Uri.tryParse(baseUrl);
|
||||
Uri targetUri = Uri(
|
||||
scheme: baseUri?.scheme,
|
||||
host: baseUri?.host,
|
||||
port: baseUri?.port,
|
||||
path: baseUri!.path + apiUrl);
|
||||
options.headers?["x-api-key"] = await _sign(targetUri.path);
|
||||
options.headers?["sid"] = TrackSessionManager().currentSid;
|
||||
options.headers?["DeviceModel"] = DeviceInfoService.model;
|
||||
return options;
|
||||
}
|
||||
|
||||
/// 签名
|
||||
Future<String> _sign(String path) async {
|
||||
Map<String, dynamic> signObj = {};
|
||||
final timeDate = DateTime.now().add(Duration(seconds: -_diffTimeInSeconds));
|
||||
int timestamp = timeDate.toUtc().millisecondsSinceEpoch ~/ 1000;
|
||||
signObj['nonce'] = const Uuid().v4();
|
||||
signObj['path'] = path;
|
||||
signObj['timestamp'] = timestamp.toString();
|
||||
signObj['token'] = await netManager.getToken();
|
||||
signObj['userAgent'] = await netManager.userAgent();
|
||||
var key = utf8.encode(Config.signKey);
|
||||
var bytes = utf8.encode(jsonEncode(signObj).toString());
|
||||
var sha1Encrypt = Hmac(sha1, key);
|
||||
var digest = sha1Encrypt.convert(bytes);
|
||||
return 'timestamp=$timestamp;sign=${digest.toString()};nonce=${signObj['nonce']}';
|
||||
}
|
||||
}
|
||||
|
||||
class _HttpManagerImp extends HttpManger {
|
||||
@override
|
||||
Future<BaseRespBean> fetchResponseByDELETE(String url,
|
||||
{Map<String, dynamic>? param,
|
||||
Options? options,
|
||||
Function(Map<String, dynamic> json)? jsonTransformation}) async {
|
||||
final reqOptions = await generateRequestOption(url,
|
||||
options: options, method: HttpMethod.delete);
|
||||
return await _requestByUrl(url,
|
||||
options: reqOptions,
|
||||
data: param,
|
||||
jsonTransformation: jsonTransformation);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BaseRespBean> fetchResponseByGET(
|
||||
String url, {
|
||||
Map<String, dynamic>? param,
|
||||
Options? options,
|
||||
Function(Map<String, dynamic> json)? jsonTransformation,
|
||||
}) async {
|
||||
final reqOptions = await generateRequestOption(url,
|
||||
options: options, method: HttpMethod.get);
|
||||
return await _requestByUrl(url,
|
||||
options: reqOptions,
|
||||
queryParameters: param,
|
||||
jsonTransformation: jsonTransformation);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BaseRespBean> fetchResponseByPOST(
|
||||
String url, {
|
||||
Map<String, dynamic>? param,
|
||||
Options? options,
|
||||
Function(Map<String, dynamic> json)? jsonTransformation,
|
||||
}) async {
|
||||
final reqOptions = await generateRequestOption(url,
|
||||
options: options, method: HttpMethod.post);
|
||||
return await _requestByUrl(url,
|
||||
options: reqOptions,
|
||||
data: param,
|
||||
jsonTransformation: jsonTransformation);
|
||||
}
|
||||
|
||||
Future<BaseRespBean> fetchDetectLineResponse(String url,
|
||||
{Options? options, CancelToken? cancelToken}) async {
|
||||
options ??= Options(
|
||||
method: HttpMethod.get.method,
|
||||
sendTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
headers: {},
|
||||
);
|
||||
final result =
|
||||
await _requestByUrl(url, options: options, cancelToken: cancelToken);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// ignore_for_file: constant_identifier_names
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
|
||||
import '../../config/config.dart';
|
||||
import '../../hj_utils/text_util.dart';
|
||||
import 'aes_util.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'base_resp_bean.dart';
|
||||
import 'net_code.dart';
|
||||
|
||||
/// 响应处理器:仅保留 [handleResponse] 静态方法,供 file_upload 等场景复用
|
||||
class HttpRespInterceptor {
|
||||
static const String TAG = "HttpRespInterceptor";
|
||||
|
||||
static Future<dynamic> handleResponse(Response response) async {
|
||||
if (response.statusCode != 200) {
|
||||
return Future.error(
|
||||
ApiException(response.statusCode, "statusCode is not 200"));
|
||||
}
|
||||
BaseRespBean? baseResp;
|
||||
if (response.data is Map) {
|
||||
baseResp = BaseRespBean.fromJson(response.data);
|
||||
} else if (response.data is String) {
|
||||
baseResp = BaseRespBean.fromJson(json.decode(response.data));
|
||||
} else {
|
||||
return Future.error(
|
||||
ApiException(Code.PARSE_DATE_ERROR, Lang.PARSE_DATE_ERROR));
|
||||
}
|
||||
|
||||
int? code = baseResp.code;
|
||||
//业务层判断
|
||||
if (code == Code.SUCCESS) {
|
||||
dynamic data = baseResp.data;
|
||||
if (baseResp.hash ?? false) {
|
||||
var decryptData = aesDecryptEx(data, Config.encryptKey);
|
||||
data = json.decode(decryptData);
|
||||
}
|
||||
baseResp.data = data;
|
||||
} else if (code == Code.FORCE_UPDATE_VERSION) {
|
||||
//需要更新
|
||||
baseResp.msg = "您的版本需要更新了";
|
||||
await handleVer(baseResp.data);
|
||||
} else if (code == Code.ACCOUNT_INVISIBLE) {
|
||||
//账户被封禁了
|
||||
baseResp.msg = "您的账号已被封禁了";
|
||||
dynamic data = baseResp.data;
|
||||
if (baseResp.hash ?? false) {
|
||||
var decryptData = aesDecryptEx(data, Config.encryptKey);
|
||||
data = json.decode(decryptData);
|
||||
}
|
||||
baseResp.data = data;
|
||||
} else if (code == Code.TOKEN_ABNORMAL) {
|
||||
//token异常
|
||||
baseResp.msg = "token异常";
|
||||
} else if (code == Code.VERIFY_CODE_REPEAT) {
|
||||
//验证码频繁异常
|
||||
baseResp.msg = "获取验证码过于频繁";
|
||||
} else {
|
||||
// unknow code
|
||||
baseResp.data = null;
|
||||
}
|
||||
|
||||
/// 展示提示
|
||||
if (code != Code.SUCCESS) {
|
||||
if (TextUtil.isEmpty(baseResp.msg) &&
|
||||
TextUtil.isEmpty(baseResp.tip) &&
|
||||
response.statusCode != 200) {
|
||||
showToast("服务器错误");
|
||||
} else {
|
||||
if (!TextUtil.isEmpty(baseResp.tip)) {
|
||||
showToast(baseResp.tip ?? "");
|
||||
} else {
|
||||
showToast(baseResp.msg ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
debugLog(
|
||||
'path:${response.requestOptions.baseUrl}${response.requestOptions.path}');
|
||||
debugLog('param: ${response.requestOptions.queryParameters}');
|
||||
debugLog('data: ${response.requestOptions.data}');
|
||||
debugLog('head: ${response.requestOptions.headers}');
|
||||
debugLog('resp:${response.data}');
|
||||
if (code == Code.SUCCESS) {
|
||||
response.data = baseResp.data;
|
||||
} else {
|
||||
if (baseResp.code != null) {
|
||||
response.statusCode = baseResp.code;
|
||||
if (baseResp.tip?.isNotEmpty == true) {
|
||||
response.statusMessage = baseResp.tip;
|
||||
} else if (baseResp.msg?.isNotEmpty == true) {
|
||||
response.statusMessage = baseResp.msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///存储更新信息
|
||||
handleVer(Map<String, dynamic> map) async {
|
||||
List<dynamic> list = [];
|
||||
list.add(map["data"]);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hgdj/config/address.dart';
|
||||
import 'package:hgdj/config/config.dart';
|
||||
import 'package:hgdj/hj_utils/light_model.dart';
|
||||
import 'package:hgdj/hj_utils/store_keys.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/net/aes_util.dart';
|
||||
import 'package:hgdj/tools_base/net/base_resp_bean.dart';
|
||||
import 'package:hgdj/tools_base/net/curl_util.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
import 'package:hgdj/tools_base/net/net_code.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
|
||||
/// 统一处理网络响应:解析 BaseRespBean、按业务码分发、密文解密、错误提示
|
||||
class HttpResponseInterceptor extends InterceptorsWrapper {
|
||||
static const String tag = "HttpRespInterceptor";
|
||||
|
||||
HttpResponseInterceptor();
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
// curl/响应体仅 debug 下生成:generateCurl 会 json.encode 整个请求体、
|
||||
// '$data' 会序列化整个响应,无条件求值在 release 也跑、纯属浪费
|
||||
if (kDebugMode) {
|
||||
_logCurl(response.requestOptions);
|
||||
debugLog('==========response==========\n${response.data}');
|
||||
}
|
||||
handleResponse(response);
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (kDebugMode) {
|
||||
_logCurl(err.requestOptions);
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
// curl 单行动辄上千字符,走 debugPrint 会被 logcat(~1KB) 截断或被 debugLog 分段;
|
||||
// developer.log 经 VM service 直达 IDE 调试控制台,不走 logcat,整行完整显示
|
||||
void _logCurl(RequestOptions options) {
|
||||
developer.log('$tag curl ====== ${CurlUtil.generateCurl(options)}',
|
||||
name: 'curl');
|
||||
}
|
||||
|
||||
void handleResponse(Response response) {
|
||||
BaseRespBean baseResp;
|
||||
if (response.statusCode != 200) {
|
||||
baseResp = BaseRespBean(response.statusCode, msg: response.statusMessage);
|
||||
} else {
|
||||
if (response.data is Map) {
|
||||
if (Address.aliCcdApi.contains(response.realUri.path)) {
|
||||
baseResp = BaseRespBean(200, data: response.data);
|
||||
} else {
|
||||
baseResp = BaseRespBean.fromJson(response.data);
|
||||
}
|
||||
} else if (response.data is String) {
|
||||
try {
|
||||
baseResp = BaseRespBean.fromJson(json.decode(response.data));
|
||||
} catch (e) {
|
||||
baseResp =
|
||||
BaseRespBean(Code.PARSE_DATE_ERROR, msg: Lang.PARSE_DATE_ERROR);
|
||||
}
|
||||
} else if (response.data is BaseRespBean) {
|
||||
baseResp = response.data;
|
||||
} else {
|
||||
baseResp =
|
||||
BaseRespBean(Code.PARSE_DATE_ERROR, msg: Lang.PARSE_DATE_ERROR);
|
||||
}
|
||||
// 同步下服务器时间
|
||||
httpManager.setServerTime(baseResp.time);
|
||||
final code = baseResp.code;
|
||||
if (code == Code.SUCCESS) {
|
||||
final data = _decryptIfHashed(baseResp);
|
||||
baseResp.printData = data;
|
||||
baseResp.data = data;
|
||||
} else if (code == Code.FORCE_UPDATE_VERSION) {
|
||||
//需要更新
|
||||
baseResp.msg = "您的版本需要更新了";
|
||||
} else if (code == Code.ACCOUNT_INVISIBLE) {
|
||||
//账户被封禁了
|
||||
baseResp.data = _decryptIfHashed(baseResp);
|
||||
//1000 也被短剧下载授权复用成「没有短剧权益」(靠 data.reason 区分),
|
||||
//那种情况顶成封禁文案会吓到正常用户,留服务端自己的 msg
|
||||
final data = baseResp.data;
|
||||
if (data is! Map || data['reason'] == null) baseResp.msg = "您的账号已被封禁了";
|
||||
} else if (code == Code.TOKEN_ABNORMAL) {
|
||||
//token异常:清理 token,后续重新登录
|
||||
baseResp.msg = "token异常";
|
||||
lightKV.setString(StoreKeys.NET_TOKEN, '');
|
||||
} else if (code == Code.VERIFY_CODE_REPEAT) {
|
||||
//验证码频繁异常
|
||||
baseResp.msg = "获取验证码过于频繁";
|
||||
}
|
||||
}
|
||||
if (!baseResp.isSuccess) showToast(baseResp.toast);
|
||||
response.data = baseResp;
|
||||
}
|
||||
|
||||
/// hash 标记的密文统一解密;失败降级为 null——CDN 偶发损坏/截断密文会让
|
||||
/// aesDecryptEx 抛 ArgumentError(corrupted pad block)/RangeError,裸调会冒成
|
||||
/// DioException[unknown] 并连带丢失业务状态(如封禁提示),这里兜底降级
|
||||
dynamic _decryptIfHashed(BaseRespBean baseResp) {
|
||||
if (baseResp.hash != true) return baseResp.data;
|
||||
try {
|
||||
return json.decode(aesDecryptEx(baseResp.data ?? '', Config.encryptKey));
|
||||
} catch (e) {
|
||||
debugLog('解密/解析失败,数据置空', e.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// ignore_for_file: deprecated_member_use
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
|
||||
import '../../../hj_utils/text_util.dart';
|
||||
import 'e_data.dart';
|
||||
|
||||
final _defaultOptions = BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
// dio 原生会加上 request header:accept-encoding gzip,导致部分请求失败
|
||||
// headers: {HttpHeaders.acceptEncodingHeader: "*"},
|
||||
validateStatus: (int? status) => (status ?? 600) < 600,
|
||||
);
|
||||
|
||||
/// 创建一个支持http/http2和兼容tls证书错误的dio层
|
||||
/// cur => http not http2后台暂时不支持
|
||||
/// [mainThread] 默认httpclient在主线程中
|
||||
Dio createDio({BaseOptions? options, bool mainThread = true}) {
|
||||
options ??= _defaultOptions;
|
||||
options.headers[HttpHeaders.acceptEncodingHeader] = "*";
|
||||
var dio = Dio(options);
|
||||
|
||||
var adapter = DefaultHttpClientAdapter();
|
||||
// var adapter = DefaultHttpClientAdapter();
|
||||
|
||||
adapter.onHttpClientCreate = (client) {
|
||||
client.badCertificateCallback = (X509Certificate cert, String host, int port) => true;
|
||||
return client;
|
||||
};
|
||||
|
||||
dio.httpClientAdapter = adapter;
|
||||
return dio;
|
||||
}
|
||||
|
||||
/// 获取一个请求的rangeStart
|
||||
/// range bytes=677636-
|
||||
int getRangeStart(Map<String, String>? reqHeaders) {
|
||||
var rangeStart = 0;
|
||||
if (null != reqHeaders && reqHeaders.containsKey(HttpHeaders.rangeHeader)) {
|
||||
// HttpHeaders.contentRangeHeader
|
||||
// HttpHeaders
|
||||
var rangeStr = reqHeaders[HttpHeaders.rangeHeader];
|
||||
if (TextUtil.isNotEmpty(rangeStr)) {
|
||||
var arr = rangeStr?.split("=");
|
||||
if (arr?.isNotEmpty == true && arr!.length > 1) {
|
||||
var arr2 = arr[1].split("-");
|
||||
if (arr2.isNotEmpty == true) {
|
||||
rangeStart = int.parse(arr2[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rangeStart;
|
||||
}
|
||||
|
||||
/// dio 帮助类
|
||||
class DioCli {
|
||||
// final BaseOptions options;
|
||||
late Dio _dio;
|
||||
DioCli({BaseOptions? options}) {
|
||||
_dio = createDio(options: options);
|
||||
}
|
||||
|
||||
/// 获取文本
|
||||
Future<EData<Response<String>>> getStr(String url, {Options? options, Map<String, dynamic>? headers, CancelToken? cancelToken}) {
|
||||
final opt = options ?? Options();
|
||||
opt.responseType = ResponseType.plain;
|
||||
opt.headers = headers ?? {};
|
||||
return asyncCall(() => _dio.get<String>(url, options: opt, cancelToken: cancelToken));
|
||||
}
|
||||
|
||||
// 获取二进制数据
|
||||
Future<EData<Response<List<int>>>> getBytes(String url, {Options? options, Map<String, dynamic>? headers, CancelToken? cancelToken}) {
|
||||
final opt = options ?? Options();
|
||||
opt.responseType = ResponseType.bytes;
|
||||
opt.headers = headers ?? {};
|
||||
return asyncCall(() => _dio.get<List<int>>(url, options: opt, cancelToken: cancelToken));
|
||||
}
|
||||
|
||||
/// 获取数据流
|
||||
Future<EData<Response<ResponseBody>>> getStream(String url, {Options? options, Map<String, dynamic>? headers, CancelToken? cancelToken}) {
|
||||
final opt = options ?? Options();
|
||||
opt.responseType = ResponseType.stream;
|
||||
opt.headers = headers ?? {};
|
||||
return asyncCall(() => _dio.get<ResponseBody>(url, options: opt, cancelToken: cancelToken));
|
||||
}
|
||||
|
||||
/// 获取json数据
|
||||
Future<EData<Response<Map<String, dynamic>>>> getJSON(String url,
|
||||
{Options? options, Map<String, dynamic>? headers, CancelToken? cancelToken}) {
|
||||
final opt = options ?? Options();
|
||||
opt.responseType = ResponseType.json;
|
||||
opt.headers = headers ?? {};
|
||||
return asyncCall(() => _dio.get<Map<String, dynamic>>(url, options: opt, cancelToken: cancelToken));
|
||||
}
|
||||
|
||||
/// 获取头
|
||||
Future<EData<Response>> getHeader(String url, {Options? options, Map<String, dynamic>? headers, CancelToken? cancelToken}) {
|
||||
final opt = options ?? Options();
|
||||
opt.responseType = ResponseType.bytes;
|
||||
opt.headers = headers ?? {};
|
||||
return asyncCall(() => _dio.head(url, options: opt, cancelToken: cancelToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
// ignore_for_file: unnecessary_null_comparison, constant_identifier_names, depend_on_referenced_packages
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:convert/convert.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'dio_cli.dart';
|
||||
import 'e_data.dart';
|
||||
|
||||
const String _rangeHeader = "Range";
|
||||
const String _acceptsRangesHeader = "Accept-Ranges";
|
||||
const String _etagHeader = "ETag";
|
||||
const String _contentRangeHeader = "Content-Range";
|
||||
|
||||
const int _M = 1024 * 1024;
|
||||
const int _sliceStep = 2 * _M;
|
||||
const Duration _readSliceTimeout = Duration(seconds: 60);
|
||||
|
||||
class _Chunk {
|
||||
final Uint8List data;
|
||||
|
||||
_Chunk(this.data);
|
||||
}
|
||||
|
||||
class DLError extends Error {
|
||||
final Object err;
|
||||
|
||||
DLError(this.err);
|
||||
|
||||
@override
|
||||
String toString() => err.toString();
|
||||
}
|
||||
|
||||
//校对错误
|
||||
class CheckSizeError extends DLError {
|
||||
CheckSizeError() : super("Check Size failed");
|
||||
}
|
||||
|
||||
//网络错误
|
||||
class NetworkError extends DLError {
|
||||
NetworkError(err) : super(err);
|
||||
}
|
||||
|
||||
// 使用Dio.download时的错误
|
||||
class DioDLError extends DLError {
|
||||
DioDLError(err) : super(err);
|
||||
}
|
||||
|
||||
// 文件系统错误
|
||||
class FileSystemError extends DLError {
|
||||
FileSystemError(err) : super(err);
|
||||
}
|
||||
|
||||
_ddPrint(Object msg) => debugLog("oldLog", "[Dio-Downloader] $msg");
|
||||
|
||||
class DioSliceDownloader {
|
||||
final DioCli cli;
|
||||
final String? url;
|
||||
final String? saveDirectory;
|
||||
final String? dioSaveName;
|
||||
final ProgressCallback? onReceiveProgress;
|
||||
final Map<String, dynamic>? headers;
|
||||
final Queue<_Chunk> _cached = Queue();
|
||||
bool _isWriting = false;
|
||||
RandomAccessFile? _raf;
|
||||
String? _remoteETag = "";
|
||||
int _remoteTotalLength = 0;
|
||||
int _localInitSavedLength = 0;
|
||||
int _localCurrentSavedLength = 0;
|
||||
bool _remoteDownloadFinish = false;
|
||||
final Completer<String> _downloadCompleter = Completer();
|
||||
|
||||
DioSliceDownloader(
|
||||
this.cli, this.url, this.headers, this.saveDirectory, this.dioSaveName,
|
||||
{this.onReceiveProgress});
|
||||
|
||||
Map<String, dynamic> _fillRangeHeader(int start, int end) {
|
||||
final Map<String, dynamic> rangeHeader = {
|
||||
_rangeHeader: "bytes=$start-$end"
|
||||
};
|
||||
if (headers != null) rangeHeader.addAll(headers!);
|
||||
return rangeHeader;
|
||||
}
|
||||
|
||||
// 返回 true OR false, 表示是否支持分片下载
|
||||
Future<EData<bool>> _fetchRemoteTotalLength() async {
|
||||
final v = await cli.getHeader(url ?? "", headers: _fillRangeHeader(0, 0));
|
||||
if (v.err != null) return EData(v.err, null);
|
||||
final statusCode = v.data?.statusCode;
|
||||
final remoteHeaders = v.data?.headers;
|
||||
if (remoteHeaders == null) return EData(null, false); //不支持
|
||||
final acceptRanges = remoteHeaders[_acceptsRangesHeader];
|
||||
if (acceptRanges == null ||
|
||||
acceptRanges.isEmpty ||
|
||||
acceptRanges.first != "bytes") return EData(null, false);
|
||||
if (statusCode != HttpStatus.partialContent) {
|
||||
return EData(null, false); //不支持
|
||||
}
|
||||
final etags = remoteHeaders[_etagHeader];
|
||||
if (etags == null || etags.isEmpty) {
|
||||
return EData(null, false); //没有etag 认为不支持
|
||||
}
|
||||
final etag = etags.first;
|
||||
if (etag.isEmpty) return EData(null, false); // etag为空,认为不支持
|
||||
final contentRangeHeaders = remoteHeaders[_contentRangeHeader];
|
||||
if (contentRangeHeaders == null || contentRangeHeaders.isEmpty) {
|
||||
return EData(null, false); //不支持
|
||||
}
|
||||
final contentRangeHeader = contentRangeHeaders.first;
|
||||
if (contentRangeHeader == null) return EData(null, false); //不支持
|
||||
final temp = contentRangeHeader.split("/");
|
||||
if (temp.length != 2) return EData(null, false); //不支持
|
||||
final totalLengthStr = temp[1];
|
||||
final totalLength = int.tryParse(totalLengthStr);
|
||||
if (totalLength == null || totalLength == 0) {
|
||||
return EData(null, false); //不支持
|
||||
}
|
||||
_remoteETag = hex.encode(utf8.encode(etag)); //etag中可能有 " 符号,这里统一处理下
|
||||
_remoteTotalLength = totalLength;
|
||||
_ddPrint("远端etag $etag, 标准化后的etag: $_remoteETag");
|
||||
return EData(null, true);
|
||||
}
|
||||
|
||||
// 使用断点续传的文件名
|
||||
String _getETagSavePath() => p.join(saveDirectory ?? "", "$_remoteETag.apk");
|
||||
|
||||
// 使用原始DIO下载的文件名
|
||||
String _getDioSavePath() => p.join(saveDirectory ?? "", dioSaveName);
|
||||
|
||||
void _closeRAFSync() {
|
||||
if (_raf == null) return;
|
||||
_raf?.closeSync();
|
||||
_raf = null;
|
||||
}
|
||||
|
||||
_clearAndEnsureApkDirectorySync() {
|
||||
final dir = Directory(saveDirectory ?? "");
|
||||
if (dir.existsSync()) dir.deleteSync(recursive: true);
|
||||
dir.createSync(recursive: true);
|
||||
}
|
||||
|
||||
int _getLocalCurrentLengthSync() {
|
||||
final file = File(_getETagSavePath());
|
||||
int length = 0;
|
||||
if (file.existsSync()) {
|
||||
length = file.lengthSync();
|
||||
} else {
|
||||
_clearAndEnsureApkDirectorySync();
|
||||
}
|
||||
_raf = file.openSync(mode: FileMode.writeOnlyAppend);
|
||||
return length;
|
||||
}
|
||||
|
||||
void _completeDownload({String? savePath, DLError? error}) {
|
||||
syncCall(() => _closeRAFSync());
|
||||
if (_downloadCompleter.isCompleted) return;
|
||||
if (error == null) {
|
||||
assert(savePath != null || savePath != "");
|
||||
_downloadCompleter.complete(savePath);
|
||||
} else {
|
||||
_downloadCompleter.completeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> download() async {
|
||||
_ddPrint("开始下载 $url");
|
||||
_fetchRemoteTotalLength().then((support) {
|
||||
if (support.err != null) {
|
||||
_ddPrint("获取远端大小失败 ${support.err}");
|
||||
_completeDownload(error: NetworkError(support.err));
|
||||
return;
|
||||
}
|
||||
if (support.data == true) {
|
||||
final mb = (_remoteTotalLength / _M).toStringAsFixed(2);
|
||||
_ddPrint(
|
||||
"远端支持断点下载 文件总大小:${_remoteTotalLength}B(${mb}MB) 文件标识:$_remoteETag");
|
||||
try {
|
||||
final localCurrentLength = _getLocalCurrentLengthSync();
|
||||
_localInitSavedLength = localCurrentLength;
|
||||
_localCurrentSavedLength = localCurrentLength;
|
||||
_ddPrint("获取本地文件大小成功 已下载:$_localCurrentSavedLength");
|
||||
} catch (e) {
|
||||
_ddPrint("获取本地文件大小失败 错误 $e");
|
||||
_completeDownload(error: FileSystemError(e));
|
||||
return;
|
||||
}
|
||||
_progress();
|
||||
if (_localCurrentSavedLength == _remoteTotalLength) {
|
||||
_ddPrint("本地文件大小和远端文件大小相等,直接完成下载");
|
||||
_completeDownload(savePath: _getETagSavePath());
|
||||
} else if (_localCurrentSavedLength > _remoteTotalLength) {
|
||||
_ddPrint("本地文件大小大于远端文件大小,清除本地目录");
|
||||
syncCall(() => _clearAndEnsureApkDirectorySync());
|
||||
_completeDownload(error: CheckSizeError());
|
||||
} else {
|
||||
_fetchSlice();
|
||||
}
|
||||
} else {
|
||||
_ddPrint("远端不支持断点下载 使用Dio直接下载 ${_getDioSavePath()}");
|
||||
//直接调用dio.
|
||||
final savePath = _getDioSavePath();
|
||||
Dio()
|
||||
.download(url ?? "", savePath, onReceiveProgress: onReceiveProgress)
|
||||
.then((_) {
|
||||
_completeDownload(savePath: savePath);
|
||||
}).catchError((err) {
|
||||
_completeDownload(error: DioDLError(err));
|
||||
});
|
||||
}
|
||||
});
|
||||
return _downloadCompleter.future;
|
||||
}
|
||||
|
||||
void _progress() {
|
||||
if (onReceiveProgress == null) return;
|
||||
syncCall(
|
||||
() => onReceiveProgress!(_localCurrentSavedLength, _remoteTotalLength));
|
||||
}
|
||||
|
||||
Future _fetch(int rangeStart, int rangeEnd) async {
|
||||
final v = await cli.getStream(url ?? "",
|
||||
headers: _fillRangeHeader(rangeStart, rangeEnd));
|
||||
if (v.err != null) {
|
||||
_ddPrint("Dio fetch 获取失败 range:$rangeStart-$rangeEnd err: ${v.err}");
|
||||
return EData(v.err, null);
|
||||
}
|
||||
final completer = Completer();
|
||||
final stream = v.data?.data?.stream;
|
||||
stream?.timeout(_readSliceTimeout, onTimeout: (sink) {
|
||||
sink.addError("Read Stream Timeout");
|
||||
sink.close();
|
||||
}).listen((data) {
|
||||
if (data.isNotEmpty) _cached.add(_Chunk(data));
|
||||
Future.microtask(() => _tryWrite());
|
||||
}, onDone: () {
|
||||
completer.complete();
|
||||
}, onError: (err) {
|
||||
_ddPrint("Dio fetch 获取流失败 range:$rangeStart-$rangeEnd err: $err");
|
||||
completer.completeError(err);
|
||||
}, cancelOnError: true);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void _fetchSlice() async {
|
||||
int currentStart = _localInitSavedLength;
|
||||
while (true) {
|
||||
if (_downloadCompleter.isCompleted) return;
|
||||
final start = currentStart;
|
||||
final end = start + _sliceStep;
|
||||
if (start >= _remoteTotalLength) {
|
||||
_ddPrint("片段下载起始位置已大于总长度, 网络下载完成");
|
||||
_remoteDownloadFinish = true;
|
||||
_tryWrite();
|
||||
break;
|
||||
}
|
||||
_ddPrint("开始下载 $start-$end, 总共$_remoteTotalLength");
|
||||
final v = await asyncCall(() => _fetch(start, end));
|
||||
if (v.err != null) {
|
||||
_ddPrint("错误下载 $start-$end, 总共$_remoteTotalLength err:${v.err}");
|
||||
_completeDownload(error: NetworkError(v.err));
|
||||
return;
|
||||
}
|
||||
currentStart = end + 1; //闭区间
|
||||
}
|
||||
}
|
||||
|
||||
void _tryWrite() async {
|
||||
if (_isWriting || _cached.isEmpty || _downloadCompleter.isCompleted) return;
|
||||
_isWriting = true;
|
||||
final first = _cached.removeFirst();
|
||||
final list = List<int>.from(first.data);
|
||||
final writeRet = await asyncCall(() => _raf?.writeFrom(list));
|
||||
if (writeRet.err != null) {
|
||||
_ddPrint("写入错误: ${writeRet.err}");
|
||||
_completeDownload(error: FileSystemError(writeRet.err));
|
||||
} else {
|
||||
_localCurrentSavedLength += first.data.length;
|
||||
//_ddPrint("写入大小 ${list.length} 当前实际大小:${_raf.lengthSync()} 内存计算大小: $_localCurrentSavedLength");
|
||||
if (_remoteDownloadFinish && _cached.isEmpty) {
|
||||
try {
|
||||
_ddPrint("写入完成 开始刷新磁盘缓存");
|
||||
_raf?.flushSync();
|
||||
_ddPrint("刷新磁盘缓存完成,开始关闭RAF");
|
||||
_closeRAFSync();
|
||||
_ddPrint("关闭RAF完成,开始检查文件大小");
|
||||
//做最后的检查
|
||||
final localLength = File(_getETagSavePath()).lengthSync();
|
||||
if (localLength != _remoteTotalLength) {
|
||||
_ddPrint("检查文件大小失败 local:$localLength, Target:$_remoteTotalLength");
|
||||
_clearAndEnsureApkDirectorySync();
|
||||
_completeDownload(error: CheckSizeError());
|
||||
} else {
|
||||
_ddPrint("检查文件大小完成, 下载完成:${_getETagSavePath()} 大小: $localLength");
|
||||
_completeDownload(savePath: _getETagSavePath());
|
||||
}
|
||||
} catch (e) {
|
||||
_ddPrint("网络下载完成,磁盘刷新错误 $e");
|
||||
_completeDownload(error: FileSystemError(e));
|
||||
}
|
||||
}
|
||||
_progress();
|
||||
}
|
||||
_isWriting = false;
|
||||
_tryWrite();
|
||||
}
|
||||
}
|
||||
|
||||
typedef OnRetry = void Function(int retryCount);
|
||||
|
||||
class DioSliceRetryDownloader {
|
||||
final DioCli cli;
|
||||
final String url;
|
||||
final Map<String, dynamic>? headers;
|
||||
final String saveDirectory;
|
||||
final String dioSaveName;
|
||||
final ProgressCallback? onReceiveProgress;
|
||||
final int retry;
|
||||
final Duration retryInterval;
|
||||
final OnRetry? onRetry;
|
||||
|
||||
DioSliceRetryDownloader(
|
||||
this.cli, this.url, this.headers, this.saveDirectory, this.dioSaveName,
|
||||
{this.onReceiveProgress,
|
||||
this.retry = 3,
|
||||
this.retryInterval = const Duration(seconds: 3),
|
||||
this.onRetry});
|
||||
|
||||
Future<String> _download() {
|
||||
return DioSliceDownloader(cli, url, headers, saveDirectory, dioSaveName,
|
||||
onReceiveProgress: onReceiveProgress)
|
||||
.download();
|
||||
}
|
||||
|
||||
Future<String> download() async {
|
||||
int tryCount = 0;
|
||||
while (true) {
|
||||
tryCount++;
|
||||
try {
|
||||
return await _download();
|
||||
} catch (e) {
|
||||
if (tryCount >= retry) rethrow;
|
||||
syncCall(() => onRetry!(tryCount));
|
||||
await Future.delayed(retryInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/// 同步和异步调用,
|
||||
/// 错误和正确数据
|
||||
class EData<T> {
|
||||
final dynamic _e;
|
||||
final T? _d;
|
||||
|
||||
EData(dynamic e, T? d)
|
||||
: _e = e,
|
||||
_d = d;
|
||||
|
||||
get err => _e;
|
||||
|
||||
T? get data => (_d is T) ? _d : null;
|
||||
}
|
||||
|
||||
/// 同步调用
|
||||
EData<T> syncCall<T>(Function f) {
|
||||
try {
|
||||
return EData(null, f() as T);
|
||||
} catch (e) {
|
||||
return EData(e, null);
|
||||
}
|
||||
}
|
||||
|
||||
//异步调用
|
||||
Future<EData<T>> asyncCall<T>(Function f) async {
|
||||
try {
|
||||
return EData(null, (await f()) as T);
|
||||
} catch (e) {
|
||||
return EData(e, null);
|
||||
}
|
||||
}
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
///错误编码
|
||||
// ignore_for_file: constant_identifier_names
|
||||
|
||||
class Code {
|
||||
///网络异常
|
||||
static const NETWORK_ERROR = 2001;
|
||||
|
||||
///网络超时
|
||||
static const NETWORK_TIMEOUT = 2002;
|
||||
|
||||
///无网络
|
||||
static const LOCAL_NO_NETWORK = 2003;
|
||||
|
||||
///取消请求
|
||||
static const LOCAL_CANCEL_REQUEST = 2004;
|
||||
|
||||
///账号被封禁
|
||||
static const ACCOUNT_INVISIBLE = 1000;
|
||||
|
||||
///数据返回异常
|
||||
static const PARSE_DATE_ERROR = 1001;
|
||||
|
||||
///需要进行强制更新
|
||||
static const FORCE_UPDATE_VERSION = 1006;
|
||||
|
||||
///token异常
|
||||
static const TOKEN_ABNORMAL = 5009;
|
||||
|
||||
///验证码1分钟重复异常
|
||||
static const VERIFY_CODE_REPEAT = 5007;
|
||||
|
||||
///重放请求(同一个 X-Request-ID 换了业务参数)
|
||||
static const REPLAY_ATTACK = 4009;
|
||||
|
||||
///参数错误 / 资源不存在或已下架
|
||||
static const PARAM_INVALID = 4001;
|
||||
|
||||
///扣次事务失败、结果不确定:必须用**同一个** X-Request-ID 重试,换新 id 会重复扣
|
||||
static const CHARGE_UNCERTAIN = 5003;
|
||||
|
||||
///钱包下载次数不足
|
||||
static const NOT_ENOUGH_DOWNLOAD = 7017;
|
||||
|
||||
///金币余额不足
|
||||
static const NOT_ENOUGH_MONEY = 8000;
|
||||
|
||||
///重复购买(视为已解锁)
|
||||
static const REPEAT_BUY = 8005;
|
||||
|
||||
static const SUCCESS = 200;
|
||||
}
|
||||
|
||||
///错误文案
|
||||
class Lang {
|
||||
static const PARSE_DATE_ERROR = '数据返回异常';
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// ignore_for_file: constant_identifier_names, deprecated_member_use
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:hgdj/tools_base/debug_log.dart';
|
||||
import 'package:hgdj/tools_base/net/http_manager.dart';
|
||||
|
||||
import '../../config/address.dart';
|
||||
import '../../hj_utils/light_model.dart';
|
||||
import '../../hj_utils/store_keys.dart';
|
||||
import '../../hj_utils/text_util.dart';
|
||||
// import 'client_api.dart';
|
||||
import '../../track_event_manager/device_service.dart';
|
||||
|
||||
/// 连接超时15秒
|
||||
const int CONNECT_TIME_OUT = 15 * 1000;
|
||||
|
||||
final netManager = NetManager();
|
||||
|
||||
class NetManager {
|
||||
static bool _inited = false;
|
||||
|
||||
init(String baseUrl) {
|
||||
httpManager.init(baseUrl);
|
||||
_inited = true;
|
||||
}
|
||||
|
||||
reset() {
|
||||
netManager.init(Address.baseApiPath ?? "");
|
||||
}
|
||||
|
||||
static bool get isInited => _inited;
|
||||
|
||||
Future<String> getToken() async {
|
||||
// 优先读内存(每次启动自动登录后 setToken 已写入),
|
||||
// 兜底再读 lightKV:iOS release 包若 MMKV 异常,至少内存 token 能保证可用
|
||||
var token = Address.token ?? '';
|
||||
if (token.isEmpty) {
|
||||
token = (await lightKV.getString(StoreKeys.NET_TOKEN)) ?? '';
|
||||
if (token.isNotEmpty) {
|
||||
Address.token = token;
|
||||
}
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
Future setToken(String? token) async {
|
||||
// 该key不共享,不放全局变量
|
||||
Address.token = token;
|
||||
return lightKV.setString(StoreKeys.NET_TOKEN, token);
|
||||
}
|
||||
|
||||
/// 获取 UA:优先命中 KV 缓存。
|
||||
Future<String> userAgent() => DeviceInfoService.getCachedUserAgent();
|
||||
|
||||
/// 强制重新生成并写回缓存(扫码登录、设备切换等场景)。
|
||||
Future<String> refreshUserAgent() => DeviceInfoService.getCachedUserAgent(
|
||||
deviceId: DeviceInfoService.deviceId);
|
||||
|
||||
/// 清除ua
|
||||
Future clearUserAgent() => DeviceInfoService.clearCachedUserAgent();
|
||||
|
||||
/// 服务器时间
|
||||
DateTime? _serverTime;
|
||||
|
||||
/// 服务器时间和本地时间的差值
|
||||
int _diffTimeInSeconds = 0;
|
||||
|
||||
/// 获取上一次服务器返回的时间
|
||||
DateTime? getServerTime() {
|
||||
if (null != _serverTime) {
|
||||
return _serverTime;
|
||||
} else {
|
||||
return DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置服务器时间
|
||||
void setServerTime(String? serverTimeS) {
|
||||
if (TextUtil.isNotEmpty(serverTimeS)) {
|
||||
_serverTime = DateTime.parse(serverTimeS!);
|
||||
_diffTimeInSeconds = DateTime.now().difference(_serverTime!).inSeconds;
|
||||
debugLog(
|
||||
"============>server diff from local in seconds:$_diffTimeInSeconds");
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取修复后的本地时间,应该是和服务器时间是同步的
|
||||
DateTime getFixedCurTime() {
|
||||
return DateTime.now().add(Duration(seconds: -_diffTimeInSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
Dio createDio({BaseOptions? options}) {
|
||||
final defaultOptions = BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 15),
|
||||
// dio 原生会加上 request header:accept-encoding gzip,导致部分请求失败
|
||||
// headers: {HttpHeaders.acceptEncodingHeader: "*"},
|
||||
validateStatus: (int? status) => (status ?? 600) < 600,
|
||||
);
|
||||
options ??= defaultOptions;
|
||||
options.headers[HttpHeaders.acceptEncodingHeader] = "*";
|
||||
var dio = Dio(options);
|
||||
|
||||
var adapter = DefaultHttpClientAdapter();
|
||||
// var adapter = DefaultHttpClientAdapter();
|
||||
adapter.onHttpClientCreate = (client) {
|
||||
client.badCertificateCallback =
|
||||
(X509Certificate cert, String host, int port) => true;
|
||||
return client;
|
||||
};
|
||||
dio.httpClientAdapter = adapter;
|
||||
|
||||
return dio;
|
||||
}
|
||||
Reference in New Issue
Block a user