Files
Ichino/lib/services/title_api_service.dart
T
Bad0RANG3 45214b11a0 refactor: 抽取高危功能页共享脚手架与传输层日志
诊断:功能票 / 解锁歌曲 / 收藏品 / 旅行伙伴 / 数值修改 / 一键跑图 /
万花筒 / 高危 hub 这些页面各自抄了一份同样的脚手架——冷却计时器、_Step
枚举、自动退登开关、卡片/提示/进度卡;TitleApiService.getUserPreview 还
重复实现了整套传输层;服务层有 37 处裸 print 会把 token / JSESSIONID
打进日志。

- 新增共享原语,7 个功能页切换到:
  * widgets/cooldown_mixin.dart  CooldownMixin(登录后冷却倒计时)
  * widgets/step_progress.dart   RiskStep + StepProgressCard
  * widgets/app_card.dart        AppCard / SectionTitle / AutoLogoutToggle
  * widgets/app_notice.dart      AppNotice + context.showSnack
- TitleApiService: getUserPreview 收敛进通用 _callApi;新增 fromHolder()
  工厂与 config getter,消除散落的 isConfigured + config!
- 传输层 print -> ApiLog(仅 debug 输出),删除全部 avoid_print 忽略
- UserAllPayloadBuilder 新增 placeholderMusicData()(原 5 处重复)
- LoginPage 从 main.dart 抽到 pages/login_page.dart,入口只留 main + MyApp
- 更新 README / CLAUDE.md 目录结构与已过时的 web/依赖说明

验证:flutter analyze 0 issue(含 info);flutter test 96/96 通过。
纯结构重构,行为保持不变;仅有意统一了功能票 / 音乐风险 / hub 的
卡片与步骤视觉样式。

净删除约 2176 行重复代码。
2026-09-28 14:50:51 +08:00

540 lines
17 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 '../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<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');
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 {
// 走通用传输层,保留 `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<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 = _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<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);
}
}
class _RandomHelper {
final _random = Random();
int nextInt(int max) => _random.nextInt(max);
}