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 '../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'; } 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'; static String? lastRawResponse; final TitleServerConfig _config; String? _cookies; TitleApiService(this._config, {this._cookies}); /// 从 [TitleServerConfigHolder] 构造;未配置时返回 `null`。 /// /// 各页原本都写 `if (!holder.isConfigured) ...; TitleApiService(holder.config!, ...)`, /// 收口成一个工厂,避免 `config!` 与 `isConfigured` 散落在十几个地方。 static TitleApiService? fromHolder({String? cookies}) { final holder = TitleServerConfigHolder(); if (!holder.isConfigured) return null; return TitleApiService(holder.config!, cookies: cookies); } 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 data) { return Uint8List.fromList(ZLibEncoder().encode(data)); } Uint8List _decompress(List data) { return Uint8List.fromList(ZLibDecoder().decodeBytes(data)); } Uint8List _buildRequestBody(Map 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'); return; } } } Map _processResponseBody(Uint8List bodyBytes) { try { final decrypted = _aesDecrypt(bodyBytes); final decompressed = _decompress(decrypted); final jsonStr = utf8.decode(decompressed); return jsonDecode(jsonStr) as Map; } 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> _callApi( String apiName, Map 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 = { '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 getUserPreview({ required int userId, required String token, }) async { // 走通用传输层,保留 `lastRawResponse`(主页调试卡要用 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> getUserData(int userId) async { return _callApi('GetUserDataApi', {'userId': userId}, userId); } Future getUserDataTyped(int userId) async { final json = await getUserData(userId); return UserDataBean.fromJson(json); } Future> getUserExtend(int userId) async { return _callApi('GetUserExtendApi', {'userId': userId}, userId); } Future> getUserOption(int userId) async { return _callApi('GetUserOptionApi', {'userId': userId}, userId); } Future> getUserRating(int userId) async { return _callApi('GetUserRatingApi', {'userId': userId}, userId); } Future getUserRatingTyped(int userId) async { final json = await getUserRating(userId); return UserRatingBean.fromJson(json); } Future> getUserCharge(int userId) async { return _callApi('GetUserChargeApi', {'userId': userId}, userId); } Future> getUserActivity(int userId) async { return _callApi('GetUserActivityApi', {'userId': userId}, userId); } Future> getUserMissionData(int userId) async { return _callApi('GetUserMissionDataApi', {'userId': userId}, userId); } // ---- UserLogin (returns full result) ---- Future 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 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 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 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 = _formatDateTime(now); final validDateStr = '${validDate.year}-${validDate.month.toString().padLeft(2, '0')}-${validDate.day.toString().padLeft(2, '0')} ' '${validDate.hour.toString().padLeft(2, '0')}:${validDate.minute.toString().padLeft(2, '0')}:${validDate.second.toString().padLeft(2, '0')}'; 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); } String _formatDateTime(DateTime dt) { return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ' '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}.0'; } // ---- UpsertUserAll (发包) ---- /// Upload a single playlog record (fake game log). Future uploadUserPlaylog( Map 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>> 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 upsertUserAll( Map packet, int userId, ) async { const apiName = 'UpsertUserAllApi'; final json = await _callApi(apiName, packet, userId); _checkReturnCode(json, apiName); } /// Fetch user character list. Future>> getUserCharacter(int userId) async { final json = await _callApi( 'GetUserCharacterApi', {'userId': userId}, userId, ); final list = json['userCharacterList'] as List? ?? []; return list.map((e) => e as Map).toList(); } /// 旅行伙伴(角色)列表,对应 UserCharacterResponseVO。 Future> 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>> getUserMaps(int userId) async { const apiName = 'GetUserMapApi'; final all = >[]; 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>()); } nextIndex = (json['nextIndex'] as num?)?.toInt() ?? 0; pages++; } while (nextIndex != 0 && pages < 20); return all; } /// 万花宿命之门进度,对应 UserKaleidxScopeResponseVO。 /// /// 与 `GetUserMapApi` 不同,这个接口不分页(`UserKaleidxScopeRequestVO` /// 只有 `userId` 一个字段),一次拿全。 Future> getUserKaleidxScopes(int userId) async { final json = await _callApi( 'GetUserKaleidxScopeApi', {'userId': userId}, userId, ); return UserKaleidxScopeBean.listFromResponse(json); } } class _RandomHelper { final _random = Random(); int nextInt(int max) => _random.nextInt(max); }