# UpsertUserAll 踩坑清单 > **这份文档只讲「坑」** —— 每一个都是我实际踩过、并且用真机数据或反编译源码验证过的。 > 想看接口全貌请读 [`上传成绩开发文档.md`](上传成绩开发文档.md); > 想看真实跑通证据请读 [`实跑验证记录.md`](实跑验证记录.md)。 > > --- > **判据来源**(全部可查): > > | 来源 | 内容 | > |---|---| > | `source/2026-10-05.log:123` | 真机 00:20 会话的 UpsertUserAll 报文 | > | `source/2026-10-05-new.log:123` | 真机 19:21 会话的 UpsertUserAll 报文 | > | `MaimaiNetSpy/raw/068_*.json` | 真机 22:31 会话的 UpsertUserAll 报文(MelonLoader hook 抓) | > | `source/Assembly-CSharp/` | 反编译源码(每个坑都标了行号) | > > **三个真机会话的横向对照**是本文最有力的工具 —— 很多坑只有横向比才看得出来。 --- ## 零、先记住这句话 > ### ★ 服务端返回 `returnCode=1` 只代表「报文格式合法」,**不代表数据被应用**。 「`returnCode=1` 但 `playCount` 纹丝不动」是这条路线上**最主要的失败模式**, 而且它有**至少 4 个完全不同的成因**(坑 1 / 2 / 3 / 4)。所以: **唯一的验收标准是「发包后重新拉一次服务器数据,看数字有没有动」。** `upsert.py --send` 已经内置了这个回查(第 7.5 步),会打印 `【落库成功】` 或 `【疑似未落库】`。 不要只看 `returnCode`。 --- ## 一、致命坑:会让「回码成功但什么都没发生」的 ### 坑 1:`userData` 是**整表覆盖**语义,不是「只写你改的字段」 **现象**:把 `GetUserDataApi` 拿到的 `userData` 原样塞进 Upsert 发出去,`returnCode=1`, 回查 `playCount` / `point` **完全不变**。 **根因**:服务端对 `userData` 这个单元素数组是**整表覆盖**。 你回传旧值 = 覆盖成旧值 = 「看起来什么都没发生」。 **正确做法**:必须先按本局演算出**新值**再整表回传。 | 字段 | Get 侧(登录时) | 必须回传的 Upsert 侧 | |---|---|---| | `playCount` | 1257 | **1258**(+1) | | `currentPlayCount` | 185 | **186**(+1) | | `lastPlayDate` | 2026-10-04 18:46:31 | **2026-10-05 00:20:57.0**(本局上报时刻) | | `lastPlayMode` | 2 | **0** | | `lastLoginDate` | 2026-10-04 18:34:22 | **2026-10-05 00:19:37.0**(本次登录时刻) | | `lastPlaceId` | 0 | **1000** | | `lastRegionId` | 11 | **5** | | `dateTime` | null | **1791134329**(登录请求里的 dateTime) | | `lastGameId` | null | **"SDGB"** | | `accessCode` | null | **""** | 对应实现:`apply_play_effects()`(`api/upsert_user_all.py`)。 **自检**:`validate_before_send()` 会检查 `playCount` / `currentPlayCount` 是否为 0、`dateTime` 是否为 0、 `lastPlayDate` / `lastLoginDate` 是否为空。 --- ### 坑 2:差分节点全空 = 服务端认为「本局什么都没变」★ 已用 A/B 实验证实 **现象**:`userData` 改对了,但 `userMusicDetailList` / `userMapList` / `userCharacterList` 全是 `[]`,回码成功但战绩表没多一条。 **根因**:这些节点是**差分语义** —— 只回传「本局发生变化的条目」。 全空等于告诉服务端「没有任何条目变化」。 #### ★★ 2026-10-06 的 A/B 对照实验(决定性证据) 同一天、同一账号、同一份代码,**唯一差别是 `userMusicDetailList` 空不空**: | 时间 | `userMusicDetailList` | 其他差分节点 | `userData.playCount` | 结果 | |---|---|---|---|---| | 12:52 | **`[]`** | 都空 | 1261 → 1261 | ❌ `returnCode=1` 但**没落库** | | 12:57 | **1 条**(次数不变) | 都空 | 1261 → **1262** | ✅ **落库** | 两次的 `returnCode` 都是 1、`apiName` 都是 `com.sega.maimai.api.UpsertUserAllApi`, **唯一变量就是这个节点**。 > **推论(已实测)**: > 只要 `userPlaylogList` 里有本局成绩,`userMusicDetailList` 就**必须**回传本局那条。 > 「有 playlog 却没有任何曲目明细」会被服务端当成无效包**静默丢弃**。 > > 注意这里的关键是**节点非空**,不是**次数必须 +1** —— > 回传条目但 `playCount` 保持不变(+0)同样能落库(12:57 那次就是)。 > 所以「不想让乐曲 playCount 增加」应该把增量设成 0, > **而不是**把整个节点清空。见 `config.PLAY_MUSIC_PLAY_COUNT_GAIN`。 **真机对照**(同一账号 Get 侧 vs Upsert 侧): | 节点 | Get 侧条数 | Upsert 侧条数 | 是差分吗 | |---|---:|---:|---| | `userMusicDetailList` | 739 组 | **1** | 是(本局那条 playCount +1) | | `userMapList` | 104 | **1** | 是(选中地图 distance +11000) | | `userCharacterList` | 312 | **5** | 是(组队 5 只各 +1 级) | | `userItemList` | 1070 | **0** | 是(无变化 → 空) | | `userCourseList` | 16 | **0** | 是(无变化 → 空) | | `userChargeList` | 3 | **3** | ★ **否!整表回传** | **正确做法**:`build_user_all()` 用 `DIFF_PRIMARY_KEYS` 对每个差分节点算 (新 − 基线)。 **★ 反例警告 1**:`userChargeList` **不是差分节点**,真机原样发了全部 3 条。 如果把它当差分处理(且本局没买票),会被误收敛成 `[]` —— 这是我踩过的具体坑。 **★ 反例警告 2(本次新增)**:差分收敛会把「和基线完全相同」的条目剔掉。 `PLAY_MUSIC_PLAY_COUNT_GAIN = 0` 时本局那条和基线一样,会被剔光 → 节点变空 → 整包失效。 所以 `build_user_all()` 里对这个节点做了**兜底**: `apply_play_effects` 会把本局那条暂存到 `_played_music_entry`, 差分收敛后如果节点空了就再塞回去,并把 `isNewMusicDetailList` 置 `"0"`(存量更新)。 --- ### 坑 3:`playlogId` / `loginDateTime` 必须**逐字节等于** `UserLoginApi` 的返回值 **现象**:`playlogId` 自己造一个、或用了上一局的 `loginId` → 服务端静默拒收。 **根因**(`PacketUpsertUserAll.cs:31`): 这两个值**不是本接口产生的**,而是登录会话的标识。服务端拿它们找会话, 找不到就不应用。 **正确做法**: ```python login = UserLogin(user_id, token, ...) # 先登录 UpsertUserAll(user_id, login.loginId, # ★ 必须是它 login_date_time=login.loginDateTime, # ★ 必须是它 auth_date_time=login.dateTime, # userData.dateTime 用这个 ...) ``` **★ 容易混的三个时间**: | 变量 | 是什么 | 用在哪 | |---|---|---| | `login.loginDateTime` | 「本地墙上时间当 UTC」(= 标准 Unix 秒 + 28800) | 顶层 `loginDateTime` 字段 | | `login.dateTime` | 登录**请求**里发的 `Auth.AuthTime` | `userData.dateTime` | | `loginDate`(playlog 里) | **标准 Unix 秒** | `userPlaylogList[].loginDate` | **中途重新登录必须重取这两个值** —— 换了会话还发旧 `loginId` 一定失败。 **自检**:`validate_before_send()` 会检查 `playlogId` / `loginDateTime` 是否为 0。 --- ### 坑 4:`confirm` 参数漏传 —— 闸门根本没开,但你以为开了 **现象**:`upsert.py --send` 跑完显示「上传成功」,其实**一个包都没发**。 **根因**:`UpsertUserAll` 的闸门规则是 `confirm=None` 时取 `config.UPSERT_CONFIRM`(默认 `False`)。 所以只写 `dry_run=False` **不够**,必须同时显式给 `confirm=True`。 ```python # ✗ 错的:dry_run=False 但 confirm 没给 → 走 config 的 False → 被拦 UpsertUserAll(..., dry_run=False) # ✓ 对的 UpsertUserAll(..., dry_run=False, confirm=True) ``` **这是「排查了半天以为服务端不收,其实是自己没发」的经典案例。** --- ## 二、结构坑:报文对不上 ### 坑 5:`userWeeklyData` / `user2pPlaylog` 是**对象**,不是数组 36 个节点里有 **2 个** JSON 形态是 `{}` 而不是 `[]`: ```jsonc "userWeeklyData": { "lastLoginWeek": "...", "beforeLoginWeek": "...", "friendBonusFlag": true } "user2pPlaylog" : { "userId1": 0, ..., "user2pPlaylogDetailList": [] } ``` 传成数组会被 `coerce_node_value()` 直接抛 `PatchError`(刻意的防呆)。 **四种形态总览**: | 类别 | 数量 | JSON 形态 | |---|---:|---| | 单元素数组(userData/userExtend/userOption/userRatingList) | 4 | `[{...}]` | | **对象** | 2 | `{...}` ★ | | 普通数组 | 19 | `[...]` | | **位串** | 11 | `"0101..."` | 4 + 2 + 19 + 11 = 36。 --- ### 坑 6:11 条位串的长度**必须**等于对应数组的条数 `isNew*` 与同名数组一一对应。长度不符 → 服务端误判「哪些是新增」。 `build_user_all()` 末尾用 `sync_bits_to_arrays()` 自动补齐,但如果你手工 `patch` 了数组, 位串也要跟着对。 **收藏夹是特例**(见坑 12):位串取值不是 `0/1` 而是 `1/2`。 --- ### 坑 7:`userExtend` 在 Upsert 侧**多出 2 个字段** | | Get 侧 | Upsert 侧 | |---|---:|---:| | 键数 | 14 | **16** | | 多出 | — | `isPhotoAgree`、`isGotoCodeRead` | 这两个字段**只在 Upsert 侧出现**(Get 响应里没有),必须手工补上, 否则报文比真机少 2 个键。 对应实现:`USER_EXTEND_EXTRA_KEYS`。 --- ### 坑 8:`userRating.udemae` 有一批**首字母大写的重复字段**,必须裁掉 **现象**:`GetUserRatingApi` 的响应里 `npcWinNum` 和 `NpcWinNum` **同时存在**(23 键)。 ```jsonc // Get 侧(source/2026-10-05.log:87)—— 23 键 { "npcWinNum": 0, ..., "NpcWinNum": 0, "NpcLoseNum": 0, "MaxLoseNum": 0, ... } // Upsert 侧(source/2026-10-05.log:123)—— 只有小写 16 键 { "npcWinNum": 0, ... } ``` **根因**:源码 `Net.VO.Mai2/UserUdemae.cs` 只声明了小写的 16 个字段, 大写那批是**服务端的冗余输出**。 **影响**:不裁掉的话 ① 报文里出现源码不存在的字段 ② 请求体比真机大 **119 字节**。 对应实现:`USER_RATING_UDEMAE_DROP_KEYS`。 > ★ 这个坑**只有做深度递归值对比才能发现** —— 光看键数或报文长度是看不出来的。 --- ### 坑 9:`userCharacterList` 要按 `characterId` **升序**,不是 `charaSlot` 顺序 | | 顺序 | |---|---| | `userData.charaSlot` | `[395, 101, 400402, 400401, 200401]` | | 真机 `userCharacterList` | `[101, 395, 200401, 400401, 400402]` | **两者顺序不同** —— 回传时按 `characterId` 升序排。 对应实现:`grown.sort(key=lambda c: int(c.get("characterId", 0)))`。 --- ### 坑 10:字段裁剪 —— Get 侧有的,Upsert 侧不一定有 | 节点 | 要丢掉的字段 | |---|---| | `userData` | `friendCode`、`nameplateId`、`trophyId`、`cmLastEmoneyCredit`、`cmLastEmoneyBrand` | | `userOption` | `tempoVolume` | | `userCharacterList` | 只保留 `characterId`/`level`/`awakening`/`useCount` | | `userMusicDetailList` | `extNum2` | | `userChargeList` | `extNum1` | | `userMissionDataList` / `userWeeklyData` | `userId` | ### 坑 10.1:`banState` 的位置会「漂移」 - **Get 侧**:`banState` 是 `userData` 的**同级顶层**字段 - **Upsert 侧**:它被**移进了 `userData` 内部** `_extract_from_get_responses()` 会自动搬运,但如果你自己手工拼快照,别忘了。 --- ## 三、字段值坑:结构对了但值错了 ### 坑 11:`characterLevelN` 是**游玩前**等级,不要加经验 **源码**(`VOExtensions.cs:1361`): ```csharp result.characterLevel1 = (int)gameScore.CharaLevel[0]; ``` `gameScore.CharaLevel` 是**本次开始游玩时**灌进去的快照,整局不变。 **三个真机会话的三方对照**(这是唯一能定论的方式): | 会话 | Get 侧 `userCharacterList` | `userPlaylogList[].characterLevel` | Upsert 侧 `userCharacterList` | |---|---|---|---| | 00:20 | 3800/4455/134 | **3800/4455/134** | 3801/4456/135 | | 19:21 | 3801/4456/135 | **3801/4456/135** | 3802/4457/136 | | 22:31 | 3802/4457/136 | **3802/4457/136** | 3803/4458/137 | **结论:`playlog.characterLevel` 恒等于 Get 侧(游玩前), `upsertUserAll.userCharacterList` 恒为 +1(游玩后)。** > ⚠ 我一度写成 `level + character_exp_gain`,因为只看了单个会话、 > 把「Get 侧」和「Upsert 侧」的数值记混了。**横向比三个会话立刻暴露。** **推荐做法**:别手填 `config.PLAY_CHARACTERS`,直接用 `derive_play_characters(snapshot)` 从服务器快照推(`upsert.py` 已接上)。 账号一升级手填值就过期,会出现「playlog 与 userCharacterList 对不上」的诡异现象。 --- ### 坑 12:收藏夹**不做剔除**,且位串取值是 `1/2` 不是 `0/1` `build_favorite_delta()` 复刻 `VOExtensions.cs:345-367`: - **恒返回完整 5 条**(不做差分剔除) - 位串按真机 `"22212"` 反推:`"2"` = 服务器侧有该 itemKind,`"1"` = 查不到 **★ 必须用未归一化的原始 Get 响应作基线**: 真机里 `itemKind=10`(搭档)的 Get 响应回的是 `{"itemKind": null}`, 正因为查不到才产生那个 `"1"`。 但 `user_favorite.py` 会「补 itemKind」,补完之后就看不出原始 null 了。 **两个真机会话都是 `"22212"`** —— 第 4 位(itemKind=10)**稳定为 "1"**, 说明服务端对该 kind 恒回 null。 **实现上的处理**: - 有原始响应 → 用它建索引,自然得到 `"22212"` - 只有归一化基线 → 传 `assume_missing=FAVORITE_MISSING_KINDS`(= `(10,)`)兜底 --- ### 坑 13:`loginDate` 是**标准 Unix 秒**,不是 `loginDateTime` ``` loginDateTime = 1791239482 (「墙上时间当 UTC」= 标准 Unix 秒 + 28800) loginDate = 1791210683 (标准 Unix 秒) ``` **三个真机会话的实际差值**: | 会话 | `loginDateTime` | `loginDate` | 差 | |---|---|---|---| | 00:20 | 1791159576 | 1791130777 | **28799** | | 19:21 | 1791228113 | 1791199315 | **28798** | | 22:31 | 1791239482 | 1791210683 | **28799** | **注意不是整 28800!** 原因:`loginDate` 来自 `TimeManager.GetUnixTime(src.Detail.LastLoginDate)`(`VOExtensions.cs:1290`), 而 `lastLoginDate` 是客户端在**登录响应处理完那一刻**写的,比 `loginDateTime` 晚 1~2 秒。 本工程用 `loginDateTime − 28800`,**稳定差 1~2 秒**。 服务端两次都接受了 → 它**不对这几个时刻做等式校验**。知道这一点可以省掉一轮无谓排查。 --- ### 坑 14:`playDate` 是**日志日**(7 点分界),不是自然日 源码 `TimeManager.cs:77-85`: ```csharp public static DateTime ToLogDateTime(DateTime dateTime, int hour = 0) { DateTime result = new(dateTime.Year, dateTime.Month, dateTime.Day, hour, 0, 0); if (7 > dateTime.Hour) return result.AddDays(-1.0); // ★ 7 点前回退一天 return result; } ``` 真机验证:00:20 那一局的 `userPlaylogList[0].playDate = "2026-10-04"` (自然日是 10-05,因为 00:20 < 07:00 所以回退)。19:21 那局则是 `"2026-10-05"`。 **`playDate` 是日期串(`YYYY-MM-DD`),`userPlayDate` 才是完整时间串**,别搞混。 --- ### 坑 15:`lastLoginDate` 与 `lastPlayDate` 是**两个不同的时刻** **现象**:所有时间字段都填成 `loginDateTime` → 数值上「能用」,但和真机对不上。 **真机规律**(三个会话全部成立): | 字段 | 语义 | 00:20 会话 | 19:21 会话 | 22:31 会话 | |---|---|---|---|---| | `userData.lastLoginDate` | **登录**时刻 | 00:19:37.0 | 19:21:55.0 | 22:31:23.0 | | `userData.eventWatchedDate` | **与 lastLoginDate 同值** | 00:19:37.0 | 19:21:55.0 | 22:31:23.0 | | `userData.lastPlayDate` | **上报**时刻 | 00:20:57.0 | 19:23:02.0 | 22:32:21.0 | | `userGamePlaylogList[0].playDate` | **与 lastPlayDate 同值** | 00:20:57.0 | 19:23:02.0 | 22:32:21.0 | | `userPlaylogList[0].userPlayDate` | **游玩**时刻(更早) | 00:20:30.0 | 19:22:36.0 | 22:31:59.0 | 即:登录 → 游玩 → 上报是三个时刻,真机分别是 「loginDateTime +1~2s」「userPlayDate」「上报那一刻」。 本工程把「游玩/上报时刻」抽成参数 `play_date_time`(不传则退回 `login_date_time`), `upsert.py` 传 `login_date_time_now()`。 --- ### 坑 16:`userGetPointList` / `userLoginBonusList` 是**登录奖励**才产生的 **真机证据**: | 会话 | `userData.point` | `userGetPointList` | `userLoginBonusList` | |---|---|---|---| | 00:20 | 4110 | `[]` | `[]` | | 19:21 | 4460 | `[{"getKind":1,"point":200},{"getKind":2,"point":150}]` | `[{"bonusId":61,"point":1,"isCurrent":true,...}]` | | 22:31 | 4460 | `[]` | `[]` | **规律**:这两个节点非空 ⟺ 本次登录触发了奖励发放。 留空则 `point` 保持基线值不变 —— 这是「不发放点数」的**必然结果,不是 bug**。 **★ 排查陷阱**:如果你把 `point` 手工 +200 但 `userGetPointList` 留空, 会出现「数字对不上」的矛盾。要么两个一起改,要么两个都不动。 对应实现:`DEFAULT_EMPTY_NODES`。 --- ### 坑 17:`userGamePlaylogList[].playCount` 是**机台本地计数器**,服务器不返回 真机值:00:20 会话 = **8**、19:21 = **9**、22:31 = **10**(每打一局 +1)。 源码 `VOExtensions.cs:881`:`result.playCount = Backup.localParameter.playCount`。 **任何 Get 接口都不返回它** —— 既推不出来,服务端也不会纠正。 填错只是统计数字不对,不影响落库。放在 `config.PLAY_LOCAL_PLAY_COUNT`。 --- ### 坑 18:`playSpecial` 不是 0,是一枚**随机位掩码** 源码 `VOExtensions.cs:846-855`: ```csharp int max = 1037933; int num2 = UnityEngine.Random.Range(1, max) * 2069; num2 += GameManager.CalcSpecialNum(); int num3 = 0; for (int i = 0; i < 32; i++) { num3 <<= 1; num3 += num2 % 2; num2 >>= 1; } result.playSpecial = num3; ``` **`CalcSpecialNum()` 是机台硬件标志位掩码**(`GameManager.cs:332-378`), 逐位含义:`SpecialAP`、`IsAutoPlay()`、键鼠数量、`IsDevelop`、 以及三条**否定条件**(不含 "ACA" / 不含 "GeForce GTX" / 不含 "Core(TM) i3-8100"), 最后 `num |= 0x400` **无条件置位**。 **真机实测**:`[CalcSpecialNum] = 1024`(= 0x400,标准 ACA 机台其余位恰好全 0)。 **反推验证**(这一步很有说服力): ``` 真机 playSpecial = 892014272 位反转32(892014272) = 57726124 = 27900 × 2069 + 1024 └───┬───┘ └─┬─┘ 随机系数 余数 = CalcSpecialNum ✅ ``` 配置项:`config.PLAY_SPECIAL_NUM = 1024`。 > 注意位反转后可能超过 int32 变成负数 —— 真机 19:21 那局就是 `-603697438`,这是正常的。 --- ### 坑 19:`extBool1/2/3` 是 bool 但**名字不带 `is` 前缀** 用 `name.startswith("is")` 判断布尔字段会**漏掉这三个**, 导致序列化成 `0` 而不是 `false`。 同理字符串字段(`vsUserName`、`playedUserName1..3`、`placeName`、`playDate`、`userPlayDate`) 默认值必须是 `""` 而不是 `0`。 对应实现:显式集合 `PLAYLOG_BOOL_FIELDS` / `PLAYLOG_STRING_FIELDS`。 --- ### 坑 20:110 个字段**一个都不能省** 把真机报文的 `userPlaylogList[0]` 与 `Net.VO.Mai2/UserPlaylog.cs` 做双向集合比对: | 方向 | 差异 | |---|---| | C# 声明但报文未出现 | **0** | | 报文有但 C# 未声明 | **0** | **能精简的是「填写的内容要真实」,而不是删字段。** --- ## 四、网络层坑 ### 坑 21:`number` 头只由 **Ping 心跳**刷新,不是每个请求都更新 **真机 70 个请求里它只变过一次**: ``` #1 ~ #10 number = 0 ← 还没跑过心跳 #11 ~ #17 Ping 包(按 Packet.cs:187 **不带** number 头) #18 ~ #70 number = 63 ← 心跳往返 63ms,此后 53 个业务请求恒定 ``` **源码链路**: ``` PingChecker.cs:166 StartPacket(new PacketPing(PingSuccess, PingError)) PacketPing.cs:34 _onDone((int)sw.ElapsedMilliseconds) ← 值 = Ping 包往返毫秒 PingChecker.cs:227 GameManager.PingMsec = pingMsec Packet.cs:189 Client.AddHeader("number", GameManager.PingMsec.ToString()) ``` **我原来的错法**:每个请求结束后把「本请求耗时」写进 `ping_msec`, 于是 number 随每个包抖动。已改成只有 Ping 包更新。 **调用方注意**:`upsert.py` 在取完服务器设置后加了一步 `Ping()`,否则 number 恒为 0。 --- ### 坑 22:Cookie —— 登录响应**会**下发 `JSESSIONID`,而且必须带着 **这个坑我踩了两次(先误判、后自我推翻)**: 1. hook 日志里显示 `Cookie : userId=12345678` —— 这是我 hook **自己拼的假串** (`gamehook/Main.cs:196-199` 的 `_cookie = "userId=" + userId`),不是真实 Cookie 头。 2. 于是我用 `Ping` 探针去验证,看到响应头里**没有 Set-Cookie**, 就得出「服务器从不下发 Cookie,无需处理」的结论 —— **这是错的**。 3. 后来跑完整登录链路,`UserLoginApi` 的响应头里明确有: ``` Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly ``` **源码**(`Packet.cs:228-234`):**只在 `UserLogin` 响应上**把 Set-Cookie 收进 `CookieContainer`;之后 `Packet.cs:190-193` 每个带 userId 的请求都挂回去。 **结论**:这是**真实的会话粘滞**。 `requests.Session` 会自动处理,**但前提是全程复用同一个 `MaimaiNetClient`** (本工程各 api 函数在 `client=None` 时都走 `get_default_client()` 单例)。 **如果谁手动 new 一个 client 去发结算包,就会丢掉 JSESSIONID。** > **教训**:用「只读探针」验证一件事时,要确认探针覆盖了完整链路 —— > 我只探了 Ping,就下了关于整个服务器的结论。 --- ### 坑 23:`Accept-Encoding` 必须**显式置空** `requests` 默认发 `Accept-Encoding: gzip, deflate`。 本协议响应体是 AES 密文(二进制),服务端若据此做内容编码会返回**空 body**。 实测:不发该头 → HTTP 200 / 0 字节;显式置空 → 正常返回。 C# 端不需要是因为 `HttpWebRequest` 默认 `AutomaticDecompression = None`。 --- ### 坑 24:扫码 chipID 用**完整形式**,游戏服务器用**短值** | 用途 | 正确值 | 源码依据 | |---|---|---| | aimedb 扫码 | `XXXX-XXXXXXXXXXX`(**带连字符**) | `ChimeReaderManager.cs:250` `KeychipId.Value` | | 游戏服务器 `clientId` | `TESTCLIENT1`(短值) | `PacketGetGameSetting.cs:23` `KeychipId.ShortValue` | 我原来扫码也用了短值,导致 aimedb 返回错误。 --- ## 五、工程/流程坑 ### 坑 25:不做**落库回查**,你永远不知道有没有写进去 `returnCode=1` 完全不可信(见坑 1~4)。**必须**发包后重新拉一次数据比对。 `upsert.py --send` 的第 7.5 步已经内置: ``` 7.5 落库回查(重新拉服务器数据) playCount 1260 -> 1261 currentPlayCount 188 -> 189 lastPlayDate 2026-10-05 22:32:21 -> 2026-10-06 01:28:04 musicId=143 level=4 playCount -> 11 【落库成功】userData.playCount 已 +1 ``` **为什么在登出前回查**:同一个登录会话内 `GetUserDataApi` 立刻能读到新值, 不用再开一次会话(也就不会消耗第二个二维码)。 --- ### 坑 26:报文体长度差异要**逐节点**看,不要只看总长 总长差几字节可能来自随机数位数(`playSpecial`),也可能来自真实的结构错误。 **逐节点比对字节数**才能定位: ``` 节点 真机068 本次 差 userData 1985 1985 0 ... userGamePlaylogList 294 295 1 <<< 只有它差(playSpecial 随机数) (36 节点合计) 14255 14256 1 ``` **35 个节点 0 差异**才是「结构完全对齐」的证据。 --- ### 坑 27:`--debug` 日志会很大 DEBUG 模式会完整打印每个请求的头 + 体 + 响应体, 一次结算流程(含 19 节点快照 + 739 组曲目分页)能到 **18 MB**。 只想看流程就别加 `--debug`;想减小体积调 `config.LOG_VALUE_MAX`(0 = 不截断)。 --- ## 六、发包前 Checklist 按顺序过一遍,能挡掉上面绝大多数坑: - [ ] **闸门**:`confirm=True`(或 `config.UPSERT_CONFIRM=True`) - [ ] **会话**:`playlogId == login.loginId`,`loginDateTime == login.loginDateTime` - [ ] **同会话**:中途没重新登录过(重登必须重取上面两个值) - [ ] **快照**:`fetch_upsert_snapshot()` 抓了 19 个节点,失败 0 个 - [ ] **userData 演算**:`playCount`/`currentPlayCount` +1,`lastPlayDate` 是上报时刻, `lastLoginDate`/`eventWatchedDate` 是登录时刻,`dateTime` 非 0 - [ ] **差分节点**:`userMusicDetailList` 至少 1 条(本局曲目战绩); `userMapList` / `userCharacterList` 按 config 增量决定 —— `PLAY_MAP_DISTANCE_GAIN` / `PLAY_CHARACTER_EXP_GAIN` **默认都是 0**, 此时它们为空是**预期行为**;只有把增量设成非 0 时才必须有条目 - [ ] **`userChargeList`** 是整表回传,没被当差分收敛成空 - [ ] **位串**:11 条长度等于对应数组条数;`isNewFavoriteList` 应为 `"22212"` - [ ] **playlog**:110 个字段齐全;`characterLevelN` 是**游玩前**等级 - [ ] **playSpecial**:非 0,且余数 = `config.PLAY_SPECIAL_NUM` - [ ] **网络**:跑过 `Ping()`(number 头),同一个 client(Cookie) - [ ] **自检**:`validate_before_send()` 返回 0 个问题 - [ ] **回查**:发包后确认 `playCount` 真的 +1 --- ## 六点五、关于 `userMapList` / `userCharacterList` 的默认空值 本工程默认: ```python PLAY_MAP_DISTANCE_GAIN = 0 # 本局不推进地图 -> userMapList 回传 [] PLAY_CHARACTER_EXP_GAIN = 0 # 本局不给角色加经验 -> userCharacterList 回传 [] ``` **这不是坑,是刻意的默认值** —— 因为「地图走多远」「角色升几级」取决于真实游玩, 纯网络脚本推不出来。想要真机那种 +11000 / +1 级,把这两个配置改回去即可。 ★ 唯一要注意的连带影响:`validate_before_send()` 的「差分节点为空」告警 只在**增量非 0 却没产出条目**时才触发,所以默认值下不会刷无意义的告警。 --- ## 七、这个清单是怎么攒出来的 | 阶段 | 手段 | 抓到的坑 | |---|---|---| | 初版实现 | 读源码 + 单个真机日志 | 坑 5、6、13、14、18、19、20、23、24 | | 深度值对比 | 逐字段递归 diff 真机报文 vs 本工程 | 坑 7、8、9、10 | | 落库失败排查 | 直接 `patch` 绝对值探测服务端行为 | 坑 1、2、3、4 | | **MelonLoader hook 抓真机** | 在游戏里 hook 网络层抓完整会话 | 坑 11、12、15、16、17、21、22 | | 真实跑通 | `--send` + 落库回查 | 坑 25、26、27 | **最有效的一招是「横向比多个真机会话」** —— 坑 11(characterLevel 语义)、 坑 13(loginDate 差 28798/28799)、坑 15(两个时刻分离)都是单看一个会话看不出来、 一横向比就立刻暴露的。 第二有效的是 **MelonLoader hook** —— 它能看到 HTTP 头(`number`)、 能抓到 `CalcSpecialNum()` 的真实返回值,这些在纯日志里根本看不到。