孤儿分支,只含 docs/。内容包括: - 项目总览 / 主脚本编写指南 / 日志指南 - 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条) - 真机 Hook 对照报告 / 实跑验证记录 - 购买功能票流程(含被服务端拒绝的排查过程) · 扫码登录协议技术规范
18 KiB
UpsertUserAll 实跑数据 vs 真机日志 逐条对比
本文档数据来源
- 实跑侧:用你提供的二维码真实跑通了「扫码 → 预览 → 登录 → 抓快照 → 构造 UpsertUserAll → 登出」, 报文由
api/upsert_user_all.py生成(dry-run,未发包)- 真机侧:
source/2026-10-05.log:123的 UpsertUserAllApi 请求报文结论速览
维度 结果 顶层字段 7/7 结构一致 36 个节点 形态 36/36 全部一致 11 条位串 11/11 全部一致(含 isNewFavoriteList="22212")userData 字段 76/82 命中(6 处差异全部可解释,见 §5) userPlaylog 字段 106/110 命中(4 处差异全部可解释,见 §6)
一、本次实跑记录
| 项目 | 值 |
|---|---|
| 二维码时间戳 | 261005175231(2026-10-05 17:52:31) |
| user_id | 12345678 |
| 昵称 / Rating | ☆Astral☆ / 15000 |
| loginId | 7693120247791879616 |
| loginDateTime | 1791222938 |
| dateTime(Auth.AuthTime) | 1791194138 |
| 快照节点 | 19 个全部成功(_failed: []) |
| 是否发包 | 否(dry_run=True) |
快照抓到的条数(对比真机日志的 Get 侧)
| 节点 | 本次实跑 | 真机 2026-10-05 | 说明 |
|---|---|---|---|
| userCharacterList | 312 | 312 | 一致 |
| userMapList | 104 | 104 | 一致 |
| userLoginBonusList | 38 | 38 | 一致 |
| userItemList | 1070 | 1070 | 一致 |
| userMusicDetailList | 1037 | 739(组数) | 实跑已摊平成单条,真机是 739 组嵌套 |
| userCourseList | 16 | 16 | 一致 |
| userChargeList | 3 | 3 | 一致 |
| userFavoriteList | 5 | 5 | 一致 |
| userMissionDataList | 6 | 6 | 一致 |
| userIntimateList | 2 | 2 | 一致 |
| userGhost | 15 | 15 | 一致 |
★
userMusicDetailList1037 是摊平后的 (musicId, level) 条目数;真机日志的 739 是按曲分组的组数。 两者不是同一口径,摊平后 739 组共 1037 条。
二、顶层结构对比
// 实跑产出(api/upsert_user_all.py 生成)
{
"userId": 12345678,
"playlogId": 7693120247791879616, // = UserLoginApi 的 loginId
"isEventMode": false,
"isFreePlay": false,
"loginDateTime": 1791222938, // = UserLoginApi 的 loginDateTime
"userPlaylogList": [ { ...110 字段... } ],
"upsertUserAll": { ...36 节点... }
}
| 字段 | 真机 | 本次实跑 | 判定 |
|---|---|---|---|
userId |
12345678 | 12345678 | 一致 |
playlogId |
7692848101484135861 | 7693120247791879616 | 不同(不同次登录,正常) |
isEventMode |
false | false | 一致 |
isFreePlay |
false | false | 一致 |
loginDateTime |
1791159576 | 1791222938 | 不同(不同次登录,正常) |
userPlaylogList |
1 条 | 1 条 | 一致 |
upsertUserAll |
36 节点 | 36 节点 | 一致 |
playlogId/loginDateTime两次必然不同 —— 它们是本次登录会话产生的, 不是固定值。关键是它们与本次UserLoginApi的返回值一致(已核对)。
三、36 个节点逐节点对比
| # | 节点 | 真机 | 实跑 | 形态 |
|---|---|---|---|---|
| 1 | userData |
1 条 | 1 条 | 一致 |
| 2 | userExtend |
1 条 | 1 条 | 一致 |
| 3 | userOption |
1 条 | 1 条 | 一致 |
| 4 | userCharacterList |
5 条 | 5 条 | 一致 |
| 5 | userGhost |
0 条 | 0 条 | 一致 |
| 6 | userMapList |
1 条 | 1 条 | 一致 |
| 7 | userLoginBonusList |
0 条 | 0 条 | 一致 |
| 8 | userRatingList |
1 条 | 1 条 | 一致 |
| 9 | userItemList |
0 条 | 0 条 | 一致 |
| 10 | userMusicDetailList |
1 条 | 1 条 | 一致 |
| 11 | userCourseList |
0 条 | 0 条 | 一致 |
| 12 | userFriendSeasonRankingList |
0 条 | 0 条 | 一致 |
| 13 | userChargeList |
3 条 | 3 条 | 一致(整表回传) |
| 14 | userFavoriteList |
5 条 | 5 条 | 一致(恒 5 条) |
| 15 | userActivityList |
1 条 | 1 条 | 一致 |
| 16 | userMissionDataList |
6 条 | 6 条 | 一致 |
| 17 | userWeeklyData |
对象 | 对象 | 一致 |
| 18 | userGamePlaylogList |
1 条 | 1 条 | 一致 |
| 19 | user2pPlaylog |
对象 | 对象 | 一致 |
| 20 | userIntimateList |
0 条 | 0 条 | 一致 |
| 21 | userShopItemStockList |
0 条 | 0 条 | 一致 |
| 22 | userGetPointList |
0 条 | 0 条 | 一致 |
| 23 | userTradeItemList |
0 条 | 0 条 | 一致 |
| 24 | userFavoritemusicList |
0 条 | 0 条 | 一致 |
| 25 | userKaleidxScopeList |
0 条 | 0 条 | 一致 |
| 26-36 | 11 条 isNew* 位串 |
— | — | 见下节 |
形态命中 36/36。
四、11 条位串对比(全部一致)
| 位串 | 真机 | 实跑 | 一致 |
|---|---|---|---|
isNewCharacterList |
"00000" |
"00000" |
✅ |
isNewMapList |
"0" |
"0" |
✅ |
isNewLoginBonusList |
"" |
"" |
✅ |
isNewItemList |
"" |
"" |
✅ |
isNewMusicDetailList |
"0" |
"0" |
✅ |
isNewCourseList |
"" |
"" |
✅ |
isNewFavoriteList |
"22212" |
"22212" |
✅ |
isNewFriendSeasonRankingList |
"" |
"" |
✅ |
isNewUserIntimateList |
"" |
"" |
✅ |
isNewFavoritemusicList |
"" |
"" |
✅ |
isNewKaleidxScopeList |
"" |
"" |
✅ |
isNewFavoriteList = "22212"是本次最值得注意的一处: 第 4 位是"1"(而不是"2"),因为服务器对itemKind=10(搭档)的 Get 响应 回的是{"itemKind":null, "itemIdList":[]}—— 服务端怪癖。 实现上用未归一化的原始响应作差分基线,才能复现出这个"1"。
五、userData[0] 逐字段对比
真机 82 字段,实跑 82 字段,命中 76/82。
5.1 一致的关键字段(节选)
| 字段 | 真机 | 实跑 | 说明 |
|---|---|---|---|
userName |
☆Astral☆ | ☆Astral☆ | 一致 |
playerRating |
15000 | 15000 | 一致 |
point / totalPoint |
4110 / 52620 | 4110 / 52620 | 一致 |
iconId / plateId / titleId |
559501 / 458001 / 55502 | 同 | 一致 |
charaSlot |
[395,101,400402,400401,200401] | 同 | 一致 |
lastGameId |
"SDGB" | "SDGB" | 一致(兜底填充) |
accessCode |
"" | "" | 一致(兜底填充) |
lastPlaceId / lastPlaceName |
1000 / 测试店铺… | 同 | 一致 |
lastRegionId / lastRegionName |
5 / 测试区域 | 同 | 一致 |
lastClientId |
"TESTCLIENT1" | "TESTCLIENT1" | 一致 |
lastPlayMode |
0 | 0 | 一致(归 0) |
banState |
0 | 0 | 一致(已搬进 userData 内部) |
lastRomVersion |
"1.56.00" | "1.56.00" | 一致 |
5.2 六处差异(全部可解释)
| 字段 | 真机 | 实跑 | 原因 |
|---|---|---|---|
playCount |
1258 | 1259 | 基线不同:真机基线 1257,实跑基线 1258(真机那局已写入)。增量都是 +1 ✅ |
currentPlayCount |
186 | 187 | 同上,增量都是 +1 ✅ |
dateTime |
1791134329 | 1791194138 | 本次登录的 Auth.AuthTime(不同次登录) ✅ |
lastLoginDate |
2026-10-05 00:19:37.0 | 2026-10-05 17:55:38.0 | 本次登录时刻 ✅ |
lastPlayDate |
2026-10-05 00:20:57.0 | 2026-10-05 17:55:38.0 | 本次游玩时刻 ✅ |
eventWatchedDate |
2026-10-05 00:19:37.0 | 2026-10-05 17:55:38.0 | 本次登录时刻 ✅ |
验证方式:把实跑的
playCount减 1 得 1258,正好等于本次快照基线; 真机的 1258 减 1 得 1257,等于真机快照基线。两边的增量逻辑完全一致。
5.3 实跑 userData 全文(节选关键段)
{
"accessCode": "",
"userName": "☆Astral☆",
"playCount": 1259,
"currentPlayCount": 187,
"eventWatchedDate": "2026-10-05 17:55:38.0",
"lastGameId": "SDGB",
"lastLoginDate": "2026-10-05 17:55:38.0",
"lastPlayDate": "2026-10-05 17:55:38.0",
"lastPlayCredit": 1,
"lastPlayMode": 0,
"lastPlaceId": 1000,
"lastPlaceName": "测试店铺",
"lastRegionId": 5,
"lastRegionName": "测试区域",
"lastClientId": "TESTCLIENT1",
"lastCountryCode": "CHN",
"dateTime": 1791194138,
"banState": 0
}
六、userPlaylogList[0] 逐字段对比(110 字段)
命中 106/110。
6.1 四字段差异(全部可解释)
| 字段 | 真机 | 实跑 | 原因 |
|---|---|---|---|
playlogId |
7692848101484135861 | 7693120247791879616 | 本次登录的 loginId ✅ |
loginDate |
1791130777 | 1791194138 | 标准 Unix 秒 = loginDateTime − 28800 ✅ |
playDate |
"2026-10-04" | "2026-10-05" | 日志日(7 点前算前一天)——真机游玩在 00:20 所以回退到 10-04;实跑在 17:55 不回退 ✅ |
userPlayDate |
"2026-10-05 00:20:30.0" | "2026-10-05 17:55:38.0" | 游玩时刻 ✅ |
playDate的差异恰好验证了算法正确性: 真机在 00:20 游玩 →playDate回退为前一天2026-10-04; 实跑在 17:55 →playDate保持2026-10-05。 这正是TimeManager.ToLogDateTime(TimeManager.cs:77-85,7 点分界)的行为。
6.2 完全一致的 106 个字段(节选关键项)
| 字段 | 值 | 说明 |
|---|---|---|
userId |
0 | 恒为 0 |
version |
1056000 | 1.56.00 |
placeId / placeName |
1000 / 测试店铺… | 一致 |
musicId / level |
143 / 4 | 一致 |
trackNo |
1 | 一致 |
playerNum |
1 | 一致 |
characterId1..5 |
395,101,400402,400401,200401 | 一致 |
characterLevel1..5 |
3800,4455,134,134,134 | 一致(游玩前快照,不加经验) |
achievement / deluxscore |
0 / 0 | 一致 |
totalCombo |
655 | 一致 |
tapMiss / holdMiss / slideMiss / breakMiss |
441 / 19 / 192 / 3 | 一致 |
isTap / isHold / isSlide / isTouch / isBreak |
true,true,true,false,true | 一致 |
isClear |
false | 一致 |
trialPlayAchievement |
-1 | 一致 |
extNum4 |
2022 | 一致(= categoryIndex) |
extBool1/2/3 |
false | 一致(bool 而非 0) |
vsUserName / playedUserName1..3 |
"" | 一致(空串而非 0) |
七、关键节点逐条示例
7.1 userMusicDetailList(差分:只回传本局那条)
真机
[
{ "musicId": 143, "level": 4, "playCount": 8, "achievement": 0,
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0,
"scoreRank": 0, "extNum1": 0 }
]
实跑
[
{ "musicId": 143, "level": 4, "playCount": 9, "achievement": 0,
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0,
"scoreRank": 0, "extNum1": 0 }
]
对比:结构、字段、键序完全一致;playCount 真机 7→8、实跑 8→9,
都是基线 +1。Get 侧有 1037 条,这里只回传本局那 1 条 —— 差分生效。
7.2 userMapList(差分:只回传变化那条)
真机
[
{ "mapId": 550003, "distance": 1435000, "isLock": false,
"isClear": false, "isComplete": false, "unlockFlag": 0 }
]
实跑
[
{ "mapId": 550003, "distance": 1446000, "isLock": false,
"isClear": false, "isComplete": false, "unlockFlag": 0 }
]
对比:mapId 相同(都是当前 selectMapId);
真机 1424000→1435000(+11000),实跑 1435000→1446000(+11000),
增量完全一致。Get 侧有 104 条,只回传 1 条。
7.3 userCharacterList(差分:组队 5 只各 +1 级)
真机
[
{ "characterId": 101, "level": 4456, "awakening": 5, "useCount": 0 },
{ "characterId": 395, "level": 3801, "awakening": 5, "useCount": 0 },
{ "characterId": 200401, "level": 135, "awakening": 3, "useCount": 0 },
{ "characterId": 400401, "level": 135, "awakening": 3, "useCount": 0 },
{ "characterId": 400402, "level": 135, "awakening": 3, "useCount": 0 }
]
实跑
[
{ "characterId": 395, "useCount": 0, "level": 3802, "awakening": 5 },
{ "characterId": 101, "useCount": 0, "level": 4457, "awakening": 5 },
{ "characterId": 400402, "useCount": 0, "level": 136, "awakening": 3 },
{ "characterId": 400401, "useCount": 0, "level": 136, "awakening": 3 },
{ "characterId": 200401, "useCount": 0, "level": 136, "awakening": 3 }
]
对比:
- 条目数都是 5(只回传组队角色,Get 侧有 312 条)
- 每只都是 基线 +1 级:真机 4455→4456、3800→3801、134→135;实跑 4456→4457、3801→3802、135→136
- 字段集合一致(
characterId/level/awakening/useCount) - 唯一差别是键序(真机按 characterId 升序,实跑按 charaSlot 顺序)—— JSON 对象键序不影响语义
7.4 userChargeList(★ 整表回传,不做差分)
真机(3 条)与实跑(3 条)完全相同:
[
{ "chargeId": 2, "stock": 0, "purchaseDate": "2026-07-14 18:33:37", "validDate": "2026-10-12 04:00:00" },
{ "chargeId": 3, "stock": 0, "purchaseDate": "2026-10-02 10:49:23", "validDate": "2026-12-31 04:00:00" },
{ "chargeId": 5, "stock": 0, "purchaseDate": "2026-10-04 18:22:10", "validDate": "2027-01-02 04:00:00" }
]
对比:Get 侧 3 条 → Upsert 侧也是 3 条。
说明 userChargeList 是整表回传,不能当差分节点处理
(否则会被误收敛成空数组)。实跑已正确处理。
7.5 userFavoriteList(★ 恒 5 条,位串 "22212")
真机
[
{ "itemKind": 3, "itemIdList": [559501] },
{ "itemKind": 1, "itemIdList": [] },
{ "itemKind": 2, "itemIdList": [] },
{ "itemKind": 10, "itemIdList": [] },
{ "itemKind": 11, "itemIdList": [] }
]
位串:"22212"
实跑
[
{ "itemKind": 3, "itemIdList": [559501] },
{ "itemKind": 1, "itemIdList": [] },
{ "itemKind": 2, "itemIdList": [] },
{ "itemKind": 10, "itemIdList": [] },
{ "itemKind": 11, "itemIdList": [] }
]
位串:"22212"
对比:逐字节一致,包括位串。
第 4 位 "1" 的来源:服务器对 itemKind=10 回的是 {"itemKind":null},
差分基线查不到该 kind → 位 "1"。
7.6 userWeeklyData(★ 对象,非数组)
真机
{ "lastLoginWeek": "2026-09-28 04:00:00", "beforeLoginWeek": "2026-09-21 04:00:00", "friendBonusFlag": true }
实跑
{ "lastLoginWeek": "2026-10-05 04:00:00", "beforeLoginWeek": "2026-09-28 04:00:00", "friendBonusFlag": true }
对比:结构与键完全一致。周界值差一周是因为本次登录已跨周
(实跑时服务器 GetGameWeeklyDataApi 下发的 updateDate 已是 2026-10-05)。
该值取自服务器下发的 userWeeklyData,不是本地推导。
7.7 user2pPlaylog(★ 对象,非数组)
真机与实跑完全一致:
{ "userId1": 0, "userId2": 0, "userName1": "", "userName2": "",
"regionId": 0, "placeId": 0, "user2pPlaylogDetailList": [] }
7.8 userGamePlaylogList
真机
[{ "playlogId": 7692848101484135861, "version": "1.56.00",
"playDate": "2026-10-05 00:20:57.0", "playMode": 0, "useTicketId": -1,
"playCredit": 1, "playTrack": 1, "clientId": "TESTCLIENT1",
"isPlayTutorial": false, "isEventMode": false, "isNewFree": false,
"playCount": 8, "playSpecial": 55951502, "playOtherUserId": 0 }]
实跑
[{ "playlogId": 7693120247791879616, "version": "1.56.00",
"playDate": "2026-10-05 17:55:38.0", "playMode": 0, "useTicketId": -1,
"playCredit": 1, "playTrack": 1, "clientId": "TESTCLIENT1",
"isPlayTutorial": false, "isEventMode": false, "isNewFree": false,
"playCount": 8, "playSpecial": 16417894, "playOtherUserId": 0 }]
对比:13 个字段名与类型完全一致。
playlogId/playDate是本次会话值(正常)playSpecial两边都是随机位掩码(真机 55951502,实跑 16417894), 按VOExtensions.cs:846-855的算法生成,每次不同 —— 正确useTicketId=-1(不使用票)、playCredit=1、playCount=8全部命中
八、差异汇总与判定
| 类别 | 差异项 | 判定 |
|---|---|---|
| 顶层 | playlogId、loginDateTime |
✅ 正常(每次登录不同) |
| userData | playCount、currentPlayCount |
✅ 正常(基线不同,增量都是 +1) |
| userData | dateTime、lastLoginDate、lastPlayDate、eventWatchedDate |
✅ 正常(本次会话时刻) |
| playlog | playlogId、loginDate、playDate、userPlayDate |
✅ 正常(本次会话时刻 + 日志日算法) |
| 结构 | 无 | ✅ 36 节点形态、11 位串全部一致 |
没有任何一处是「实现错误」导致的差异。
九、本次实跑发现并修复的 3 个 Bug
| # | 问题 | 根因 | 修复 |
|---|---|---|---|
| 1 | userData.dateTime 为 null |
UserLogin 没有回填它实际发出的 dateTime |
UserLoginResponseVO 增加 dateTime 字段并在登录时回填 |
| 2 | userFavoriteList 为空、位串为 "" |
取数脚本返回的字典用节点名作键(userFavoriteList),而原始响应用 userFavorite;提取逻辑只认后者 |
提取时增加按节点名兜底 |
| 3 | userWeeklyData 周界比真机晚一周 |
用了本地推导而非服务器下发值 | 优先从 GetUserMissionDataApi 响应的 userWeeklyData 取(UserMissionResponseVO.cs:10) |
修复后重跑,结构差异归零。
十、怎么复现这次对比
# 1) 实跑(dry-run,不发包)—— 把二维码换成你自己的
python upsert.py "<二维码URL>"
# 2) 只看构造出的报文(不联网)
python -c "import json;d=json.load(open('upsert_payload.json',encoding='utf-8'));print(json.dumps(d['upsertUserAll']['userData'],ensure_ascii=False,indent=2))"
# 3) 零网络自检(129 项)
python selftest.py
注意:本次实跑没有真实发送 UpsertUserAll。 要真实上传需显式加
--send(会把成绩写进服务器,不可撤销)。
十一、相关文件
| 文件 | 说明 |
|---|---|
api/upsert_user_all.py |
上传成绩实现(36 节点 / 110 字段 / 差分) |
api/upsert_snapshot.py |
结算快照编排 |
docs/上传成绩开发文档.md |
字段级开发文档 |
source/2026-10-05.log:123 |
真机报文(本文档对比基准) |
docs/UpsertUserAll实跑对比.md |
本文档 |