初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
+32
View File
@@ -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;
}
+12
View File
@@ -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";
}
}
+60
View File
@@ -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;
}
+57
View File
@@ -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;
}
}
+308
View File
@@ -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/BaseRespBeannull/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
View File
@@ -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;
}
}
}
+108
View File
@@ -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);
}
}
}
}
+32
View File
@@ -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);
}
}
+56
View File
@@ -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 = '数据返回异常';
}
+118
View File
@@ -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 已写入),
// 兜底再读 lightKViOS 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;
}