Files
Astral/docs/UpsertUserAll踩坑清单.md
T
Txiaochen 41909b9169 docs: WaHappy 开发文档
孤儿分支,只含 docs/。内容包括:
- 项目总览 / 主脚本编写指南 / 日志指南
- 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条)
- 真机 Hook 对照报告 / 实跑验证记录
- 购买功能票流程(含被服务端拒绝的排查过程)
  · 扫码登录协议技术规范
2026-10-06 14:42:32 +08:00

671 lines
27 KiB
Markdown
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.
# 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()` 的真实返回值,这些在纯日志里根本看不到。