Files
Ichino/lib/services/user_all_payload_builder.dart
T
Cookiues 8dda399f76 feat: 旅行伙伴发放与编组、修复退登卡死、优化宽屏布局、加 CI
自动退出登录卡死:_performLogoutAndExit 只 pop() 一层,而解锁/收藏品页是 push
在 HomePage 之上的,退登后被弹回「风险」hub,同时 _loggingOut 仍为 true,
PopScope(canPop:false) + 返回键转圈形成死局。改为 popUntil(isFirst)。

旅行伙伴发不了:它是 ItemKind.Character = 9,与搭档 Partner = 10 是两种东西,
原先收藏品的类型列表 [1,2,3,10,11,12] 里根本没有 9。按客户端实现,角色不走
userItemList(ExportUserItems 不导出 itemKind 9),而是
upsertUserAll.userCharacterList 的 {characterId, level, awakening, useCount},
配 isNewCharacterList 一行一字符。新增发放与 5 槽 charaSlot 编组页,
含「把槽 0 复制到其他槽位」,并校验所选角色确为已拥有或本次待发放。

宽屏布局:各页只有 ConstrainedBox 限宽、没有居中容器,1440 窗口下内容贴左、
右侧大片空白。抽出 responsiveBody 统一居中;B50 预览改用按宽度适配
(InteractiveViewer constrained:false + FittedBox fitWidth),横屏下不再被
contain 缩成中间一小块,导出仍是原始 1762x1850。

CI:.github/workflows/build.yml 先跑 analyze+test 作为门槛,再产出
Windows exe、Android apk、iOS 未签名 ipa(macOS runner,无证书故不可安装)。
README 从 Flutter 模板改写为实际文档,含 path_provider/jni 与 JBR jlink
两个构建环境坑的说明。
2026-09-26 00:37:54 +08:00

