refactor: 拆分主页与公共组件,扫码登录改走 WinHTTP
- 抽出 lib/core 的日期/JSON 工具,收敛各页重复解析 - 主页与音乐风险页拆成组件,风险功能共用一套运行框架 - 修复设置页作为主页页签保存时弹掉根路由导致的黑屏 - 登录对齐 Astral aimedb 协议:84 位二维码校验、签名与请求头修正, 补 GlobalSign 中间证书;Windows 改用 WinHTTP 绕过服务端对 BoringSSL 的 TLS 指纹拦截(证书校验保持开启) - 补开发预览数据与测试
This commit is contained in:
1 parent
0cb5320290
commit
e9357f5969
55 files changed
+4387
-3392
No files matched your search
@@ -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)}');
|
||||
}
|
||||
}(),
|
||||
]);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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 中,否则服务器会静默丢弃整包处理。
|
||||
|
||||
Reference in new issue
Block a user