Files
Ichino/lib/services/title_api_service.dart
T
Bad0RANG3 e9357f5969 refactor: 拆分主页与公共组件,扫码登录改走 WinHTTP
- 抽出 lib/core 的日期/JSON 工具,收敛各页重复解析
- 主页与音乐风险页拆成组件,风险功能共用一套运行框架
- 修复设置页作为主页页签保存时弹掉根路由导致的黑屏
- 登录对齐 Astral aimedb 协议:84 位二维码校验、签名与请求头修正,
  补 GlobalSign 中间证书;Windows 改用 WinHTTP 绕过服务端对
  BoringSSL 的 TLS 指纹拦截(证书校验保持开启)
- 补开发预览数据与测试
2026-10-25 15:51:10 +08:00

613 lines
19 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 'dart:math';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:pointycastle/export.dart';
import '../config/title_server_config.dart';
import '../core/date_time_format.dart';
import '../models/user_data.dart';
import 'api_log.dart';
import '../models/user_character.dart';
import '../models/user_kaleidx_scope.dart';
import '../models/user_preview.dart';
import '../models/user_rating.dart';
class TitleApiException implements Exception {
final String message;
const TitleApiException(this.message);
@override
String toString() => 'TitleApiException: $message';
}
/// 面向用户的错误文本:协议层异常只取 message,其余保留 `toString()` 便于排查。
String describeApiError(Object error) =>
error is TitleApiException ? error.message : error.toString();
typedef CookiesChangedCallback = void Function(String cookies);
class UserLoginResult {
final String token;
final int loginId;
final String lastLoginDate;
final int loginDateTime;
const UserLoginResult({
required this.token,
required this.loginId,
required this.lastLoginDate,
required this.loginDateTime,
});
}
class TitleApiService {
static const String _obfuscateConstant = 'MaimaiChn';
/// 最近一次 `captureRaw` 请求的响应原文(主页调试卡用)。
String? lastRawResponse;
final TitleServerConfig _config;
final CookiesChangedCallback? onCookiesChanged;
String? _cookies;
// 私有命名参数不合法(不能写 `this._cookies`),只能手写初始化。
TitleApiService(
this._config, {
String? cookies,
this.onCookiesChanged,
}) :
// ignore: prefer_initializing_formals
_cookies = cookies;
/// 从 [TitleServerConfigHolder] 构造;未配置时返回 `null`。
///
/// 各页原本都写 `if (!holder.isConfigured) ...; TitleApiService(holder.config!, ...)`,
/// 收口成一个工厂,避免 `config!` 与 `isConfigured` 散落在十几个地方。
static TitleApiService? fromHolder({
String? cookies,
CookiesChangedCallback? onCookiesChanged,
}) {
final holder = TitleServerConfigHolder();
if (!holder.isConfigured) return null;
return TitleApiService(
holder.config!,
cookies: cookies,
onCookiesChanged: onCookiesChanged,
);
}
String? get cookies => _cookies;
/// 组装 payload 时需要同一份配置(地点 / 区域 / clientId)。
TitleServerConfig get config => _config;
Uint8List _aesEncrypt(Uint8List plaintext) {
final cipher = PaddedBlockCipherImpl(
PKCS7Padding(),
CBCBlockCipher(AESEngine()),
)..init(
true,
PaddedBlockCipherParameters(
ParametersWithIV(
KeyParameter(Uint8List.fromList(_config.aesKeyBytes)),
Uint8List.fromList(_config.aesIvBytes),
),
null,
),
);
return cipher.process(plaintext);
}
Uint8List _aesDecrypt(Uint8List ciphertext) {
final cipher = PaddedBlockCipherImpl(
PKCS7Padding(),
CBCBlockCipher(AESEngine()),
)..init(
false,
PaddedBlockCipherParameters(
ParametersWithIV(
KeyParameter(Uint8List.fromList(_config.aesKeyBytes)),
Uint8List.fromList(_config.aesIvBytes),
),
null,
),
);
return cipher.process(ciphertext);
}
Uint8List _compress(List<int> data) {
return Uint8List.fromList(ZLibEncoder().encode(data));
}
Uint8List _decompress(List<int> data) {
return Uint8List.fromList(ZLibDecoder().decodeBytes(data));
}
Uint8List _buildRequestBody(Map<String, dynamic> packet) {
final jsonStr = jsonEncode(packet);
final jsonBytes = utf8.encode(jsonStr);
final compressed = _compress(jsonBytes);
return _aesEncrypt(compressed);
}
/// Port of empurple `HttpResult.cookieHeader()`: only the `JSESSIONID`
/// from the Set-Cookie response headers is used for subsequent requests.
void _captureCookiesFromResponse(http.Response response, String apiName) {
final headerKeys = response.headers.keys.toList();
for (final key in headerKeys) {
if (!key.toLowerCase().contains('set-cookie')) continue;
final value = response.headers[key]!;
final match =
RegExp(r'JSESSIONID=([^;,]+)', caseSensitive: false).firstMatch(value);
if (match != null) {
_cookies = 'JSESSIONID=${match.group(1)!.trim()}';
ApiLog.log('[$apiName] >>> Captured session cookie: $_cookies');
onCookiesChanged?.call(_cookies!);
return;
}
}
}
Map<String, dynamic> _processResponseBody(Uint8List bodyBytes) {
try {
final decrypted = _aesDecrypt(bodyBytes);
final decompressed = _decompress(decrypted);
final jsonStr = utf8.decode(decompressed);
return jsonDecode(jsonStr) as Map<String, dynamic>;
} on FormatException catch (e) {
throw TitleApiException('Response body is not valid JSON: $e');
} on Exception catch (e) {
throw TitleApiException('Failed to process response body: $e');
}
}
String _buildHash(String apiName) {
final raw = apiName + _obfuscateConstant + _config.obfuscateParam;
return md5.convert(utf8.encode(raw)).toString();
}
Future<Map<String, dynamic>> _callApi(
String apiName,
Map<String, dynamic> packet,
int userId, {
bool captureRaw = false,
}) async {
final hash = _buildHash(apiName);
final body = _buildRequestBody(packet);
final baseUrl = _normalizeUrl(_config.titleServerUrl);
final url = Uri.parse('$baseUrl/$hash');
ApiLog.log('══════════════════════════════════════');
ApiLog.log('[$apiName] >>> REQUEST >>>');
ApiLog.log('[$apiName] URL: $url');
ApiLog.log('[$apiName] userId: $userId');
ApiLog.log('[$apiName] hash: $hash');
ApiLog.log('[$apiName] body (encrypted): ${body.length} bytes');
final packetStr = const JsonEncoder.withIndent(' ').convert(packet);
ApiLog.log('[$apiName] packet (plain):');
ApiLog.log(packetStr);
final uaSuffix = userId != 0 ? '$userId' : _config.clientId;
final headers = <String, String>{
'User-Agent': '$hash#$uaSuffix',
'Content-Type': 'application/json',
'Mai-Encoding': _config.apiVersion,
'Accept-Encoding': '',
'Charset': 'UTF-8',
'Content-Encoding': 'deflate',
'number': '0',
};
final cookies = _cookies;
if (cookies != null) {
headers['Cookie'] = cookies;
ApiLog.log('[$apiName] Cookie: $cookies');
}
final response = await http
.post(
url,
headers: headers,
body: body,
)
.timeout(const Duration(seconds: 15));
_captureCookiesFromResponse(response, apiName);
ApiLog.log('[$apiName] HTTP status: ${response.statusCode}');
ApiLog.log('[$apiName] response bytes: ${response.bodyBytes.length}');
if (response.statusCode != 200) {
throw TitleApiException('$apiName returned ${response.statusCode}');
}
if (response.bodyBytes.isEmpty) {
throw TitleApiException('$apiName returned empty body');
}
final json = _processResponseBody(response.bodyBytes);
final raw = const JsonEncoder.withIndent(' ').convert(json);
if (captureRaw) lastRawResponse = raw;
ApiLog.log('[$apiName] <<< RESPONSE <<<');
ApiLog.log(raw);
ApiLog.log('══════════════════════════════════════');
return json;
}
static int calcRandom() {
final rand = _RandomHelper();
final max = 1037933;
final num2 = (rand.nextInt(max - 1) + 1) * 2069 + 1024;
var num3 = 0;
var n = num2;
for (var i = 0; i < 32; i++) {
num3 <<= 1;
num3 += n % 2;
n >>= 1;
}
// 原版 num5 是 C# 的 int,`<<=` 溢出按二进制补码环绕,所以结果恒为合法的
// signed int32(高位被置起时是负数)。Dart 的 int 是 64 位且 n 恒为正,
// 不重解释就会产出 >2^31 的正数,超出 UserGamePlaylog.playSpecial 的 int
// 声明,服务器反序列化失败直接返回 HTTP 500。
return num3.toSigned(32);
}
String _normalizeUrl(String url) {
var normalized = url.trim();
if (normalized.endsWith('/')) {
normalized = normalized.substring(0, normalized.length - 1);
}
return normalized;
}
Future<UserPreviewDataBean> getUserPreview({
required int userId,
required String token,
}) async {
// 走通用传输层,把 GetUserPreviewApi 的原文留在实例上(主页调试卡要用它,
// 而不是随后 UserLoginApi / GetUserDataApi 的响应)。
final json = await _callApi(
'GetUserPreviewApi',
{
'userId': userId,
'segaIdAuthKey': '',
'token': token,
'clientId': _config.clientId,
},
userId,
captureRaw: true,
);
return UserPreviewDataBean.fromJson(json);
}
// ---- Generic data APIs ----
Future<Map<String, dynamic>> getUserData(int userId) async {
return _callApi('GetUserDataApi', {'userId': userId}, userId);
}
Future<UserDataBean> getUserDataTyped(int userId) async {
final json = await getUserData(userId);
return UserDataBean.fromJson(json);
}
Future<Map<String, dynamic>> getUserExtend(int userId) async {
return _callApi('GetUserExtendApi', {'userId': userId}, userId);
}
Future<Map<String, dynamic>> getUserOption(int userId) async {
return _callApi('GetUserOptionApi', {'userId': userId}, userId);
}
Future<Map<String, dynamic>> getUserRating(int userId) async {
return _callApi('GetUserRatingApi', {'userId': userId}, userId);
}
Future<UserRatingBean> getUserRatingTyped(int userId) async {
final json = await getUserRating(userId);
return UserRatingBean.fromJson(json);
}
Future<Map<String, dynamic>> getUserCharge(int userId) async {
return _callApi('GetUserChargeApi', {'userId': userId}, userId);
}
Future<Map<String, dynamic>> getUserActivity(int userId) async {
return _callApi('GetUserActivityApi', {'userId': userId}, userId);
}
Future<Map<String, dynamic>> getUserMissionData(int userId) async {
return _callApi('GetUserMissionDataApi', {'userId': userId}, userId);
}
// ---- UserLogin (returns full result) ----
Future<UserLoginResult> userLoginFull({
required int userId,
required String token,
}) async {
const apiName = 'UserLoginApi';
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final packet = {
'userId': userId,
'accessCode': '',
'regionId': _config.regionId,
'placeId': _config.placeId,
'clientId': _config.clientId,
'dateTime': now - 600,
'loginDateTime': now,
'isContinue': false,
'genericFlag': 0,
'token': token,
};
final json = await _callApi(apiName, packet, userId);
final returnCode = json['returnCode'] as int? ?? -1;
if (returnCode != 1) {
throw TitleApiException('UserLoginApi returnCode=$returnCode');
}
final loginId = (json['loginId'] as num?)?.toInt() ?? 0;
final lastLoginDate = json['lastLoginDate'] as String? ?? '';
final newToken = json['token'] as String? ?? '';
return UserLoginResult(
token: newToken,
loginId: loginId,
lastLoginDate: lastLoginDate,
loginDateTime: now,
);
}
// ---- UserLogout ----
/// Port of empurple/eaquira behavior: the server may omit `returnCode`
/// for fire-and-forget APIs. Only a present-but-not-1 code is an error.
void _checkReturnCode(Map<String, dynamic> json, String apiName) {
final returnCode = json['returnCode'];
if (returnCode == null) return;
final code = (returnCode as num).toInt();
if (code != 1) {
throw TitleApiException('$apiName returnCode=$code');
}
}
Future<void> userLogout({
required int userId,
required int loginDateTime,
}) async {
const apiName = 'UserLogoutApi';
final packet = {
'userId': userId,
'accessCode': '',
'regionId': _config.regionId,
'placeId': _config.placeId,
'clientId': _config.clientId,
'loginDateTime': loginDateTime,
'type': 1, // LogoutType.Logout
};
final json = await _callApi(apiName, packet, userId);
_checkReturnCode(json, apiName);
}
// ---- UpsertUserChargeLog (使用功能票) ----
Future<void> upsertUserChargeLog({
required int userId,
required int ticketId,
required int loginDateTime,
required int playerRating,
int playCount = 0,
}) async {
const apiName = 'UpsertUserChargelogApi';
final now = DateTime.now();
final validDate = DateTime(now.year, now.month, now.day, 4, 0, 0)
.add(const Duration(days: 90));
final purchaseDateStr = formatPlayDateTime(now);
final validDateStr = formatDateTime(validDate);
final packet = {
'userId': userId,
'userChargelog': {
'chargeId': ticketId,
'price': 0,
'purchaseDate': purchaseDateStr,
'playCount': playCount,
'playerRating': playerRating,
'placeId': _config.placeId,
'regionId': _config.regionId,
'clientId': _config.clientId,
},
'userCharge': {
'chargeId': ticketId,
'stock': 1,
'purchaseDate': purchaseDateStr,
'validDate': validDateStr,
},
'loginDateTime': loginDateTime,
};
final json = await _callApi(apiName, packet, userId);
_checkReturnCode(json, apiName);
}
// ---- UpsertUserAll (发包) ----
/// Upload a single playlog record (fake game log).
Future<void> uploadUserPlaylog(
Map<String, dynamic> userPlaylog,
int userId,
) async {
const apiName = 'UploadUserPlaylogApi';
final json = await _callApi(apiName, {
'userId': userId,
'userPlaylog': userPlaylog,
}, userId);
_checkReturnCode(json, apiName);
}
/// Fetch all user data needed to build an UpsertUserAll payload.
/// Returns a map keyed by the raw API names (e.g. 'GetUserDataApi').
Future<Map<String, Map<String, dynamic>>> fetchUserAllData(int userId) async {
final results = await Future.wait([
getUserData(userId),
getUserExtend(userId),
getUserOption(userId),
getUserRating(userId),
getUserCharge(userId),
getUserActivity(userId),
getUserMissionData(userId),
]);
return {
'GetUserDataApi': results[0],
'GetUserExtendApi': results[1],
'GetUserOptionApi': results[2],
'GetUserRatingApi': results[3],
'GetUserChargeApi': results[4],
'GetUserActivityApi': results[5],
'GetUserMissionDataApi': results[6],
};
}
/// Send an UpsertUserAll payload to the server.
Future<void> upsertUserAll(
Map<String, dynamic> packet,
int userId,
) async {
const apiName = 'UpsertUserAllApi';
final json = await _callApi(apiName, packet, userId);
_checkReturnCode(json, apiName);
}
/// Fetch user character list.
Future<List<Map<String, dynamic>>> getUserCharacter(int userId) async {
final json = await _callApi(
'GetUserCharacterApi',
{'userId': userId},
userId,
);
final list = json['userCharacterList'] as List<dynamic>? ?? [];
return list.map((e) => e as Map<String, dynamic>).toList();
}
/// 旅行伙伴(角色)列表,对应 UserCharacterResponseVO。
Future<List<UserCharacterBean>> getUserCharacters(int userId) async {
final json = await _callApi(
'GetUserCharacterApi',
{'userId': userId},
userId,
);
return UserCharacterBean.listFromResponse(json);
}
/// 区域进度列表,对应 UserMapResponseVO。
///
/// 客户端 `PacketGetUserMap.cs:51-57` 是分页拉的:`nextIndex != 0` 就用它续拉,
/// 把所有页的 `userMapList` 拼起来。这里照做,否则只能看到第一页的区域。
Future<List<Map<String, dynamic>>> getUserMaps(int userId) async {
const apiName = 'GetUserMapApi';
final all = <Map<String, dynamic>>[];
var nextIndex = 0;
var pages = 0;
do {
final json = await _callApi(apiName, {
'userId': userId,
'nextIndex': nextIndex,
}, userId);
final list = json['userMapList'];
if (list is List) {
all.addAll(list.whereType<Map<String, dynamic>>());
}
nextIndex = (json['nextIndex'] as num?)?.toInt() ?? 0;
pages++;
} while (nextIndex != 0 && pages < 20);
return all;
}
/// 万花宿命之门进度,对应 UserKaleidxScopeResponseVO。
///
/// 与 `GetUserMapApi` 不同,这个接口不分页(`UserKaleidxScopeRequestVO`
/// 只有 `userId` 一个字段),一次拿全。
Future<List<UserKaleidxScopeBean>> getUserKaleidxScopes(int userId) async {
final json = await _callApi(
'GetUserKaleidxScopeApi',
{'userId': userId},
userId,
);
return UserKaleidxScopeBean.listFromResponse(json);
}
/// 某一类道具的持有情况,对应 UserItemResponseVO。
///
/// `GetUserItemApi` 是**按 itemKind 分页**的:客户端
/// `PacketGetUserItem.cs:19` 首次请求把 `nextIndex` 塞成
/// `itemKind * 10000000000`(`ConstParameter.ItemKindConvert = 1e10`),
/// 之后拿响应里的 `nextIndex` 续拉,直到它为 0。
/// 直接发 `nextIndex: 0` 只会拿到空页,所以要照这个编码来。
Future<List<Map<String, dynamic>>> getUserItems(
int userId,
int itemKind,
) async {
const apiName = 'GetUserItemApi';
final all = <Map<String, dynamic>>[];
var nextIndex = itemKind * 10000000000;
var pages = 0;
do {
final json = await _callApi(apiName, {
'userId': userId,
'nextIndex': nextIndex,
}, userId);
final list = json['userItemList'];
if (list is List) {
all.addAll(list.whereType<Map<String, dynamic>>());
}
nextIndex = (json['nextIndex'] as num?)?.toInt() ?? 0;
pages++;
} while (nextIndex != 0 && pages < 40);
return all;
}
/// 一次读多个 itemKind,返回 `'kind:itemId'` 的键集合。
///
/// 用于给 `isNewItemList` 判每一位是 '1'(服务器没有这行、插入)
/// 还是 '0'(已有、更新)——`VOExtensions.BuildListData:274` 的
/// userItemList 主键是 (itemKind, itemId) 二元组。
Future<Set<String>> getUserItemKeys(
int userId,
Iterable<int> itemKinds,
) async {
final keys = <String>{};
await Future.wait([
for (final kind in itemKinds.toSet())
getUserItems(userId, kind).then((rows) {
for (final row in rows) {
final id = (row['itemId'] as num?)?.toInt();
if (id == null) continue;
keys.add('$kind:$id');
}
}),
]);
return keys;
}
}
class _RandomHelper {
final _random = Random();
int nextInt(int max) => _random.nextInt(max);
}