petsHero-AI/lib/core/api/proxy_client.dart

336 lines
9.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import '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;
}