403 lines
14 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 '../config/title_server_config.dart';
import '../models/user_character.dart';
import 'title_api_service.dart';
/// Dart port of `eaquira/src/sdgb/payload.py::UserAll_payload` and
/// `eaquira/action/UnlockMusic.py::music_user_all_patcher`.
///
/// Builds the UpsertUserAllApi request packet used by the
/// UpsertMusic (上传成绩) / UnlockMusic (解锁歌曲) workflows.
class UserAllPayloadBuilder {
final TitleServerConfig config;
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';
static int _toInt(dynamic v) {
if (v is int) return v;
if (v is num) return v.toInt();
return 0;
}
/// "1.56.00" -> 1056000 (Lionheart convertVersionNumber).
static int _convertVersionNumber(String version) {
final parts = version.split('.');
if (parts.length != 3) return 1053000;
final major = int.tryParse(parts[0]);
final minor = int.tryParse(parts[1]);
final patch = int.tryParse(parts[2]);
if (major == null || minor == null || patch == null) return 1053000;
return major * 1000000 + minor * 1000 + patch;
}
/// Build the UpsertUserAllApi packet.
///
/// [generalUserInfo] is the result of `TitleApiService.fetchUserAllData`,
/// keyed by the raw API names.
Map<String, dynamic> build({
required int userId,
required int loginId,
required int loginDateTime,
required Map<String, dynamic> musicData,
required Map<String, Map<String, dynamic>> generalUserInfo,
}) {
final userDataResp = generalUserInfo['GetUserDataApi'] ?? const {};
final userExtendResp = generalUserInfo['GetUserExtendApi'] ?? const {};
final userOptionResp = generalUserInfo['GetUserOptionApi'] ?? const {};
final userRatingResp = generalUserInfo['GetUserRatingApi'] ?? const {};
final userChargeResp = generalUserInfo['GetUserChargeApi'] ?? const {};
final userActivityResp = generalUserInfo['GetUserActivityApi'] ?? const {};
final userMissionResp = generalUserInfo['GetUserMissionDataApi'] ?? const {};
final userData =
userDataResp['userData'] as Map<String, dynamic>? ?? const {};
final playerRating = _toInt(userData['playerRating']);
final charaSlot = (userData['charaSlot'] as List<dynamic>? ?? const [])
.map((e) => _toInt(e))
.toList();
final paddedCharaSlot = List<int>.generate(
5,
(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)));
// Lionheart: GetUserDataApi 返回的部分字段不能出现在 UpsertUserAll 的
// userData 中,否则服务器会静默丢弃整包处理。
final newUserData = Map<String, dynamic>.from(userData)
..remove('friendCode')
..remove('nameplateId')
..remove('trophyId')
..remove('cmLastEmoneyBrand')
..remove('cmLastEmoneyCredit');
newUserData
..['accessCode'] = ''
..['isNetMember'] = 1
..['playCount'] = _toInt(userData['playCount']) + 1
..['currentPlayCount'] = _toInt(userData['currentPlayCount']) + 1
..['lastGameId'] = 'SDGB'
..['lastLoginDate'] = loginDate
..['lastPlayDate'] = playDateTime
..['lastPlayCredit'] = 1
..['lastPlayMode'] = 0
..['lastPlaceId'] = config.placeId
..['lastPlaceName'] = config.placeName
..['lastAllNetId'] = 0
..['lastRegionId'] = config.regionId
..['lastRegionName'] = config.regionName
..['lastClientId'] = config.clientId
..['lastCountryCode'] = 'CHN'
..['banState'] = userDataResp['banState']
..['dateTime'] = loginDateTime;
// Lionheart: userOption 需去掉 tempoVolume。
final userOption =
userOptionResp['userOption'] as Map<String, dynamic>? ?? const {};
final newUserOption = Map<String, dynamic>.from(userOption)
..remove('tempoVolume');
// Lionheart: userRating.udemae 的大写重复字段需去掉。
final userRating =
userRatingResp['userRating'] as Map<String, dynamic>? ?? const {};
final udemae = userRating['udemae'] as Map<String, dynamic>?;
if (udemae != null) {
for (final key in [
'MaxLoseNum',
'NpcLoseNum',
'NpcMaxLoseNum',
'NpcMaxWinNum',
'NpcTotalLoseNum',
'NpcTotalWinNum',
'NpcWinNum',
]) {
udemae.remove(key);
}
}
// Lionheart: userChargeList 只保留 4 个字段 (无 extNum1)。
final userChargeList = [
for (final c in (userChargeResp['userChargeList'] as List<dynamic>? ??
const []))
if (c is Map<String, dynamic>)
{
'chargeId': c['chargeId'],
'stock': c['stock'],
'purchaseDate': c['purchaseDate'],
'validDate': c['validDate'],
},
];
final missionList =
userMissionResp['userMissionDataList'] as List<dynamic>? ?? const [];
final missions = <Map<String, dynamic>>[];
for (final raw in missionList.take(6)) {
final m = raw as Map<String, dynamic>;
missions.add({
'type': m['type'],
'difficulty': m['difficulty'],
'targetGenreId': m['targetGenreId'],
'targetGenreTableId': m['targetGenreTableId'],
'conditionGenreId': m['conditionGenreId'],
'conditionGenreTableId': m['conditionGenreTableId'],
'clearFlag': m['clearFlag'],
});
}
final weekly =
userMissionResp['userWeeklyData'] as Map<String, dynamic>? ?? const {};
final achievement = _toInt(musicData['achievement']);
final gradeRating = _toInt(userData['gradeRating']);
final playlog = <String, dynamic>{
'userId': 0,
'orderId': 0,
'playlogId': loginId,
'version': _convertVersionNumber(
userData['lastRomVersion'] as String? ?? '',
),
'placeId': config.placeId,
'placeName': config.placeName,
'loginDate': loginDateTime,
'playDate': playDate,
'userPlayDate': playDateTime,
'type': 0,
'musicId': musicData['musicId'],
'level': musicData['level'],
'trackNo': 1,
'vsMode': 0,
'vsUserName': '',
'vsStatus': 0,
'vsUserRating': 0,
'vsUserAchievement': 0,
'vsUserGradeRank': 0,
'vsRank': 0,
'playerNum': 1,
'playedUserId1': 0,
'playedUserName1': '',
'playedMusicLevel1': 0,
'playedUserId2': 0,
'playedUserName2': '',
'playedMusicLevel2': 0,
'playedUserId3': 0,
'playedUserName3': '',
'playedMusicLevel3': 0,
'achievement': achievement,
'deluxscore': musicData['deluxscoreMax'],
'scoreRank': musicData['scoreRank'],
'maxCombo': 0,
'totalCombo': 1,
'maxSync': 1,
'totalSync': 0,
'tapCriticalPerfect': 0,
'tapPerfect': 0,
'tapGreat': 0,
'tapGood': 0,
'tapMiss': 1,
'holdCriticalPerfect': 0,
'holdPerfect': 0,
'holdGreat': 0,
'holdGood': 0,
'holdMiss': 0,
'slideCriticalPerfect': 0,
'slidePerfect': 0,
'slideGreat': 0,
'slideGood': 0,
'slideMiss': 0,
'touchCriticalPerfect': 0,
'touchPerfect': 0,
'touchGreat': 0,
'touchGood': 0,
'touchMiss': 0,
'breakCriticalPerfect': 0,
'breakPerfect': 0,
'breakGreat': 0,
'breakGood': 0,
'breakMiss': 0,
'isTap': true,
'isHold': false,
'isSlide': false,
'isTouch': false,
'isBreak': false,
'isCriticalDisp': false,
'isFastLateDisp': true,
'fastCount': 0,
'lateCount': 0,
'isAchieveNewRecord': false,
'isDeluxscoreNewRecord': false,
'comboStatus': musicData['comboStatus'],
'syncStatus': musicData['syncStatus'],
'isClear': achievement >= 500000,
'beforeRating': playerRating,
'afterRating': playerRating,
'beforeGrade': gradeRating,
'afterGrade': gradeRating,
'afterGradeRank': _toInt(userData['gradeRank']),
'beforeDeluxRating': playerRating,
'afterDeluxRating': playerRating,
'isPlayTutorial': false,
'isEventMode': false,
'isFreedomMode': false,
'playMode': 0,
'isNewFree': false,
'trialPlayAchievement': -1,
'extNum1': 0,
'extNum2': 0,
'extNum4': 0,
'extBool1': false,
'extBool2': false,
'extBool3': false,
};
for (var i = 0; i < 5; i++) {
playlog['characterId${i + 1}'] = paddedCharaSlot[i];
playlog['characterLevel${i + 1}'] = 1;
playlog['characterAwakening${i + 1}'] = 0;
}
return {
'userId': userId,
'playlogId': loginId,
'isEventMode': false,
'isFreePlay': false,
'loginDateTime': loginDateTime,
'userPlaylogList': [playlog],
'upsertUserAll': {
'userData': [newUserData],
'userExtend': [userExtendResp['userExtend']],
'userOption': [newUserOption],
'userCharacterList': [],
'userGhost': [],
'userMapList': [],
'userLoginBonusList': [],
'userRatingList': [userRating],
'userItemList': [],
'userMusicDetailList': [musicData],
'userCourseList': [],
'userFriendSeasonRankingList': [],
'userChargeList': userChargeList,
'userFavoriteList': [],
'userActivityList': [userActivityResp['userActivity']],
'userMissionDataList': missions,
'userWeeklyData': {
'lastLoginWeek': weekly['lastLoginWeek'],
'beforeLoginWeek': weekly['beforeLoginWeek'],
'friendBonusFlag': weekly['friendBonusFlag'],
},
'userGamePlaylogList': [
{
'playlogId': loginId,
'version': userData['lastRomVersion'],
'playDate': playDateTime,
'playMode': 0,
'useTicketId': -1,
'playCredit': 1,
'playTrack': 1,
'clientId': config.clientId,
'isPlayTutorial': false,
'isEventMode': false,
'isNewFree': false,
'playCount': 0,
'playSpecial': TitleApiService.calcRandom(),
'playOtherUserId': 0,
},
],
'user2pPlaylog': {
'userId1': 0,
'userId2': 0,
'userName1': '',
'userName2': '',
'regionId': 0,
'placeId': 0,
'user2pPlaylogDetailList': [],
},
'userIntimateList': [],
'userShopItemStockList': [],
'userGetPointList': [],
'userTradeItemList': [],
'userFavoritemusicList': [],
'userKaleidxScopeList': [],
'isNewCharacterList': '',
'isNewMapList': '',
'isNewLoginBonusList': '',
'isNewItemList': '',
'isNewMusicDetailList': '0',
'isNewCourseList': '',
'isNewFavoriteList': '',
'isNewFriendSeasonRankingList': '',
'isNewUserIntimateList': '',
'isNewFavoritemusicList': '',
'isNewKaleidxScopeList': '',
},
};
}
/// Generic item patch: replace `userItemList` / `isNewItemList` in the packet.
/// Used by UnlockMusic (itemKind 5/6/7) and 收藏品获取 (itemKind 1/2/3/10/11/12).
void applyItemListPatch(
Map<String, dynamic> packet, {
required List<Map<String, dynamic>> items,
}) {
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
upsert['userItemList'] = items;
upsert['isNewItemList'] = List.filled(items.length, '1').join();
}
/// 解锁歌曲/谱面时补一条 musicDetail 记录并标记为新增。
/// 难度本身由 userItemList 的 itemKind 5/6/7 行表达(见客户端
/// VOExtensions.ExportUserItems),不写在 musicDetail 上。
void applyMusicDetailPatch(
Map<String, dynamic> packet, {
required Map<String, dynamic> musicData,
}) {
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
upsert['userMusicDetailList'] = [musicData];
upsert['isNewMusicDetailList'] = '1';
}
/// 发放/更新旅行伙伴:写 `userCharacterList` 并按行数生成 `isNewCharacterList`。
/// 客户端 BuildListData 只发增量、每位一个字符,所以这里只放要新增的行。
void applyCharacterListPatch(
Map<String, dynamic> packet, {
required List<Map<String, dynamic>> characters,
}) {
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
upsert['userCharacterList'] = characters;
upsert['isNewCharacterList'] = List.filled(characters.length, '1').join();
}
/// 编组旅行伙伴:改 `userData[0].charaSlot`(int[5],槽 0 为队长)。
/// 客户端 ExportUserPlaylog 用同一份 CharaSlot 填 playlog 的
/// characterId1..5,这里一起同步,避免 playlog 与 userData 互相矛盾。
void applyCharaSlotPatch(
Map<String, dynamic> packet, {
required List<int> charaSlot,
}) {
final slots = normalizeCharaSlot(charaSlot);
final upsert = packet['upsertUserAll'] as Map<String, dynamic>;
final userDataList = upsert['userData'] as List;
(userDataList.first as Map<String, dynamic>)['charaSlot'] = slots;
for (final raw in (packet['userPlaylogList'] as List)) {
final playlog = raw as Map<String, dynamic>;
for (var i = 0; i < 5; i++) {
playlog['characterId${i + 1}'] = slots[i];
}
}
}
}