refactor: 拆分主页与公共组件,扫码登录改走 WinHTTP

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

No files matched your search

+2 -2
View File
@@ -64,7 +64,7 @@ class AccountBackupService {
final value = await read();
sections[name] = value;
} catch (e) {
failed.add('$name: $e');
failed.add('$name: ${describeApiError(e)}');
}
}
@@ -103,7 +103,7 @@ class AccountBackupService {
try {
items['$kind'] = await _api.getUserItems(userId, kind);
} catch (e) {
failed.add('userItemList/$kind: $e');
failed.add('userItemList/$kind: ${describeApiError(e)}');
}
}(),
]);
+374
View File
@@ -0,0 +1,374 @@
import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
import 'api_log.dart';
/// aimedb 的 HTTPS 传输层。
///
/// 服务器按 TLS 指纹拦截:Dart/BoringSSL 的握手能被接受,但紧接着连接就被
/// 服务端关掉(Connection closed before full header was received),而系统栈
/// (WinHTTP / Schannel)能正常拿到响应。Windows 上因此改走 WinHTTP;其它平台
/// 仍用 HttpClient,并手动补上服务器没下发的中间证书。
class AimedbTransport {
AimedbTransport._();
/// 服务器只下发叶子证书,中间证书要自己补:Dart 的 BoringSSL 不会像浏览器
/// 那样顺着证书里的 AIA 地址去补链,缺了它就报 unable to get local issuer。
/// 这里仅把缺失的 GlobalSign 中间证书加进信任链,证书校验本身仍然开启。
static const String _globalSignIntermediatePem = '''
-----BEGIN CERTIFICATE-----
MIIFfjCCA2agAwIBAgIRAIRDWG9jliZDgTN8gBouYRgwDQYJKoZIhvcNAQELBQAw
RjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExHDAaBgNV
BAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwHhcNMjUwOTE3MDI1NTMwWhcNMjkwNjIz
MDAwMDAwWjBWMQswCQYDVQQGEwJCRTEZMBcGA1UEChMQR2xvYmFsU2lnbiBudi1z
YTEsMCoGA1UEAxMjR2xvYmFsU2lnbiBHQ0MgUjQ2IEFscGhhU1NMIENBIDIwMjUw
ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCV0s/1NfwruxzhrfoOWN/B
V8j/6KxInyuIpVJ50pmHimyU5ACjC4ST2ZyU2Ltggfc/ydc1OrTUqDyoTGjWOazH
obK+GQQtBd+MUEykZbnVCfNj14Um7UiqTGOFaOK551Etd9aE/F9/hYl+4XMXJPqC
+V3ziJbO0QQcL4/wZBvqbW3xZjqb/bcLKHttfNF+JMmO+DrT9VWyomNWVeC3Grs4
DybZLgs4RVmg8sp/Ic0quOdBjIhE+W0jYbUW6Z9HP9q2lh7UVCwn1rZ1mFTRhIXa
shGqL43uXnXfRls3T93w2dnCpgTundq28WmWuzb/RZ6kHvALQmu8f891gHfbb+kb
AgMBAAGjggFVMIIBUTAOBgNVHQ8BAf8EBAMCAYYwEwYDVR0lBAwwCgYIKwYBBQUH
AwEwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUo0yssb+0iO2LdNhfNDjJ
6QolnnswHwYDVR0jBBgwFoAUA1yrc4GHqMywptWU4jaWSf8FmSwwewYIKwYBBQUH
AQEEbzBtMC4GCCsGAQUFBzABhiJodHRwOi8vb2NzcC5nbG9iYWxzaWduLmNvbS9y
b290cjQ2MDsGCCsGAQUFBzAChi9odHRwOi8vc2VjdXJlLmdsb2JhbHNpZ24uY29t
L2NhY2VydC9yb290cjQ2LmNydDA2BgNVHR8ELzAtMCugKaAnhiVodHRwOi8vY3Js
Lmdsb2JhbHNpZ24uY29tL3Jvb3RyNDYuY3JsMCEGA1UdIAQaMBgwCAYGZ4EMAQIB
MAwGCisGAQQBoDIKAQMwDQYJKoZIhvcNAQELBQADggIBAFidiFkGjaslf0o1kWbY
Y1Fe0N/OtR28cj5js0b6mhb0AXgyi8m3IOBBnHFsyGb/OGpGlsfnyOCNHNc4p12Z
f8tqkqtd1qh2oks7+MvEAatwDy4NMlQYmjRpdTzTu6+HFv3waK+UOHbm1NC5s5fb
lPjio082KdjQsG+isWSCUGP7hjVjTcPioy5v0HJDYzmbX1oro7fa7potZ4vjNPRI
mMH2St+E2OphOO4NkrllXtSUw5ThyiFaymFIvWfSXSWOHIcK3HwOlUxgpgrJMDi0
ZuKX3W2+wDVRmrPJXgaX+6R/uBqtdMi2O+ebkjmS5zyk2U7sHsaa9lPQz3PS5hBv
aeW0FHeJK4Yc2yeQ/HBRL3YORG5JQdH1+P/+OJnv7s10Qjipe0tPwHccfMSprzRs
0t/2wG3b2GdTBX9JJjxWp3SJs/Bib7ScMJYyMMgrUBQ/BSCreEpvKvrsw2SAsPwY
dx7fjCpsFBM0Tdrqzc1HUm/qNgETPA6tWTMn+27ot19Q94KpnEHYL1hRyCJ5JB/6
OWVNCbn2YhtwJON6787ZbkVHOz9itAZKajPNH/nO/wB4gtlhnQb1yhZ6nG6LZAsu
gJBL58/BSqH1KWGnHyp9s7VwFJPI3LoSEqLd5BryAOQg/5P5uW339YFmbJCcqS3G
3CVvDrQnx+qrRlxBrS/QeigQ
-----END CERTIFICATE-----
''';
static Future<http.Response> post(
Uri uri, {
required Map<String, String> headers,
required List<int> body,
}) async {
if (Platform.isWindows) {
ApiLog.log('[aimedb] transport=WinHTTP');
return _winHttpPost(uri, headers, body);
}
ApiLog.log('[aimedb] transport=Dart HttpClient');
return _dartPost(uri, headers, body);
}
static Future<http.Response> _dartPost(
Uri uri,
Map<String, String> headers,
List<int> body,
) async {
final context = SecurityContext(withTrustedRoots: true)
..setTrustedCertificatesBytes(utf8.encode(_globalSignIntermediatePem));
final client = IOClient(HttpClient(context: context));
try {
final request = http.Request('POST', uri)
..headers.addAll(headers)
..bodyBytes = body;
return await http.Response.fromStream(await client.send(request));
} finally {
client.close();
}
}
/// FFI 调用是同步阻塞的,放到后台 isolate 里跑,避免卡住登录页。
static Future<http.Response> _winHttpPost(
Uri uri,
Map<String, String> headers,
List<int> body,
) async {
final result = await Isolate.run(
() => _winHttpRequest(
host: uri.host,
path: uri.path,
headers: headers,
body: Uint8List.fromList(body),
),
);
if (result.error != null) {
throw HttpException('WinHTTP 请求失败:${result.error}', uri: uri);
}
return http.Response.bytes(
result.body,
result.statusCode,
request: http.Request('POST', uri),
);
}
}
class _WinHttpResult {
final int statusCode;
final Uint8List body;
final String? error;
const _WinHttpResult(this.statusCode, this.body, [this.error]);
}
const int _winHttpAccessAutomaticProxy = 4;
const int _winHttpFlagSecure = 0x00800000;
const int _winHttpQueryStatusCode = 19;
const int _winHttpQueryFlagNumber = 0x20000000;
typedef _OpenNative = IntPtr Function(
Pointer<Utf16>,
Uint32,
Pointer<Utf16>,
Pointer<Utf16>,
Uint32,
);
typedef _OpenDart = int Function(
Pointer<Utf16>,
int,
Pointer<Utf16>,
Pointer<Utf16>,
int,
);
typedef _ConnectNative = IntPtr Function(
IntPtr,
Pointer<Utf16>,
Uint16,
Uint32,
);
typedef _ConnectDart = int Function(int, Pointer<Utf16>, int, int);
typedef _OpenRequestNative = IntPtr Function(
IntPtr,
Pointer<Utf16>,
Pointer<Utf16>,
Pointer<Utf16>,
Pointer<Utf16>,
Pointer<IntPtr>,
Uint32,
);
typedef _OpenRequestDart = int Function(
int,
Pointer<Utf16>,
Pointer<Utf16>,
Pointer<Utf16>,
Pointer<Utf16>,
Pointer<IntPtr>,
int,
);
typedef _SetTimeoutsNative = Int32 Function(IntPtr, Int32, Int32, Int32, Int32);
typedef _SetTimeoutsDart = int Function(int, int, int, int, int);
typedef _SendNative = Int32 Function(
IntPtr,
Pointer<Utf16>,
Uint32,
Pointer<Uint8>,
Uint32,
Uint32,
IntPtr,
);
typedef _SendDart = int Function(
int,
Pointer<Utf16>,
int,
Pointer<Uint8>,
int,
int,
int,
);
typedef _ReceiveNative = Int32 Function(IntPtr, Pointer<Void>);
typedef _ReceiveDart = int Function(int, Pointer<Void>);
typedef _QueryHeadersNative = Int32 Function(
IntPtr,
Uint32,
Pointer<Utf16>,
Pointer<Void>,
Pointer<Uint32>,
Pointer<Uint32>,
);
typedef _QueryHeadersDart = int Function(
int,
int,
Pointer<Utf16>,
Pointer<Void>,
Pointer<Uint32>,
Pointer<Uint32>,
);
typedef _ReadNative = Int32 Function(
IntPtr,
Pointer<Uint8>,
Uint32,
Pointer<Uint32>,
);
typedef _ReadDart = int Function(int, Pointer<Uint8>, int, Pointer<Uint32>);
typedef _CloseNative = Int32 Function(IntPtr);
typedef _CloseDart = int Function(int);
typedef _LastErrorNative = Uint32 Function();
typedef _LastErrorDart = int Function();
class _WinHttp {
static final DynamicLibrary _winhttp = DynamicLibrary.open('winhttp.dll');
static final DynamicLibrary _kernel32 = DynamicLibrary.open('kernel32.dll');
static final _OpenDart open = _winhttp
.lookupFunction<_OpenNative, _OpenDart>('WinHttpOpen');
static final _ConnectDart connect = _winhttp
.lookupFunction<_ConnectNative, _ConnectDart>('WinHttpConnect');
static final _OpenRequestDart openRequest = _winhttp
.lookupFunction<_OpenRequestNative, _OpenRequestDart>(
'WinHttpOpenRequest',
);
static final _SetTimeoutsDart setTimeouts = _winhttp
.lookupFunction<_SetTimeoutsNative, _SetTimeoutsDart>(
'WinHttpSetTimeouts',
);
static final _SendDart send = _winhttp
.lookupFunction<_SendNative, _SendDart>('WinHttpSendRequest');
static final _ReceiveDart receive = _winhttp
.lookupFunction<_ReceiveNative, _ReceiveDart>('WinHttpReceiveResponse');
static final _QueryHeadersDart queryHeaders = _winhttp
.lookupFunction<_QueryHeadersNative, _QueryHeadersDart>(
'WinHttpQueryHeaders',
);
static final _ReadDart read = _winhttp
.lookupFunction<_ReadNative, _ReadDart>('WinHttpReadData');
static final _CloseDart close = _winhttp
.lookupFunction<_CloseNative, _CloseDart>('WinHttpCloseHandle');
static final _LastErrorDart lastError = _kernel32
.lookupFunction<_LastErrorNative, _LastErrorDart>('GetLastError');
}
/// 同步版本,只能在后台 isolate 里调用。
_WinHttpResult _winHttpRequest({
required String host,
required String path,
required Map<String, String> headers,
required Uint8List body,
}) {
final headerText = headers.entries
.map((e) => '${e.key}: ${e.value}')
.join('\r\n');
final agent = 'Ichino/1.0'.toNativeUtf16();
final hostPtr = host.toNativeUtf16();
final verb = 'POST'.toNativeUtf16();
final pathPtr = path.toNativeUtf16();
final version = 'HTTP/1.1'.toNativeUtf16();
final headersPtr = '$headerText\r\n'.toNativeUtf16();
final bodyPtr = calloc<Uint8>(body.isEmpty ? 1 : body.length);
if (body.isNotEmpty) {
bodyPtr.asTypedList(body.length).setAll(0, body);
}
final statusPtr = calloc<Uint32>();
final statusLen = calloc<Uint32>()..value = 4;
final readBuf = calloc<Uint8>(16384);
final readLen = calloc<Uint32>();
var session = 0;
var connect = 0;
var request = 0;
try {
session = _WinHttp.open(
agent,
_winHttpAccessAutomaticProxy,
nullptr,
nullptr,
0,
);
if (session == 0) return _error('WinHttpOpen');
// 同步调用本身没有超时,交给 WinHTTP 内部的四段超时兜底。
_WinHttp.setTimeouts(session, 8000, 12000, 20000, 20000);
connect = _WinHttp.connect(session, hostPtr, 443, 0);
if (connect == 0) return _error('WinHttpConnect');
request = _WinHttp.openRequest(
connect,
verb,
pathPtr,
version,
nullptr,
nullptr,
_winHttpFlagSecure,
);
if (request == 0) return _error('WinHttpOpenRequest');
final sent = _WinHttp.send(
request,
headersPtr,
0xFFFFFFFF,
bodyPtr,
body.length,
body.length,
0,
);
if (sent == 0) return _error('WinHttpSendRequest');
if (_WinHttp.receive(request, nullptr) == 0) {
return _error('WinHttpReceiveResponse');
}
final statusOk = _WinHttp.queryHeaders(
request,
_winHttpQueryStatusCode | _winHttpQueryFlagNumber,
nullptr,
statusPtr.cast(),
statusLen,
nullptr,
);
final statusCode = statusOk == 0 ? 0 : statusPtr.value;
// 必须让 BytesBuilder 复制:readBuf 会在下一轮读取时被覆盖。
final chunks = BytesBuilder();
while (true) {
if (_WinHttp.read(request, readBuf, 16384, readLen) == 0) {
return _error('WinHttpReadData');
}
final n = readLen.value;
if (n == 0) break;
chunks.add(readBuf.asTypedList(n));
if (chunks.length > 4 * 1024 * 1024) break;
}
return _WinHttpResult(statusCode, chunks.takeBytes());
} finally {
if (request != 0) _WinHttp.close(request);
if (connect != 0) _WinHttp.close(connect);
if (session != 0) _WinHttp.close(session);
calloc.free(agent);
calloc.free(hostPtr);
calloc.free(verb);
calloc.free(pathPtr);
calloc.free(version);
calloc.free(headersPtr);
calloc.free(bodyPtr);
calloc.free(statusPtr);
calloc.free(statusLen);
calloc.free(readBuf);
calloc.free(readLen);
}
}
_WinHttpResult _error(String what) {
final code = _WinHttp.lastError();
return _WinHttpResult(0, Uint8List(0), '$what(GetLastError=$code)');
}
+139 -58
View File
@@ -1,9 +1,9 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import '../config/title_server_config.dart';
import 'aimedb_transport.dart';
import 'api_log.dart';
class LoginResult {
@@ -12,90 +12,171 @@ class LoginResult {
final int userId;
final String token;
/// 服务器拒绝登录时给用户的说明(含 errorID 的含义)。
final String? message;
const LoginResult({
required this.success,
required this.errorId,
required this.userId,
required this.token,
this.message,
});
}
/// aimedb 扫码登录:二维码串(或二维码链接)→ userID + token。
///
/// 与 Astral `api/Chime.py` 保持一致:
/// * 签名 key = SHA-256(完整 Keychip + 本机本地时间 + 签名盐),大写 hex;
/// 拼接里不含 qrCode,也不含 titlekey。
/// * `qrCode` 字段发 84 串第 21 位起的 64 位载荷,不是整串。
/// * 请求头**不能带 Content-Type**,带了服务器直接回 errorID=57。
/// * chipID 用完整 Keychip(带连字符);游戏服务器那边才用 11 位短值。
class ApiService {
static const String _aimeHost = 'ai.sys-allnet.cn';
static const String _aimePath = '/wc_aime/api/get_data';
static const String _signKey = 'XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW';
static const String _openGameId = 'MAID';
static const String _titleKey = 'SDGB';
static const String _userAgent = 'WC_AIME_LIB';
static const String _qrPrefix = 'SGWC';
static const String _qrGameId = 'MAID';
/// SGWC(4) + MAID(4) + 时间戳(12)
static const int _qrHeadLength = 20;
static const int _qrTotalLength = 84;
static final Uri _aimeUri = Uri.https(_aimeHost, _aimePath);
final TitleServerConfig? _config;
ApiService([this._config]);
TitleServerConfig? get _cfg => _config ?? TitleServerConfigHolder().config;
String get _chipId => _cfg?.keychipId ?? '';
String get _aimeSalt => _cfg?.aimeSalt ?? '';
String get _aimeUrl => _cfg?.aimeUrl ?? '';
String get _openGameID {
final v = _cfg?.openGameID;
return (v != null && v.isNotEmpty) ? v : 'MAID';
}
String _formatTimestamp() {
final tokyo = DateTime.now().toUtc().add(const Duration(hours: 9));
final y = (tokyo.year % 100).toString().padLeft(2, '0');
final M = tokyo.month.toString().padLeft(2, '0');
final d = tokyo.day.toString().padLeft(2, '0');
final h = tokyo.hour.toString().padLeft(2, '0');
final m = tokyo.minute.toString().padLeft(2, '0');
final s = tokyo.second.toString().padLeft(2, '0');
return '$y$M$d$h$m$s';
}
/// 任意输入 -> 标准 84 字符二维码串,格式不合预期时抛 [FormatException]。
static String normalizeQr(String input) {
var qr = input.trim();
String _sha256(String input) {
final bytes = utf8.encode(input);
return sha256.convert(bytes).toString();
}
static String extractQRCode(String qrCodeToken) {
if (qrCodeToken.length > 64) {
return qrCodeToken.substring(qrCodeToken.length - 64);
if (qr.startsWith('http://') || qr.startsWith('https://')) {
final path = Uri.parse(qr).path;
final match = RegExp(r'/qrcode/req/([^/]+)\.html').firstMatch(path);
if (match == null) {
throw FormatException('这个链接里取不出二维码数据:$qr');
}
qr = _qrPrefix + match.group(1)!;
}
return qrCodeToken;
if (qr.length != _qrTotalLength) {
throw FormatException(
'二维码长度应为 $_qrTotalLength 字符,实际 ${qr.length} 字符',
);
}
if (!qr.startsWith(_qrPrefix)) {
throw FormatException('二维码应当以 "$_qrPrefix" 开头');
}
if (qr.substring(4, 8) != _qrGameId) {
throw FormatException('二维码里的 GameID 应当是 "$_qrGameId"');
}
final payload = qr.substring(_qrHeadLength);
if (!RegExp(r'^[0-9A-F]+$').hasMatch(payload)) {
throw FormatException('二维码载荷必须是大写十六进制');
}
return qr;
}
Future<LoginResult> login(String qrCodeToken) async {
final timestamp = _formatTimestamp();
final qrCode = extractQRCode(qrCodeToken);
final chipId = _chipId;
final rawKey = chipId + timestamp + _aimeSalt;
final key = _sha256(rawKey).toUpperCase();
/// 请求体 `qrCode` 字段的值:84 串去掉前 20 位后的 64 位载荷。
static String extractQRCode(String input) =>
normalizeQr(input).substring(_qrHeadLength);
final body = jsonEncode({
'chipID': chipId,
'openGameID': _openGameID,
'key': key,
'qrCode': qrCode,
'timestamp': timestamp,
});
/// 真机报的是机台本地墙上时间,所以这里跟着设备时区走,不固定偏移。
static String formatTimestamp(DateTime now) {
String two(int v) => v.toString().padLeft(2, '0');
return '${two(now.year % 100)}${two(now.month)}${two(now.day)}'
'${two(now.hour)}${two(now.minute)}${two(now.second)}';
}
ApiLog.log('[login] POST $_aimeUrl');
ApiLog.log('[login] chipId=$chipId openGameID=$_openGameID');
Future<LoginResult> login(String qrInput) async {
final chipId = _cfg?.fullKeychipId ?? '';
if (chipId.isEmpty) {
throw const FormatException('设置里没有 Keychip,无法生成签名');
}
final response = await http
.post(
Uri.parse(_aimeUrl),
headers: {
'Content-Type': 'application/json',
'User-Agent': 'WC_AIME_LIB',
},
body: body,
)
.timeout(const Duration(seconds: 15));
final qr = normalizeQr(qrInput);
final timestamp = formatTimestamp(DateTime.now());
final key = sha256
.convert(utf8.encode(chipId + timestamp + _signKey))
.toString()
.toUpperCase();
final obj = jsonDecode(response.body) as Map<String, dynamic>;
final errorId = obj['errorID'] as int? ?? -1;
final userId = obj['userID'] as int? ?? -1;
final bodyBytes = utf8.encode(
jsonEncode({
'chipID': chipId,
'openGameID': _openGameId,
'key': key,
'qrCode': qr.substring(_qrHeadLength),
'timestamp': timestamp,
'titlekey': _titleKey,
}),
);
ApiLog.log('[login] POST https://$_aimeHost$_aimePath');
ApiLog.log('[login] chipID=$chipId timestamp=$timestamp');
// 不带 Content-Type:aimedb 见到这个头会回 errorID=57。
// 传输层在 Windows 上走 WinHTTP,绕过服务器对 BoringSSL 指纹的拦截。
final response = await AimedbTransport.post(
_aimeUri,
headers: const {'Connection': 'Keep-Alive', 'User-Agent': _userAgent},
body: bodyBytes,
).timeout(const Duration(seconds: 25));
ApiLog.log(
'[login] HTTP ${response.statusCode}, ${response.bodyBytes.length} bytes',
);
final Map<String, dynamic> obj;
try {
obj = jsonDecode(response.body) as Map<String, dynamic>;
} on Exception {
final preview = response.body.trim();
throw FormatException(
'aimedb 的响应不是合法 JSON:'
'${preview.length > 200 ? preview.substring(0, 200) : preview}',
);
}
final errorId = (obj['errorID'] as num?)?.toInt() ?? -1;
final userId = (obj['userID'] as num?)?.toInt() ?? -1;
final token = obj['token'] as String? ?? '';
// Session cookies (JSESSIONID) are captured from the title server's
// UserLoginApi response, not from the Aime server — see empurple.
if (errorId != 0) {
return LoginResult(
success: false,
errorId: errorId,
userId: userId,
token: token,
message: errorId == 57
? '登录被拒(errorID=57):二维码已失效或已被使用'
: '登录被拒(errorID=$errorId)',
);
}
if (userId == -1 || token.isEmpty) {
return LoginResult(
success: false,
errorId: errorId,
userId: userId,
token: token,
message: '扫码登录没有拿到 userID/token'
'(userID=$userId,token 长度 ${token.length})',
);
}
final tokenHead = token.length < 8 ? token : token.substring(0, 8);
ApiLog.log('[login] 成功 userID=$userId token=$tokenHead...');
return LoginResult(
success: errorId == 0,
success: true,
errorId: errorId,
userId: userId,
token: token,
+4 -17
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import '../core/json_value.dart';
import 'music_data_cache.dart';
class MusicInfo {
@@ -62,32 +63,18 @@ class MusicDataService {
if (decoded is! List) return;
_byId.clear();
for (final item in decoded.whereType<Map<String, dynamic>>()) {
final id = _toInt(item['id']);
final id = jsonInt(item['id']);
if (id == 0) continue;
final ds = item['ds'];
_byId[id] = MusicInfo(
title: item['title']?.toString() ?? 'Unknown',
title: jsonString(item['title'], fallback: 'Unknown'),
dsByLevel: ds is List
? {
for (var level = 0; level < ds.length; level++)
level: _toDouble(ds[level]),
level: jsonDouble(ds[level]),
}
: const {},
);
}
}
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
static double _toDouble(dynamic v) {
if (v is double) return v;
if (v is int) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0.0;
return 0.0;
}
}
+32 -15
View File
@@ -8,6 +8,7 @@ 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';
@@ -22,6 +23,12 @@ class TitleApiException implements Exception {
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;
@@ -39,21 +46,37 @@ class UserLoginResult {
class TitleApiService {
static const String _obfuscateConstant = 'MaimaiChn';
static String? lastRawResponse;
/// 最近一次 `captureRaw` 请求的响应原文(主页调试卡用)。
String? lastRawResponse;
final TitleServerConfig _config;
final CookiesChangedCallback? onCookiesChanged;
String? _cookies;
TitleApiService(this._config, {this._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}) {
static TitleApiService? fromHolder({
String? cookies,
CookiesChangedCallback? onCookiesChanged,
}) {
final holder = TitleServerConfigHolder();
if (!holder.isConfigured) return null;
return TitleApiService(holder.config!, cookies: cookies);
return TitleApiService(
holder.config!,
cookies: cookies,
onCookiesChanged: onCookiesChanged,
);
}
String? get cookies => _cookies;
@@ -122,6 +145,7 @@ class TitleApiService {
if (match != null) {
_cookies = 'JSESSIONID=${match.group(1)!.trim()}';
ApiLog.log('[$apiName] >>> Captured session cookie: $_cookies');
onCookiesChanged?.call(_cookies!);
return;
}
}
@@ -243,8 +267,8 @@ class TitleApiService {
required int userId,
required String token,
}) async {
// 走通用传输层,保留 `lastRawResponse`(主页调试卡要用 GetUserPreviewApi
// 的原文,而不是随后 UserLoginApi / GetUserDataApi 的)。
// 走通用传输层,把 GetUserPreviewApi 的原文留在实例上(主页调试卡要用它,
// 而不是随后 UserLoginApi / GetUserDataApi 的响应)。
final json = await _callApi(
'GetUserPreviewApi',
{
@@ -386,10 +410,8 @@ class TitleApiService {
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 purchaseDateStr = formatPlayDateTime(now);
final validDateStr = formatDateTime(validDate);
final packet = {
'userId': userId,
@@ -416,11 +438,6 @@ class TitleApiService {
_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).
+8 -23
View File
@@ -1,4 +1,6 @@
import '../config/title_server_config.dart';
import '../core/date_time_format.dart';
import '../core/json_value.dart';
import '../models/user_character.dart';
import 'title_api_service.dart';
@@ -43,28 +45,12 @@ class UserAllPayloadBuilder {
const UserAllPayloadBuilder(this.config);
static DateTime _shanghaiNow() =>
DateTime.now().toUtc().add(const Duration(hours: 8));
static String _two(int v) => v.toString().padLeft(2, '0');
static String _formatPlayDate(DateTime dt) =>
'${dt.year}-${_two(dt.month)}-${_two(dt.day)}';
static String _formatPlayDateTime(DateTime dt) =>
'${dt.year}-${_two(dt.month)}-${_two(dt.day)} '
'${_two(dt.hour)}:${_two(dt.minute)}:${_two(dt.second)}.0';
/// 客户端 `TimeManager.GetDateString` 的格式是
/// `ToString("yyyy-MM-dd HH:mm:ss.f")`,也就是带一位小数的秒。
/// 万花筒的 `clearDate` 之类字段要按这个形状给。
static String nowTimestamp() => _formatPlayDateTime(_shanghaiNow());
static String nowTimestamp() => formatPlayDateTime(shanghaiNow());
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
return 0;
}
static int _toInt(dynamic v) => jsonInt(v);
static int clampValue(int v) =>
v < minValue ? minValue : (v > maxValue ? maxValue : v);
@@ -122,11 +108,10 @@ class UserAllPayloadBuilder {
(i) => i < charaSlot.length ? charaSlot[i] : 0,
);
final now = _shanghaiNow();
final playDate = _formatPlayDate(now);
final playDateTime = _formatPlayDateTime(now);
final loginDate =
_formatPlayDateTime(DateTime.fromMillisecondsSinceEpoch(loginDateTime * 1000).toUtc().add(const Duration(hours: 8)));
final now = shanghaiNow();
final playDate = formatDate(now);
final playDateTime = formatPlayDateTime(now);
final loginDate = formatPlayDateTime(shanghaiFromUnixSeconds(loginDateTime));
// Lionheart: GetUserDataApi 返回的部分字段不能出现在 UpsertUserAll 的
// userData 中,否则服务器会静默丢弃整包处理。