Files
guangya_flutter/lib/api/guangya_api.dart
T
2026-07-18 10:23:34 +08:00

892 lines
28 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import '../core/http/dio_client.dart';
import '../core/http/http.dart';
import '../core/http/http_error.dart';
import '../core/config/app_config.dart';
/// API client for Guangya Cloud Drive (光鸭云盘).
/// 基于 Dio 封装,参考 harvest_flutter 的 HTTP 架构。
class GuangyaAPI {
String accessToken;
String? refreshTokenValue;
DateTime? tokenExpiresAt;
final String deviceID;
GuangyaAPI({
this.accessToken = '',
String? refreshToken,
String? deviceID,
}) : deviceID = deviceID ?? _generateDeviceID();
static String _generateDeviceID() {
return 'flutter-${DateTime.now().millisecondsSinceEpoch}';
}
/// Update tokens from a JSON response.
AuthTokens? updateTokens(Map<String, dynamic> value) {
final access = _findStringDeep(value, ['access_token', 'accessToken']);
if (access == null) return null;
accessToken = access;
refreshTokenValue =
_findStringDeep(value, ['refresh_token', 'refreshToken']) ?? refreshTokenValue;
final expires = _findIntDeep(value, ['expires_in', 'expiresIn']);
if (expires != null) {
tokenExpiresAt = DateTime.now().add(Duration(seconds: expires));
}
return AuthTokens(
accessToken: access,
refreshToken: refreshTokenValue,
expiresIn: expires?.toDouble(),
);
}
void clearTokens() {
accessToken = '';
refreshTokenValue = null;
tokenExpiresAt = null;
}
// ── Authentication ──────────────────────────────────────────────────
Future<Map<String, dynamic>> loginSMSInit(String phoneNumber,
{String? captchaToken}) async {
final body = <String, dynamic>{
'client_id': AppConfig.clientID,
'action': 'POST:/v1/auth/verification',
'device_id': deviceID,
'meta': {'phone_number': phoneNumber},
};
if (captchaToken != null) body['captcha_token'] = captchaToken;
return Http.accountRequest('/v1/shield/captcha/init', body: body);
}
Future<Map<String, dynamic>> loginSMSSend(String phoneNumber,
{String captchaToken = '', String target = 'ANY'}) async {
return Http.accountRequest('/v1/auth/verification',
body: {
'phone_number': phoneNumber,
'target': target,
'client_id': AppConfig.clientID,
},
extraHeaders: {'x-captcha-token': captchaToken});
}
Future<Map<String, dynamic>> loginSMSVerify(
String verificationID, String verificationCode) async {
return Http.accountRequest('/v1/auth/verification/verify', body: {
'verification_id': verificationID,
'verification_code': verificationCode,
'client_id': AppConfig.clientID,
});
}
Future<Map<String, dynamic>> loginSMSSignIn({
required String code,
required String verificationToken,
required String username,
required String captchaToken,
}) async {
final result = await Http.accountRequest('/v1/auth/signin',
body: {
'verification_code': code,
'verification_token': verificationToken,
'username': username,
'client_id': AppConfig.clientID,
},
extraHeaders: {'x-captcha-token': captchaToken});
updateTokens(result);
return result;
}
Future<Map<String, dynamic>> loginQRInit() async {
return Http.accountRequest('/v1/auth/device/code', body: {
'scope': 'user',
'client_id': AppConfig.clientID,
});
}
Future<Map<String, dynamic>> loginQRPoll(String token) async {
final result = await Http.accountRequest('/v1/auth/token', body: {
'grant_type': 'urn:ietf:params:oauth:grant-type:device_code',
'device_code': token,
'client_id': AppConfig.clientID,
});
updateTokens(result);
return result;
}
Future<Map<String, dynamic>> refreshAccessToken([String? token]) async {
final t = token ?? refreshTokenValue;
if (t == null) throw Exception('没有可用的刷新令牌');
final result = await Http.accountRequest('/v1/auth/token',
body: {
'client_id': AppConfig.clientID,
'grant_type': 'refresh_token',
'refresh_token': t,
},
extraHeaders: {'x-action': '401'});
updateTokens(result);
return result;
}
Future<Map<String, dynamic>> userInfo() async {
return Http.accountRequest('/v1/user/me', body: null, authenticated: true);
}
// ── Cloud downloads ────────────────────────────────────────────────
Future<Map<String, dynamic>> cloudTaskList({
int page = 0,
int pageSize = 50,
List<int> status = const [0, 1, 3, 4],
}) async {
return Http.apiRequest('/nd.bizcloudcollection.s/v1/list_task', body: {
'page': page,
'pageSize': pageSize,
'status': status,
});
}
Future<Map<String, dynamic>> cloudResolveURL(String url) async {
return Http.apiRequest('/nd.bizcloudcollection.s/v1/resolve_res',
body: {'url': url});
}
Future<Map<String, dynamic>> cloudResolveTorrent(File torrentFile) async {
final bytes = await torrentFile.readAsBytes();
final formData = FormData.fromMap({
'torrent': MultipartFile.fromBytes(bytes, filename: 'file.torrent'),
});
return Http.apiRequest('/nd.bizcloudcollection.s/v1/resolve_torrent',
body: formData);
}
Future<Map<String, dynamic>> cloudCreateTask(String url,
{String? parentID}) async {
return Http.apiRequest('/nd.bizcloudcollection.s/v1/create_task', body: {
'url': url,
'parentId': parentID ?? '',
});
}
Future<Map<String, dynamic>> taskStatus(String taskID) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_task_status',
body: {'taskId': taskID});
}
// ── File system ────────────────────────────────────────────────────
Future<Map<String, dynamic>> fsFiles({
String? parentID,
int page = 0,
int pageSize = 50,
int orderBy = 0,
int sortType = 0,
List<int>? fileTypes,
int? resType,
int? dirType,
bool needPlayRecord = false,
}) async {
final body = <String, dynamic>{
'parentId': parentID ?? '',
'page': page,
'pageSize': pageSize,
'orderBy': orderBy,
'sortType': sortType,
};
if (fileTypes != null) body['fileTypes'] = fileTypes;
if (resType != null) body['resType'] = resType;
if (dirType != null) body['dirType'] = dirType;
if (needPlayRecord) body['needPlayRecord'] = true;
return Http.apiRequest('/userres/v1/file/get_file_list', body: body);
}
Future<Map<String, dynamic>> fsCreateDir(String name,
{String? parentID, bool failIfNameExist = false}) async {
final body = <String, dynamic>{
'dirName': name,
'parentId': parentID ?? '',
};
if (failIfNameExist) body['failIfNameExist'] = true;
return Http.apiRequest('/nd.bizuserres.s/v1/file/create_dir',
body: body,
allowedCodes: failIfNameExist ? [159] : []);
}
Future<Map<String, dynamic>> fsCopy(List<String> fileIDs,
{String? parentID}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/copy_file', body: {
'fileIds': fileIDs,
'parentId': parentID ?? '',
});
}
Future<Map<String, dynamic>> fsMove(List<String> fileIDs,
{String? parentID}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/move_file', body: {
'fileIds': fileIDs,
'parentId': parentID ?? '',
});
}
Future<Map<String, dynamic>> fsDelete(List<String> fileIDs) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/delete_file',
body: {'fileIds': fileIDs});
}
Future<Map<String, dynamic>> fsRecycle(List<String> fileIDs) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/recycle_file',
body: {'fileIds': fileIDs});
}
Future<Map<String, dynamic>> fsClearRecycleBin() async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/clear_recycle_bin');
}
Future<Map<String, dynamic>> fsRename(String fileID, String newName) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/rename', body: {
'fileId': fileID,
'newName': newName,
});
}
Future<Map<String, dynamic>> fsDetail(String fileID) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/get_file_detail',
body: {'fileId': fileID});
}
Future<Map<String, dynamic>> fsImageList() async {
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [1], resType: 1);
}
Future<Map<String, dynamic>> fsVideoList() async {
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [2], resType: 1);
}
Future<Map<String, dynamic>> fsAudioList() async {
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [3], resType: 1, needPlayRecord: true);
}
Future<Map<String, dynamic>> fsDocumentList() async {
return fsFiles(parentID: '*', orderBy: 3, sortType: 1, fileTypes: [4], resType: 1);
}
Future<Map<String, dynamic>> fsRecycleFiles() async {
return fsFiles(orderBy: 10, dirType: 4);
}
Future<Map<String, dynamic>> downloadURL(String fileID) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_download_url',
body: {'fileId': fileID});
}
Future<Map<String, dynamic>> vodDownloadURL(String fileID, String gcid) async {
return Http.apiRequest('/userres/v1/file/get_vod_download_url',
body: {'fileId': fileID, 'gcid': gcid});
}
Future<Map<String, dynamic>> recentViewed({
int pageSize = 100,
String cursor = '',
}) async {
return Http.apiRequest('/userres/v1/get_user_action',
body: {'cursor': cursor, 'pageSize': pageSize});
}
Future<Map<String, dynamic>> recentRestored({int pageSize = 100}) async {
return Http.apiRequest('/userres/v1/get_restore_list', body: {
'pageSize': pageSize,
'orderBy': 2,
'sortType': 1,
});
}
// ── Shares ─────────────────────────────────────────────────────────
Future<Map<String, dynamic>> shareCreate(
List<String> fileIDs, {
required String title,
int validateDuration = 0,
int shareType = 1,
String code = '',
bool autoFillCode = true,
String trafficLimit = '0',
int maxRestoreCount = 0,
int downloadType = 1,
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/share_file', body: {
'fileIds': fileIDs,
'title': title,
'validateDuration': validateDuration,
'shareType': shareType,
'code': code,
'autoFillCode': autoFillCode,
'trafficLimit': trafficLimit,
'maxRestoreCount': maxRestoreCount,
'downloadType': downloadType,
});
}
Future<Map<String, dynamic>> shareUserList({
int page = 0,
int pageSize = 50,
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_share_list', body: {
'page': page,
'pageSize': pageSize,
'orderType': 1,
'sortType': 1,
});
}
Future<Map<String, dynamic>> shareDelete(List<String> ids) async {
return Http.apiRequest('/nd.bizuserres.s/v1/delete_share',
body: {'ids': ids});
}
Future<Map<String, dynamic>> shareUpdate(
String shareID, {
required String title,
int validateDuration = 0,
int shareType = 1,
String code = '',
bool autoFillCode = true,
String trafficLimit = '0',
int maxRestoreCount = 0,
int downloadType = 1,
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/update_share', body: {
'id': shareID,
'title': title,
'validateDuration': validateDuration,
'shareType': shareType,
'code': code,
'autoFillCode': autoFillCode,
'trafficLimit': trafficLimit,
'maxRestoreCount': maxRestoreCount,
'downloadType': downloadType,
});
}
Future<Map<String, dynamic>> shareRestore(
String accessToken,
List<String> fileIDs, {
String parentID = '',
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/restore_share', body: {
'accessToken': accessToken,
'fileIds': fileIDs,
'parentId': parentID,
});
}
Future<Map<String, dynamic>> shareDownloadURL(
String fileID, String accessToken) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_share_download_url',
body: {'fileId': fileID, 'accessToken': accessToken});
}
Future<Map<String, dynamic>> shareFilesSize(
String accessToken,
List<String> fileIDs, {
bool download = true,
}) async {
return Http.apiRequest('/nd.bizuserres.s/v1/get_share_files_size',
body: {
'accessToken': accessToken,
'fileIds': fileIDs,
'download': download,
});
}
Future<Map<String, dynamic>> shareFilesList(
String accessToken, {
String parentID = '',
int page = 1,
int pageSize = 50,
int orderBy = 0,
int sortType = 0,
}) async {
return Http.publicRequest(
'/nd.bizuserres.s/v1/get_share_page_files_list',
body: {
'accessToken': accessToken,
'parentId': parentID,
'page': page,
'pageSize': pageSize,
'orderBy': orderBy,
'sortType': sortType,
});
}
Future<Map<String, dynamic>> shareSummary(String shareID) async {
return Http.publicRequest('/nd.bizuserres.s/v1/get_share_summary',
body: {'shareId': shareID});
}
Future<Map<String, dynamic>> shareAccessToken(
String shareID, String code) async {
return Http.publicRequest('/nd.bizuserres.s/v1/get_share_access_token',
body: {'shareId': shareID, 'code': code});
}
// ── Upload ─────────────────────────────────────────────────────────
Future<Map<String, dynamic>> uploadToken({
required String name,
required int fileSize,
String? parentID,
String? md5,
}) async {
final res = <String, dynamic>{'fileSize': fileSize};
if (md5 != null) res['md5'] = _base64MD5(md5);
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_center_token', body: {
'capacity': 2,
'name': name,
'res': res,
'parentId': parentID ?? '',
}, allowedCodes: const [156]);
}
Future<Map<String, dynamic>> flashTransferToken({
required String name,
required int fileSize,
String? parentID,
required String md5,
}) async {
final normalizedMD5 = md5.trim().toLowerCase();
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_center_token', body: {
'capacity': 1,
'name': name,
'res': {'md5': normalizedMD5, 'fileSize': fileSize},
'parentId': parentID ?? '',
}, allowedCodes: const [156]);
}
Future<Map<String, dynamic>> flashTransferGCIDToken({
required String name,
required int fileSize,
String? parentID,
required String gcid,
}) async {
final normalizedGCID = gcid.trim().toUpperCase();
return Http.apiRequest('/nd.bizuserres.s/v1/get_res_center_token', body: {
'capacity': 1,
'name': name,
'res': {'gcid': normalizedGCID, 'fileSize': fileSize},
'parentId': parentID ?? '',
}, allowedCodes: const [156]);
}
Future<Map<String, dynamic>> deleteUploadTask(List<String> taskIDs) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/delete_upload_task',
body: {'taskIds': taskIDs});
}
Future<Map<String, dynamic>> checkCanFlashUpload(
String taskID, String gcid) async {
return Http.apiRequest('/nd.bizuserres.s/v1/check_can_flash_upload',
body: {'taskId': taskID, 'gcid': gcid});
}
Future<Map<String, dynamic>> uploadInfo(String taskID) async {
return Http.apiRequest('/nd.bizuserres.s/v1/file/get_info_by_task_id',
body: {'taskId': taskID},
allowedCodes: const [145, 146, 155, 163]);
}
Future<Map<String, dynamic>> fileUpload(
File file, {
String? parentID,
String contentType = 'application/octet-stream',
int chunkSize = 5 * 1024 * 1024,
}) async {
final bytes = await file.readAsBytes();
final name = file.uri.pathSegments.isEmpty
? file.path.split(Platform.pathSeparator).last
: Uri.decodeComponent(file.uri.pathSegments.last);
final size = bytes.length;
Map<String, dynamic> token;
if (size < 1024 * 1024) {
final md5Base64 = base64Encode(md5.convert(bytes).bytes);
token = await uploadToken(
name: name,
fileSize: size,
parentID: parentID,
md5: md5Base64,
);
final taskID = _findStringDeep(token, ['taskId', 'task_id']);
if (taskID == null) return token;
return uploadInfo(taskID);
}
token = await uploadToken(name: name, fileSize: size, parentID: parentID);
final taskID = _findStringDeep(token, ['taskId', 'task_id']);
if (taskID == null) throw Exception('响应缺少字段:taskId');
final gcid = _calculateGCID(bytes);
final canFlash = await checkCanFlashUpload(taskID, gcid);
if (_findBoolDeep(canFlash, ['canFlashUpload', 'can_flash_upload']) == true) {
return uploadInfo(taskID);
}
final tokenData = _findMapDeep(token, ['data']) ?? token;
await _cdnUpload(
bytes,
tokenData: tokenData,
contentType: contentType,
chunkSize: chunkSize,
);
return uploadInfo(taskID);
}
Future<String> _cdnUpload(
List<int> bytes, {
required Map<String, dynamic> tokenData,
required String contentType,
required int chunkSize,
}) async {
final creds = _findMapDeep(tokenData, ['creds']) ?? const <String, dynamic>{};
final accessKeyID = _findStringDeep(creds, ['accessKeyID', 'accessKeyId']);
final secret = _findStringDeep(creds, ['secretAccessKey']);
final sessionToken = _findStringDeep(creds, ['sessionToken']);
final endpoint = _findStringDeep(tokenData, ['fullEndPoint', 'fullEndpoint']);
final bucket = _findStringDeep(tokenData, ['bucketName']);
final objectPath = _findStringDeep(tokenData, ['objectPath']);
if (accessKeyID == null ||
secret == null ||
sessionToken == null ||
endpoint == null ||
bucket == null ||
objectPath == null) {
throw Exception('响应缺少字段:OSS token data');
}
final objectURL = '$endpoint/$objectPath';
final init = await _ossRequest(
method: 'POST',
url: objectURL,
bucket: bucket,
objectKey: objectPath,
accessKeyID: accessKeyID,
secret: secret,
sessionToken: sessionToken,
subResources: {'uploads': ''},
);
final uploadID = _xmlValue(init.body, 'UploadId');
if (uploadID == null) throw Exception('响应缺少字段:UploadId');
final parts = <MapEntry<int, String>>[];
var offset = 0;
var number = 1;
while (offset < bytes.length) {
final end = min(offset + chunkSize, bytes.length);
final chunk = bytes.sublist(offset, end);
final contentMD5 = base64Encode(md5.convert(chunk).bytes);
final response = await _ossRequest(
method: 'PUT',
url: objectURL,
bucket: bucket,
objectKey: objectPath,
accessKeyID: accessKeyID,
secret: secret,
sessionToken: sessionToken,
content: chunk,
contentType: 'application/octet-stream',
contentMD5: contentMD5,
subResources: {'partNumber': '$number', 'uploadId': uploadID},
);
parts.add(MapEntry(number, response.headers['etag']?.replaceAll('"', '') ?? ''));
offset = end;
number += 1;
}
final xml =
'<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>${parts.map((part) => '<Part><PartNumber>${part.key}</PartNumber><ETag>"${part.value}"</ETag></Part>').join()}</CompleteMultipartUpload>';
final xmlBytes = utf8.encode(xml);
final result = await _ossRequest(
method: 'POST',
url: objectURL,
bucket: bucket,
objectKey: objectPath,
accessKeyID: accessKeyID,
secret: secret,
sessionToken: sessionToken,
content: xmlBytes,
contentType: 'application/xml',
contentMD5: base64Encode(md5.convert(xmlBytes).bytes),
subResources: {'uploadId': uploadID},
);
return _xmlValue(result.body, 'ETag') ?? '';
}
Future<({String body, Map<String, String> headers})> _ossRequest({
required String method,
required String url,
required String bucket,
required String objectKey,
required String accessKeyID,
required String secret,
required String sessionToken,
List<int> content = const [],
String contentType = '',
String contentMD5 = '',
Map<String, String> subResources = const {},
}) async {
final date = HttpDate.format(DateTime.now().toUtc());
final canonicalResource = '/$bucket/$objectKey${_subResourceQuery(subResources)}';
final canonical = [
method.toUpperCase(),
contentMD5,
contentType,
date,
'x-oss-date:$date',
'x-oss-security-token:${sessionToken.trim()}',
canonicalResource,
].join('\n');
final signature = base64Encode(
Hmac(sha1, utf8.encode(secret)).convert(utf8.encode(canonical)).bytes,
);
final uri = Uri.parse('$url${_subResourceQuery(subResources)}');
final response = await Dio().requestUri<List<int>>(
uri,
data: content.isEmpty ? null : Stream.fromIterable([content]),
options: Options(
method: method,
responseType: ResponseType.bytes,
headers: {
'Authorization': 'OSS $accessKeyID:$signature',
'x-oss-date': date,
'x-oss-security-token': sessionToken,
if (contentType.isNotEmpty) 'Content-Type': contentType,
if (contentMD5.isNotEmpty) 'Content-MD5': contentMD5,
},
),
);
final status = response.statusCode ?? 0;
if (status < 200 || status >= 300) {
throw Exception('OSS 请求失败 ($status)');
}
return (
body: utf8.decode(response.data ?? const [], allowMalformed: true),
headers: response.headers.map.map(
(key, value) => MapEntry(key.toLowerCase(), value.join(',')),
),
);
}
// ── TMDB ───────────────────────────────────────────────────────────
Future<Map<String, dynamic>> tmdbSearch(
String query, {
required String apiKey,
String mediaKind = 'auto',
String proxyHost = '',
String proxyPort = '',
int? year,
}) async {
final endpoint = mediaKind == 'movie'
? 'movie'
: (mediaKind == 'tv' ? 'tv' : 'multi');
final params = {
'api_key': apiKey,
'query': query,
'language': 'zh-CN',
'region': 'CN',
'include_adult': 'false',
};
if (year != null) {
if (mediaKind == 'movie') {
params['primary_release_year'] = year.toString();
} else if (mediaKind == 'tv') {
params['first_air_date_year'] = year.toString();
}
}
final uri = Uri.https('api.themoviedb.org', '/3/search/$endpoint', params);
return _tmdbRequest(uri, proxyHost: proxyHost, proxyPort: proxyPort);
}
Future<Map<String, dynamic>> tmdbDetails(
int id, {
required String mediaKind,
required String apiKey,
String proxyHost = '',
String proxyPort = '',
}) async {
final endpoint = mediaKind == 'tv' ? 'tv' : 'movie';
final params = {
'api_key': apiKey,
'language': 'zh-CN',
'append_to_response': 'credits,images,external_ids,translations',
'include_image_language': 'zh-CN,zh,null,en',
};
final uri = Uri.https('api.themoviedb.org', '/3/$endpoint/$id', params);
return _tmdbRequest(uri, proxyHost: proxyHost, proxyPort: proxyPort);
}
Future<Map<String, dynamic>> tmdbEpisodeDetails(
int seriesID, {
required int season,
required int episode,
required String apiKey,
String proxyHost = '',
String proxyPort = '',
}) async {
final params = {
'api_key': apiKey,
'language': 'zh-CN',
'append_to_response': 'credits,images,translations',
'include_image_language': 'zh-CN,zh,null,en',
};
final uri = Uri.https('api.themoviedb.org',
'/3/tv/$seriesID/season/$season/episode/$episode', params);
return _tmdbRequest(uri, proxyHost: proxyHost, proxyPort: proxyPort);
}
// ── HTTP helpers ───────────────────────────────────────────────────
Future<Map<String, dynamic>> _tmdbRequest(
Uri url, {
String proxyHost = '',
String proxyPort = '',
}) async {
final dio = DioClient.dio;
final response = await dio.getUri(url, options: Options(
headers: {'Accept': 'application/json'},
));
if (response.statusCode! < 200 || response.statusCode! >= 300) {
throw Exception('TMDB 请求失败');
}
return response.data as Map<String, dynamic>;
}
// ── Helpers ───────────────────────────────────────────────────────
static String? _findStringDeep(Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final v = json[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
for (final entry in json.entries) {
if (entry.value is Map<String, dynamic>) {
final found =
_findStringDeep(entry.value as Map<String, dynamic>, keys);
if (found != null) return found;
}
}
return null;
}
static int? _findIntDeep(Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final v = json[key];
if (v != null) return v is int ? v : int.tryParse(v.toString());
}
return null;
}
static bool? _findBoolDeep(Map<String, dynamic>? json, List<String> keys) {
if (json == null) return null;
for (final key in keys) {
final value = json[key];
if (value is bool) return value;
if (value != null) {
final text = value.toString().toLowerCase();
if (text == 'true' || text == '1') return true;
if (text == 'false' || text == '0') return false;
}
}
for (final entry in json.entries) {
if (entry.value is Map) {
final found = _findBoolDeep(
Map<String, dynamic>.from(entry.value as Map),
keys,
);
if (found != null) return found;
}
}
return null;
}
static Map<String, dynamic>? _findMapDeep(
Map<String, dynamic>? json,
List<String> keys,
) {
if (json == null) return null;
for (final key in keys) {
final value = json[key];
if (value is Map) return Map<String, dynamic>.from(value);
}
for (final entry in json.entries) {
if (entry.value is Map) {
final found = _findMapDeep(
Map<String, dynamic>.from(entry.value as Map),
keys,
);
if (found != null) return found;
}
}
return null;
}
static String _subResourceQuery(Map<String, String> values) {
if (values.isEmpty) return '';
final keys = values.keys.toList()..sort();
return '?${keys.map((key) {
final value = values[key] ?? '';
return value.isEmpty ? key : '$key=$value';
}).join('&')}';
}
static String? _xmlValue(String xml, String tag) {
final match = RegExp('<$tag>(.*?)</$tag>').firstMatch(xml);
return match?.group(1);
}
static String _base64MD5(String value) {
final normalized = value.trim();
if (!RegExp(r'^[A-Fa-f0-9]{32}$').hasMatch(normalized)) {
return normalized;
}
final bytes = <int>[];
for (var i = 0; i < normalized.length; i += 2) {
bytes.add(int.parse(normalized.substring(i, i + 2), radix: 16));
}
return base64Encode(bytes);
}
static String _calculateGCID(List<int> bytes) {
final length = bytes.length;
final chunkSize = length <= 0x8000000
? 262144
: length <= 0x10000000
? 524288
: length <= 0x20000000
? 1048576
: 2097152;
final hashes = <int>[];
for (var offset = 0; offset < bytes.length; offset += chunkSize) {
final end = min(offset + chunkSize, bytes.length);
hashes.addAll(sha1.convert(bytes.sublist(offset, end)).bytes);
}
return sha1
.convert(hashes)
.bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
.join();
}
void dispose() {
// DioClient 的 Dio 实例是静态的,不需要手动关闭
}
}