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 行重复代码。
This commit is contained in:
Bad0RANG3 committed 2026-09-28 14:50:51 +08:00
1 parent 0808e367f7
commit 45214b11a0
21 files changed
+1196 -2176

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
/// 传输层调试日志。
///
/// 之前 `TitleApiService` / `ApiService` 里是 30 多处裸 `print` + `avoid_print`
/// 忽略注释,把请求包、响应包、`token`、`JSESSIONID` 全打到 stdout。这里统一
/// 收口:
/// - 默认只在 debug 构建输出([enabled] 跟随 [kDebugMode]),release 下静默;
/// - 需要单独抓包时可以设置 `ApiLog.enabled = true`。
class ApiLog {
ApiLog._();
static bool enabled = kDebugMode;
static void log(String message) {
if (enabled) debugPrint(message);
}
}
+3 -4
View File
@@ -4,6 +4,7 @@ import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import '../config/title_server_config.dart';
import 'api_log.dart';
class LoginResult {
final bool success;
@@ -71,10 +72,8 @@ class ApiService {
'timestamp': timestamp,
});
// ignore: avoid_print
print('[login] POST $_aimeUrl');
// ignore: avoid_print
print('[login] chipId=$chipId openGameID=$_openGameID');
ApiLog.log('[login] POST $_aimeUrl');
ApiLog.log('[login] chipId=$chipId openGameID=$_openGameID');
final response = await http
.post(
+47 -126
View File
@@ -9,6 +9,7 @@ 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';
@@ -45,8 +46,21 @@ class TitleApiService {
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(),
@@ -107,8 +121,7 @@ class TitleApiService {
RegExp(r'JSESSIONID=([^;,]+)', caseSensitive: false).firstMatch(value);
if (match != null) {
_cookies = 'JSESSIONID=${match.group(1)!.trim()}';
// ignore: avoid_print
print('[$apiName] >>> Captured session cookie: $_cookies');
ApiLog.log('[$apiName] >>> Captured session cookie: $_cookies');
return;
}
}
@@ -135,31 +148,23 @@ class TitleApiService {
Future<Map<String, dynamic>> _callApi(
String apiName,
Map<String, dynamic> packet,
int userId,
) async {
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');
// ignore: avoid_print
print('══════════════════════════════════════');
// ignore: avoid_print
print('[$apiName] >>> REQUEST >>>');
// ignore: avoid_print
print('[$apiName] URL: $url');
// ignore: avoid_print
print('[$apiName] userId: $userId');
// ignore: avoid_print
print('[$apiName] hash: $hash');
// ignore: avoid_print
print('[$apiName] body (encrypted): ${body.length} bytes');
// ignore: avoid_print
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);
// ignore: avoid_print
print('[$apiName] packet (plain):');
// ignore: avoid_print
print(packetStr);
ApiLog.log('[$apiName] packet (plain):');
ApiLog.log(packetStr);
final uaSuffix = userId != 0 ? '$userId' : _config.clientId;
final headers = <String, String>{
@@ -174,8 +179,7 @@ class TitleApiService {
final cookies = _cookies;
if (cookies != null) {
headers['Cookie'] = cookies;
// ignore: avoid_print
print('[$apiName] Cookie: $cookies');
ApiLog.log('[$apiName] Cookie: $cookies');
}
final response = await http
@@ -188,10 +192,8 @@ class TitleApiService {
_captureCookiesFromResponse(response, apiName);
// ignore: avoid_print
print('[$apiName] HTTP status: ${response.statusCode}');
// ignore: avoid_print
print('[$apiName] response bytes: ${response.bodyBytes.length}');
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}');
@@ -203,12 +205,10 @@ class TitleApiService {
final json = _processResponseBody(response.bodyBytes);
final raw = const JsonEncoder.withIndent(' ').convert(json);
// ignore: avoid_print
print('[$apiName] <<< RESPONSE <<<');
// ignore: avoid_print
print(raw);
// ignore: avoid_print
print('══════════════════════════════════════');
if (captureRaw) lastRawResponse = raw;
ApiLog.log('[$apiName] <<< RESPONSE <<<');
ApiLog.log(raw);
ApiLog.log('══════════════════════════════════════');
return json;
}
@@ -243,99 +243,20 @@ class TitleApiService {
required int userId,
required String token,
}) async {
// ignore: avoid_print
print('[getUserPreview] ===== START =====');
// ignore: avoid_print
print('[getUserPreview] userId=$userId');
// ignore: avoid_print
print('[getUserPreview] token=$token');
// ignore: avoid_print
print('[getUserPreview] clientId=${_config.clientId}');
// ignore: avoid_print
print('[getUserPreview] titleServerUrl=${_config.titleServerUrl}');
// ignore: avoid_print
print('[getUserPreview] aesKey.len=${_config.aesKeyBytes.length} aesIv.len=${_config.aesIvBytes.length}');
const apiName = 'GetUserPreviewApi';
final hash = _buildHash(apiName);
// ignore: avoid_print
print('[getUserPreview] hash=$hash');
final packet = {
'userId': userId,
'segaIdAuthKey': '',
'token': token,
'clientId': _config.clientId,
};
// ignore: avoid_print
print('[getUserPreview] packet=${jsonEncode(packet)}');
// ignore: avoid_print
print('[getUserPreview] building request body (compress + encrypt)...');
final body = _buildRequestBody(packet);
// ignore: avoid_print
print('[getUserPreview] body size=${body.length} bytes');
final baseUrl = _normalizeUrl(_config.titleServerUrl);
final url = Uri.parse('$baseUrl/$hash');
// ignore: avoid_print
print('[getUserPreview] POST $url');
try {
final previewHeaders = <String, String>{
'User-Agent': '$hash#$userId',
'Content-Type': 'application/json',
'Mai-Encoding': _config.apiVersion,
'Accept-Encoding': '',
'Charset': 'UTF-8',
'Content-Encoding': 'deflate',
'number': '0',
};
final previewCookies = _cookies;
if (previewCookies != null) {
previewHeaders['Cookie'] = previewCookies;
// ignore: avoid_print
print('[getUserPreview] Cookie: $previewCookies');
}
final response = await http
.post(
url,
headers: previewHeaders,
body: body,
)
.timeout(const Duration(seconds: 15));
// ignore: avoid_print
print('[getUserPreview] HTTP status=${response.statusCode}');
// ignore: avoid_print
print('[getUserPreview] response body size=${response.bodyBytes.length} bytes');
if (response.statusCode != 200) {
// ignore: avoid_print
print('[getUserPreview] non-200 response body: ${utf8.decode(response.bodyBytes.take(500).toList())}');
throw TitleApiException(
'Server returned ${response.statusCode}',
);
}
// ignore: avoid_print
print('[getUserPreview] decrypting + decompressing...');
final json = _processResponseBody(response.bodyBytes);
final raw = const JsonEncoder.withIndent(' ').convert(json);
lastRawResponse = raw;
// ignore: avoid_print
print('[getUserPreview] === RESPONSE ===');
// ignore: avoid_print
print(raw);
// ignore: avoid_print
print('[getUserPreview] ===== END =====');
return UserPreviewDataBean.fromJson(json);
} catch (e) {
// ignore: avoid_print
print('[getUserPreview] ERROR: $e');
rethrow;
}
// 走通用传输层,保留 `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 ----
@@ -22,6 +22,23 @@ class UserAllPayloadBuilder {
static const int minCharacterLevel = 1;
static const int maxCharacterLevel = 999999;
/// 高危操作不写真实成绩,playlog 统一沿用这一条占位记录
/// (eaquira `settings.musicData` 的默认曲 Amber Chronicle)。
static const int placeholderMusicId = 11538;
/// 占位 `musicData`。解锁歌曲时用待解锁曲目覆盖 [musicId],其余字段照旧。
static Map<String, dynamic> placeholderMusicData({int? musicId}) => {
'musicId': musicId ?? placeholderMusicId,
'level': 0,
'playCount': 1,
'achievement': 0,
'comboStatus': 0,
'syncStatus': 0,
'deluxscoreMax': 0,
'scoreRank': 0,
'extNum1': 0,
};
final TitleServerConfig config;
const UserAllPayloadBuilder(this.config);