petsHero-AI/lib/core/api/proxy_client.dart
2026-04-28 17:41:36 +08:00

371 lines
12 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.

/// HTTP 代理客户端:业务请求统一 POST 到 [ApiConfig.proxyPath],正文经 AES 加密包装。
///
/// **日志**`_log` / [logWithEmbeddedJson] 仅在 `kDebugMode` 或 [ApiConfig.debugLogs] 为 true 时输出
///release 排障:`flutter run --release --dart-define=APP_LOG_LEVEL=trace`)。
/// 会打印明文入参headers/query/body 加密前)、加密后的 JSON、`Content-Type` 等传输层头,以及解密后的响应。
///
/// **字段**:业务语义放在 V2 `sanctum``portal`/`knight` 等落在逻辑 headers再加密进 `quest_rank` 等槽位,
/// 见下方 [ProxyKeys] 与 [ProxyClient.request]。
library proxy_client;
import 'dart:async';
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 片段并尝试美化输出;否则按普通字符串处理。
///
/// 无输出条件:非 debug 且未开启 [ApiConfig.debugLogs](与 [AppLogger] 一致)。
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);
}
/// 代理调试输出的统一入口(走 [logWithEmbeddedJson])。
void _log(String msg) {
logWithEmbeddedJson(msg);
}
/// 发往网关 JSON 体中的固定键名(各槽位承载加密后的 path/method/headers/query/body
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';
}
/// 代理请求客户端:维护可选 `knight`(用户 Token并对单次请求做加密与解析。
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`、`method`、`headers`、`queryParams`、`body` 会先序列化为 JSON再分别加密写入 [ProxyKeys]
/// 实际 HTTP 始终为 POST`Content-Type: application/json`。
/// 超时见 [ApiConfig.httpRequestTimeout];超时返回 `code == -1`。
///
/// [path] 例如 `/v1/user/fast_login`[headers] 常用 `portal`、`knight`
/// [queryParams] 常用 `sentinel`、`asset`[body] 映射进包装体 `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 =
'========== request (pre-encrypt) ===========\n'
'path: $path\n'
'method: $method\n'
'headers(V2 logical → encrypted into body): $headersEncoded\n'
'queryParams: $paramsEncoded\n'
'body(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');
const httpTransportHeaders = {'Content-Type': 'application/json'};
_log('HTTP transport headers: ${jsonEncode(httpTransportHeaders)}');
http.Response response;
try {
response = await http
.post(
Uri.parse(url),
headers: httpTransportHeaders,
body: jsonEncode(proxyBody),
)
.timeout(ApiConfig.httpRequestTimeout);
} on TimeoutException {
_proxyLog.d('HTTP 超时 (${ApiConfig.httpRequestTimeout.inSeconds}s): $path');
return ApiResponse(
code: -1,
msg: 'Request timed out. Check your network and try again.',
);
}
return _parseResponse(response);
}
/// 解密响应体 JSON映射为 [ApiResponse](业务字段:`helm`=code`rampart`=msg`sidekick`=data
ApiResponse _parseResponse(http.Response response) {
try {
// Base64 → AES-ECB → UTF-8 JSON
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());
}
}
}
/// 代理层统一响应:`code == 0` 表示成功。
class ApiResponse {
ApiResponse({
required this.code,
this.msg = '',
this.data,
});
final int code;
final String msg;
final dynamic data;
bool get isSuccess => code == 0;
}