336 lines
9.7 KiB
Dart
336 lines
9.7 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:http/http.dart' as http;
|
||
|
||
import '../log/app_logger.dart';
|
||
import 'api_config.dart';
|
||
import 'api_crypto.dart';
|
||
|
||
final _proxyLog = AppLogger('ProxyClient');
|
||
|
||
/// 单条日志最大长度;同一 JSON 分多条时按行切分,保持每行完整从而对齐不变
|
||
const int _maxLogChunk = 1000;
|
||
|
||
/// 在 [str] 中从 [start] 起找与 [open] 匹配的括号位置,尊重字符串内不配对。返回匹配的 ] 或 } 的下标,未找到返回 -1。
|
||
int _findMatchingBracket(String str, int start, String open) {
|
||
final close = open == '{' ? '}' : ']';
|
||
int depth = 1;
|
||
int i = start + 1;
|
||
while (i < str.length) {
|
||
final c = str[i];
|
||
if (c == '"') {
|
||
i = _skipJsonString(str, i);
|
||
if (i < 0) return -1;
|
||
continue;
|
||
}
|
||
if (c == open) {
|
||
depth++;
|
||
} else if (c == close) {
|
||
depth--;
|
||
if (depth == 0) return i;
|
||
}
|
||
i++;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
/// 从 [str] 中 [start] 位置的 " 开始,跳过整个 JSON 字符串(处理 \"),返回结束 " 的下一个下标,失败返回 -1。
|
||
int _skipJsonString(String str, int start) {
|
||
if (start >= str.length || str[start] != '"') return -1;
|
||
int i = start + 1;
|
||
while (i < str.length) {
|
||
final c = str[i++];
|
||
if (c == '\\') {
|
||
if (i < str.length) i++;
|
||
continue;
|
||
}
|
||
if (c == '"') return i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
/// 长文本按行分块输出,每块不超过 [_maxLogChunk];按行切分保证同一 JSON 分多条时缩进对齐。
|
||
void _logLong(String text) {
|
||
if (text.isEmpty) return;
|
||
final lines = text.split('\n');
|
||
if (lines.length == 1 && text.length <= _maxLogChunk) {
|
||
_proxyLog.d(text);
|
||
return;
|
||
}
|
||
final buffer = StringBuffer();
|
||
int chunkIndex = 0;
|
||
for (final line in lines) {
|
||
final lineWithNewline = buffer.isEmpty ? line : '\n$line';
|
||
if (buffer.length + lineWithNewline.length > _maxLogChunk &&
|
||
buffer.isNotEmpty) {
|
||
chunkIndex++;
|
||
_proxyLog.d('(part $chunkIndex)\n$buffer');
|
||
buffer.clear();
|
||
buffer.write(line);
|
||
} else {
|
||
if (buffer.isNotEmpty) buffer.write('\n');
|
||
buffer.write(line);
|
||
}
|
||
}
|
||
if (buffer.isNotEmpty) {
|
||
chunkIndex++;
|
||
_proxyLog
|
||
.d(chunkIndex > 1 ? '(part $chunkIndex)\n$buffer' : buffer.toString());
|
||
}
|
||
}
|
||
|
||
/// 遇到 { 或 [ 视为 JSON 开始,直到与之匹配的 } 或 ] 结束;格式化后单条不超过 1000 字,分条时按行切分保持对齐。
|
||
void logWithEmbeddedJson(Object? msg) {
|
||
if (!kDebugMode && !ApiConfig.debugLogs) return;
|
||
|
||
if (msg is! String) {
|
||
_proxyLog.d(msg);
|
||
return;
|
||
}
|
||
|
||
final String str = msg.trim();
|
||
final sb = StringBuffer();
|
||
|
||
void write(String line) {
|
||
sb.writeln(line);
|
||
}
|
||
|
||
void printJson(dynamic value, [int indent = 0]) {
|
||
final pad = ' ' * indent;
|
||
final padInner = ' ' * (indent + 1);
|
||
if (value is Map<String, dynamic>) {
|
||
write('$pad{');
|
||
value.forEach((k, v) {
|
||
if (v is Map || v is List) {
|
||
write('$padInner"$k":');
|
||
printJson(v, indent + 1);
|
||
} else {
|
||
write('$padInner"$k": ${json.encode(v)}');
|
||
}
|
||
});
|
||
write('$pad}');
|
||
} else if (value is List) {
|
||
write('$pad[');
|
||
for (final item in value) {
|
||
printJson(item, indent + 1);
|
||
}
|
||
write('$pad]');
|
||
} else {
|
||
write('$pad${json.encode(value)}');
|
||
}
|
||
}
|
||
|
||
int i = 0;
|
||
int lastEnd = 0;
|
||
|
||
while (i < str.length) {
|
||
final c = str[i];
|
||
if (c == '"') {
|
||
final next = _skipJsonString(str, i);
|
||
if (next > 0) {
|
||
i = next;
|
||
continue;
|
||
}
|
||
i++;
|
||
continue;
|
||
}
|
||
if (c == '{' || c == '[') {
|
||
final end = _findMatchingBracket(str, i, c);
|
||
if (end >= 0) {
|
||
final prefix = str.substring(lastEnd, i).trim();
|
||
if (prefix.isNotEmpty) write(prefix);
|
||
final jsonStr = str.substring(i, end + 1);
|
||
try {
|
||
final parsed = json.decode(jsonStr);
|
||
printJson(parsed, 0);
|
||
} catch (e) {
|
||
write(jsonStr);
|
||
}
|
||
lastEnd = end + 1;
|
||
i = end + 1;
|
||
continue;
|
||
}
|
||
}
|
||
i++;
|
||
}
|
||
|
||
final trailing = str.substring(lastEnd).trim();
|
||
if (trailing.isNotEmpty) write(trailing);
|
||
|
||
final out = sb.toString().trim();
|
||
if (out.isNotEmpty) _logLong(out);
|
||
}
|
||
|
||
void _log(String msg) {
|
||
logWithEmbeddedJson(msg);
|
||
}
|
||
|
||
/// 代理请求体字段名(统一请求参数)
|
||
abstract final class ProxyKeys {
|
||
static const String heroClass = 'hero_class';
|
||
static const String petSpecies = 'pet_species';
|
||
static const String powerLevel = 'power_level';
|
||
static const String questRank = 'quest_rank';
|
||
static const String battleScore = 'battle_score';
|
||
static const String loyaltyIndex = 'loyalty_index';
|
||
static const String billingAddr = 'billing_addr';
|
||
static const String utmTerm = 'utm_term';
|
||
static const String clusterId = 'cluster_id';
|
||
static const String lsnValue = 'lsn_value';
|
||
static const String accuracyVal = 'accuracy_val';
|
||
static const String dirPath = 'dir_path';
|
||
}
|
||
|
||
/// 代理请求客户端
|
||
class ProxyClient {
|
||
ProxyClient({
|
||
this.baseUrl,
|
||
this.packageName = ApiConfig.packageName,
|
||
String? userToken,
|
||
}) : _userToken = userToken;
|
||
|
||
final String? baseUrl;
|
||
final String packageName;
|
||
String? _userToken;
|
||
|
||
String? get userToken => _userToken;
|
||
set userToken(String? value) => _userToken = value;
|
||
|
||
String get _baseUrl => baseUrl ?? ApiConfig.baseUrl;
|
||
|
||
/// 构建 V2 包装体,业务参数填入 sanctum
|
||
Map<String, dynamic> _buildV2Wrapper(Map<String, dynamic> sanctum) {
|
||
return {
|
||
'arsenal': 4,
|
||
'vault': {
|
||
'tome': {
|
||
'codex': {
|
||
'grimoire': {
|
||
'sanctum': sanctum,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
'roar': ApiCrypto.randomAlnum(),
|
||
'clash': ApiCrypto.randomAlnum(),
|
||
'thunder': ApiCrypto.randomAlnum(),
|
||
'rumble': ApiCrypto.randomAlnum(),
|
||
'howl': ApiCrypto.randomAlnum(),
|
||
'growl': ApiCrypto.randomAlnum(),
|
||
};
|
||
}
|
||
|
||
/// 发送代理请求
|
||
/// [path] 接口路径,如 /v1/user/fast_login
|
||
/// [method] HTTP 方法,POST 或 GET
|
||
/// [headers] 请求头,使用 V2 字段名(portal、knight 等)
|
||
/// [queryParams] 查询参数,使用 V2 字段名(sentinel、asset 等)
|
||
/// [body] 请求体,使用 V2 字段名,将填入 sanctum
|
||
Future<ApiResponse> request({
|
||
required String path,
|
||
required String method,
|
||
Map<String, String>? headers,
|
||
Map<String, String>? queryParams,
|
||
Map<String, dynamic>? body,
|
||
}) async {
|
||
final headersMap = Map<String, dynamic>.from(headers ?? {});
|
||
if (packageName.isNotEmpty) {
|
||
headersMap['portal'] = packageName;
|
||
}
|
||
if (_userToken != null && _userToken!.isNotEmpty) {
|
||
headersMap['knight'] = _userToken!;
|
||
}
|
||
|
||
final paramsMap = Map<String, dynamic>.from(
|
||
queryParams?.map((k, v) => MapEntry(k, v)) ?? {},
|
||
);
|
||
|
||
final sanctum = body ?? {};
|
||
final v2Body = _buildV2Wrapper(sanctum);
|
||
|
||
// 原始入参
|
||
final headersEncoded = jsonEncode(headersMap);
|
||
final paramsEncoded = jsonEncode(paramsMap);
|
||
final v2BodyEncoded = jsonEncode(v2Body);
|
||
|
||
final logStr =
|
||
'========== 原始入参 ===========\npath: $path\nmethod: $method\nqueryParams: $paramsEncoded\nbody(sanctum): ${jsonEncode(sanctum)}';
|
||
_log(logStr);
|
||
|
||
final petSpeciesEnc = ApiCrypto.encrypt(path);
|
||
final powerLevelEnc = ApiCrypto.encrypt(method);
|
||
final questRankEnc = ApiCrypto.encrypt(headersEncoded);
|
||
final battleScoreEnc = ApiCrypto.encrypt(paramsEncoded);
|
||
final loyaltyIndexEnc = ApiCrypto.encrypt(v2BodyEncoded);
|
||
|
||
final proxyBody = {
|
||
ProxyKeys.heroClass: 'petsHeroAI',
|
||
ProxyKeys.petSpecies: petSpeciesEnc,
|
||
ProxyKeys.powerLevel: powerLevelEnc,
|
||
ProxyKeys.questRank: questRankEnc,
|
||
ProxyKeys.battleScore: battleScoreEnc,
|
||
ProxyKeys.loyaltyIndex: loyaltyIndexEnc,
|
||
ProxyKeys.billingAddr: ApiCrypto.randomBase64(),
|
||
ProxyKeys.utmTerm: ApiCrypto.randomBase64(),
|
||
ProxyKeys.clusterId: ApiCrypto.randomBase64(),
|
||
ProxyKeys.lsnValue: ApiCrypto.randomBase64(),
|
||
ProxyKeys.accuracyVal: ApiCrypto.randomBase64(),
|
||
ProxyKeys.dirPath: ApiCrypto.randomBase64(),
|
||
};
|
||
_log('加密后的请求体: ${jsonEncode(proxyBody)}');
|
||
|
||
final url = '$_baseUrl${ApiConfig.proxyPath}';
|
||
_log('真实请求URL: $url');
|
||
final response = await http.post(
|
||
Uri.parse(url),
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: jsonEncode(proxyBody),
|
||
);
|
||
|
||
return _parseResponse(response);
|
||
}
|
||
|
||
ApiResponse _parseResponse(http.Response response) {
|
||
try {
|
||
// 响应解密流程:Base64 解码 → AES-ECB 解密 → PKCS5 去填充 → UTF-8 字符串
|
||
var responseLogStr = '========== 响应 ===========';
|
||
final decrypted = ApiCrypto.decrypt(response.body);
|
||
final json = jsonDecode(decrypted) as Map<String, dynamic>;
|
||
responseLogStr += jsonEncode(json);
|
||
_log(responseLogStr);
|
||
// 解析 helm=code, rampart=msg, sidekick=data
|
||
final sanctum = json['vault']?['tome']?['codex']?['grimoire']?['sanctum'];
|
||
if (sanctum is Map<String, dynamic>) {
|
||
return ApiResponse(
|
||
code: sanctum['helm'] as int? ?? -1,
|
||
msg: sanctum['rampart'] as String? ?? '',
|
||
data: sanctum['sidekick'],
|
||
);
|
||
}
|
||
return ApiResponse(
|
||
code: json['helm'] as int? ?? -1,
|
||
msg: json['rampart'] as String? ?? '',
|
||
data: json['sidekick'],
|
||
);
|
||
} catch (e) {
|
||
return ApiResponse(code: -1, msg: e.toString());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 统一 API 响应
|
||
class ApiResponse {
|
||
ApiResponse({
|
||
required this.code,
|
||
this.msg = '',
|
||
this.data,
|
||
});
|
||
|
||
final int code;
|
||
final String msg;
|
||
final dynamic data;
|
||
|
||
bool get isSuccess => code == 0;
|
||
}
|