Files
Ichino/lib/services/title_api_service.dart
T
Cookiues 80df51c239 fix: 解歌的 isNewItemList 按主键回读再算,不再一律发 1
排查「发 11820 解歌不显示」时发现两处问题:

- applyItemListPatch 的 isNewItemList 一直硬写全 '1'(新增),从没读服务器
  已有行。真机 BuildListData 是按 (itemKind, itemId) 比对快照给 0/1 的,
  重复解锁一首已存在的歌时服务器按插入冲突丢行,表现就是上传成功但没生效。
  这和角色/地图/万花筒三处的处理不一致,现补齐。
- 新增 TitleApiService.getUserItems/getUserItemKeys。GetUserItemApi 是按
  itemKind 分页的,首次 nextIndex 要按 itemKind * 1e10 编码
  (PacketGetUserItem.cs:19),发 0 只会拿到空页。
- 解锁上传后回读复查,把「服务器没存这行道具」与「存了但曲目池不给进」分开报。
- 待解锁列表含 10000~19999 的 DX 曲时提示:Master/Re:Master 不像老曲那样
  白送(IsUnlockMaster 里 id<10000 直接放过),必须显式带 itemKind 6/7。

需要说明:一首歌进不进选曲池由 NotesListManager.cs:70 决定
(IsOpenEvent(eventName) && !IsGameNgMusicId),事件窗口来自 GetGameEventApi、
曲目本体来自机台 Music.xml。11820 若卡在这一层,任何用户包都改不动,
复查那一步就是为了把这种情况和包的问题区分开。

README 补 isNew*List 语义、曲目可见性链路与 DX 段解锁规则三条说明。
删除因此不再被引用的 unlockMusicSuccess。
2026-10-01 22:14:50 +08:00

596 lines
18 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);
}
/// 某一类道具的持有情况,对应 UserItemResponseVO。
///
/// `GetUserItemApi` 是**按 itemKind 分页**的:客户端
/// `PacketGetUserItem.cs:19` 首次请求把 `nextIndex` 塞成
/// `itemKind * 10000000000`(`ConstParameter.ItemKindConvert = 1e10`),
/// 之后拿响应里的 `nextIndex` 续拉,直到它为 0。
/// 直接发 `nextIndex: 0` 只会拿到空页,所以要照这个编码来。
Future<List<Map<String, dynamic>>> getUserItems(
int userId,
int itemKind,
) async {
const apiName = 'GetUserItemApi';
final all = <Map<String, dynamic>>[];
var nextIndex = itemKind * 10000000000;
var pages = 0;
do {
final json = await _callApi(apiName, {
'userId': userId,
'nextIndex': nextIndex,
}, userId);
final list = json['userItemList'];
if (list is List) {
all.addAll(list.whereType<Map<String, dynamic>>());
}
nextIndex = (json['nextIndex'] as num?)?.toInt() ?? 0;
pages++;
} while (nextIndex != 0 && pages < 40);
return all;
}
/// 一次读多个 itemKind,返回 `'kind:itemId'` 的键集合。
///
/// 用于给 `isNewItemList` 判每一位是 '1'(服务器没有这行、插入)
/// 还是 '0'(已有、更新)——`VOExtensions.BuildListData:274` 的
/// userItemList 主键是 (itemKind, itemId) 二元组。
Future<Set<String>> getUserItemKeys(
int userId,
Iterable<int> itemKinds,
) async {
final keys = <String>{};
await Future.wait([
for (final kind in itemKinds.toSet())
getUserItems(userId, kind).then((rows) {
for (final row in rows) {
final id = (row['itemId'] as num?)?.toInt();
if (id == null) continue;
keys.add('$kind:$id');
}
}),
]);
return keys;
}
}
class _RandomHelper {
final _random = Random();
int nextInt(int max) => _random.nextInt(max);
}