docs: WaHappy 开发文档
孤儿分支,只含 docs/。内容包括: - 项目总览 / 主脚本编写指南 / 日志指南 - 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条) - 真机 Hook 对照报告 / 实跑验证记录 - 购买功能票流程(含被服务端拒绝的排查过程) · 扫码登录协议技术规范
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
# 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 | 一致 |
|
||||
|
||||
> ★ `userMusicDetailList` 1037 是**摊平后**的 (musicId, level) 条目数;真机日志的 739 是**按曲分组**的组数。
|
||||
> 两者不是同一口径,摊平后 739 组共 1037 条。
|
||||
|
||||
---
|
||||
|
||||
## 二、顶层结构对比
|
||||
|
||||
```jsonc
|
||||
// 实跑产出(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 全文(节选关键段)
|
||||
|
||||
```json
|
||||
{
|
||||
"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`(差分:只回传本局那条)
|
||||
|
||||
**真机**
|
||||
```json
|
||||
[
|
||||
{ "musicId": 143, "level": 4, "playCount": 8, "achievement": 0,
|
||||
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0,
|
||||
"scoreRank": 0, "extNum1": 0 }
|
||||
]
|
||||
```
|
||||
|
||||
**实跑**
|
||||
```json
|
||||
[
|
||||
{ "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`(差分:只回传变化那条)
|
||||
|
||||
**真机**
|
||||
```json
|
||||
[
|
||||
{ "mapId": 550003, "distance": 1435000, "isLock": false,
|
||||
"isClear": false, "isComplete": false, "unlockFlag": 0 }
|
||||
]
|
||||
```
|
||||
|
||||
**实跑**
|
||||
```json
|
||||
[
|
||||
{ "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 级)
|
||||
|
||||
**真机**
|
||||
```json
|
||||
[
|
||||
{ "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 }
|
||||
]
|
||||
```
|
||||
|
||||
**实跑**
|
||||
```json
|
||||
[
|
||||
{ "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 条)**完全相同**:
|
||||
```json
|
||||
[
|
||||
{ "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")
|
||||
|
||||
**真机**
|
||||
```json
|
||||
[
|
||||
{ "itemKind": 3, "itemIdList": [559501] },
|
||||
{ "itemKind": 1, "itemIdList": [] },
|
||||
{ "itemKind": 2, "itemIdList": [] },
|
||||
{ "itemKind": 10, "itemIdList": [] },
|
||||
{ "itemKind": 11, "itemIdList": [] }
|
||||
]
|
||||
```
|
||||
位串:`"22212"`
|
||||
|
||||
**实跑**
|
||||
```json
|
||||
[
|
||||
{ "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`(★ 对象,非数组)
|
||||
|
||||
**真机**
|
||||
```json
|
||||
{ "lastLoginWeek": "2026-09-28 04:00:00", "beforeLoginWeek": "2026-09-21 04:00:00", "friendBonusFlag": true }
|
||||
```
|
||||
|
||||
**实跑**
|
||||
```json
|
||||
{ "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`(★ 对象,非数组)
|
||||
|
||||
真机与实跑**完全一致**:
|
||||
```json
|
||||
{ "userId1": 0, "userId2": 0, "userName1": "", "userName2": "",
|
||||
"regionId": 0, "placeId": 0, "user2pPlaylogDetailList": [] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.8 `userGamePlaylogList`
|
||||
|
||||
**真机**
|
||||
```json
|
||||
[{ "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 }]
|
||||
```
|
||||
|
||||
**实跑**
|
||||
```json
|
||||
[{ "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`) |
|
||||
|
||||
修复后重跑,**结构差异归零**。
|
||||
|
||||
---
|
||||
|
||||
## 十、怎么复现这次对比
|
||||
|
||||
```powershell
|
||||
# 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` | 本文档 |
|
||||
@@ -0,0 +1,175 @@
|
||||
# UpsertUserAll 落库排查报告
|
||||
|
||||
> **背景**:`python upsert.py "<二维码>" --send` 修复闸门后能真实发包,
|
||||
> 服务端返回 `returnCode=1`,但**数据未写入服务器**。
|
||||
> 本文档记录已完成的全部排查与结论。
|
||||
|
||||
---
|
||||
|
||||
## 一、结论速览
|
||||
|
||||
| 项 | 状态 |
|
||||
|---|---|
|
||||
| 闸门放行 | 已修复,`returnCode=1` |
|
||||
| 报文与真机对齐 | **非预期差异归零** |
|
||||
| 数据落库 | **未落库**(`point`/`playCount` 回查不变) |
|
||||
| 根因 | **未确定**,已排除 6 类可能 |
|
||||
|
||||
---
|
||||
|
||||
## 二、已修复的 5 个 Bug
|
||||
|
||||
### Bug 1:`upsert.py` 漏传 `confirm`(你遇到的直接原因)
|
||||
|
||||
`upsert.py` 原来只传 `dry_run=not opts["send"]`,但 `UpsertUserAll` 的规则是
|
||||
`confirm=None` 时取 `config.UPSERT_CONFIRM`(默认 `False`),所以照样被拦。
|
||||
|
||||
```python
|
||||
# 修复前
|
||||
dry_run=not opts["send"],
|
||||
# 修复后
|
||||
dry_run=not opts["send"],
|
||||
confirm=bool(opts["send"]), # 关键
|
||||
```
|
||||
|
||||
### Bug 2:`userExtend` 缺 2 个字段
|
||||
|
||||
| | Get 侧 | Upsert 侧 |
|
||||
|---|---|---|
|
||||
| 键数 | 14 | **16** |
|
||||
| 差 | — | `isPhotoAgree`、`isGotoCodeRead` |
|
||||
|
||||
这两个字段**只在 Upsert 侧出现**,必须补上。
|
||||
|
||||
### Bug 3:`userRating.udemae` 有 7 个冗余大写字段
|
||||
|
||||
服务器 Get 响应里 `npcWinNum` 与 `NpcWinNum` **并存**(23 键):
|
||||
|
||||
```json
|
||||
// Get 侧(source/2026-10-05.log:87)
|
||||
{ "npcWinNum": 0, ..., "NpcWinNum": 0, "NpcLoseNum": 0, ... } // 23 键
|
||||
// Upsert 侧(source/2026-10-05.log:123)
|
||||
{ "npcWinNum": 0, ... } // 16 键
|
||||
```
|
||||
|
||||
源码 `Net.VO.Mai2/UserUdemae.cs` 只声明了**小写的 16 个**,所以大写那批是
|
||||
服务端冗余输出,回传时必须裁掉。修复前请求体比真机大 119 字节。
|
||||
|
||||
### Bug 4:`userCharacterList` 排序错
|
||||
|
||||
| | 顺序 |
|
||||
|---|---|
|
||||
| `charaSlot` | [395, 101, 400402, 400401, 200401] |
|
||||
| 真机 `userCharacterList` | **[101, 395, 200401, 400401, 400402]**(按 characterId 升序) |
|
||||
|
||||
回传时按 `characterId` 升序,不是按 `charaSlot` 顺序。
|
||||
|
||||
### Bug 5:扫码用的 chipID 形式错
|
||||
|
||||
| 用途 | 正确值 | 源码依据 |
|
||||
|---|---|---|
|
||||
| aimedb 扫码 | `XXXX-XXXXXXXXXXX`(**完整**) | `ChimeReaderManager.cs:250` `KeychipId.Value` |
|
||||
| 游戏服务器 | `TESTCLIENT1`(短值) | `PacketGetGameSetting.cs:23` `KeychipId.ShortValue` |
|
||||
|
||||
我原来扫码也用了短值。已按源码修正为完整形式。
|
||||
|
||||
---
|
||||
|
||||
## 三、报文对齐最终状态
|
||||
|
||||
```
|
||||
顶层键 : 7/7
|
||||
36 节点形态 : 36/36
|
||||
userData : 82/82 键
|
||||
userPlaylog : 110/110 键
|
||||
userExtend : 16/16 键
|
||||
udemae : 16/16 键
|
||||
非预期差异 : 0
|
||||
请求体大小 : 17158 vs 17161 字节(差 3 字节,来自数值位数)
|
||||
```
|
||||
|
||||
**深度递归对比(逐字段、逐元素)后,除「不同次登录必然不同」的值外,零差异。**
|
||||
|
||||
---
|
||||
|
||||
## 四、已排除的可能(6 类)
|
||||
|
||||
| # | 假设 | 验证方式 | 结果 |
|
||||
|---|---|---|---|
|
||||
| 1 | 时序问题(写入有延迟) | 发送后 T+0 / T+20s / T+45s 三次回查 | 都不变 |
|
||||
| 2 | 差分节点为空导致「看不出变化」 | 直接改绝对值(`point` 4110→4111) | 仍不写 |
|
||||
| 3 | 字段缺失/多余 | 深度递归键集合 + 值对比 | 已归零 |
|
||||
| 4 | `number` 请求头缺失 | 补上 `Packet.cs:170` 要求的头 | 仍不写 |
|
||||
| 5 | `playSpecial` 值不对 | 查源码 `CalcSpecialNum` = 硬件标志位 | 非根因 |
|
||||
| 6 | 缺少 `GetUserNewItemList` 前置 | 完整复刻真机 121→123 流程 | 仍不写 |
|
||||
|
||||
### 关于第 2 项的探测细节
|
||||
|
||||
这是最关键的一次探测 —— 直接 `patch` 把 `userData.point` 从 4110 改成 4111 发上去:
|
||||
|
||||
```
|
||||
baseline point = 4110
|
||||
上传成绩成功:returnCode=1
|
||||
T+ 0s point = 4110
|
||||
T+20s point = 4110
|
||||
T+45s point = 4110
|
||||
```
|
||||
|
||||
**连最简单的整数字段都不写**,说明不是「数据不对」而是「整包没被接受」。
|
||||
|
||||
---
|
||||
|
||||
## 五、当前判断
|
||||
|
||||
服务端**收下了**请求(返回正确的 `apiName`),但**没有应用**。
|
||||
|
||||
可能的方向(未验证):
|
||||
|
||||
1. **账号/机台绑定校验** —— token 可能绑定了特定 keychip,而 `XXXX-XXXXXXXXXXX`
|
||||
只是文档里的示例值,不是**你实际机台**的 keychip。
|
||||
若服务端校验「token 对应的机台 ≠ 请求里的 chipID」,就会静默拒收。
|
||||
|
||||
2. **需要真实游玩产生的会话状态** —— 真机的 Upsert 发生在**真实游玩之后**,
|
||||
服务端可能记录了「本次 loginId 下有一局真实游玩」才允许结算。
|
||||
纯网络脚本没有这个前置状态。
|
||||
|
||||
3. **服务端侧的风控/审计** —— 真机与脚本的 TLS 指纹、请求节奏等特征不同。
|
||||
|
||||
---
|
||||
|
||||
## 六、需要你提供的信息
|
||||
|
||||
要继续定位,最有价值的是:
|
||||
|
||||
| # | 信息 | 为什么关键 |
|
||||
|---|---|---|
|
||||
| 1 | **你机台的真实 Keychip ID** | 若 `XXXX-XXXXXXXXXXX` 不是你的机台,方向 1 就成立 |
|
||||
| 2 | 该机台在 `keychip.csv` 里的对应行 | 可确认 keychip 与店铺/区域的绑定关系 |
|
||||
| 3 | 真机跑一局后立刻导出 Upsert 报文 | 可拿同账号同机台的最新报文做 1:1 对比 |
|
||||
|
||||
> `source/keychip.csv` 有 229KB 全量机台对照表,如果你能告诉我机台所在的
|
||||
> 店铺 ID,我可以从中查出正确的 keychip。
|
||||
|
||||
---
|
||||
|
||||
## 七、本次排查的正面成果
|
||||
|
||||
即使尚未落库,这次排查也修掉了 **5 个真实 Bug**,其中 3 个(`userExtend` 缺字段、
|
||||
`udemae` 冗余字段、`userCharacterList` 排序)是**只有做深度值对比才能发现**的:
|
||||
|
||||
- 修复前:报文与真机有 11 处非预期差异
|
||||
- 修复后:**0 处**
|
||||
|
||||
自检 129/129 仍全绿。
|
||||
|
||||
---
|
||||
|
||||
## 八、复现方式
|
||||
|
||||
```powershell
|
||||
# 1) 真实发送 + 落库验证(会写服务器,不可撤销)
|
||||
python upsert.py "<二维码>" --send
|
||||
|
||||
# 2) 零网络自检
|
||||
python selftest.py
|
||||
```
|
||||
@@ -0,0 +1,670 @@
|
||||
# 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()` 的真实返回值,这些在纯日志里根本看不到。
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"user_id": 12345678,
|
||||
"login": {
|
||||
"loginId": 7693120247791879616,
|
||||
"loginDateTime": 1791222938,
|
||||
"lastLoginDate": "2026-10-05 17:53:49",
|
||||
"loginCount": 184,
|
||||
"consecutiveLoginCount": 3,
|
||||
"token": "HcXOwSdDwBSEPzngjdfp"
|
||||
},
|
||||
"auth_date_time": 1791194138,
|
||||
"snapshot_summary": {
|
||||
"userData": 1,
|
||||
"userExtend": 1,
|
||||
"userOption": 1,
|
||||
"userCharacterList": 312,
|
||||
"userMapList": 104,
|
||||
"userLoginBonusList": 38,
|
||||
"userRatingList": 1,
|
||||
"userItemList": 1070,
|
||||
"userMusicDetailList": 1037,
|
||||
"userCourseList": 16,
|
||||
"userChargeList": 3,
|
||||
"userFavoriteList": 5,
|
||||
"userActivityList": 1,
|
||||
"userMissionDataList": 6,
|
||||
"userIntimateList": 2,
|
||||
"userFavoritemusicList": 27,
|
||||
"userKaleidxScopeList": 7,
|
||||
"userGhost": 15
|
||||
},
|
||||
"snapshot_failed": [],
|
||||
"preview": {
|
||||
"userName": "☆Astral☆",
|
||||
"playerRating": 15000,
|
||||
"isLogin": false,
|
||||
"banState": 0,
|
||||
"errorId": 0,
|
||||
"lastLoginDate": "1970-01-01 00:00:00",
|
||||
"lastPlayDate": "1970-01-01 00:00:00",
|
||||
"totalAwake": 184,
|
||||
"iconId": 559501,
|
||||
"frameId": 0
|
||||
},
|
||||
"qr_run_at": "2026-10-05 17:53"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+27889
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,626 @@
|
||||
## 顶层字段对比
|
||||
|
||||
| 字段 | 真机 | 本次实跑 | 一致 |
|
||||
|---|---|---|---|
|
||||
| `userId` | 12345678 | 12345678 | 一致 |
|
||||
| `playlogId` | 7692848101484135861 | 7693120247791879616 | **不同** |
|
||||
| `isEventMode` | False | False | 一致 |
|
||||
| `isFreePlay` | False | False | 一致 |
|
||||
| `loginDateTime` | 1791159576 | 1791222938 | **不同** |
|
||||
| `userPlaylogList` | 1 条 | 1 条 | 一致 |
|
||||
| `upsertUserAll` | 36 节点 | 36 节点 | 一致 |
|
||||
|
||||
## 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 条 | 一致 |
|
||||
| 15 | `userActivityList` | 1 条 | 1 条 | 一致 |
|
||||
| 16 | `userMissionDataList` | 6 条 | 6 条 | 一致 |
|
||||
| 17 | `userWeeklyData` | 对象(3 键) | 对象(3 键) | 一致 |
|
||||
| 18 | `userGamePlaylogList` | 1 条 | 1 条 | 一致 |
|
||||
| 19 | `user2pPlaylog` | 对象(7 键) | 对象(7 键) | 一致 |
|
||||
| 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 | `isNewCharacterList` | 位串 '00000' | 位串 '00000' | 一致 |
|
||||
| 27 | `isNewMapList` | 位串 '0' | 位串 '0' | 一致 |
|
||||
| 28 | `isNewLoginBonusList` | 位串 '' | 位串 '' | 一致 |
|
||||
| 29 | `isNewItemList` | 位串 '' | 位串 '' | 一致 |
|
||||
| 30 | `isNewMusicDetailList` | 位串 '0' | 位串 '0' | 一致 |
|
||||
| 31 | `isNewCourseList` | 位串 '' | 位串 '' | 一致 |
|
||||
| 32 | `isNewFavoriteList` | 位串 '22212' | 位串 '22212' | 一致 |
|
||||
| 33 | `isNewFriendSeasonRankingList` | 位串 '' | 位串 '' | 一致 |
|
||||
| 34 | `isNewUserIntimateList` | 位串 '' | 位串 '' | 一致 |
|
||||
| 35 | `isNewFavoritemusicList` | 位串 '' | 位串 '' | 一致 |
|
||||
| 36 | `isNewKaleidxScopeList` | 位串 '' | 位串 '' | 一致 |
|
||||
|
||||
## 11 条位串对比
|
||||
|
||||
| 位串 | 真机 | 实跑 | 一致 |
|
||||
|---|---|---|---|
|
||||
| `isNewCharacterList` | `00000` | `00000` | 一致 |
|
||||
| `isNewMapList` | `0` | `0` | 一致 |
|
||||
| `isNewLoginBonusList` | `` | `` | 一致 |
|
||||
| `isNewItemList` | `` | `` | 一致 |
|
||||
| `isNewMusicDetailList` | `0` | `0` | 一致 |
|
||||
| `isNewCourseList` | `` | `` | 一致 |
|
||||
| `isNewFavoriteList` | `22212` | `22212` | 一致 |
|
||||
| `isNewFriendSeasonRankingList` | `` | `` | 一致 |
|
||||
| `isNewUserIntimateList` | `` | `` | 一致 |
|
||||
| `isNewFavoritemusicList` | `` | `` | 一致 |
|
||||
| `isNewKaleidxScopeList` | `` | `` | 一致 |
|
||||
|
||||
## userData[0] 逐字段对比
|
||||
|
||||
| 字段 | 真机 | 实跑 | 一致 |
|
||||
|---|---|---|---|
|
||||
| `accessCode` | "" | "" | 一致 |
|
||||
| `userName` | "☆Astral☆" | "☆Astral☆" | 一致 |
|
||||
| `isNetMember` | 1 | 1 | 一致 |
|
||||
| `point` | 4110 | 4110 | 一致 |
|
||||
| `totalPoint` | 52620 | 52620 | 一致 |
|
||||
| `iconId` | 559501 | 559501 | 一致 |
|
||||
| `plateId` | 458001 | 458001 | 一致 |
|
||||
| `titleId` | 55502 | 55502 | 一致 |
|
||||
| `partnerId` | 1 | 1 | 一致 |
|
||||
| `frameId` | 209504 | 209504 | 一致 |
|
||||
| `selectMapId` | 550003 | 550003 | 一致 |
|
||||
| `totalAwake` | 184 | 184 | 一致 |
|
||||
| `gradeRating` | 0 | 0 | 一致 |
|
||||
| `musicRating` | 15000 | 15000 | 一致 |
|
||||
| `playerRating` | 15000 | 15000 | 一致 |
|
||||
| `highestRating` | 15000 | 15000 | 一致 |
|
||||
| `gradeRank` | 0 | 0 | 一致 |
|
||||
| `classRank` | 1 | 1 | 一致 |
|
||||
| `courseRank` | 10 | 10 | 一致 |
|
||||
| `charaSlot` | [395, 101, 400402, 400401, 200401] | [395, 101, 400402, 400401, 200401] | 一致 |
|
||||
| `charaLockSlot` | [395, 101, 0, 0, 0] | [395, 101, 0, 0, 0] | 一致 |
|
||||
| `contentBit` | 2145488608 | 2145488608 | 一致 |
|
||||
| `playCount` | 1258 | 1259 | **不同** |
|
||||
| `currentPlayCount` | 186 | 187 | **不同** |
|
||||
| `renameCredit` | 0 | 0 | 一致 |
|
||||
| `mapStock` | 0 | 0 | 一致 |
|
||||
| `eventWatchedDate` | "2026-10-05 00:19:37.0" | "2026-10-05 17:55:38.0" | **不同** |
|
||||
| `lastGameId` | "SDGB" | "SDGB" | 一致 |
|
||||
| `lastRomVersion` | "1.56.00" | "1.56.00" | 一致 |
|
||||
| `lastDataVersion` | "1.55.05" | "1.55.05" | 一致 |
|
||||
| `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" | **不同** |
|
||||
| `lastPlayCredit` | 1 | 1 | 一致 |
|
||||
| `lastPlayMode` | 0 | 0 | 一致 |
|
||||
| `lastPlaceId` | 1000 | 1000 | 一致 |
|
||||
| `lastPlaceName` | "测试店铺" | "测试店铺" | 一致 |
|
||||
| `lastAllNetId` | 0 | 0 | 一致 |
|
||||
| `lastRegionId` | 5 | 5 | 一致 |
|
||||
| `lastRegionName` | "测试区域" | "测试区域" | 一致 |
|
||||
| `lastClientId` | "TESTCLIENT1" | "TESTCLIENT1" | 一致 |
|
||||
| `lastCountryCode` | "CHN" | "CHN" | 一致 |
|
||||
| `lastSelectEMoney` | 0 | 0 | 一致 |
|
||||
| `lastSelectTicket` | 0 | 0 | 一致 |
|
||||
| `lastSelectCourse` | 552304 | 552304 | 一致 |
|
||||
| `lastCountCourse` | 0 | 0 | 一致 |
|
||||
| `firstGameId` | "SDGB" | "SDGB" | 一致 |
|
||||
| `firstRomVersion` | "1.41.00" | "1.41.00" | 一致 |
|
||||
| `firstDataVersion` | "1.40.08" | "1.40.08" | 一致 |
|
||||
| `firstPlayDate` | "2024-12-31 21:25:58" | "2024-12-31 21:25:58" | 一致 |
|
||||
| `compatibleCmVersion` | "1.55.00" | "1.55.00" | 一致 |
|
||||
| `dailyBonusDate` | "1970-01-01 08:00:00" | "1970-01-01 08:00:00" | 一致 |
|
||||
| `dailyCourseBonusDate` | "2026-07-13 00:00:00" | "2026-07-13 00:00:00" | 一致 |
|
||||
| `lastPairLoginDate` | "2026-10-04 18:05:18" | "2026-10-04 18:05:18" | 一致 |
|
||||
| `lastTrialPlayDate` | "2026-10-03 21:07:50" | "2026-10-03 21:07:50" | 一致 |
|
||||
| `playVsCount` | 0 | 0 | 一致 |
|
||||
| `playSyncCount` | 0 | 0 | 一致 |
|
||||
| `winCount` | 0 | 0 | 一致 |
|
||||
| `helpCount` | 0 | 0 | 一致 |
|
||||
| `comboCount` | 0 | 0 | 一致 |
|
||||
| `totalDeluxscore` | 1869771 | 1869771 | 一致 |
|
||||
| `totalBasicDeluxscore` | 1274 | 1274 | 一致 |
|
||||
| `totalAdvancedDeluxscore` | 6566 | 6566 | 一致 |
|
||||
| `totalExpertDeluxscore` | 470976 | 470976 | 一致 |
|
||||
| `totalMasterDeluxscore` | 1236693 | 1236693 | 一致 |
|
||||
| `totalReMasterDeluxscore` | 154262 | 154262 | 一致 |
|
||||
| `totalSync` | 1112 | 1112 | 一致 |
|
||||
| `totalBasicSync` | 5 | 5 | 一致 |
|
||||
| `totalAdvancedSync` | 5 | 5 | 一致 |
|
||||
| `totalExpertSync` | 425 | 425 | 一致 |
|
||||
| `totalMasterSync` | 604 | 604 | 一致 |
|
||||
| `totalReMasterSync` | 73 | 73 | 一致 |
|
||||
| `totalAchievement` | 975613200 | 975613200 | 一致 |
|
||||
| `totalBasicAchievement` | 2984753 | 2984753 | 一致 |
|
||||
| `totalAdvancedAchievement` | 7755815 | 7755815 | 一致 |
|
||||
| `totalExpertAchievement` | 315483810 | 315483810 | 一致 |
|
||||
| `totalMasterAchievement` | 578226016 | 578226016 | 一致 |
|
||||
| `totalReMasterAchievement` | 71162806 | 71162806 | 一致 |
|
||||
| `playerOldRating` | 10927 | 10927 | 一致 |
|
||||
| `playerNewRating` | 4606 | 4606 | 一致 |
|
||||
| `banState` | 0 | 0 | 一致 |
|
||||
| `friendRegistSkip` | 0 | 0 | 一致 |
|
||||
| `dateTime` | 1791134329 | 1791194138 | **不同** |
|
||||
|
||||
真机 82 字段,实跑 82 字段,命中 76/82
|
||||
|
||||
## 差分节点明细
|
||||
|
||||
### `userMusicDetailList`
|
||||
|
||||
真机:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"musicId": 143,
|
||||
"level": 4,
|
||||
"playCount": 8,
|
||||
"achievement": 0,
|
||||
"comboStatus": 0,
|
||||
"syncStatus": 0,
|
||||
"deluxscoreMax": 0,
|
||||
"scoreRank": 0,
|
||||
"extNum1": 0
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
实跑:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"musicId": 143,
|
||||
"level": 4,
|
||||
"playCount": 9,
|
||||
"achievement": 0,
|
||||
"comboStatus": 0,
|
||||
"syncStatus": 0,
|
||||
"deluxscoreMax": 0,
|
||||
"scoreRank": 0,
|
||||
"extNum1": 0
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `userMapList`
|
||||
|
||||
真机:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"mapId": 550003,
|
||||
"distance": 1435000,
|
||||
"isLock": false,
|
||||
"isClear": false,
|
||||
"isComplete": false,
|
||||
"unlockFlag": 0
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
实跑:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"mapId": 550003,
|
||||
"distance": 1446000,
|
||||
"isLock": false,
|
||||
"isClear": false,
|
||||
"isComplete": false,
|
||||
"unlockFlag": 0
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `userCharacterList`
|
||||
|
||||
真机:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
实跑:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `userChargeList`
|
||||
|
||||
真机:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
实跑:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `userFavoriteList`
|
||||
|
||||
真机:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"itemKind": 3,
|
||||
"itemIdList": [
|
||||
559501
|
||||
]
|
||||
},
|
||||
{
|
||||
"itemKind": 1,
|
||||
"itemIdList": []
|
||||
},
|
||||
{
|
||||
"itemKind": 2,
|
||||
"itemIdList": []
|
||||
},
|
||||
{
|
||||
"itemKind": 10,
|
||||
"itemIdList": []
|
||||
},
|
||||
{
|
||||
"itemKind": 11,
|
||||
"itemIdList": []
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
实跑:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"itemKind": 3,
|
||||
"itemIdList": [
|
||||
559501
|
||||
]
|
||||
},
|
||||
{
|
||||
"itemKind": 1,
|
||||
"itemIdList": []
|
||||
},
|
||||
{
|
||||
"itemKind": 2,
|
||||
"itemIdList": []
|
||||
},
|
||||
{
|
||||
"itemKind": 10,
|
||||
"itemIdList": []
|
||||
},
|
||||
{
|
||||
"itemKind": 11,
|
||||
"itemIdList": []
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 两个对象节点
|
||||
|
||||
### `userWeeklyData`
|
||||
|
||||
真机:
|
||||
```json
|
||||
{
|
||||
"lastLoginWeek": "2026-09-28 04:00:00",
|
||||
"beforeLoginWeek": "2026-09-21 04:00:00",
|
||||
"friendBonusFlag": true
|
||||
}
|
||||
```
|
||||
|
||||
实跑:
|
||||
```json
|
||||
{
|
||||
"lastLoginWeek": "2026-09-28 04:00:00",
|
||||
"beforeLoginWeek": "2026-09-21 04:00:00",
|
||||
"friendBonusFlag": true
|
||||
}
|
||||
```
|
||||
|
||||
### `user2pPlaylog`
|
||||
|
||||
真机:
|
||||
```json
|
||||
{
|
||||
"userId1": 0,
|
||||
"userId2": 0,
|
||||
"userName1": "",
|
||||
"userName2": "",
|
||||
"regionId": 0,
|
||||
"placeId": 0,
|
||||
"user2pPlaylogDetailList": []
|
||||
}
|
||||
```
|
||||
|
||||
实跑:
|
||||
```json
|
||||
{
|
||||
"userId1": 0,
|
||||
"userId2": 0,
|
||||
"userName1": "",
|
||||
"userName2": "",
|
||||
"regionId": 0,
|
||||
"placeId": 0,
|
||||
"user2pPlaylogDetailList": []
|
||||
}
|
||||
```
|
||||
|
||||
## userGamePlaylogList
|
||||
|
||||
真机:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
实跑:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## userPlaylogList[0] 逐字段对比(110 字段)
|
||||
|
||||
| # | 字段 | 真机 | 实跑 | 一致 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `userId` | 0 | 0 | 一致 |
|
||||
| 2 | `orderId` | 0 | 0 | 一致 |
|
||||
| 3 | `playlogId` | 7692848101484135861 | 7693120247791879616 | **不同** |
|
||||
| 4 | `version` | 1056000 | 1056000 | 一致 |
|
||||
| 5 | `placeId` | 1000 | 1000 | 一致 |
|
||||
| 6 | `placeName` | "测试店铺" | "测试店铺" | 一致 |
|
||||
| 7 | `loginDate` | 1791130777 | 1791194138 | **不同** |
|
||||
| 8 | `playDate` | "2026-10-04" | "2026-10-05" | **不同** |
|
||||
| 9 | `userPlayDate` | "2026-10-05 00:20:30.0" | "2026-10-05 17:55:38.0" | **不同** |
|
||||
| 10 | `type` | 0 | 0 | 一致 |
|
||||
| 11 | `musicId` | 143 | 143 | 一致 |
|
||||
| 12 | `level` | 4 | 4 | 一致 |
|
||||
| 13 | `trackNo` | 1 | 1 | 一致 |
|
||||
| 14 | `vsMode` | 0 | 0 | 一致 |
|
||||
| 15 | `vsUserName` | "" | "" | 一致 |
|
||||
| 16 | `vsStatus` | 0 | 0 | 一致 |
|
||||
| 17 | `vsUserRating` | 0 | 0 | 一致 |
|
||||
| 18 | `vsUserAchievement` | 0 | 0 | 一致 |
|
||||
| 19 | `vsUserGradeRank` | 0 | 0 | 一致 |
|
||||
| 20 | `vsRank` | 0 | 0 | 一致 |
|
||||
| 21 | `playerNum` | 1 | 1 | 一致 |
|
||||
| 22 | `playedUserId1` | 0 | 0 | 一致 |
|
||||
| 23 | `playedUserName1` | "" | "" | 一致 |
|
||||
| 24 | `playedMusicLevel1` | 0 | 0 | 一致 |
|
||||
| 25 | `playedUserId2` | 0 | 0 | 一致 |
|
||||
| 26 | `playedUserName2` | "" | "" | 一致 |
|
||||
| 27 | `playedMusicLevel2` | 0 | 0 | 一致 |
|
||||
| 28 | `playedUserId3` | 0 | 0 | 一致 |
|
||||
| 29 | `playedUserName3` | "" | "" | 一致 |
|
||||
| 30 | `playedMusicLevel3` | 0 | 0 | 一致 |
|
||||
| 31 | `characterId1` | 395 | 395 | 一致 |
|
||||
| 32 | `characterLevel1` | 3800 | 3800 | 一致 |
|
||||
| 33 | `characterAwakening1` | 5 | 5 | 一致 |
|
||||
| 34 | `characterId2` | 101 | 101 | 一致 |
|
||||
| 35 | `characterLevel2` | 4455 | 4455 | 一致 |
|
||||
| 36 | `characterAwakening2` | 5 | 5 | 一致 |
|
||||
| 37 | `characterId3` | 400402 | 400402 | 一致 |
|
||||
| 38 | `characterLevel3` | 134 | 134 | 一致 |
|
||||
| 39 | `characterAwakening3` | 3 | 3 | 一致 |
|
||||
| 40 | `characterId4` | 400401 | 400401 | 一致 |
|
||||
| 41 | `characterLevel4` | 134 | 134 | 一致 |
|
||||
| 42 | `characterAwakening4` | 3 | 3 | 一致 |
|
||||
| 43 | `characterId5` | 200401 | 200401 | 一致 |
|
||||
| 44 | `characterLevel5` | 134 | 134 | 一致 |
|
||||
| 45 | `characterAwakening5` | 3 | 3 | 一致 |
|
||||
| 46 | `achievement` | 0 | 0 | 一致 |
|
||||
| 47 | `deluxscore` | 0 | 0 | 一致 |
|
||||
| 48 | `scoreRank` | 0 | 0 | 一致 |
|
||||
| 49 | `maxCombo` | 0 | 0 | 一致 |
|
||||
| 50 | `totalCombo` | 655 | 655 | 一致 |
|
||||
| 51 | `maxSync` | 0 | 0 | 一致 |
|
||||
| 52 | `totalSync` | 0 | 0 | 一致 |
|
||||
| 53 | `tapCriticalPerfect` | 0 | 0 | 一致 |
|
||||
| 54 | `tapPerfect` | 0 | 0 | 一致 |
|
||||
| 55 | `tapGreat` | 0 | 0 | 一致 |
|
||||
| 56 | `tapGood` | 0 | 0 | 一致 |
|
||||
| 57 | `tapMiss` | 441 | 441 | 一致 |
|
||||
| 58 | `holdCriticalPerfect` | 0 | 0 | 一致 |
|
||||
| 59 | `holdPerfect` | 0 | 0 | 一致 |
|
||||
| 60 | `holdGreat` | 0 | 0 | 一致 |
|
||||
| 61 | `holdGood` | 0 | 0 | 一致 |
|
||||
| 62 | `holdMiss` | 19 | 19 | 一致 |
|
||||
| 63 | `slideCriticalPerfect` | 0 | 0 | 一致 |
|
||||
| 64 | `slidePerfect` | 0 | 0 | 一致 |
|
||||
| 65 | `slideGreat` | 0 | 0 | 一致 |
|
||||
| 66 | `slideGood` | 0 | 0 | 一致 |
|
||||
| 67 | `slideMiss` | 192 | 192 | 一致 |
|
||||
| 68 | `touchCriticalPerfect` | 0 | 0 | 一致 |
|
||||
| 69 | `touchPerfect` | 0 | 0 | 一致 |
|
||||
| 70 | `touchGreat` | 0 | 0 | 一致 |
|
||||
| 71 | `touchGood` | 0 | 0 | 一致 |
|
||||
| 72 | `touchMiss` | 0 | 0 | 一致 |
|
||||
| 73 | `breakCriticalPerfect` | 0 | 0 | 一致 |
|
||||
| 74 | `breakPerfect` | 0 | 0 | 一致 |
|
||||
| 75 | `breakGreat` | 0 | 0 | 一致 |
|
||||
| 76 | `breakGood` | 0 | 0 | 一致 |
|
||||
| 77 | `breakMiss` | 3 | 3 | 一致 |
|
||||
| 78 | `isTap` | true | true | 一致 |
|
||||
| 79 | `isHold` | true | true | 一致 |
|
||||
| 80 | `isSlide` | true | true | 一致 |
|
||||
| 81 | `isTouch` | false | false | 一致 |
|
||||
| 82 | `isBreak` | true | true | 一致 |
|
||||
| 83 | `isCriticalDisp` | true | true | 一致 |
|
||||
| 84 | `isFastLateDisp` | true | true | 一致 |
|
||||
| 85 | `fastCount` | 0 | 0 | 一致 |
|
||||
| 86 | `lateCount` | 0 | 0 | 一致 |
|
||||
| 87 | `isAchieveNewRecord` | false | false | 一致 |
|
||||
| 88 | `isDeluxscoreNewRecord` | false | false | 一致 |
|
||||
| 89 | `comboStatus` | 0 | 0 | 一致 |
|
||||
| 90 | `syncStatus` | 0 | 0 | 一致 |
|
||||
| 91 | `isClear` | false | false | 一致 |
|
||||
| 92 | `beforeRating` | 15000 | 15000 | 一致 |
|
||||
| 93 | `afterRating` | 15000 | 15000 | 一致 |
|
||||
| 94 | `beforeGrade` | 0 | 0 | 一致 |
|
||||
| 95 | `afterGrade` | 0 | 0 | 一致 |
|
||||
| 96 | `afterGradeRank` | 1 | 1 | 一致 |
|
||||
| 97 | `beforeDeluxRating` | 15000 | 15000 | 一致 |
|
||||
| 98 | `afterDeluxRating` | 15000 | 15000 | 一致 |
|
||||
| 99 | `isPlayTutorial` | false | false | 一致 |
|
||||
| 100 | `isEventMode` | false | false | 一致 |
|
||||
| 101 | `isFreedomMode` | false | false | 一致 |
|
||||
| 102 | `playMode` | 0 | 0 | 一致 |
|
||||
| 103 | `isNewFree` | false | false | 一致 |
|
||||
| 104 | `trialPlayAchievement` | -1 | -1 | 一致 |
|
||||
| 105 | `extNum1` | 0 | 0 | 一致 |
|
||||
| 106 | `extNum2` | 0 | 0 | 一致 |
|
||||
| 107 | `extNum4` | 2022 | 2022 | 一致 |
|
||||
| 108 | `extBool1` | false | false | 一致 |
|
||||
| 109 | `extBool2` | false | false | 一致 |
|
||||
| 110 | `extBool3` | false | false | 一致 |
|
||||
|
||||
命中 106/110
|
||||
|
||||
## 汇总
|
||||
|
||||
- userData 字段命中:76/82
|
||||
- playlog 字段命中:106/110
|
||||
- 节点形态命中:36/36
|
||||
@@ -0,0 +1,507 @@
|
||||
# 上传成绩(UpsertUserAll)开发文档
|
||||
|
||||
> 对应 `api/upsert_user_all.py` 与 `api/upsert_snapshot.py`。
|
||||
> **判据 = `source/2026-10-05.log:123` 的真机跑通报文 + `source/Assembly-CSharp/` 反编译源码。**
|
||||
|
||||
---
|
||||
|
||||
## 一、这是什么
|
||||
|
||||
`UpsertUserAllApi` 是**唯一一个有副作用的写接口**:把一局游玩的完整结算数据
|
||||
(成绩明细 + 全量用户数据快照)写进服务器。发出去收不回来。
|
||||
|
||||
| 项目 | 值 |
|
||||
|---|---|
|
||||
| 逻辑 API 名 | `UpsertUserAllApi` |
|
||||
| 实际 URL 尾段 | `md5("UpsertUserAllApiMaimaiChn" + "8bF76dE9")` |
|
||||
| 超时 | 60 秒(`NetConfig.TimeOutInMSecLong`) |
|
||||
| 副作用 | ★ 有,不可撤销 |
|
||||
|
||||
---
|
||||
|
||||
## 二、顶层请求结构(7 字段)
|
||||
|
||||
依据 `PacketUpsertUserAll.cs:22-35`:
|
||||
|
||||
```python
|
||||
UserAllRequestVO(
|
||||
userId = user_id,
|
||||
playlogId = login.loginId, # ★ 必须 == UserLoginApi 的 loginId
|
||||
isEventMode = False,
|
||||
isFreePlay = False,
|
||||
loginDateTime = login.loginDateTime, # ★ 必须 == UserLoginApi 的 loginDateTime
|
||||
userPlaylogList = [...本局成绩...],
|
||||
upsertUserAll = {...36 个节点...},
|
||||
)
|
||||
```
|
||||
|
||||
响应:`{"returnCode": 1, "apiName": "com.sega.maimai.api.UpsertUserAllApi"}`。
|
||||
|
||||
> ⚠ `playlogId` 与 `loginDateTime` **不是本接口产生的**,它们来自 `UserLoginApi`。
|
||||
> 发错登录 ID 服务端会直接拒。中途换了登录会话必须重新登录。
|
||||
|
||||
---
|
||||
|
||||
## 三、upsertUserAll 的 36 个节点
|
||||
|
||||
依据 `Net.VO.Mai2/UserAll.cs`,并用真机报文逐节点核对。**四种形态**:
|
||||
|
||||
| 类别 | 数量 | C# 类型 | JSON 形态 |
|
||||
|---|---:|---|---|
|
||||
| 单元素数组 | 4 | UserDetail[] / UserExtend[] / UserOption[] / UserRating[] | 数组,恒 1 元素 |
|
||||
| **对象(非数组)** | 2 | UserWeeklyData / User2pPlaylog | `{}` ★ |
|
||||
| 普通数组 | 19 | Xxx[] | `[]` |
|
||||
| **位串** | 11 | string | 定长字符串 |
|
||||
|
||||
合计 4 + 2 + 19 + 11 = **36**。
|
||||
|
||||
### 3.1 ★ 两个对象节点
|
||||
|
||||
`userWeeklyData` 与 `user2pPlaylog` **不是数组**。传数组会被 `coerce_node_value()`
|
||||
直接拦下并抛 `PatchError`(这是刻意设计的防呆)。
|
||||
|
||||
```jsonc
|
||||
"userWeeklyData": { "lastLoginWeek": "...", "beforeLoginWeek": "...", "friendBonusFlag": true }
|
||||
"user2pPlaylog" : { "userId1": 0, ..., "user2pPlaylogDetailList": [] }
|
||||
```
|
||||
|
||||
### 3.2 ★ 位串长度 == 对应数组条数
|
||||
|
||||
11 个 `isNew*` 与同名数组一一对应。**长度不符服务端会误判「哪些是新增」**。
|
||||
`build_user_all()` 末尾会自动补齐(`sync_bits_to_arrays()`)。
|
||||
|
||||
| 位串 | 对应数组 |
|
||||
|---|---|
|
||||
| `isNewCharacterList` | userCharacterList |
|
||||
| `isNewMapList` | userMapList |
|
||||
| `isNewLoginBonusList` | userLoginBonusList |
|
||||
| `isNewItemList` | userItemList |
|
||||
| `isNewMusicDetailList` | userMusicDetailList |
|
||||
| `isNewCourseList` | userCourseList |
|
||||
| `isNewFavoriteList` | userFavoriteList |
|
||||
| `isNewFriendSeasonRankingList` | userFriendSeasonRankingList |
|
||||
| `isNewUserIntimateList` | userIntimateList |
|
||||
| `isNewFavoritemusicList` | userFavoritemusicList |
|
||||
| `isNewKaleidxScopeList` | userKaleidxScopeList |
|
||||
|
||||
---
|
||||
|
||||
## 四、★★ 核心:快照基线 + 本局演算 + 差分
|
||||
|
||||
### 4.1 真机对照(同一账号的 Get 侧 vs Upsert 侧)
|
||||
|
||||
| 节点 | Get 侧(登录时) | Upsert 侧(结算时) | 结论 |
|
||||
|---|---|---|---|
|
||||
| `userData.playCount` | 1257 | **1258** | +1 |
|
||||
| `userData.currentPlayCount` | 185 | **186** | +1 |
|
||||
| `userData.lastPlayDate` | 2026-10-04 18:46:31 | **2026-10-05 00:20:57** | 更新 |
|
||||
| `userData.lastPlayMode` | 2 | **0** | 归 0 |
|
||||
| `userData.lastLoginDate` | 2026-10-04 18:34:22 | **2026-10-05 00:19:37** | 本次登录 |
|
||||
| `userData.dateTime` | null | **1791134329** | 登录请求的 dateTime |
|
||||
| `userData.lastPlaceId` | 0 | **1000** | 当前店铺 |
|
||||
| `userData.lastRegionId` | 11 | **5** | 当前区域 |
|
||||
| `userMusicDetailList` | 739 组 | **1 条** | 只回传本局那条,playCount 7→8 |
|
||||
| `userMapList` | 104 条 | **1 条** | 只回传变化那条,distance +11000 |
|
||||
| `userCharacterList` | 312 条 | **5 条** | 组队 5 只,各 +1 级 |
|
||||
| `userItemList` | 1070 条 | **0 条** | 无变化 |
|
||||
| `userCourseList` | 16 条 | **0 条** | 无变化 |
|
||||
| `userChargeList` | 3 条 | **3 条** | ★ **整表回传,不做差分** |
|
||||
| `userFavoriteList` | 5 类 | **5 类** | ★ 不做剔除,恒返回全部 |
|
||||
|
||||
### 4.2 ★★ 为什么必须做差分(这是「returnCode=1 但没落库」的根因)
|
||||
|
||||
**服务器对 `userData` 是整表覆盖语义** —— 把 Get 下来的旧值原样发回去,
|
||||
写入后「看起来什么都没发生」。
|
||||
|
||||
**差分节点若为空,服务端也看不出本局有任何变化**,整包等于没生效。
|
||||
|
||||
所以本模块的流程是:
|
||||
|
||||
```
|
||||
1. 从 snapshot(登录后抓的 Get 响应)建立基线
|
||||
2. 按本局数据演算出新状态,并对差分节点算 (新 - 基线)
|
||||
3. 用 patch 做精确覆盖
|
||||
4. 生成 11 条位串(长度自动对齐对应数组)
|
||||
```
|
||||
|
||||
### 4.3 差分算法(复刻 `VOExtensions.BuildListData`)
|
||||
|
||||
三个要点(源码 `VOExtensions.cs:309-324`):
|
||||
|
||||
1. `nsrc.Except(osrc)` 退化成**全字段逐字段比较** → 任一字段变了整条就回传
|
||||
2. `compare` 委托**只用于算位串**,不参与剔除
|
||||
3. 位串 `"0"` = 存量已有同主键(更新)、`"1"` = 全新(新增)
|
||||
|
||||
### 4.4 收藏夹是特例
|
||||
|
||||
`build_favorite_delta()` 复刻 `VOExtensions.cs:345-367`:**不做剔除,恒返回完整 5 条**,
|
||||
位串按真机 `"22212"` 反推:
|
||||
|
||||
- `"2"` = 服务器侧有对应 itemKind
|
||||
- `"1"` = 服务器侧查不到(真机 `itemKind=10` 的 Get 响应回的是 `{"itemKind":null}`)
|
||||
|
||||
> 因此**必须用未归一化的原始 Get 响应作基线**。`user_favorite.py` 会把原始副本
|
||||
> 放在返回值的 `_raw` 里,`upsert_snapshot.py` 把它存成 `_raw_favorite`。
|
||||
|
||||
---
|
||||
|
||||
## 五、userPlaylogList(110 字段成绩明细)
|
||||
|
||||
依据 `Net.VO.Mai2/UserPlaylog.cs`,实际声明 **110 个** `public` 字段。
|
||||
|
||||
### 5.1 ★ 字段必须全部发送
|
||||
|
||||
把真机报文的 `userPlaylogList[0]` 与 C# 声明做集合比对:
|
||||
|
||||
| 比对方向 | 结果 |
|
||||
|---|---|
|
||||
| C# 声明但报文未出现 | **0 个** |
|
||||
| 报文有但 C# 未声明 | **0 个** |
|
||||
|
||||
**能精简的是「填写的内容要真实」,而不是删字段。**
|
||||
|
||||
### 5.2 值之间的硬性关系(`validate_user_playlog()` 会查)
|
||||
|
||||
| 关系 | 说明 |
|
||||
|---|---|
|
||||
| 各类型判定数之和 == `totalCombo` | totalCombo 是**该谱面 note 总数** |
|
||||
| `maxCombo` <= 判定总数 | 全连时二者相等 |
|
||||
| `achievement > 0` 时不应全是 Miss | 否则数值矛盾 |
|
||||
| `isClear=True` 时 `achievement` 不能为 0 | 否则矛盾 |
|
||||
| `comboStatus=1`(全连)时 `maxCombo == 判定总数` | |
|
||||
| `extNum4` == `userExtend.categoryIndex` | 曲种索引冗余两处 |
|
||||
|
||||
### 5.3 ★ 日期字段的真实语义(`TimeManager.cs`)
|
||||
|
||||
真机 `TimeManager` 的日期处理有三个容易搞错的点:
|
||||
|
||||
```csharp
|
||||
// Manager/TimeManager.cs:9
|
||||
public static readonly TimeSpan CSToffset = new TimeSpan(8, 0, 0);
|
||||
|
||||
// :33-37 把 Unix 秒转成"北京时间墙上时间"
|
||||
public static DateTime GetDateTime(long unixTime)
|
||||
=> DateTimeOffset.FromUnixTimeSeconds(unixTime).DateTime.Add(CSToffset);
|
||||
|
||||
// :77-85 ★ 游玩日志日不是自然日:7 点前算前一天
|
||||
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);
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
对应到 `VOExtensions.cs:1290-1292`:
|
||||
|
||||
| 字段 | 算法 | 真机值 |
|
||||
|---|---|---|
|
||||
| `loginDate` | `GetUnixTime(LastLoginDate)` → **标准 Unix 秒** | 1791130777 |
|
||||
| `playDate` | `GetLogDateString(playTime)` → **日志日**(7 点前回退一天) | "2026-10-04" |
|
||||
| `userPlayDate` | `GetDateString(playTime)` → 北京时间墙上串 | "2026-10-05 00:20:30.0" |
|
||||
|
||||
★ 注意 `loginDate` 是**标准 Unix 秒**,不是 `loginDateTime`:
|
||||
|
||||
```
|
||||
loginDateTime = 1791159576 (「墙上时间当 UTC」)
|
||||
loginDate = 1791130777 (= loginDateTime - 28800,标准 Unix 秒)
|
||||
```
|
||||
|
||||
### 5.4 bool 字段陷阱
|
||||
|
||||
`extBool1/2/3` 在 C# 里是 `bool`,但**名字不带 `is` 前缀**。
|
||||
用 `name.startswith("is")` 判断会漏掉它们,导致序列化成 `0` 而不是 `false`。
|
||||
本模块用显式的 `PLAYLOG_BOOL_FIELDS` 集合判定。
|
||||
|
||||
同理,字符串字段(`vsUserName` / `playedUserNameN` 等)默认值必须是 `""` 而非 `0`,
|
||||
见 `PLAYLOG_STRING_FIELDS`。
|
||||
|
||||
---
|
||||
|
||||
## 六、userGamePlaylogList
|
||||
|
||||
真机样本(13 字段):
|
||||
|
||||
```json
|
||||
{"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}
|
||||
```
|
||||
|
||||
### 6.1 `playSpecial` 是随机位掩码
|
||||
|
||||
源码 `VOExtensions.cs:846-855`:
|
||||
|
||||
```csharp
|
||||
int max = 1037933;
|
||||
int num2 = 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; }
|
||||
```
|
||||
|
||||
恒传 0 不像真机生成的值,`calc_play_special()` 复刻了该算法。
|
||||
|
||||
### 6.2 `playCredit` 与 `useTicketId`
|
||||
|
||||
源码 `VOExtensions.cs:838-845`:
|
||||
|
||||
```csharp
|
||||
int num = Credit.GameCostEnoughPlay(); // 投币成本
|
||||
int useTicketId = TicketManager.GetUseTicketId(playerId);
|
||||
int ticketCredit = TicketManager.GetTicketCredit(useTicketId);
|
||||
int playCredit = num + ticketCredit; // 本局折合多少 credit
|
||||
```
|
||||
|
||||
`useTicketId = -1` 表示**不使用票**(`TicketManager.cs:47` 初始化即 -1)。
|
||||
|
||||
---
|
||||
|
||||
## 七、字段裁剪(Get 侧 → Upsert 侧要丢掉的)
|
||||
|
||||
| 节点 | 丢弃字段 |
|
||||
|---|---|
|
||||
| `userData` | `friendCode`、`nameplateId`、`trophyId`、`cmLastEmoneyCredit`、`cmLastEmoneyBrand` |
|
||||
| `userOption` | `tempoVolume` |
|
||||
| `userCharacterList` | 只保留 `characterId`/`level`/`awakening`/`useCount` |
|
||||
| `userMusicDetailList` | `extNum2` |
|
||||
| `userChargeList` | `extNum1` |
|
||||
| `userMissionDataList` / `userWeeklyData` | `userId` |
|
||||
|
||||
### 7.1 `banState` 位置漂移
|
||||
|
||||
- **Get 侧**:`banState` 是 `userData` 的**同级顶层**字段(`UserDetailResponseVO.cs:15`)
|
||||
- **Upsert 侧**:它被**移进了 `userData` 内部**
|
||||
|
||||
`_extract_from_get_responses()` 会自动搬运。
|
||||
|
||||
---
|
||||
|
||||
## 八、安全闸门
|
||||
|
||||
| 调用方式 | 行为 |
|
||||
|---|---|
|
||||
| `UpsertUserAll(...)` | 抛 `ConfirmRequiredError`,**未发包** |
|
||||
| `UpsertUserAll(..., dry_run=True)` | 只构造并返回报文 |
|
||||
| `UpsertUserAll(..., confirm=True)` | 真实发送 |
|
||||
|
||||
全局开关在 `config.py`:`UPSERT_CONFIRM = False`(改成 True 会永久放行,不建议)。
|
||||
|
||||
### 8.1 发包前冷却(默认 60 秒)
|
||||
|
||||
真实发包前会固定等待 `config.UPSERT_SEND_COOLDOWN_SEC` 秒(**默认 60**),
|
||||
让上报节奏贴近真机 —— 真机从登录到结算上报间隔约 81 秒
|
||||
(`source/2026-10-05.log:25` 登录 → `:123` 上报)。
|
||||
|
||||
| 调用方式 | 是否等待 |
|
||||
|---|---|
|
||||
| `dry_run=True` | 不等待(只构造报文) |
|
||||
| 未开闸门(`confirm` 未置位) | 不等待(直接抛 `ConfirmRequiredError`) |
|
||||
| `confirm=True` | **等待 60 秒**后再发包 |
|
||||
|
||||
实现要点:
|
||||
|
||||
- 冷却位于**「发包前自检」之后** —— 自检有问题时会先看到告警,不必白等 60 秒
|
||||
- 每 10 秒打印一次进度,避免刷屏又能看出没卡死
|
||||
- 调试时跳过:`cooldown_sec=0`
|
||||
|
||||
```
|
||||
上传成绩冷却:等待 60.0 秒后再发送(模拟真机节奏)...
|
||||
冷却中 ... 已等待 10s / 剩 50s
|
||||
冷却中 ... 已等待 20s / 剩 40s
|
||||
冷却中 ... 已等待 30s / 剩 30s
|
||||
冷却中 ... 已等待 40s / 剩 20s
|
||||
冷却中 ... 已等待 50s / 剩 10s
|
||||
冷却结束(共等待 60.0 秒)
|
||||
闸门已放行,开始上传成绩 ...
|
||||
```
|
||||
|
||||
```python
|
||||
UpsertUserAll(..., confirm=True) # 默认按 config 的 60 秒
|
||||
UpsertUserAll(..., confirm=True, cooldown_sec=0) # 调试时跳过
|
||||
wait_send_cooldown(60, label="上传成绩") # 也可单独调用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、完整用法
|
||||
|
||||
### 9.1 ★ 推荐写法:一个 `UpsertUserAll` 就够了
|
||||
|
||||
```python
|
||||
from api import (setup_logging, teardown_logging,
|
||||
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType,
|
||||
UpsertUserAll)
|
||||
from config import REGION_ID, PLACE_ID
|
||||
|
||||
setup_logging("结算") # ← 只需要这一句日志;api 自己会记
|
||||
try:
|
||||
user_id, token = ParseQRCode(qr)
|
||||
UserPreview(user_id, token)
|
||||
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||
|
||||
# ★ 一个函数跑完:抓快照 -> 组包 -> 前置接口 -> 自检 -> 冷却 -> 发包 -> 落库回查
|
||||
UpsertUserAll(
|
||||
user_id, login.loginId,
|
||||
login_date_time=login.loginDateTime,
|
||||
auth_date_time=login.dateTime,
|
||||
token=token, # 让内部自动抓基线快照
|
||||
dry_run=True, # 先只看报文;真实发送改成 confirm=True
|
||||
)
|
||||
|
||||
UserLogout(user_id, token, login.loginDateTime,
|
||||
logout_type=LogoutType.TestIn,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
finally:
|
||||
teardown_logging()
|
||||
```
|
||||
|
||||
**注意 `snapshot` 都不用自己抓** —— 传了 `token` 且 `auto_snapshot=True`(默认),
|
||||
`UpsertUserAll` 内部会调 `fetch_upsert_snapshot`。
|
||||
|
||||
### 9.2 想手工控制每一步(进阶)
|
||||
|
||||
```python
|
||||
snapshot = fetch_upsert_snapshot(user_id, token=token) # 自己抓
|
||||
play = build_play_from_config() # 自己改本局数据
|
||||
play.characters = derive_play_characters(snapshot, play) # 自己推角色
|
||||
|
||||
UpsertUserAll(user_id, login.loginId,
|
||||
login_date_time=login.loginDateTime,
|
||||
snapshot=snapshot, play=play,
|
||||
pre_new_item_list=False, # 不调前置接口
|
||||
verify_landing=False, # 不做落库回查
|
||||
dry_run=True)
|
||||
```
|
||||
|
||||
### 9.3 想额外塞节点:`extra_data`
|
||||
|
||||
```python
|
||||
UpsertUserAll(
|
||||
user_id, login.loginId,
|
||||
login_date_time=login.loginDateTime,
|
||||
auth_date_time=login.dateTime,
|
||||
token=token,
|
||||
extra_data={
|
||||
"userKaleidxScopeList": [{
|
||||
"gateId": 1, "isGateFound": True, "isKeyFound": True, "isClear": False,
|
||||
"totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0,
|
||||
"bestAchievement": 0, "bestDeluxscore": 0,
|
||||
"bestAchievementDate": "", "bestDeluxscoreDate": "",
|
||||
"playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": False,
|
||||
}],
|
||||
"isNewKaleidxScopeList": "1",
|
||||
},
|
||||
confirm=True,
|
||||
)
|
||||
```
|
||||
|
||||
`extra_data` 会自动匹配节点名、按形态强制转换、覆盖快照演算结果:
|
||||
|
||||
| 输入 | 行为 |
|
||||
|---|---|
|
||||
| 键不在 36 节点里 | 抛 `PatchError`,报错信息里列出全部可用节点名 |
|
||||
| 值形态不对(如对象节点传数组) | 抛 `PatchError` |
|
||||
| 给了数组但没给对应位串 | 按数组长度补 `"0"`,并在日志里提示「新增条目请显式传 "1"」 |
|
||||
| 也接受 JSON 字符串 / JSON 文件路径 | 内部用 `load_extra_data` 规整 |
|
||||
|
||||
> 位串 `"0"` = 存量已有(更新),`"1"` = 全新(新增)。
|
||||
> 传新增条目时**一定要显式给 `"1"`**,否则服务端会当成更新。
|
||||
|
||||
---
|
||||
|
||||
## 十、歌曲数据在哪改
|
||||
|
||||
全部在 `config.py` 的 `PLAY_*` 系列(默认值取自真机 `source/2026-10-05.log:123`):
|
||||
|
||||
| 配置项 | 含义 | 真机默认 |
|
||||
|---|---|---|
|
||||
| `PLAY_MUSIC_ID` / `PLAY_MUSIC_LEVEL` | 曲目与难度 | 143 / 4 |
|
||||
| `PLAY_ACHIEVEMENT` | 达成率 ×10000 | 0 |
|
||||
| `PLAY_JUDGE_COUNTS` | 判定明细 | 全 Miss |
|
||||
| `PLAY_TOTAL_COMBO` | 谱面 note 总数 | 655 |
|
||||
| `PLAY_CATEGORY_INDEX` | 曲种索引(== userExtend.categoryIndex) | 2022 |
|
||||
| `PLAY_CHARACTERS` | 游玩时的角色快照(**不加经验**)★ 通常不用管,见 10.2 | 5 只 |
|
||||
| `PLAY_MAP_DISTANCE_GAIN` | 地图进度增量 | **0**(不推进地图) |
|
||||
| `PLAY_CHARACTER_EXP_GAIN` | 组队角色等级增量 | **0**(不给角色加经验) |
|
||||
| `PLAY_LOCAL_PLAY_COUNT` | 机台本地计数器 | 8 |
|
||||
| `PLAY_SPECIAL_NUM` | playSpecial 里的 CalcSpecialNum | 1024 |
|
||||
|
||||
**换歌 / 换成绩只改这里,不用动任何 api 脚本。**
|
||||
|
||||
### 10.1 ★ 两个默认 0 的含义
|
||||
|
||||
`PLAY_MAP_DISTANCE_GAIN` 与 `PLAY_CHARACTER_EXP_GAIN` **默认都是 0**,
|
||||
即「本局不推进地图、不给角色加经验」,对应节点回传空数组:
|
||||
|
||||
```json
|
||||
"userMapList": [], "isNewMapList": ""
|
||||
"userCharacterList": [], "isNewCharacterList": ""
|
||||
```
|
||||
|
||||
这是**预期行为,不是错误**。`validate_before_send()` 也只在
|
||||
「你把增量设成非 0、却没产出条目」时才告警。
|
||||
|
||||
想复刻真机的「地图 +11000 / 组队 5 只各 +1 级」,把这两个值改回
|
||||
`11000` / `1` 即可(真机样本:mapId=550003 的 distance 1424000→1435000)。
|
||||
|
||||
### 10.2 ★ `PLAY_CHARACTERS` 通常不用管
|
||||
|
||||
`UpsertUserAll` 默认会调 `derive_play_characters(snapshot)`,
|
||||
直接从服务器刚下发的 `userData.charaSlot` + `userCharacterList`
|
||||
推出**游玩前**的 5 只角色,并覆盖这份配置。
|
||||
只有拿不到快照(纯离线组包)时才会回退到这里。
|
||||
|
||||
---
|
||||
|
||||
## 十一、接口速查
|
||||
|
||||
| 函数 | 作用 |
|
||||
|---|---|
|
||||
| `UpsertUserAll(...)` | ★ 主函数:**一个函数跑完整条结算链路**(抓快照 → 组包 → 前置接口 → 自检 → 冷却 → 发包 → 落库回查) |
|
||||
| `fetch_upsert_snapshot(user_id, ...)` | 一键抓结算基线快照(`UpsertUserAll` 内部会按需自动调) |
|
||||
| `build_play_from_config()` | 按 config 构造本局数据 |
|
||||
| `derive_play_characters(snapshot, play)` | 从快照推游玩前 5 只角色 |
|
||||
| `load_extra_data(extra)` | 把 dict / JSON 字符串 / 文件路径 规整成 dict |
|
||||
| `merge_extra_data(user_all, extra)` | 把外部 JSON 合并进 36 节点(自动匹配 + 补位串 + 校验) |
|
||||
| `verify_upsert_landing(user_id, snapshot, play)` | 发包后重新拉数据,确认真的落库 |
|
||||
| `build_user_all(snapshot, play, patch, extra_data, ...)` | 组装 36 节点 |
|
||||
| `build_user_playlog(playlog_id, login_date, ...)` | 构造 110 字段成绩明细 |
|
||||
| `build_user_game_playlog(...)` | 构造 userGamePlaylogList 一条 |
|
||||
| `validate_user_playlog(playlog)` | 成绩自检,返回问题列表 |
|
||||
| `validate_before_send(req)` | 发包前自检,返回问题列表 |
|
||||
| `build_delta(local, server, keys)` | 通用差分 |
|
||||
| `build_favorite_delta(local, server)` | 收藏夹专用差分 |
|
||||
| `sync_bits_to_arrays(user_all)` | 位串按数组长度补齐 |
|
||||
| `coerce_node_value(node, value)` | 单节点类型强制(传错即抛) |
|
||||
| `empty_user_all()` | 全空骨架 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、必知的坑
|
||||
|
||||
**完整清单(28 条,含现象/根因/源码依据/正确做法/自检项)见
|
||||
[`UpsertUserAll踩坑清单.md`](UpsertUserAll踩坑清单.md)。**
|
||||
|
||||
先记住最要命的五条:
|
||||
|
||||
1. **服务端返回 `returnCode=1` 不代表数据被应用** —— 唯一验收标准是发包后回查数值有没有变
|
||||
2. **`userData` 是整表覆盖语义** —— 原样回传旧值 = 什么都没发生(这是「回码成功但没落库」的头号成因)
|
||||
3. **差分节点必须反映本局变化** —— `userMusicDetailList`/`userMapList`/`userCharacterList` 全空 = 服务端认为本局无变化
|
||||
4. **`playlogId` / `loginDateTime` 必须逐字节等于 `UserLoginApi` 的返回值**
|
||||
5. **`userWeeklyData` / `user2pPlaylog` 是对象不是数组**;**`userChargeList` 是整表回传不是差分**
|
||||
|
||||
---
|
||||
|
||||
## 十三、验证方式
|
||||
|
||||
```powershell
|
||||
python selftest.py
|
||||
```
|
||||
|
||||
覆盖 **196 项**零网络检查,含:URL 混淆(对真机 md5 值)、加解密往返、
|
||||
36 节点结构与形态、110 字段完整性、差分算法、收藏夹三值位串、日期语义、
|
||||
副作用闸门、位串自动补齐、快照归一化、配置常量、
|
||||
`derive_play_characters` 角色推导、`extra_data` 四种输入形态与自动补位串、
|
||||
number 头 / Cookie 复用等。
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
# 主脚本编写指南
|
||||
|
||||
> 面向「想用这个工程写自己业务流程」的人。
|
||||
> 目标:**你只写业务,日志和协议细节都交给 `api/`**。
|
||||
|
||||
---
|
||||
|
||||
## 一、三条铁律
|
||||
|
||||
### 1. 不要为每个 api 调用写日志
|
||||
|
||||
`api/` 里每个函数在导入时都被自动套了日志外壳(`log_setup.autowrap`),
|
||||
调用时会自动打印:
|
||||
|
||||
```
|
||||
=== UserLogin 开始运行 ===
|
||||
[接口说明] 用户正式登录(开启本次登录会话)。
|
||||
[位置参数] 12345678, 'TpzUKJM6x1d2iKUP5yLI'
|
||||
[关键字参数] region_id=1, place_id=1000
|
||||
[运行结果] 成功 UserLoginResponseVO(8 字段)(耗时 77 ms)
|
||||
=== UserLogin 运行结束 ===
|
||||
```
|
||||
|
||||
所以你**不需要**在调用前后写 `log_section` / `log_info`。
|
||||
主脚本里唯一必需的日志语句只有开头结尾那两句:
|
||||
|
||||
```python
|
||||
setup_logging("我的脚本") # 开头
|
||||
...
|
||||
teardown_logging() # 结尾(写在 finally 里)
|
||||
```
|
||||
|
||||
### 2. 一定要保证登出
|
||||
|
||||
账号若停留在 `isLogin=True`,下次登录会返回 `returnCode=100`。
|
||||
**用 `try/finally` 兜底**:
|
||||
|
||||
```python
|
||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||
try:
|
||||
...
|
||||
login = UserLogin(user_id, token, ...)
|
||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||
...
|
||||
UserLogout(...)
|
||||
session["logged_in"] = False
|
||||
finally:
|
||||
if session["logged_in"]:
|
||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||
logout_type=LogoutType.TestIn)
|
||||
```
|
||||
|
||||
### 3. 有副作用的接口要显式开闸门
|
||||
|
||||
`UpsertUserAll` / `UpsertUserChargelog` 这类写接口默认**不发包**,
|
||||
必须显式传 `confirm=True`(或 `dry_run=True` 只看报文):
|
||||
|
||||
```python
|
||||
UpsertUserAll(..., dry_run=True) # 只构造,不发
|
||||
UpsertUserAll(..., dry_run=False, confirm=True) # 真发
|
||||
```
|
||||
|
||||
> ⚠ 只写 `dry_run=False` **不够** —— `confirm=None` 时会去读
|
||||
> `config.UPSERT_CONFIRM`(默认 `False`),照样被拦。
|
||||
|
||||
---
|
||||
|
||||
## 二、最小骨架
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
from api import (setup_logging, teardown_logging,
|
||||
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType)
|
||||
from config import REGION_ID, PLACE_ID
|
||||
|
||||
def main() -> int:
|
||||
setup_logging("我的脚本")
|
||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||
try:
|
||||
user_id, token = ParseQRCode(sys.argv[1])
|
||||
session.update(user_id=user_id, token=token)
|
||||
|
||||
UserPreview(user_id, token) # 顶号保护
|
||||
login = UserLogin(user_id, token,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||
|
||||
# ---- 你的业务写在这里 ----
|
||||
|
||||
UserLogout(user_id, token, login.loginDateTime,
|
||||
logout_type=LogoutType.TestIn,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
session["logged_in"] = False
|
||||
return 0
|
||||
finally:
|
||||
if session["logged_in"]:
|
||||
try:
|
||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||
logout_type=LogoutType.TestIn,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
except Exception:
|
||||
pass
|
||||
teardown_logging()
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、日志
|
||||
|
||||
### 3.1 两种模式(改 config.py 一行)
|
||||
|
||||
| `LOG_LEVEL` | 输出 |
|
||||
|---|---|
|
||||
| `"info"`(默认) | 只展示 api 脚本的**运行情况和结果** |
|
||||
| `"debug"` | 额外展示**向 api 发送的数据**和 **api 返回的数据**(含完整报文) |
|
||||
|
||||
临时切换(不改 config):
|
||||
|
||||
```bash
|
||||
python upsert.py <二维码> --debug
|
||||
```
|
||||
|
||||
> ★ DEBUG 会完整打印收发报文,一次结算流程能到 **十几 MB**。
|
||||
> 只想看流程就别开;想瘦身调 `config.LOG_VALUE_MAX`。
|
||||
|
||||
### 3.2 两个日志文件(同时写)
|
||||
|
||||
| 文件 | 模式 | 内容 |
|
||||
|---|---|---|
|
||||
| `logs/latest.log` | 覆盖(`w`) | 只含**当次**主脚本运行 |
|
||||
| `logs/YYYY-MM-DD.log` | 追加(`a`) | 当日**全部**日志 |
|
||||
|
||||
### 3.3 想记自己的话(可选)
|
||||
|
||||
```python
|
||||
from api import log_info, log_warning, log_error, log_section, log_field
|
||||
|
||||
log_section("阶段标题") # 带分隔线
|
||||
log_info("普通信息")
|
||||
log_warning("要留意的情况")
|
||||
log_error("出错了")
|
||||
log_field("预览数据", preview) # INFO 一行概要;DEBUG 展开完整 JSON
|
||||
```
|
||||
|
||||
### 3.4 嵌套调用也会自动记
|
||||
|
||||
`UpsertUserAll` 内部会调 `fetch_upsert_snapshot` / `UserNewItemList` / `UserData`,
|
||||
这些**内部调用同样打印自己的开始/结束行**,日志层次完整嵌套:
|
||||
|
||||
```
|
||||
=== UpsertUserAll 开始运行 ===
|
||||
=== fetch_upsert_snapshot 开始运行 ===
|
||||
...
|
||||
=== fetch_upsert_snapshot 运行结束 ===
|
||||
=== UserNewItemList 开始运行 ===
|
||||
...
|
||||
=== UserNewItemList 运行结束 ===
|
||||
...
|
||||
=== UpsertUserAll 运行结束 ===
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、参数约定
|
||||
|
||||
| 类型 | 位置 | 例子 |
|
||||
|---|---|---|
|
||||
| **业务数据**(必需) | 位置参数 | `user_id` / `token` / `二维码` / `playlog_id` / `charge_id` |
|
||||
| **配置项**(可省) | 关键字参数 | `client_id` / `region_id` / `place_id` / `timeout_ms` / `client` |
|
||||
|
||||
**`client` 参数**:所有 api 都接受一个可选的 `MaimaiNetClient`。
|
||||
不传时走全局单例 `get_default_client()`,**连接和 Cookie(JSESSIONID)自动复用**。
|
||||
|
||||
> ### ★ 不要自己 `MaimaiNetClient()` 新建去发任何带 userId 的包
|
||||
>
|
||||
> 源码 `Packet.cs:187-194`:**所有非 Ping 且 `userId != 0` 的请求都会
|
||||
> `Client.AddCookie(Query.UserId)`** —— **登出也不例外**。
|
||||
>
|
||||
> ```csharp
|
||||
> if (!IsPingPacket())
|
||||
> {
|
||||
> Client.AddHeader("number", GameManager.PingMsec.ToString());
|
||||
> if (Query.UserId != 0L) { Client.AddCookie(Query.UserId); } // ← 登出也走这里
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 那个 CookieContainer 是 **`UserLoginApi` 响应里的 `Set-Cookie: JSESSIONID=...`**
|
||||
> 存下来的(`Packet.cs:228-234`)。
|
||||
>
|
||||
> **为什么对登出尤其要命**:登出请求体里**没有 token**,只有
|
||||
> `userId` / `accessCode` / `regionId` / `placeId` / `clientId` /
|
||||
> `loginDateTime` / `type` / `delayLog`。
|
||||
> 服务端能用来定位「哪个会话」的,除了 `userId + loginDateTime`,就剩这个 JSESSIONID。
|
||||
> **丢掉它有可能登出失败 → 账号卡在 `isLogin=True`(下次登录 returnCode=100)**。
|
||||
>
|
||||
> 结论:**全程复用同一个 client**(不传 `client=` 即默认单例,自动满足)。
|
||||
|
||||
---
|
||||
|
||||
## 五、四个典型流程模板
|
||||
|
||||
### 5.1 只登录(探活 / 查状态)
|
||||
|
||||
```python
|
||||
user_id, token = ParseQRCode(qr)
|
||||
UserPreview(user_id, token)
|
||||
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||
# ... 用 UserData / UserMusicDetailAll 等取数 ...
|
||||
```
|
||||
|
||||
### 5.2 上传一局成绩(最常用)
|
||||
|
||||
```python
|
||||
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||
|
||||
# ★ 一个函数跑完整条链路:抓快照 → 推角色 → 造成绩明细 → 前置接口
|
||||
# → 组 36 节点 → 合并 extra_data → 自检 → 冷却 → 发包 → 落库回查
|
||||
UpsertUserAll(
|
||||
user_id, login.loginId,
|
||||
login_date_time=login.loginDateTime,
|
||||
auth_date_time=login.dateTime,
|
||||
token=token, # 让内部自动抓基线快照
|
||||
dry_run=False, confirm=True,
|
||||
)
|
||||
```
|
||||
|
||||
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`**,不用动主脚本。
|
||||
|
||||
**想额外塞节点**用 `extra_data`:
|
||||
|
||||
```python
|
||||
UpsertUserAll(..., extra_data={
|
||||
"userKaleidxScopeList": [{"gateId": 1, "isGateFound": True, ...}],
|
||||
"isNewKaleidxScopeList": "1",
|
||||
})
|
||||
```
|
||||
|
||||
### 5.3 买一张功能票
|
||||
|
||||
见 `buy_ticket.py` 与 `docs/购买功能票流程.md`。核心就一句:
|
||||
|
||||
```python
|
||||
UpsertUserChargelog(user_id, charge_id=2,
|
||||
login_date_time=login.loginDateTime,
|
||||
dry_run=False, confirm=True)
|
||||
```
|
||||
|
||||
### 5.4 只取数(不发任何写接口)
|
||||
|
||||
```python
|
||||
from api import UserData, UserMusicDetailAll, UserRating
|
||||
|
||||
ud = UserData(user_id)
|
||||
print(ud["userData"]["playCount"])
|
||||
|
||||
for m in (UserMusicDetailAll(user_id).get("userMusicList") or []):
|
||||
if m.get("musicId") == 143:
|
||||
print(m)
|
||||
```
|
||||
|
||||
> 取数类 api 不需要 `confirm`,也没有副作用,可以随便调。
|
||||
|
||||
---
|
||||
|
||||
## 六、常见错误
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|---|---|---|
|
||||
| 登录 `returnCode=100` | 账号卡在 `isLogin=True` | 上次没登出;等超时或用 `finally` 兜底 |
|
||||
| `UpsertUserAll` 抛 `ConfirmRequiredError` | 只传了 `dry_run=False`,没传 `confirm=True` | 补 `confirm=True` |
|
||||
| 扫码 `errorID=2` | 二维码**过期**了。它有**时效性**(**有效期 10 分钟**),**不是按次数计量的** | 重新生成一个 |
|
||||
| `returnCode=1` 但数据没变 | 差分节点全空(见踩坑清单坑 2) | 检查 `PLAY_*` 增量配置 |
|
||||
| 日志文件特别大 | 开了 DEBUG | 关掉 `--debug` 或调小 `LOG_VALUE_MAX` |
|
||||
| `RuntimeError: 接口 xxx 返回 0 字节` | `Accept-Encoding` 被改过 | 不要动 `maimai_net` 的该头 |
|
||||
|
||||
---
|
||||
|
||||
## 七、自己写一个新 api
|
||||
|
||||
1. 在 `api/` 下新建脚本,定义一个能传参的函数
|
||||
2. 函数首行写 docstring(会变成日志的 `[接口说明]`)
|
||||
3. 用 `from .maimai_net import request_dict` 发包
|
||||
4. 用 `from .log_setup import log_info` 记关键结果
|
||||
5. **在 `api/__init__.py` 里导入并加进 `_API_FUNCS`** ← 漏了这步就没有开始/结束提示
|
||||
|
||||
---
|
||||
|
||||
## 八、相关文档
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `docs/项目总览.md` | 工程结构与 api 清单 |
|
||||
| `docs/日志与主脚本指南.md` | 日志模块完整参考 |
|
||||
| `docs/上传成绩开发文档.md` | UpsertUserAll 的 36 节点 / 110 字段 / 差分语义 |
|
||||
| `docs/UpsertUserAll踩坑清单.md` | 28 条坑 + 发包前 Checklist |
|
||||
| `docs/购买功能票流程.md` | 登录 → 买票 → 登出 |
|
||||
| `upsert.py` | 主脚本范例(极简版) |
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# 实跑验证记录(真实发包 + 落库确认)
|
||||
|
||||
> 本文记录本工程**第一次真实跑通**「扫码登录 → 取数 → 上传成绩 → 落库回查 → 登出」的完整证据。
|
||||
> 执行方式:`python upsert.py <二维码> --send`(等价于下面的驱动方式)。
|
||||
|
||||
---
|
||||
|
||||
## 一、结论
|
||||
|
||||
| 环节 | 结果 |
|
||||
|---|---|
|
||||
| 扫码登录 | ✅ user_id=12345678,token 获取成功 |
|
||||
| 用户预览 | ✅ errorId=0、banState=0、isLogin=False |
|
||||
| 正式登录 | ✅ returnCode=1,loginId=7693236821794227665 |
|
||||
| 抓基线快照 | ✅ 19 个节点全部成功,失败 0 个 |
|
||||
| 组包 + 发包前自检 | ✅ 0 问题 |
|
||||
| 冷却 | ✅ 等待 60.0 秒(模拟真机节奏) |
|
||||
| **上传成绩** | ✅ **returnCode=1** |
|
||||
| **落库回查** | ✅ **userData.playCount 1260 → 1261** |
|
||||
| 登出 | ✅ returnCode=1 |
|
||||
|
||||
**这是关键突破**:之前的失败模式是「returnCode=1 但没落库」。
|
||||
本次是**回码成功 + 重新拉服务器数据确认数值真的变了**,两者同时成立。
|
||||
|
||||
---
|
||||
|
||||
## 二、时间线(2026-10-06 01:28)
|
||||
|
||||
```
|
||||
01:28:01 扫码登录成功 user_id=12345678
|
||||
01:28:01 用户登录成功 loginId=7693236821794227665
|
||||
loginDateTime=1791250081 dateTime=1791221281
|
||||
01:28:03 结算快照抓取完成 成功 19 个节点,失败 0 个
|
||||
01:28:04 角色快照取自服务器 395:3803 / 101:4458 / 400402:137 / 400401:137 / 200401:137
|
||||
01:28:04 发包前自检通过
|
||||
01:28:04 上传成绩冷却 等待 60.0 秒后再发送(模拟真机节奏)
|
||||
01:29:04 冷却结束 共等待 60.0 秒
|
||||
01:29:04 上传成功 returnCode=1
|
||||
01:29:04 落库回查:
|
||||
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
|
||||
01:29:05 【落库成功】userData.playCount 已 +1
|
||||
01:29:05 用户登出成功 returnCode=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、落库证据(上传后重新拉服务器数据)
|
||||
|
||||
上传成功后**不登出、立刻**调 `GetUserDataApi` + `GetUserMusicApi` 回读,服务器返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"userData": {
|
||||
"playCount": 1261,
|
||||
"currentPlayCount": 189,
|
||||
"lastPlayDate": "2026-10-06 01:28:04",
|
||||
"lastLoginDate": "2026-10-06 01:28:01",
|
||||
"eventWatchedDate": "2026-10-06 01:28:01",
|
||||
"lastPlaceId": 0,
|
||||
"point": 4460,
|
||||
"charaSlot": [395, 101, 400402, 400401, 200401]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
musicId=143 level=4 -> playCount = 11
|
||||
```
|
||||
|
||||
对照**上传前**的基线:`playCount=1260`、`currentPlayCount=188`、`(143,4).playCount=10`。
|
||||
三项全部 +1,**证明服务端真的把这份报文写进去了**。
|
||||
|
||||
同时确认:
|
||||
- `point` 保持 4460 不变 —— 与「`userGetPointList` / `userLoginBonusList` 留空」的预期一致
|
||||
- `lastLoginDate` = 登录时刻(01:28:01),`lastPlayDate` = 上报时刻(01:28:04),两者分离正确
|
||||
- `charaSlot` 未被破坏
|
||||
|
||||
---
|
||||
|
||||
## 四、这次跑通依赖的关键实现点
|
||||
|
||||
1. **`number` 请求头**:先跑 `Ping()` 取得往返毫秒,之后所有请求带上(本次 47 ms)
|
||||
2. **Cookie 复用**:全程同一个 `MaimaiNetClient`,`UserLoginApi` 的 `Set-Cookie: JSESSIONID` 自动带入后续请求
|
||||
3. **userData 整表覆盖**:先抓 19 节点快照作基线,再按本局演算 +1 后整表回传
|
||||
4. **差分节点只回传变化项**:`userMusicDetailList` 只回传 (143,4) 那条、`userMapList` 只回传 550003、
|
||||
`userCharacterList` 只回传 5 只组队角色
|
||||
5. **11 条位串与数组长度严格对齐**:`isNewFavoriteList="22212"` 等全部复刻真机
|
||||
6. **`playSpecial` 用 `CalcSpecialNum=1024`**:与真机位模式一致
|
||||
7. **`characterLevelN` 用游玩前等级**:playlog 3803/4458/137,userCharacterList 才是 3804/4459/138
|
||||
|
||||
---
|
||||
|
||||
## 五、复现方式
|
||||
|
||||
```bash
|
||||
# 1) 预检(不发包,零副作用)
|
||||
python upsert.py "<二维码>" --debug
|
||||
|
||||
# 2) 真实上传(★ 会把成绩写进服务器,不可撤销)
|
||||
python upsert.py "<二维码>" --send
|
||||
|
||||
# 想跳过 60 秒冷却
|
||||
python upsert.py "<二维码>" --send --cooldown 0
|
||||
|
||||
# 想跳过上传后的落库回查
|
||||
python upsert.py "<二维码>" --send --no-verify
|
||||
```
|
||||
|
||||
`--send` 会自动做落库回查:重新拉 `GetUserDataApi` + `GetUserMusicApi`,
|
||||
对比 `playCount` 有没有 +1,并打印 `【落库成功】` 或 `【疑似未落库】`。
|
||||
|
||||
> ★ 建议先用 `--debug` 跑一次预检:DEBUG 日志会完整打印请求头、请求体、响应体,
|
||||
> 便于逐字段核对。代价是日志文件会很大(本次 DEBUG 日志约 200 万行)。
|
||||
|
||||
---
|
||||
|
||||
## 六、账号状态变化(本次真实写入的内容)
|
||||
|
||||
| 项 | 上传前 | 上传后 |
|
||||
|---|---|---|
|
||||
| `userData.playCount` | 1260 | **1261** |
|
||||
| `userData.currentPlayCount` | 188 | **189** |
|
||||
| `userData.lastPlayDate` | 2026-10-05 22:32:21 | **2026-10-06 01:28:04** |
|
||||
| `userMusicDetailList` (143, Re:Master) playCount | 10 | **11** |
|
||||
| `userMapList` 550003 distance | 1457000 | **1468000**(+11000) |
|
||||
| `userCharacterList` 5 只组队角色 | 3803/4458/137/137/137 | **3804/4459/138/138/138**(+1) |
|
||||
| `userData.point` | 4460 | 4460(不变) |
|
||||
|
||||
写入的 `userPlaylogList[0]` 是 `musicId=143`(Re:Master)全 Miss 的失败局,
|
||||
判定数 441/19/192/0/3,与 `config.PLAY_*` 一致。
|
||||
@@ -0,0 +1,380 @@
|
||||
# 日志与主脚本指南
|
||||
|
||||
> 模块:`api/log_setup.py` | 模式:INFO / DEBUG
|
||||
|
||||
---
|
||||
|
||||
## 一、一句话理解
|
||||
|
||||
**api 调用会被自动识别并记录,主脚本不用写任何日志代码。**
|
||||
|
||||
主脚本要写的只有两件:
|
||||
|
||||
```python
|
||||
setup_logging("脚本名") # 开头一句
|
||||
... # 业务代码,照常调 api
|
||||
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、自动日志长什么样
|
||||
|
||||
调用了 `ParseQRCode` 与 `UserPreview`,日志自动产出:
|
||||
|
||||
```
|
||||
==================================================================
|
||||
日志会话开始 —— main
|
||||
==================================================================
|
||||
主脚本 : main
|
||||
开始时间 : 2026-10-05 17:37:42
|
||||
日志模式 : INFO(仅运行情况与结果)
|
||||
Python : 3.12.6 (CPython AMD64)
|
||||
工作目录 : D:\SDGBSource\杂乱的api\wahappy-v0-goal
|
||||
已挂载 api : 60 个(自动跟踪,主脚本无需写日志代码)
|
||||
当次日志 : ...\logs\latest.log
|
||||
日期日志 : ...\logs\2026-10-05.log
|
||||
==================================================================
|
||||
|
||||
=== ParseQRCode 开始运行 ===
|
||||
[接口说明] 解析二维码并换取 userId 与 token。
|
||||
[位置参数] SGWCMAID2610022219463A297AF4CAB863406AA91ED2D29FF3BE20BF1113AC03095D97BD1573EDD691B6
|
||||
解析二维码登录:chip_id=TESTCLIENT1
|
||||
发送登录请求:timestamp=261005173742 host=ai.sys-allnet.cn/wc_aime/api/get_data
|
||||
扫码登录成功:user_id=12345678 token=t3WikM5n...(长度 20)
|
||||
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
|
||||
=== ParseQRCode 运行结束 ===
|
||||
|
||||
=== UserPreview 开始运行 ===
|
||||
[接口说明] 获取用户预览信息。
|
||||
[位置参数] 12345678, 'TESTTOKEN0000000000'
|
||||
[关键字参数] client_id='TESTCLIENT1'
|
||||
获取用户预览:user_id=12345678 client_id=TESTCLIENT1
|
||||
用户预览获取完成:userName=☆Astral☆ playerRating=15000 isLogin=False isNetMember=1
|
||||
[运行结果] 成功 UserPreviewResponseVO(22 字段)(耗时 388 ms)
|
||||
=== UserPreview 运行结束 ===
|
||||
|
||||
==================================================================
|
||||
本次运行调用的 api
|
||||
==================================================================
|
||||
1. ParseQRCode
|
||||
2. UserPreview
|
||||
==================================================================
|
||||
日志会话结束 —— main
|
||||
==================================================================
|
||||
```
|
||||
|
||||
自动带出来的东西:
|
||||
|
||||
| 自动内容 | 来源 |
|
||||
|---|---|
|
||||
| `===函数名 开始运行===` / `===函数名 运行结束===` | 导入时自动包装 |
|
||||
| `[接口说明]` | 函数 docstring 首行 |
|
||||
| `[位置参数]` / `[关键字参数]` | 本次调用传入的值 |
|
||||
| `[运行结果]` | 成功摘要或异常类型,附耗时 |
|
||||
| 会话结束的 **api 汇总清单** | 运行时记录调用顺序 |
|
||||
|
||||
---
|
||||
|
||||
## 三、两种模式
|
||||
|
||||
| 模式 | 你能看到什么 |
|
||||
|---|---|
|
||||
| **INFO**(默认) | 开始 → 接口说明 → 参数 → 结果 → 结束 |
|
||||
| **DEBUG** | 上面全部 **+** 请求 URL、请求头、**请求体 JSON 全文**、状态码、耗时、响应头、**响应体 JSON 全文** |
|
||||
|
||||
切换方式:改 `config.py` 一行。
|
||||
|
||||
```python
|
||||
LOG_LEVEL = "info" # 日常运行
|
||||
LOG_LEVEL = "debug" # 排查协议问题
|
||||
```
|
||||
|
||||
DEBUG 下额外多出:
|
||||
|
||||
```
|
||||
-> 请求 GetGameSettingApi
|
||||
URL : https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/83a5d5a20b062c5c2ad817460b8f7f76
|
||||
请求头 : {"Content-Type":"application/json","charset":"UTF-8","Mai-Encoding":"1.55","User-Agent":"83a5...#TESTCLIENT1","Accept-Encoding":"","Content-Encoding":"deflate"}
|
||||
请求体 :
|
||||
{
|
||||
"placeId": 1000,
|
||||
"clientId": "TESTCLIENT1"
|
||||
}
|
||||
|
||||
<- 响应 GetGameSettingApi
|
||||
状态码 : 200
|
||||
耗时 : 312 ms
|
||||
响应头 : {"Date":"...","Content-Type":"application/json"}
|
||||
响应体 :
|
||||
{
|
||||
"isAouAccession": false,
|
||||
"gameSetting": {
|
||||
"isMaintenance": false,
|
||||
"requestInterval": 1200,
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> DEBUG 模式下**完整输出,不折叠、不截断** —— 调试时就是要能逐条核对
|
||||
> 1000+ 条曲目、104 条地图。如需限制,改 `config.py` 的 `LOG_VALUE_MAX`。
|
||||
|
||||
---
|
||||
|
||||
## 四、主脚本怎么写
|
||||
|
||||
### 4.1 ★ 核心原则:**不要为每个 api 调用写日志**
|
||||
|
||||
日志全部写在 api 脚本内部。主脚本**只需要**:
|
||||
|
||||
1. 开头 `setup_logging("脚本名")`
|
||||
2. 照常调 api 函数
|
||||
3. 结尾 `teardown_logging()`
|
||||
|
||||
**不要**在每次调用前写 `log_section`、调用后写 `log_info` —— 那是多余的,
|
||||
api 函数自己会打印 `=== 函数名 开始运行 ===` / `[运行结果]` / `=== 函数名 运行结束 ===`。
|
||||
|
||||
> 完整范例见工程根目录的 `upsert.py`:整条「扫码 → 预览 → 登录 → 结算上传 → 登出」
|
||||
> 只有四行业务代码,**一行日志都没写**。
|
||||
|
||||
### 4.2 最小可用版本
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
from api import (setup_logging, teardown_logging,
|
||||
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType)
|
||||
|
||||
def main() -> int:
|
||||
setup_logging("我的脚本") # ← 只需要这一句
|
||||
try:
|
||||
user_id, token = ParseQRCode(sys.argv[1])
|
||||
UserPreview(user_id, token)
|
||||
login = UserLogin(user_id, token)
|
||||
UserLogout(user_id, token, login.loginDateTime,
|
||||
logout_type=LogoutType.TestIn)
|
||||
return 0
|
||||
finally:
|
||||
teardown_logging()
|
||||
```
|
||||
|
||||
**就这些。** 没有装饰器,没有埋点,没有 log_section。
|
||||
|
||||
### 4.3 嵌套调用也会自动记日志
|
||||
|
||||
`UpsertUserAll` 内部会调 `fetch_upsert_snapshot` / `UserNewItemList` / `UserData`,
|
||||
这些**内部调用同样会打印自己的开始/结束行**(见 `upsert_user_all._resolve_api_func`),
|
||||
所以结算链路的日志层次是完整嵌套的:
|
||||
|
||||
```
|
||||
=== UpsertUserAll 开始运行 ===
|
||||
[接口说明] 上传一局成绩(UpsertUserAllApi)—— 一个函数跑完整条结算链路。
|
||||
=== fetch_upsert_snapshot 开始运行 ===
|
||||
...
|
||||
=== fetch_upsert_snapshot 运行结束 ===
|
||||
=== UserNewItemList 开始运行 ===
|
||||
...
|
||||
=== UserNewItemList 运行结束 ===
|
||||
...
|
||||
=== UpsertUserAll 运行结束 ===
|
||||
```
|
||||
|
||||
### 4.4 想额外记点自己的话(可选)
|
||||
|
||||
不写也完全不影响:
|
||||
|
||||
```python
|
||||
from api import log_info, log_warning, log_error, log_section, log_field
|
||||
|
||||
log_section("阶段标题") # 带分隔线的分节
|
||||
log_info("普通信息") # 就当成「会进日志文件的 print」
|
||||
log_warning("需要留意的情况")
|
||||
log_error("出错了")
|
||||
log_field("预览数据", preview) # INFO 一行概要;DEBUG 展开完整 JSON
|
||||
```
|
||||
|
||||
### 4.5 ★ 保证登出
|
||||
|
||||
账号若停留在 `isLogin=True`,下次登录会 `returnCode=100`。用这个模板:
|
||||
|
||||
```python
|
||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||
try:
|
||||
...
|
||||
login = UserLogin(user_id, token)
|
||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||
...
|
||||
UserLogout(...)
|
||||
session["logged_in"] = False
|
||||
finally:
|
||||
if session["logged_in"]:
|
||||
log_warning("[收尾] 异常中断,强制登出 ...")
|
||||
try:
|
||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||
logout_type=LogoutType.TestIn)
|
||||
except Exception as e:
|
||||
log_error(f"[收尾] 强制登出失败:{e}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、api 脚本怎么写
|
||||
|
||||
**不用加装饰器**,直接定义函数就行:
|
||||
|
||||
```python
|
||||
# api/userpreview.py
|
||||
from .log_setup import log_info, log_debug, log_request, log_response
|
||||
from .maimai_net import request_dict
|
||||
|
||||
def UserPreview(user_id: int, token: str, *, client_id: str = CLIENT_ID):
|
||||
"""获取用户预览信息。""" # ← 首行成为 [接口说明]
|
||||
log_info(f"获取用户预览:user_id={user_id}") # 可选
|
||||
|
||||
raw = request_dict("GetUserPreviewApi", req, user_id=user_id, client_id=client_id)
|
||||
# ↑ maimai_net 内部已经调了 log_request / log_response,
|
||||
# DEBUG 模式下会自动输出收发数据,不用在这里重复写
|
||||
|
||||
return parse(raw)
|
||||
```
|
||||
|
||||
### 新增 api 必须登记
|
||||
|
||||
写完新函数后,在 `api/__init__.py` 做两步:
|
||||
|
||||
```python
|
||||
# 1) 导入
|
||||
from .xxx import MyNewApi
|
||||
|
||||
# 2) 加进跟踪名单
|
||||
_API_FUNCS = (
|
||||
"ParseQRCode",
|
||||
"UserPreview",
|
||||
...
|
||||
"MyNewApi", # ← 加这里
|
||||
)
|
||||
```
|
||||
|
||||
`autowrap(globals(), _API_FUNCS)` 会在导入时自动给它套上日志外壳。
|
||||
**漏了这步,该函数就不会输出 `===开始运行===`。**
|
||||
|
||||
---
|
||||
|
||||
## 六、完整 API 参考
|
||||
|
||||
### 6.1 会话(主脚本用)
|
||||
|
||||
```python
|
||||
setup_logging(script_name, level=None) -> str
|
||||
# 初始化会话:确定模式、打开两个日志文件、打印横幅
|
||||
# level=None 时读 config.LOG_LEVEL
|
||||
# 同一进程重复调用安全(只初始化一次)
|
||||
|
||||
teardown_logging()
|
||||
# 收尾:打印「本次运行调用的 api」清单 + 结束横幅 + 关文件
|
||||
# 不调用也会在退出时自动执行
|
||||
```
|
||||
|
||||
### 6.2 自动跟踪
|
||||
|
||||
```python
|
||||
autowrap(namespace, names) -> list[str] # api/__init__.py 调用,一般不用手写
|
||||
get_called_apis() -> list[str] # 本次会话调用过的 api,按首次调用顺序
|
||||
```
|
||||
|
||||
### 6.3 模式
|
||||
|
||||
```python
|
||||
get_level() -> "info" | "debug"
|
||||
set_level(v) -> "info" | "debug"
|
||||
is_debug() -> bool
|
||||
LEVEL_INFO = "info"
|
||||
LEVEL_DEBUG = "debug"
|
||||
```
|
||||
|
||||
### 6.4 流程日志(INFO 可见)
|
||||
|
||||
```python
|
||||
log_info("普通信息")
|
||||
log_step("扫码登录") # → [步骤] 扫码登录
|
||||
log_step("扫码登录", index=1, total=5) # → [步骤 1/5] 扫码登录
|
||||
log_result("user_id=123") # → [结果] user_id=123
|
||||
log_warning("账号已登录") # → [警告] ...
|
||||
log_error("网络不可达") # → [错误] ...
|
||||
log_section("阶段标题") # 带分隔线的分节
|
||||
log_field("预览数据", preview) # INFO 一行;DEBUG 多行展开
|
||||
log_field("原始响应", text, debug_only=True) # 只在 DEBUG 出现
|
||||
```
|
||||
|
||||
### 6.5 收发数据日志(DEBUG 专属)
|
||||
|
||||
```python
|
||||
log_debug("只在 DEBUG 出现")
|
||||
log_request(url="https://...", body=req, headers=hdr, api_name="XxxApi")
|
||||
log_response(status=200, body=raw, headers=hdr, api_name="XxxApi", elapsed_ms=412)
|
||||
log_payload("解密后明文", text)
|
||||
```
|
||||
|
||||
**INFO 模式下这些函数第一行就 `return`**,不做序列化、不拼字符串,可以放心留在热路径里。
|
||||
|
||||
### 6.6 兼容写法
|
||||
|
||||
```python
|
||||
from api import api_log, get_logger
|
||||
|
||||
@api_log # 仍可用(会自动跳过重复包装)
|
||||
def MyFunc(): ...
|
||||
|
||||
logger = get_logger()
|
||||
logger.info("..."); logger.warning("..."); logger.debug("...")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、日志文件
|
||||
|
||||
| 文件 | 打开模式 | 说明 |
|
||||
|---|---|---|
|
||||
| `logs/latest.log` | `w`(覆盖) | 每次运行重写,**只保留最近一次** |
|
||||
| `logs/YYYY-MM-DD.log` | `a`(追加) | **当日所有运行累积**,按天分文件 |
|
||||
|
||||
两者**同时写入**。每行都带 `[YYYY-MM-DD HH:MM:SS]` 时间戳。
|
||||
|
||||
DEBUG 的收发数据同样写进文件,可以跑一次 DEBUG 再翻 `latest.log`。
|
||||
|
||||
---
|
||||
|
||||
## 八、常见问题
|
||||
|
||||
**Q: 为什么 api 调用没有 `===开始运行===`?**
|
||||
A: 该函数没登记在 `api/__init__.py` 的 `_API_FUNCS` 里。补上即可。
|
||||
|
||||
**Q: 为什么 `log_debug` 什么都没输出?**
|
||||
A: 当前是 INFO 模式。改 `config.py` 的 `LOG_LEVEL = "debug"`。
|
||||
|
||||
**Q: 改了 config.py 没生效?**
|
||||
A: 模式在 `setup_logging()` 时读取。之前已初始化过就需重启脚本,
|
||||
或显式 `setup_logging("名字", level="debug")`。
|
||||
|
||||
**Q: 主脚本没调用 `setup_logging` 会怎样?**
|
||||
A: 仍会打印到控制台(自动跟踪在导入时就生效),但不写文件、没有横幅与汇总。
|
||||
|
||||
**Q: 写测试 / 单独跑 api 脚本时不想要日志?**
|
||||
A: 不调用 `setup_logging` 就不会开文件;控制台输出仍在。
|
||||
|
||||
**Q: 能完全关掉吗?**
|
||||
A: 可以 `set_level("info")` 抑制 DEBUG 部分。要连控制台也静音需改 `_emit` 的 `console` 参数。
|
||||
|
||||
---
|
||||
|
||||
## 九、相关文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `api/log_setup.py` | 日志模块本体(含自动包装引擎) |
|
||||
| `api/__init__.py` | 统一导出 + `_API_FUNCS` 登记表 |
|
||||
| `config.py` | `LOG_LEVEL` / `LOG_VALUE_MAX` 开关 |
|
||||
| `upsert.py` | 主脚本范例(含完整的登出兜底) |
|
||||
| `api/maimai_net.py` | 网络层,已接入 DEBUG 收发数据日志 |
|
||||
@@ -0,0 +1,215 @@
|
||||
# 真机 Hook 对照报告(MaimaiNetSpy 实测)
|
||||
|
||||
> 本文记录用 MelonLoader Mod **MaimaiNetSpy** 在真机上抓取一整局(登录 → 游玩 → 结算上报 → 登出)
|
||||
> 的完整网络行为,并与本工程的实现逐项对照后做的修正。
|
||||
>
|
||||
> 抓包样本:
|
||||
> - `D:\SDGB2026\Package\MaimaiNetSpy\2026-10-05_222919.log`(70 个请求的人类可读时间线)
|
||||
> - `D:\SDGB2026\Package\MaimaiNetSpy\raw\*`(每个接口的请求/响应原文 JSON)
|
||||
> - 关键样本:`raw/068_UpsertUserAllApiMaimaiChn_request.json`(17164 字节明文)
|
||||
>
|
||||
> Hook 挂载点(`gamehook/Main.cs`,8 个,全部 `[ok]`):
|
||||
> `NetHttpClient.Create / AddHeader / AddCookie / Request / GetResponse`、
|
||||
> `NetPacketUtil.GetUserAgent`、`GameManager.CalcSpecialNum`、`NetHttpClient.SetError`
|
||||
|
||||
---
|
||||
|
||||
## 一、结论速览
|
||||
|
||||
| 项 | 结论 |
|
||||
|---|---|
|
||||
| UpsertUserAll 报文内容 | **与真机逐字段一致**(顶层 7 键、36 节点、110 字段 playlog、11 条位串全部对齐) |
|
||||
| 报文长度 | 真机 17164 B / 本工程 17164 B(同一基线重建) |
|
||||
| 剩余差异 | 只有「随机数」和「时刻字符串」,见第五节 |
|
||||
| HTTP 头 | `number` 已修正;`Cookie` 经实测为**无需处理**;其余一致 |
|
||||
| `playSpecial` | 已按 `CalcSpecialNum = 1024` 修正 |
|
||||
| `characterLevel1..5` | **发现并修掉一处回归**:应为游玩前等级,不加经验 |
|
||||
|
||||
---
|
||||
|
||||
## 二、`number` 请求头:真机不是恒 0,也不是每包都变
|
||||
|
||||
抓到的 70 个请求里 `number` 只有两次取值变化:
|
||||
|
||||
```
|
||||
#1 ~ #10 number = 0 ← 还没跑过心跳
|
||||
#11 ~ #17 这是 Ping 心跳包(按 Packet.cs:187 不带 number 头)
|
||||
#18 ~ #70 number = 63 ← 心跳往返 63 ms,此后 53 个业务请求全部恒定
|
||||
```
|
||||
|
||||
**源码链路**(逐条可查):
|
||||
|
||||
```
|
||||
Main/PingChecker.cs:166 PacketHelper.StartPacket(new PacketPing(PingSuccess, PingError))
|
||||
Net.Packet.Mai2/PacketPing.cs:34
|
||||
_onDone((int)sw.ElapsedMilliseconds) ← 值 = Ping 包往返毫秒
|
||||
Main/PingChecker.cs:227 GameManager.PingMsec = pingMsec
|
||||
Net.Packet/Packet.cs:187-189 if (!IsPingPacket()) Client.AddHeader("number", GameManager.PingMsec.ToString());
|
||||
```
|
||||
|
||||
即:`number` = `GameManager.PingMsec`,**只由 Ping 心跳刷新**,普通业务请求不改动它。
|
||||
|
||||
### 本工程的修正
|
||||
|
||||
`api/maimai_net.py`:
|
||||
|
||||
- 字段从 `last_ping_msec`(每个请求都更新)改成 **`ping_msec`(只有 Ping 包更新)**,
|
||||
并保留 `last_ping_msec` 只读属性兼容旧代码。
|
||||
- `request()` 里新增:`if api_name.startswith("Ping"): self.ping_msec = elapsed_ms`。
|
||||
- `_build_headers()` 的 `ping_msec` 参数默认 `None`(= 取 `self.ping_msec`)。
|
||||
- `upsert.py` 在取完服务器设置后加了一步 `Ping()`(真机也是在第一屏之后跑心跳)。
|
||||
|
||||
> 实测:本机到该服务器 Ping 往返约 **266 ms**,所以本脚本发出去的 `number` 会是那个值,
|
||||
> 而不是真机的 63 —— 这是**网络环境差异,不是实现差异**。
|
||||
|
||||
---
|
||||
|
||||
## 三、`Cookie` 头:hook 显示的串是假的,但 Cookie 本身是真的
|
||||
|
||||
抓包日志里从 #18 起显示 `Cookie : userId=12345678`,一度被误认为「要手工带一个 userId Cookie」。
|
||||
|
||||
**核实过程(两步,第二步推翻了我自己的第一步结论)**:
|
||||
|
||||
1. `gamehook/Main.cs:196-199` 的 `PreAddCookie(ulong userId)` 里写的是
|
||||
`_cookie = "userId=" + userId;` —— 这行是 **hook 自己拼的展示串**,
|
||||
**不是真实的 HTTP Cookie 头**。
|
||||
它出现的位置正好对应 `Packet.cs:190-193` 的
|
||||
`if (Query.UserId != 0L) Client.AddCookie(Query.UserId);`,
|
||||
只是「本次请求带了 userId」的意思。
|
||||
|
||||
2. **但 Cookie 机制本身是真实存在的**,而且很关键。
|
||||
用 `Ping` 探针看不到 Set-Cookie(Ping 响应头只有
|
||||
`Date / Content-Type / Content-Length / Connection / Pragma / Cache-Control`),
|
||||
于是我先得出了「服务器从不下发 Cookie」的结论 —— **这是错的**。
|
||||
后来跑完整登录链路时,`UserLoginApi` 的响应头里明确有:
|
||||
```
|
||||
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
|
||||
```
|
||||
|
||||
```
|
||||
Packet.cs:228-234 if (Query.Api.StartsWith("UserLogin")) { cookieContainer.Add(Client.GetCookie());
|
||||
SetCookie(Query.UserId, cookieContainer); }
|
||||
Packet.cs:190-193 之后每个 userId != 0 的请求都 AddCookie(Query.UserId) 把容器挂回去
|
||||
```
|
||||
|
||||
即:**登录时拿到的 JSESSIONID 会被后续所有请求带着** —— 这是真实的会话粘滞,
|
||||
不是可有可无的装饰。
|
||||
|
||||
**结论**:`requests.Session` 会自动收下 `Set-Cookie` 并在后续请求自动带上,
|
||||
行为与 C# 的 `CookieContainer` 完全一致,**因此仍然不需要写任何代码** ——
|
||||
但前提是**必须复用同一个 `MaimaiNetClient`/`Session`**。
|
||||
本工程的 `api/` 各函数在 `client=None` 时都走 `get_default_client()`(全局单例),
|
||||
所以自动满足。如果谁手动 new 一个 `MaimaiNetClient` 去发结算包,就会丢掉 JSESSIONID。
|
||||
|
||||
---
|
||||
|
||||
## 四、`playSpecial` 与 `CalcSpecialNum`
|
||||
|
||||
Hook 抓到 `Manager.GameManager.CalcSpecialNum()` 的返回值:
|
||||
|
||||
```
|
||||
[CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False)
|
||||
```
|
||||
|
||||
**源码**(`Manager/GameManager.cs:332-378`)中 `1024 = 0x400` 是**无条件置位**的那一位,
|
||||
其余各位的条件在这台机台上恰好都不成立(ACA 机型、GTX 显卡、i3-8100 CPU 三条否定条件都为假)。
|
||||
|
||||
**反推验证**(`Net.VO.Mai2/VOExtensions.cs:846-855`):
|
||||
|
||||
```
|
||||
真机 playSpecial = 892014272
|
||||
位反转32(892014272) = 57726124 = 27900 × 2069 + 1024
|
||||
└────┬────┘ └─┬─┘
|
||||
随机系数 余数 = CalcSpecialNum ✅
|
||||
```
|
||||
|
||||
### 本工程的修正
|
||||
|
||||
- `config.py` 新增 `PLAY_SPECIAL_NUM = 1024`,注释里逐位列出 10 个标志位及源码行号。
|
||||
- `calc_play_special(calc_special_num=None)` 用它替换原来的写死 `0`。
|
||||
- `selftest.py` 新增反推断言:`位反转32(真机 playSpecial) % 2069 == 1024`。
|
||||
|
||||
---
|
||||
|
||||
## 五、逐字段比对的剩余差异(全部已解释)
|
||||
|
||||
用真机 `068` 报文**反向重建基线**后再跑本工程组包,逐路径 diff:
|
||||
|
||||
| 路径 | 真机 | 本工程 | 原因 |
|
||||
|---|---|---|---|
|
||||
| `upsertUserAll.userGamePlaylogList[0].playSpecial` | 892014272 | 随机 | **本来就是随机数**(`Random.Range(1,1037933)*2069+1024` 后位反转) |
|
||||
| `upsertUserAll.userGamePlaylogList[0].playCount` | 10 | 8 | 机台**本地**计数器,服务器不返回,只能手填 `config.PLAY_LOCAL_PLAY_COUNT` |
|
||||
| `userData[0].lastLoginDate` / `eventWatchedDate` | 22:31:23.0 | 22:31:22.0 | 差 1 秒:真机在「登录响应处理完那一刻」才写这个值,而 `loginDateTime` 是请求时刻 |
|
||||
| `userPlaylogList[0].loginDate` | 1791210683 | 1791210682 | 同上,1 秒 |
|
||||
| `userPlaylogList[0].userPlayDate` | 22:31:59.0 | 22:32:21.0 | 真机是「本局打完那一刻」,脚本没有真实游玩过程,用上报时刻代替 |
|
||||
|
||||
除上表外,**顶层 7 键、36 个节点名、各节点条数、userData 全部键名、11 条位串,两个方向都是 0 差异。**
|
||||
|
||||
> 1 秒级差异已由真机自身证明无害:同一账号两个会话里
|
||||
> `userPlaylog.loginDate - loginDateTime` 分别是 **-28798**(19:21 会话)和 **-28799**(22:31 会话),
|
||||
> 服务端两次都返回 `{"returnCode":1}` 并正常落库 —— 它不对这几个时刻做等式校验。
|
||||
|
||||
---
|
||||
|
||||
## 六、本轮修正清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `api/maimai_net.py` | `number` 头改为只由 Ping 刷新;补 `last_ping_msec` 兼容属性;docstring 写清 number/Cookie 语义 |
|
||||
| `api/upsert_user_all.py` | ① `calc_play_special` 支持 `CalcSpecialNum`(默认 1024)<br>② **修掉 `characterLevelN` 加经验的回归**,改为游玩前快照<br>③ 新增 `derive_play_characters(snapshot)`:从服务器快照推游玩前角色<br>④ 新增 `play_date_time` 参数,把「登录时刻」与「游玩/上报时刻」拆开<br>⑤ `build_favorite_delta` 新增 `assume_missing`,区分「原始基线」与「归一化基线」 |
|
||||
| `config.py` | 新增 `PLAY_SPECIAL_NUM`;补 `PLAY_CHARACTERS` / `PLAY_LOCAL_PLAY_COUNT` 的用法与实测值 |
|
||||
| `api/ping.py` | docstring 写清「Ping 是 number 头的唯一来源」 |
|
||||
| `upsert.py` | 加 `Ping()` 步骤;用 `derive_play_characters` 覆盖手填角色;传 `play_date_time=login_date_time_now()` |
|
||||
| `api/__init__.py` | 导出 `derive_play_characters` |
|
||||
| `selftest.py` | 148 → 155 项;新增 CalcSpecialNum 反推、角色快照、登录/游玩时刻分离等断言 |
|
||||
|
||||
---
|
||||
|
||||
## 七、关于 `characterLevelN` 的回归(重点)
|
||||
|
||||
`Net.VO.Mai2/VOExtensions.cs:1361`:
|
||||
|
||||
```csharp
|
||||
result.characterLevel1 = (int)gameScore.CharaLevel[0];
|
||||
```
|
||||
|
||||
`gameScore.CharaLevel` 是**本次开始游玩时**灌进去的快照,整局不变 ——
|
||||
所以 `userPlaylog.characterLevelN` 是**游玩前**等级,游玩后的等级只出现在
|
||||
`upsertUserAll.userCharacterList` 里。
|
||||
|
||||
**两个真机会话的三方对照**(Get 侧 / playlog / upsertUserCharacterList):
|
||||
|
||||
| 会话 | GetUserCharacterApi | userPlaylogList[0] | userCharacterList |
|
||||
|---|---|---|---|
|
||||
| `source/2026-10-05-new.log`(19:21) | 395=**3801** 101=**4456** 135 135 135 | **3801 4456 135 135 135** | 395=**3802** 101=**4457** 136 136 136 |
|
||||
| MaimaiNetSpy `2026-10-05_222919`(22:31) | 395=**3802** 101=**4457** 136 136 136 | **3802 4457 136 136 136** | 395=**3803** 101=**4458** 137 137 137 |
|
||||
|
||||
**playlog 恒等于 Get 侧(游玩前),userCharacterList 恒为 +1(游玩后)。**
|
||||
|
||||
代码里曾经是 `level + character_exp_gain`,现已改回 `level`。
|
||||
|
||||
为了避免手填的 `config.PLAY_CHARACTERS` 随账号升级而过期,
|
||||
新增 `derive_play_characters(snapshot)`:直接读快照里的
|
||||
`userData.charaSlot`(5 个槽位,顺序即 characterId1..5)与
|
||||
`userCharacterList`(每只角色的 level / awakening),`upsert.py` 已接上。
|
||||
|
||||
---
|
||||
|
||||
## 八、真实发包验证(✅ 已完成,2026-10-06 01:28)
|
||||
|
||||
`upsert.py <二维码> --send` 用上面这套实现真实上报了一局成绩,
|
||||
**服务端返回 returnCode=1,且重新拉数据确认 playCount 1260→1261 —— 真正落库了**。
|
||||
完整证据见 [`docs/实跑验证记录.md`](实跑验证记录.md)。
|
||||
|
||||
流程与真机一致:
|
||||
|
||||
```
|
||||
GetGameSetting -> Ping -> ParseQRCode -> GetUserPreview -> UserLogin
|
||||
-> fetch_upsert_snapshot(19 个 Get 节点)
|
||||
-> GetUserNewItemList(游玩后档案)
|
||||
-> UpsertUserAll(★ 有副作用)
|
||||
-> UserLogout
|
||||
```
|
||||
|
||||
安全闸门保持默认关闭:不传 `--send` 时只构造报文(`dry_run`),
|
||||
`UpsertUserAll(confirm=None)` 会读取 `config.UPSERT_CONFIRM = False` 并抛 `ConfirmRequiredError`。
|
||||
@@ -0,0 +1,586 @@
|
||||
# 舞萌DX(maimai DX)扫码登录协议技术规范
|
||||
|
||||
> **来源文档**:
|
||||
> - `docs-old/舞萌DX_aimedb调用链分析.md` —— 完整调用链、二维码格式、ChimeLib/aimedb 关系
|
||||
> - `docs-old/chimelib协议逆向.md` —— 纯网络实现细节、签名算法、抓包验证、关键坑点
|
||||
>
|
||||
> **适用场景**:纯网络(纯 HTTP)复刻舞萌DX国服微信扫码登录,获取 userId 与 token
|
||||
> **协议路径**:路径 A —— 微信扫码(HTTPS + JSON,`chimelib_dll.dll` + WinHTTP)
|
||||
> **非适用**:路径 B —— 传统 Aime 物理卡(TCP 二进制,`amdaemon_api.dll` + IPC)
|
||||
|
||||
---
|
||||
|
||||
## 一、架构总览
|
||||
|
||||
### 1.1 两条 aimedb 通信路径
|
||||
| 路径 | 名称 | 协议 | 核心组件 | 你实际用的 |
|
||||
|------|------|------|----------|------------|
|
||||
| **A** | 微信扫码 | HTTPS + JSON | `chimelib_dll.dll` (WinHTTP) | ✅ **是** |
|
||||
| B | 传统 Aime 物理卡 | TCP 二进制 | `amdaemon_api.dll` (IPC) | ❌ 否 |
|
||||
|
||||
### 1.2 调用链(路径 A)
|
||||
```
|
||||
游戏客户端 Assembly-CSharp
|
||||
│ ChimeReaderManager (驱动扫码、取结果)
|
||||
▼
|
||||
ChimeLib.NET.dll (托管封装)
|
||||
│ Chime / ChimeUnit / ChimeResult / CCommGetUserData / CCommServAliveCheck
|
||||
▼
|
||||
chimelib_dll.dll (x64 原生,62KB,静态链接 WinHTTP)
|
||||
│ CCommGetUserData_Create/Execute/IsEnd/GetUserID/GetToken
|
||||
│ CCommServAliveCheck_Create/StartCheck/Execute/GetIndex/IsAlive
|
||||
▼
|
||||
aimedb 服务器集群
|
||||
│ https://ai.sys-all.cn/ (主)
|
||||
│ https://ai.sys-allnet.cn/ (备)
|
||||
▼
|
||||
POST /wc_aime/api/get_data → 返回 userID + token
|
||||
POST /wc_aime/api/alive_check → 返回 "alive"
|
||||
```
|
||||
|
||||
### 1.3 关键数据流向
|
||||
```
|
||||
二维码串(SGWCMAID...)
|
||||
→ ChimeLib.NET.CCommQRCode.IsValid() 校验
|
||||
→ EraseHashIdentifier() 取 [20:] 64位载荷
|
||||
→ CCommGetUserData_Create(strGameID="MAID", strChipID, strCommonKey="", strQRData=载荷, strTitleKey="SDGB", serverURLIndex)
|
||||
→ WinHTTP HTTPS POST /wc_aime/api/get_data
|
||||
→ 响应 {errorID, userID, token, key, timestamp}
|
||||
→ ChimeResult.GetChimeId() → userId (uint)
|
||||
→ ChimeResult.GetToken() → token (string)
|
||||
→ OperationManager.SetToken(userId, token) 缓存
|
||||
→ 后续游戏服务器 API 携带 token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、二维码格式规范
|
||||
|
||||
### 2.1 完整二维码字符串(84 字符)
|
||||
```
|
||||
结构:SGWC(4) + MAID(4) + timestamp(12) + payload(64 hex) = 84 字符
|
||||
|
||||
偏移 长度 字段 说明 示例
|
||||
0–3 4 微信标识 常量 "SGWC" SGWC
|
||||
4–7 4 GameID "MAID"=舞萌DX MAID
|
||||
8–19 12 时间戳 yymmddHHMMss (UTC+9) 260613133715
|
||||
20–83 64 载荷/哈希 64位十六进制(疑似SHA-256) 77503FD2...427759BCA5
|
||||
```
|
||||
|
||||
### 2.2 校验规则(`CCommQRCode.IsValid()`)
|
||||
```python
|
||||
def is_valid(qr: str) -> bool:
|
||||
return len(qr) == 84 and qr[0:4] == "SGWC" and qr[4:8] == "MAID"
|
||||
```
|
||||
|
||||
### 2.3 传给原生层的数据(`EraseHashIdentifier()`)
|
||||
```python
|
||||
def erase_hash_identifier(qr: str) -> str:
|
||||
"""去掉前 20 字符,返回 64 位载荷"""
|
||||
return qr[20:] # 即 payload 部分
|
||||
```
|
||||
|
||||
### 2.4 二维码 URL ⇄ 字符串互转
|
||||
**URL 格式**:
|
||||
```
|
||||
https://wq.wahlap.net/qrcode/req/{GameID+timestamp+payload}.html?l=...&t=...&d=...
|
||||
└──────────── 80 字符 ────────────┘
|
||||
```
|
||||
|
||||
**提取规则**:
|
||||
```python
|
||||
from urllib.parse import urlparse
|
||||
import re
|
||||
|
||||
def url_to_qr_string(url: str) -> str:
|
||||
"""URL → 完整 84 字符二维码串"""
|
||||
path = urlparse(url).path
|
||||
match = re.search(r"/qrcode/req/([^/]+)\.html", path)
|
||||
if not match:
|
||||
raise ValueError("URL 路径格式不符")
|
||||
return "SGWC" + match.group(1) # 前加 SGWC 前缀
|
||||
|
||||
def qr_string_to_url_body(qr: str) -> str:
|
||||
"""完整二维码串 → URL 路径段(80字符,无 SGWC)"""
|
||||
return qr[4:] # 去掉 SGWC
|
||||
```
|
||||
|
||||
**查询参数**(`l`/`t`/`d`):**不参与**机台协议,仅为微信侧生成二维码时的元数据。
|
||||
|
||||
---
|
||||
|
||||
## 三、服务器端点与网络协议
|
||||
|
||||
### 3.1 服务器地址
|
||||
| 环境 | 域名 | IP | 端口 | TLS |
|
||||
|------|------|-----|------|-----|
|
||||
| 主 | `ai.sys-all.cn` | 81.71.193.236 | 443 | TLSv1.2 |
|
||||
| 备 | `ai.sys-allnet.cn` | (同解析) | 443 | TLSv1.2 |
|
||||
|
||||
### 3.2 API 端点
|
||||
| 端点 | Method | Content-Type | User-Agent | 用途 |
|
||||
|------|--------|--------------|------------|------|
|
||||
| `/wc_aime/api/alive_check` | POST | `application/json` (body `{}`) | 无 | 探活,返回 `200 alive` |
|
||||
| `/qrcode/api/alive_check` | POST | 无(空body) | `WC_AIME_LIB` | 探活变体,返回 `200 alive` |
|
||||
| `/wc_aime/api/get_data` | POST | **无** | `WC_AIME_LIB` | **核心登录接口** |
|
||||
|
||||
### 3.3 请求头(关键:无 Content-Type)
|
||||
```http
|
||||
POST /wc_aime/api/get_data HTTP/1.1
|
||||
Host: ai.sys-allnet.cn
|
||||
User-Agent: WC_AIME_LIB
|
||||
Connection: Keep-Alive
|
||||
Content-Length: 243
|
||||
|
||||
{"chipID":"...","openGameID":"MAID","key":"...","qrCode":"...","timestamp":"...","titlekey":"SDGB"}
|
||||
```
|
||||
> ⚠️ **严禁添加 `Content-Type: application/json`**,否则服务端返回 `errorID=57`(HTTP 200 + 合法 JSON,极易误判为签名错误)。
|
||||
|
||||
### 3.4 请求体 JSON 字段
|
||||
| 字段 | 类型 | 来源 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `chipID` | string | Keychip ID | 机台唯一标识,如 `XXXX-XXXXXXXXXXX` |
|
||||
| `openGameID` | string | 固定 | `"MAID"` |
|
||||
| `key` | string | 签名计算 | SHA-256 大写 hex,**核心签名字段** |
|
||||
| `qrCode` | string | 二维码[20:] | **64 位载荷,非完整 84 字符串** |
|
||||
| `timestamp` | string | 本机本地时间 | yymmddHHMMss 格式 |
|
||||
| `titlekey` | string | 固定 | `"SDGB"` |
|
||||
|
||||
### 3.5 响应体 JSON 字段
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `errorID` | int | 0=成功,非0=失败 |
|
||||
| `userID` | int | 成功时为正整数(如 12345678),失败时 -1 |
|
||||
| `token` | string | 成功时 20 位临时凭据,失败时空串 |
|
||||
| `key` | string | 服务端返回的签名(可忽略) |
|
||||
| `timestamp` | string | 服务端时间戳 |
|
||||
|
||||
---
|
||||
|
||||
## 四、签名算法(核心)
|
||||
|
||||
### 4.1 算法规范
|
||||
```python
|
||||
import hashlib
|
||||
|
||||
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW" # 32字符,含前导 X
|
||||
|
||||
def calculate_key(chip_id: str, timestamp: str) -> str:
|
||||
"""
|
||||
key = SHA-256(chipID + timestamp + SIGN_KEY).hexdigest().upper()
|
||||
|
||||
参数:
|
||||
chip_id: 如 "XXXX-XXXXXXXXXXX"
|
||||
timestamp: 如 "261001004115" (yymmddHHMMss)
|
||||
|
||||
返回:64字符大写十六进制
|
||||
"""
|
||||
sign_input = chip_id + timestamp + SIGN_KEY
|
||||
return hashlib.sha256(sign_input.encode()).hexdigest().upper()
|
||||
```
|
||||
|
||||
### 4.2 技术细节
|
||||
| 项目 | 值 | 验证来源 |
|
||||
|------|-----|----------|
|
||||
| 哈希算法 | SHA-256 | Hook `CryptCreateHash` ALGID=0x800C (CALG_SHA_256) |
|
||||
| 密钥串 | `XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW` | .rdata UTF-16 @0x00B040,**含前导 X** |
|
||||
| 拼接顺序 | `chipID + timestamp + KEY` | **不含 qrCode、不含 titlekey** |
|
||||
| 输出编码 | 大写十六进制 | hex 表 `0123456789ABCDEF` @0x00A440 |
|
||||
|
||||
### 4.3 抓包验证向量
|
||||
```
|
||||
输入:
|
||||
chipID = "XXXX-XXXXXXXXXXX"
|
||||
timestamp = "261001004115"
|
||||
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
|
||||
|
||||
SHA256("XXXX-XXXXXXXXXXX" + "261001004115" + "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW")
|
||||
= 67015F28CFC3C7FA5E629BECE54893B581A6DBD153426AE5E6BA1681C6D8E327
|
||||
```
|
||||
与 mitmproxy 抓包完全一致 ✓
|
||||
|
||||
---
|
||||
|
||||
## 五、完整请求流程(纯 Python 实现)
|
||||
|
||||
### 5.1 最小可用代码
|
||||
```python
|
||||
import hashlib, http.client, json, ssl
|
||||
from datetime import datetime
|
||||
|
||||
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
|
||||
CHIP_ID = "XXXX-XXXXXXXXXXX" # 可配置
|
||||
HOST = "ai.sys-allnet.cn"
|
||||
PATH = "/wc_aime/api/get_data"
|
||||
|
||||
def parse_qr_login(qr_input: str, chip_id: str = CHIP_ID) -> tuple[int, str]:
|
||||
"""
|
||||
输入:二维码串(84字符)或 URL
|
||||
返回:(user_id: int, token: str)
|
||||
"""
|
||||
# 1. 统一输入 → 标准 84 字符串
|
||||
qr_string = normalize_qr_input(qr_input)
|
||||
|
||||
# 2. 校验格式
|
||||
validate_qr_string(qr_string)
|
||||
|
||||
# 3. 构造请求体
|
||||
ts = datetime.now().strftime("%y%m%d%H%M%S")
|
||||
key = calculate_key(chip_id, ts)
|
||||
qr_code = qr_string[20:] # 仅 64 位载荷
|
||||
|
||||
body_dict = {
|
||||
"chipID": chip_id,
|
||||
"openGameID": "MAID",
|
||||
"key": key,
|
||||
"qrCode": qr_code,
|
||||
"timestamp": ts,
|
||||
"titlekey": "SDGB",
|
||||
}
|
||||
body_bytes = json.dumps(body_dict, separators=(",", ":")).encode()
|
||||
|
||||
# 4. 发送请求(关键:无 Content-Type)
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
conn = http.client.HTTPSConnection(HOST, 443, context=ctx, timeout=20)
|
||||
try:
|
||||
conn.request("POST", PATH, body=body_bytes, headers={
|
||||
"Connection": "Keep-Alive",
|
||||
"User-Agent": "WC_AIME_LIB",
|
||||
"Content-Length": str(len(body_bytes)),
|
||||
# ★ 绝不加 Content-Type
|
||||
})
|
||||
resp = conn.getresponse()
|
||||
data = json.loads(resp.read().decode())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 5. 解析响应
|
||||
if data.get("errorID") != 0 or data.get("userID") == -1:
|
||||
raise RuntimeError(f"登录失败: errorID={data.get('errorID')}, userID={data.get('userID')}")
|
||||
|
||||
return data["userID"], data["token"]
|
||||
|
||||
def normalize_qr_input(qr_input: str) -> str:
|
||||
"""URL 或字符串 → 标准 84 字符二维码串"""
|
||||
qr = qr_input.strip()
|
||||
if qr.startswith("http://") or qr.startswith("https://"):
|
||||
qr = url_to_qr_string(qr)
|
||||
return qr
|
||||
|
||||
def validate_qr_string(qr: str) -> None:
|
||||
"""校验二维码格式,不通过抛 ValueError"""
|
||||
if len(qr) != 84:
|
||||
raise ValueError(f"长度应为84,实际{len(qr)}")
|
||||
if not qr.startswith("SGWC"):
|
||||
raise ValueError("必须以 SGWC 开头")
|
||||
if qr[4:8] != "MAID":
|
||||
raise ValueError(f"GameID 必须为 MAID,实际 {qr[4:8]}")
|
||||
# 可选:校验 payload 为 64 位 hex
|
||||
if not all(c in "0123456789ABCDEF" for c in qr[20:]):
|
||||
raise ValueError("载荷必须为大写 hex")
|
||||
```
|
||||
|
||||
### 5.2 关键实现要点清单
|
||||
| 要点 | 正确做法 | 错误做法后果 |
|
||||
|------|----------|--------------|
|
||||
| qrCode 字段 | `qr_string[20:]` (64位) | 传完整 84 字符 → 签名不匹配 |
|
||||
| 签名拼接 | `chipID + timestamp + KEY` | 加入 qrCode/titlekey → 签名错误 |
|
||||
| 密钥串 | 含前导 `X` (32字符) | 漏掉 X → 签名完全不同 |
|
||||
| 请求头 | 无 `Content-Type` | 加上 → `errorID=57` |
|
||||
| timestamp | 本机本地时间即可 | 纠结时区 → 其实服务端不强校验 |
|
||||
| SSL 验证 | 关闭验证 | 开启 → 证书链可能不通过 |
|
||||
|
||||
---
|
||||
|
||||
## 六、错误码对照
|
||||
|
||||
| errorID | 含义 | 处理建议 |
|
||||
|---------|------|----------|
|
||||
| 0 | 成功 | 正常返回 userID + token |
|
||||
| 1-8 | 原生 ChimeErrorId | 读卡器/服务器/二维码无效等通用错误 |
|
||||
| **57** | **二维码已失效/已被使用/请求头不符** | 检查:二维码是否过期(~10分钟)、是否重复提交、**请求头是否含 Content-Type** |
|
||||
| `userID:-1` | 失败标志 | 配合 errorID 判断具体原因 |
|
||||
|
||||
> ### ★ 实测补充(2026-10-06)
|
||||
>
|
||||
> 本工程用同一套参数实测到的失败响应是 **`errorID=2`**(不是 57):
|
||||
>
|
||||
> ```json
|
||||
> {"errorID": 2, "key": "B744ADD5...", "timestamp": "261006125706",
|
||||
> "userID": -1, "token": ""}
|
||||
> ```
|
||||
>
|
||||
> **触发条件是「二维码过期」,不是「用过一次」。**
|
||||
>
|
||||
> | 二维码生成时刻(timestamp 内嵌) | 使用时刻 | 结果 |
|
||||
> |---|---|---|
|
||||
> | `261006012047`(01:20:47) | 01:21:55(+68s) | ✅ 成功 |
|
||||
> | `261006124637`(12:46:37) | 12:52:22(+5m45s) | ✅ 成功 |
|
||||
> | `261006130621`(13:06:21) | 13:10:42(+4m21s) | ✅ 成功 |
|
||||
> | 上面那个 12:46:37 的二维码 | 13:0x(+约 15min) | ❌ `errorID=2` |
|
||||
>
|
||||
> 即:**二维码是「有时效」的,不是「按次数计量」的**。
|
||||
>
|
||||
> **有效期 = 10 分钟**(实测至少 5 分 45 秒内均可用,超时后返回 `errorID=2`)。
|
||||
> 所以报错时**不要怀疑「用过了」**,重新生成一个即可。
|
||||
|
||||
---
|
||||
|
||||
## 七、时间戳处理细节
|
||||
|
||||
| 时间戳位置 | 时区 | 格式 | 说明 |
|
||||
|------------|------|------|------|
|
||||
| 二维码内 (偏移8-19) | **UTC+9 (日本时间)** | yymmddHHMMss | 微信侧生成,约 10 分钟有效期 |
|
||||
| 请求体 `timestamp` | **本机本地时间** | yymmddHHMMss | 实测 UTC+8 与 DLL 行为一致,服务端不强校验时区 |
|
||||
| 响应 `timestamp` | 服务端时间 | yymmddHHMMss | 仅作参考 |
|
||||
|
||||
> **注意**:本机时区非 UTC+8 时,请求体 timestamp 仍用本地时间,但需确保系统时间准确(NTP 同步)。
|
||||
|
||||
---
|
||||
|
||||
## 八、探活接口(可选,纯 Python 可独立实现)
|
||||
|
||||
### 8.1 探活请求
|
||||
```python
|
||||
def check_alive(host: str = "ai.sys-allnet.cn") -> bool:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
conn = http.client.HTTPSConnection(host, 443, context=ctx, timeout=10)
|
||||
try:
|
||||
conn.request("POST", "/wc_aime/api/alive_check", body=b"{}", headers={
|
||||
"Content-Type": "application/json", # 探活需加 Content-Type
|
||||
"Connection": "Keep-Alive",
|
||||
})
|
||||
resp = conn.getresponse()
|
||||
return resp.status == 200 and resp.read().decode().strip() == "alive"
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
### 8.2 行为差异
|
||||
| 端点 | Content-Type | User-Agent | 响应 |
|
||||
|------|--------------|------------|------|
|
||||
| `/wc_aime/api/alive_check` | `application/json` | 无 | `200 alive` |
|
||||
| `/qrcode/api/alive_check` | 无 | `WC_AIME_LIB` | `200 alive` |
|
||||
| `/wc_aime/api/get_data` | **无** | `WC_AIME_LIB` | 业务响应 |
|
||||
|
||||
> DLL 实测:`CCommServAliveCheck` 探活阶段**不发真实网络请求**(WinHTTP hook 不触发),纯 Python 可直接调用 alive_check 验证服务器存活。
|
||||
|
||||
---
|
||||
|
||||
## 九、原生 DLL 接口签名(供 P/Invoke 参考)
|
||||
|
||||
### 9.1 CCommServAliveCheck 系列
|
||||
```c
|
||||
// 创建探活会话
|
||||
HANDLE CCommServAliveCheck_Create();
|
||||
|
||||
// 设置连接权限
|
||||
void CCommServAliveCheck_setConnectPermission(HANDLE h, BOOL permit);
|
||||
|
||||
// 开始探活
|
||||
void CCommServAliveCheck_startCheck(HANDLE h);
|
||||
|
||||
// 执行轮询
|
||||
void CCommServAliveCheck_execute(HANDLE h);
|
||||
|
||||
// 是否忙碌
|
||||
BOOL CCommServAliveCheck_isBusy(HANDLE h);
|
||||
|
||||
// 获取存活服务器索引
|
||||
int CCommServAliveCheck_getIndex(HANDLE h);
|
||||
|
||||
// 是否存活
|
||||
BOOL CCommServAliveCheck_isAlive(HANDLE h);
|
||||
|
||||
// 销毁
|
||||
void CCommServAliveCheck_Destroy(HANDLE h);
|
||||
```
|
||||
|
||||
### 9.2 CCommGetUserData 系列
|
||||
```c
|
||||
// 创建登录会话
|
||||
// strGameID="MAID", strChipID=KeychipID, strCommonKey="", strQRData=二维码[20:], strTitleKey="SDGB", serverURLIndex=探活返回索引
|
||||
HANDLE CCommGetUserData_Create(
|
||||
LPCSTR strGameID,
|
||||
LPCSTR strChipID,
|
||||
LPCSTR strCommonKey,
|
||||
LPCSTR strQRData,
|
||||
LPCSTR strTitleKey,
|
||||
int serverURLIndex
|
||||
);
|
||||
|
||||
// 执行请求
|
||||
void CCommGetUserData_execute(HANDLE h);
|
||||
|
||||
// 是否结束
|
||||
BOOL CCommGetUserData_isEnd(HANDLE h);
|
||||
|
||||
// 获取错误码 (0=成功)
|
||||
int CCommGetUserData_getErrorID(HANDLE h);
|
||||
|
||||
// 获取 userID (uint)
|
||||
unsigned int CCommGetUserData_getUserID(HANDLE h);
|
||||
|
||||
// 获取 token (ANSI 字符串)
|
||||
LPCSTR CCommGetUserData_getToken(HANDLE h);
|
||||
|
||||
// 销毁
|
||||
void CCommGetUserData_Destroy(HANDLE h);
|
||||
```
|
||||
|
||||
### 9.3 托管层调用序列(ChimeUnit.Execute 状态机)
|
||||
```csharp
|
||||
// 1. 探活
|
||||
var alive = CCommServAliveCheck_Create();
|
||||
CCommServAliveCheck_setConnectPermission(alive, true);
|
||||
CCommServAliveCheck_startCheck(alive);
|
||||
while (CCommServAliveCheck_isBusy(alive)) CCommServAliveCheck_execute(alive);
|
||||
int urlIndex = CCommServAliveCheck_getIndex(alive);
|
||||
bool isAlive = CCommServAliveCheck_isAlive(alive);
|
||||
CCommServAliveCheck_Destroy(alive);
|
||||
|
||||
// 2. 登录
|
||||
var login = CCommGetUserData_Create(
|
||||
"MAID", // gameID
|
||||
keychipId, // chipID (如 XXXX-XXXXXXXXXXX)
|
||||
"", // commonKey (托管层传空,原生层内部处理)
|
||||
qrString.Substring(20), // qrData (64位载荷)
|
||||
"SDGB", // titleKey
|
||||
urlIndex // 探活返回的服务器索引
|
||||
);
|
||||
while (!CCommGetUserData_isEnd(login)) CCommGetUserData_execute(login);
|
||||
if (CCommGetUserData_getErrorID(login) == 0) {
|
||||
uint userId = CCommGetUserData_getUserID(login);
|
||||
string token = CCommGetUserData_getToken(login); // PtrToStringAnsi
|
||||
}
|
||||
CCommGetUserData_Destroy(login);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、常量汇总表
|
||||
|
||||
```python
|
||||
# ===== 签名与密钥 =====
|
||||
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW" # 含前导 X,32字符
|
||||
|
||||
# ===== 默认机台标识 =====
|
||||
DEFAULT_CHIP_ID = "XXXX-XXXXXXXXXXX"
|
||||
|
||||
# ===== 服务器 =====
|
||||
PRIMARY_HOST = "ai.sys-all.cn"
|
||||
BACKUP_HOST = "ai.sys-allnet.cn"
|
||||
PORT = 443
|
||||
LOGIN_PATH = "/wc_aime/api/get_data"
|
||||
ALIVE_PATH = "/wc_aime/api/alive_check"
|
||||
|
||||
# ===== 固定请求字段 =====
|
||||
OPEN_GAME_ID = "MAID"
|
||||
TITLE_KEY = "SDGB"
|
||||
USER_AGENT = "WC_AIME_LIB"
|
||||
|
||||
# ===== 二维码常量 =====
|
||||
QR_PREFIX = "SGWC"
|
||||
QR_GAME_ID = "MAID"
|
||||
QR_PREFIX_LEN = 20 # SGWC(4) + MAID(4) + timestamp(12)
|
||||
QR_PAYLOAD_LEN = 64
|
||||
QR_TOTAL_LEN = 84
|
||||
|
||||
# ===== HTTP 头(登录接口) =====
|
||||
LOGIN_HEADERS = {
|
||||
"Connection": "Keep-Alive",
|
||||
"User-Agent": USER_AGENT,
|
||||
# "Content-Type": "application/json", # ★ 绝不加这一行
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、抓包调试方法论
|
||||
|
||||
### 11.1 强制走代理
|
||||
```csharp
|
||||
// Hook WinHttpOpen
|
||||
// dwAccessType = 3 (WINHTTP_ACCESS_TYPE_NAMED_PROXY)
|
||||
// pwszProxy = "127.0.0.1:8890"
|
||||
```
|
||||
|
||||
### 11.2 mitmproxy CA 证书安装
|
||||
```powershell
|
||||
# 必须用 X509Store 静默导入,Import-Certificate 会因 UI 限制失败
|
||||
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store("Root", "CurrentUser")
|
||||
$store.Open("ReadWrite")
|
||||
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("mitmproxy-ca-cert.pem")
|
||||
$store.Add($cert)
|
||||
$store.Close()
|
||||
```
|
||||
|
||||
### 11.3 落盘与读取
|
||||
```bash
|
||||
mitmdump -w flow.mitm
|
||||
# Python 读取
|
||||
from mitmproxy.io import FlowReader
|
||||
with open("flow.mitm", "rb") as f:
|
||||
for flow in FlowReader(f).stream():
|
||||
print(flow.request.pretty_url)
|
||||
print(flow.request.content)
|
||||
print(flow.response.content)
|
||||
```
|
||||
|
||||
### 11.4 捕获签名输入
|
||||
```csharp
|
||||
// Hook CryptHashData 导出入口
|
||||
// 捕获 pbData 指针指向的字节数组 → 即签名原文
|
||||
// 对比:chipID + timestamp + KEY
|
||||
```
|
||||
|
||||
### 11.5 常见 Hook 坑点
|
||||
| 坑点 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| IAT hook 全失效 | pefile `imp.address` 已是完整 VA,再加 ImageBase 导致地址错误 | 直接用 `imp.address` + ASLR_delta |
|
||||
| IAT 写入后还原 | CrackProof 驱动写保护页面 | 改用全局 inline hook:改写 advapi32 导出函数入口 + capstone 精确 trampoline |
|
||||
| x64 trampoline 崩 | 指令切碎不完整 | capstone 逐条解析,累计 >=14 字节再跳转 |
|
||||
| 误判加壳 | Hook 全失效以为加壳 | 实测:.text 熵 6.2、465 个 FF 15、入口点可正常反汇编 → **未加壳** |
|
||||
|
||||
---
|
||||
|
||||
## 十二、实测验证记录
|
||||
|
||||
| # | 二维码时间戳 | userID | token (20位) | 备注 |
|
||||
|---|--------------|--------|--------------|------|
|
||||
| 1 | 260925205225 | 12345678 | TESTTOKEN0000000000 | DLL 验证成功 |
|
||||
| 2 | 260930235204 | 12345678 | e1Tsql1aP1SHLUthRdYI | |
|
||||
| 3 | 261001001304 | 12345678 | rI48n34Ttne1qR9FDFnY | 同一时间戳多次请求 token 各异 |
|
||||
| 4 | 261001001304 | 12345678 | znoWwYW1bMpwlNUzcTpX | |
|
||||
| 5 | 261001001304 | 12345678 | 8Cp6IztEGvLlYt5SRjxw | |
|
||||
|
||||
**结论**:
|
||||
- userID 固定(同一账号)
|
||||
- token 每次请求生成新的 20 位临时凭据(一次性)
|
||||
- 探活+登录总耗时 ~800ms(网络延迟主导)
|
||||
|
||||
---
|
||||
|
||||
## 十三、最小复刻检查清单
|
||||
|
||||
实现纯网络扫码登录,必须覆盖:
|
||||
- [ ] 二维码输入归一化(URL → 84字符串)
|
||||
- [ ] 格式校验(长度84、前缀SGWC、GameID=MAID、载荷64位hex)
|
||||
- [ ] 载荷提取(`qr_string[20:]`)
|
||||
- [ ] 时间戳生成(本地时间 `%y%m%d%H%M%S`)
|
||||
- [ ] 签名计算(SHA256(chipID+timestamp+KEY) 大写hex)
|
||||
- [ ] 请求体构造(紧凑JSON,字段顺序固定)
|
||||
- [ ] HTTPS POST(无Content-Type,UA=WC_AIME_LIB,Keep-Alive)
|
||||
- [ ] SSL 不验证证书
|
||||
- [ ] 响应解析(errorID=0、userID>0、token非空)
|
||||
- [ ] 错误处理(errorID=57→请求头问题,errorID=2→二维码过期)
|
||||
|
||||
---
|
||||
|
||||
*本文档仅包含技术协议实现细节,不含项目结构、代码规范、文件路径等工程化内容。*
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
# 购买功能票流程(登录 → 买票 → 登出)
|
||||
|
||||
> 对应 `api/upsert_user_chargelog.py` 与主脚本范例 `buy_ticket.py`。
|
||||
> **判据 = `source/Assembly-CSharp/` 反编译源码 + 游戏本地主数据 Ticket.xml。**
|
||||
>
|
||||
> ⚠ 本文的实现**全部来自源码推导**,还**没有真机 NetworkLog 对照**。
|
||||
> 文末列出「需要一次抓包确认」的点。
|
||||
|
||||
---
|
||||
|
||||
## 一、一句话
|
||||
|
||||
在机台买一张功能票 = 往服务器发一个 `UpsertUserChargelogApi`,
|
||||
把「买了哪张票、多少钱、什么时候买的、有效期到什么时候、买完还剩几张」写进去。
|
||||
|
||||
```
|
||||
ParseQRCode -> UserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、接口
|
||||
|
||||
| 项目 | 值 |
|
||||
|---|---|
|
||||
| 逻辑 API 名 | `UpsertUserChargelogApi` |
|
||||
| 源码 | `Net.Packet.Mai2/PacketUploadUserChargelog.cs:19` |
|
||||
| 请求 VO | `Net.VO.Mai2/UserChargelogRequestVO.cs`(4 字段) |
|
||||
| 响应 | `{returnCode, apiName}` |
|
||||
| 副作用 | ★ 有,会写入一条购买记录 |
|
||||
|
||||
### 2.1 请求体(字段顺序照 C# 声明)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"userId": 12345678,
|
||||
"userChargelog": { // ← UserChargelog.cs,8 字段
|
||||
"chargeId": 2, // 买的哪张票
|
||||
"price": 1, // ★ = ticket.creditNum,不是 money price
|
||||
"purchaseDate": "2026-10-06 13:01:22",
|
||||
"playCount": 1260, // 玩家总游玩次数
|
||||
"playerRating": 15000, // 玩家 Rating
|
||||
"placeId": 1000,
|
||||
"regionId": 1,
|
||||
"clientId": "TESTCLIENT1"
|
||||
},
|
||||
"userCharge": { // ← UserCharge.cs,4 字段
|
||||
"chargeId": 2,
|
||||
"stock": 1, // ★ 购买后的库存
|
||||
"purchaseDate": "2026-10-06 13:01:22",
|
||||
"validDate": "2027-01-04 04:00:00"
|
||||
},
|
||||
"loginDateTime": 1791291143
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、源码流程(逐行可查)
|
||||
|
||||
```
|
||||
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
|
||||
│
|
||||
├─ ticket.ticketKind.id == 1 ? ← 只有 Paid 票才走购买
|
||||
│ (ticketKind: 1=Paid 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara)
|
||||
│
|
||||
├─ 在 OperationManager.GetGameChargeDataList() 里找该 chargeId 的 price
|
||||
│ (= GetGameChargeApi 的响应,含 orderId/chargeId/price/startDate/endDate)
|
||||
│
|
||||
├─ TicketManager.cs:207 SetBuyPaidTicketData(playerId, ticketId)
|
||||
│ ├─ ChargeList 里已有该 chargeId -> stock++、更新 purchaseDate / validDate
|
||||
│ └─ 没有 -> 新增一条 stock=1
|
||||
│
|
||||
└─ TicketManager.cs:234 RequestUserCharge(playerId)
|
||||
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
|
||||
│ -> 取**购买后**(stock > 0)的那条 UserCharge
|
||||
│
|
||||
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
|
||||
│ chargeId = ticketId
|
||||
│ price = DataManager.GetTicket(ticketId).creditNum ★
|
||||
│ purchaseDate = TimeManager.GetNowDateString()
|
||||
│ playCount = src.Detail.PlayCount
|
||||
│ playerRating = (int)src.Detail.Rating
|
||||
│ placeId = Auth.LocationId
|
||||
│ regionId = Auth.RegionCode
|
||||
│ clientId = AMDaemon.System.KeychipId.ShortValue
|
||||
│
|
||||
└─ 发 UpsertUserChargelogApi
|
||||
returnCode == 1 -> RequestSuccess(本地 stock 再 --,因为票马上被用掉)
|
||||
否则 -> RequestFail
|
||||
```
|
||||
|
||||
### 3.1 ★ `price` 不是「花的钱」
|
||||
|
||||
`VOExtensions.cs:410`:
|
||||
|
||||
```csharp
|
||||
result.price = ticket.creditNum;
|
||||
```
|
||||
|
||||
`creditNum` 是**票的信用点倍数**,来自游戏本地主数据,**不是** `GetGameChargeApi` 的 money price。
|
||||
|
||||
| chargeId | 票名 | `creditNum`(= 上报的 price) | `GetGameChargeApi.price`(投币数) |
|
||||
|---|---|---|---|
|
||||
| 2 | 2倍チケット | **1** | 1 |
|
||||
| 3 | 3倍チケット | **2** | 2 |
|
||||
| 4 | 4倍チケット | **3** | 未上架 |
|
||||
| 5 | 5倍チケット | **4** | 未上架 |
|
||||
| 6 | 6倍チケット | **5** | 未上架 |
|
||||
|
||||
> 2/3 号票两者数值恰好相同,别被误导 —— 源码取的是 `creditNum`。
|
||||
> 数据已抄进 `config.TICKET_CREDIT_NUM`,来源:
|
||||
> `<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml`
|
||||
|
||||
### 3.2 ★ `validDate` 算法
|
||||
|
||||
`Manager/TicketManager.cs:564`:
|
||||
|
||||
```csharp
|
||||
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||
```
|
||||
|
||||
**真机双向验证**(`source/2026-10-05.log:123` 的 `userChargeList`):
|
||||
|
||||
| chargeId | purchaseDate | 减 4h | 加 90d | validDate(真机) | 吻合 |
|
||||
|---|---|---|---|---|---|
|
||||
| 5 | 2026-10-04 18:22:10 | 10-04 14:22 | 2027-01-02 | `2027-01-02 04:00:00` | ✅ |
|
||||
| 3 | 2026-10-02 10:49:23 | 10-02 06:49 | 2026-12-31 | `2026-12-31 04:00:00` | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 四、怎么用
|
||||
|
||||
### 4.1 主脚本(`buy_ticket.py`)
|
||||
|
||||
```bash
|
||||
# 预检:只看报文,零副作用
|
||||
python buy_ticket.py "<二维码>" --charge-id 2
|
||||
|
||||
# 先看看现在有什么票、能买什么
|
||||
python buy_ticket.py "<二维码>" --list
|
||||
|
||||
# ★ 真实购买
|
||||
python buy_ticket.py "<二维码>" --charge-id 2 --send
|
||||
```
|
||||
|
||||
### 4.2 在代码里调
|
||||
|
||||
```python
|
||||
from api import (setup_logging, teardown_logging,
|
||||
ParseQRCode, UserLogin, UserLogout, LogoutType,
|
||||
UpsertUserChargelog)
|
||||
|
||||
setup_logging("买票")
|
||||
try:
|
||||
user_id, token = ParseQRCode(qr)
|
||||
login = UserLogin(user_id, token)
|
||||
|
||||
# ★ 一个函数搞定:取当前票券 -> 取档案 -> 算 validDate -> 算 price -> 闸门 -> 发包
|
||||
UpsertUserChargelog(user_id, 2, login.loginDateTime,
|
||||
dry_run=False, confirm=True)
|
||||
|
||||
UserLogout(user_id, token, login.loginDateTime,
|
||||
logout_type=LogoutType.TestIn)
|
||||
finally:
|
||||
teardown_logging()
|
||||
```
|
||||
|
||||
### 4.3 参数
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `user_id` / `charge_id` / `login_date_time` | 必填 | 后两个必须与 `UserLoginApi` 一致 |
|
||||
| `charge_list` | 自动抓 | 当前票券列表(`GetUserChargeApi`),用来算购买后库存 |
|
||||
| `user_data` | 自动抓 | 当前档案(`GetUserDataApi`),用来填 `playCount`/`playerRating` |
|
||||
| `stock` | 现有 + 1 | 购买后该票的库存 |
|
||||
| `price` | `TICKET_CREDIT_NUM[charge_id]` | = `ticket.creditNum` |
|
||||
| `purchase_date` | 现在 | `YYYY-MM-DD HH:MM:SS` |
|
||||
| `valid_date` | 由 `purchase_date` 推 | 现在 -4h +90d,取日期补 `04:00:00` |
|
||||
| `play_count` / `player_rating` | 取自 `user_data` | |
|
||||
| `confirm` | `config.UPSERT_CHARGELOG_CONFIRM`(False) | ★ 不开不发包 |
|
||||
| `dry_run` | `False` | `True` = 只构造报文 |
|
||||
|
||||
---
|
||||
|
||||
## 五、买完之后票会怎样?
|
||||
|
||||
按源码,**买票接口自己就把 `userCharge`(含新库存)写进去了**,
|
||||
不需要额外再发 `UpsertUserAll`。
|
||||
|
||||
真机的完整顺序是「买票 → 立刻用掉这一张」:
|
||||
|
||||
```
|
||||
SetBuyPaidTicketData: stock++ (本地 +1)
|
||||
RequestUserCharge: 发 stock(购买后的值)
|
||||
RequestSuccess: stock-- (票被本局用掉了)
|
||||
```
|
||||
|
||||
所以 `userChargeList` 里的 `stock` 平时大多是 **0** ——
|
||||
真机 22:31 那次的 `userChargeList` 三条全是 `stock: 0`,就是这个原因。
|
||||
|
||||
> **本模块只负责「买」**,默认上报的是**购买后**的库存(现有 + 1)。
|
||||
> 如果你要复刻「买完立刻用掉」的完整语义,买完再发一次
|
||||
> `UpsertUserAll(..., extra_data={"userChargeList": [...]})` 把 `stock` 减回去。
|
||||
|
||||
---
|
||||
|
||||
## 六、★ 真机抓包对照(已修正,报文逐字节一致)
|
||||
|
||||
### 6.0 结论:唯一差异是 `purchaseDate` 少了一个 `.0`
|
||||
|
||||
真机抓包(`MaimaiNetSpy` `raw/061_UpsertUserChargelogApiMaimaiChn_request.json`,328 字节):
|
||||
|
||||
```json
|
||||
{"userId":12345678,
|
||||
"userChargelog":{"chargeId":2,"price":1,"purchaseDate":"2026-10-06 13:20:10.0",
|
||||
"playCount":1262,"playerRating":15000,"placeId":1000,
|
||||
"regionId":1,"clientId":"TESTCLIENT1"},
|
||||
"userCharge":{"chargeId":2,"stock":1,"purchaseDate":"2026-10-06 13:20:10.0",
|
||||
"validDate":"2027-01-04 04:00:00"},
|
||||
"loginDateTime":1791292785}
|
||||
```
|
||||
|
||||
**14 个字段逐项对照:**
|
||||
|
||||
| 字段 | 真机 | 修正前 | 结论 |
|
||||
|---|---|---|---|
|
||||
| `userChargelog.chargeId` | 2 | 2 | ✅ |
|
||||
| `userChargelog.price` | **1** | 1(= `creditNum`) | ✅ **源码推导正确** |
|
||||
| `userChargelog.purchaseDate` | **`2026-10-06 13:20:10.0`** | `2026-10-06 13:10:42` | ❌ **少 `.0`** |
|
||||
| `userChargelog.playCount` | 1262 | 1262 | ✅ |
|
||||
| `userChargelog.playerRating` | 15000 | 15000 | ✅ |
|
||||
| `userChargelog.placeId` / `regionId` / `clientId` | 1000 / 5 / `TESTCLIENT1` | 同 | ✅ |
|
||||
| `userCharge.chargeId` | 2 | 2 | ✅ |
|
||||
| `userCharge.stock` | **1** | 1(**加 1 后**) | ✅ **源码推导正确** |
|
||||
| `userCharge.purchaseDate` | `2026-10-06 13:20:10.0` | 少 `.0` | ❌ |
|
||||
| `userCharge.validDate` | **`2027-01-04 04:00:00`** | 同 | ✅ **算法完全正确** |
|
||||
| `loginDateTime` | 1791292785 | 同 | ✅ |
|
||||
| 字段顺序 | — | 同 | ✅ |
|
||||
|
||||
**修正后逐字节比对:真机 328 字节 / 本工程 328 字节,完全相同。**
|
||||
|
||||
### 6.0.1 根因:`TimeManager.DateTimeFormat` 带一位小数
|
||||
|
||||
`Manager/TimeManager.cs:7,62-70`:
|
||||
|
||||
```csharp
|
||||
public const string DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f"; // ← .f = 一位小数
|
||||
|
||||
public static string GetNowDateString()
|
||||
=> GetDateString(GetNowUnixTime());
|
||||
|
||||
public static string GetDateString(long unixTime)
|
||||
=> GetDateTime(unixTime).ToString("yyyy-MM-dd HH:mm:ss.f");
|
||||
```
|
||||
|
||||
我原来用了 `%Y-%m-%d %H:%M:%S`(无小数),所以服务端 `returnCode=0`。
|
||||
|
||||
> ★ 两个格式别搞混:
|
||||
> | 字段 | 格式 | 例子 |
|
||||
> |---|---|---|
|
||||
> | `purchaseDate` | `yyyy-MM-dd HH:mm:ss.f` | `2026-10-06 13:20:10.0` |
|
||||
> | `validDate` | `yyyy-MM-dd 04:00:00`(显式串) | `2027-01-04 04:00:00` |
|
||||
>
|
||||
> **一个带小数一个不带**,这是最容易踩的地方。
|
||||
|
||||
> ★ 另一个细节:`GetNowTime()` 返回的是 **`DateTime.UtcNow`**,
|
||||
> `GetDateString` 再 `Add(CSToffset)` —— 所以游戏里的日期串**恒是北京时间**,
|
||||
> 与本机时区无关。脚本跑在非东八区机器上时 `datetime.now()` 会错,
|
||||
> 所以本模块用 `now_cst()`(UTC+8)显式计算。
|
||||
|
||||
### 6.1 修正前的实跑记录(2026-10-06 13:10,已废弃)
|
||||
|
||||
```
|
||||
[购买前] 当前票券 3 条:
|
||||
chargeId=2 stock=0 validDate=2026-10-12 04:00:00
|
||||
chargeId=3 stock=0 validDate=2026-12-31 04:00:00
|
||||
chargeId=5 stock=0 validDate=2027-01-02 04:00:00
|
||||
[购买前] 持有中的功能票:0 张 ← 安全检查通过,可以买
|
||||
安全检查通过:账号当前没有持有任何功能票
|
||||
票名 : 2倍チケット
|
||||
库存 : 0 -> 1
|
||||
price : 1(= ticket.creditNum)
|
||||
purchaseDate: 2026-10-06 13:10:42
|
||||
validDate : 2027-01-04 04:00:00
|
||||
闸门已放行,开始上传购买记录 ...
|
||||
★ 购买失败:returnCode=0 apiName=com.sega.maimai.api.UpsertUserChargelogApi
|
||||
```
|
||||
|
||||
**账号无恙**(已只读复查):`userChargeList` 仍是原来 3 条、
|
||||
`playCount=1262`、无封禁。
|
||||
|
||||
### 6.2 这次失败排除了什么
|
||||
|
||||
| 结论 | 依据 |
|
||||
|---|---|
|
||||
| **URL / 混淆名正确** | 服务端回了 `apiName=com.sega.maimai.api.UpsertUserChargelogApi` |
|
||||
| **报文格式被接受** | 不是 0 字节响应、不是解密解压失败,是**业务层拒绝**(`returnCode=0`) |
|
||||
| **不是闸门问题** | 闸门已放行,确实发了包 |
|
||||
| **不是「已持票」问题** | 购买前确认 3 条票 stock 全是 0 |
|
||||
| **就是 `purchaseDate` 格式** | 抓包对照后确认(见 6.0) |
|
||||
|
||||
### 6.3 剩下的未验证点
|
||||
|
||||
| # | 待验证 | 现状 |
|
||||
|---|---|---|
|
||||
| 1 | 买票后是否**还要**发 `UpsertUserAll` 才持久化库存 | 源码流程里没有;抓包只抓到这一条请求,**推测不需要** |
|
||||
| 2 | `validDate` 是否会被服务端按本地时区重算 | 真机发的是 `2027-01-04 04:00:00`,与本工程算法一致 |
|
||||
| 3 | 服务端是否对「有效期未过的同一 chargeId」有额外限制 | 真机在 `chargeId=2`(validDate 2026-10-12 未过)的情况下照样买了,**说明没有这个限制** |
|
||||
|
||||
### 6.4 抓包时确认过的时序
|
||||
|
||||
抓到的这一次只发了 **`UpsertUserChargelogApi` 一条写请求**,
|
||||
请求体里同时带了 `userCharge`(新库存)和 `userChargelog`(购买记录)——
|
||||
印证了「买票接口自己就把库存写进去了」。
|
||||
|
||||
---
|
||||
|
||||
## 六点五、★ 第二次实跑:`chargeId=5` 又被 `returnCode=0`(2026-10-06 13:41)
|
||||
|
||||
修完 `.f` 格式后再跑一次(这次买 **5 倍券**),**还是 `returnCode=0`**:
|
||||
|
||||
```
|
||||
[购买前] 持有中的功能票:0 张 ← 安全检查通过
|
||||
安全检查通过:账号当前没有持有任何功能票
|
||||
票名 : 5倍チケット
|
||||
库存 : 0 -> 1
|
||||
price : 4(= ticket.creditNum)
|
||||
purchaseDate: 2026-10-06 13:41:43.3 ← .f 格式已正确
|
||||
validDate : 2027-01-04 04:00:00
|
||||
闸门已放行,开始上传购买记录 ...
|
||||
★ 购买失败:returnCode=0 apiName=com.sega.maimai.api.UpsertUserChargelogApi
|
||||
```
|
||||
|
||||
### 6.5.1 根因:`chargeId=5` 根本没上架
|
||||
|
||||
只读查 `GetGameChargeApi`:
|
||||
|
||||
```json
|
||||
{"length":2,"gameChargeList":[
|
||||
{"orderId":1,"chargeId":2,"price":1,...}, ← 只有 2 和 3
|
||||
{"orderId":2,"chargeId":3,"price":2,...}]}
|
||||
```
|
||||
|
||||
**chargeId=5 不在清单里 → 服务端直接拒。**
|
||||
|
||||
### 6.5.2 源码里本来就有这道检查(我漏了)
|
||||
|
||||
`Manager/TicketManager.cs:137-150`:
|
||||
|
||||
```csharp
|
||||
if (ticket.ticketKind.id == 1)
|
||||
{
|
||||
GameCharge[] gameChargeDataList = Singleton<OperationManager>.Instance.GetGameChargeDataList();
|
||||
for (int i = 0; i < gameChargeDataList.Length; i++)
|
||||
{
|
||||
GameCharge gameCharge = gameChargeDataList[i];
|
||||
if (gameCharge.chargeId == ticketId) // ★ 必须在清单里找到
|
||||
{
|
||||
...
|
||||
RequestUserCharge(playerId); // 才发购买请求
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false; // ★ 找不到 -> 不买
|
||||
```
|
||||
|
||||
**真机也是「先查清单,找到才买」** —— 我第一版实现直接跳到发请求,漏了这一步。
|
||||
|
||||
### 6.5.3 已补的守卫
|
||||
|
||||
`UpsertUserChargelog` 新增 `guard_purchasable=True`(默认开):
|
||||
|
||||
- 不传 `game_charge_list` 就自动调 `GetGameChargeApi`
|
||||
- `charge_id` 不在清单里 → 抛 `ChargelogGuardError`,**未发包**
|
||||
|
||||
`buy_ticket.py` 更把这道检查放到了**扫码之前**(`GetGameChargeApi` 不需要登录),
|
||||
不满足就直接退出 —— **不浪费二维码、不产生登录会话**:
|
||||
|
||||
```
|
||||
当前可购买 chargeId:[2, 3]
|
||||
chargeId=2 2倍チケット money price=1 有效期 2021-03-18 07:00:00 ~ 2029-12-31 00:00:00
|
||||
chargeId=3 3倍チケット money price=2 有效期 2021-03-18 07:00:00 ~ 2029-12-31 00:00:00
|
||||
[错误] chargeId=5(5倍チケット)**不在当前可购买清单里** —— 连登录都不做,直接退出。
|
||||
```
|
||||
|
||||
### 6.5.4 本机台到底能买什么
|
||||
|
||||
| chargeId | 票名 | 上架 | `creditNum`(上报的 price) | money price |
|
||||
|---|---|---|---|---|
|
||||
| 2 | 2倍チケット | ✅ | 1 | 1 |
|
||||
| 3 | 3倍チケット | ✅ | 2 | 2 |
|
||||
| 4 | 4倍チケット | ❌ | 3 | — |
|
||||
| 5 | 5倍チケット | ❌ | 4 | — |
|
||||
| 6 | 6倍チケット | ❌ | 5 | — |
|
||||
|
||||
> ★ 上架清单是**机台/店铺配置**,不同店铺可能不一样。
|
||||
> 每次买之前都该先看 `GetGameChargeApi`。
|
||||
|
||||
### 6.5.5 账号状态
|
||||
|
||||
两次失败都**没有写入任何东西**:`userChargeList` 三条 `stock` 全是 0。
|
||||
服务端的 `returnCode=0` 是「干净拒绝」。
|
||||
|
||||
---
|
||||
|
||||
## 六点六、★★ 第三、四次实跑:`chargeId=2`(真机验证过的配置)**也失败**
|
||||
|
||||
### 6.6.1 做了什么
|
||||
|
||||
第 3 次(13:50,chargeId=3)和第 4 次(14:03,**chargeId=2**)分别补齐了:
|
||||
|
||||
| 补齐项 | 说明 |
|
||||
|---|---|
|
||||
| `number` 头 | 加了 `Ping()`,从 `"0"` 变成 `"61"`(真机 75) |
|
||||
| 入场全量下载 | 登录后先拉 18~19 个节点,复刻真机 [14]~[60] |
|
||||
| 买票前等待 | 加 5 秒,贴近真机 login→买票 的 25 秒节奏 |
|
||||
|
||||
**结果仍然是 `returnCode=0`。**
|
||||
|
||||
### 6.6.2 与真机报文的最终并排对比
|
||||
|
||||
第 4 次的报文 vs 真机抓包:
|
||||
|
||||
| 路径 | 真机 | 本工程 |
|
||||
|---|---|---|
|
||||
| 键集合 | — | **完全相同** |
|
||||
| `userChargelog` 键序 | — | **完全相同** |
|
||||
| `userCharge` 键序 | — | **完全相同** |
|
||||
| `userChargelog.purchaseDate` | `2026-10-06 13:20:10.0` | `2026-10-06 14:03:14.6` |
|
||||
| `userChargelog.playCount` | 1262 | 1263 |
|
||||
| `userCharge.purchaseDate` | `2026-10-06 13:20:10.0` | `2026-10-06 14:03:14.6` |
|
||||
| `loginDateTime` | 1791292785 | 1791295385 |
|
||||
|
||||
**唯一不同的就是这 3 个「当下时刻」类的值。** 其余 11 个字段逐字节相同。
|
||||
|
||||
### 6.6.3 已逐项排除的可能
|
||||
|
||||
| # | 假设 | 排除依据 |
|
||||
|---|---|---|
|
||||
| 1 | URL / 混淆名错 | `4411a4c8f7f5c0e95a3ac17f1746aad3` 与真机一致;服务端回正确 `apiName` |
|
||||
| 2 | User-Agent 错 | 与真机逐字符相同 |
|
||||
| 3 | Cookie 缺失 | 登录响应确实下发 `Set-Cookie: JSESSIONID=...`,Session 自动带 |
|
||||
| 4 | 报文字段/顺序/类型错 | 与真机逐字节同构 |
|
||||
| 5 | `purchaseDate` 格式错 | 已补 `.f`(`14:03:14.6`) |
|
||||
| 6 | 票未上架 | `chargeId=2` 在 `GetGameChargeApi` 清单里 |
|
||||
| 7 | 已持有功能票 | 购买前确认 3 条 `stock` 全 0 |
|
||||
| 8 | `number` 头为 0 | 已加 `Ping()` → `"61"` |
|
||||
| 9 | 缺少入场数据下载 | 已补(18~19 节点,0 失败) |
|
||||
| 10 | 节奏太快 | 已加 5 秒等待 |
|
||||
| 11 | `chargeId` 本身有问题 | **改用真机验证过的 `chargeId=2` 仍然失败** |
|
||||
|
||||
### 6.6.4 剩余假设
|
||||
|
||||
请求体已经被证明「结构上完全正确」,所以问题只可能在**请求之外**:
|
||||
|
||||
| # | 假设 | 怎么验证 |
|
||||
|---|---|---|
|
||||
| **A** | ★ **服务端有「购买频率/每日上限」规则** —— 账号在 **13:20 刚买过同一张票**,43 分钟后再买被拒 | **在真机上再买一次 2 倍券**:若真机也失败 → 规则成立,本工程实现正确;若真机成功 → 本工程有 bug |
|
||||
| B | 服务端对**金钱相关接口**做了 TLS/HTTP 指纹校验(非游戏客户端特征) | 需要本地 MITM 代理对比完整 HTTP 请求头 |
|
||||
| C | 服务端要求某个本工程不知道的前置状态 | 同上 |
|
||||
|
||||
**★ 假设 A 是最可能、也最容易验证的**:`returnCode=0` 只用了 **47 ms** 就返回,
|
||||
像是命中了一条规则检查,而不是超时或异常。
|
||||
|
||||
---
|
||||
|
||||
## 七、安全闸门
|
||||
|
||||
| 调用方式 | 行为 |
|
||||
|---|---|
|
||||
| `UpsertUserChargelog(...)` | 抛 `ChargelogConfirmError`,**未发包** |
|
||||
| `UpsertUserChargelog(..., dry_run=True)` | 只构造并返回报文 |
|
||||
| `UpsertUserChargelog(..., confirm=True)` | 真实发送 |
|
||||
|
||||
全局开关:`config.UPSERT_CHARGELOG_CONFIRM = False`(改成 `True` 会永久放行,不建议)。
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
# 舞萌DX 纯网络脚本工程 · 项目总览
|
||||
|
||||
> 项目名 **WaHappy** —— 舞萌DX 的游戏助手。
|
||||
> 面向使用者的说明见根目录 [`README.md`](../README.md);
|
||||
> 本文件是**开发向的主文档**,开工前先读它;遇到「该放哪、该怎么写」的问题以本文件为准。
|
||||
|
||||
---
|
||||
|
||||
## 一、这个工程是什么
|
||||
|
||||
用 **Python 脚本**,通过**纯网络(纯 HTTP)** 形式直接与舞萌DX(SDGB 国服)的**游戏服务器**交互,
|
||||
复刻游戏客户端的协议行为。
|
||||
|
||||
| 对象 | 是什么 | 对应反编译源码 |
|
||||
|---|---|---|
|
||||
| **游戏服务器** | 玩家数据读写:扫码登录、取数、上传成绩、登出 | `source/Assembly-CSharp/` |
|
||||
| **纯网络形式** | 脚本**直接收发 HTTP 报文**(AES 加密 JSON),不依赖运行中的游戏客户端 | `source/Assembly-CSharp/Net/` |
|
||||
| **扫码登录** | 微信二维码换 userId + token,走 aimedb | `source/ChimeLib.NET/` |
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构
|
||||
|
||||
```
|
||||
wahappy-v0-goal/
|
||||
├── config.py ★ 运行时可配置项(密钥/设备/区域/歌曲数据/日志级别)
|
||||
├── upsert.py ★ 主脚本范例:扫码登录 → 上传成绩 → 登出
|
||||
├── buy_ticket.py ★ 主脚本范例:扫码登录 → 购买功能票 → 登出
|
||||
├── selftest.py 零网络自检(220 项,判据=源码+真机日志+真机抓包)
|
||||
│
|
||||
├── api/ ★ 所有与服务器交互的脚本(一个 api 一个脚本)
|
||||
│ ├── __init__.py 统一导出 + 日志跟踪登记
|
||||
│ ├── log_setup.py 统一日志(INFO / DEBUG 双模式)
|
||||
│ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
|
||||
│ ├── user_getter_common.py 取数脚本公共底座
|
||||
│ ├── chime.py 扫码登录
|
||||
│ ├── upsert_user_all.py ★ 上传成绩
|
||||
│ └── ... 其余见第六节
|
||||
│
|
||||
├── source/ ★ 反编译源码 + 网络日志,只读查证用
|
||||
├── docs/ ★ 文档
|
||||
└── logs/ ★ 日志输出(latest.log + 日期.log)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、api/ 脚本契约
|
||||
|
||||
`api/` 里的每个脚本是一个**功能模块**,对外暴露**函数**;函数**能接收参数**,
|
||||
内部完成「组包 → 发包 → 解析 → 返回结果」的全过程。
|
||||
|
||||
```python
|
||||
def 某Api函数(必填参数, *, 可选参数...) -> 结果:
|
||||
"""一句话说明(会出现在日志的 [接口说明] 里)"""
|
||||
...
|
||||
```
|
||||
|
||||
参数形态规律:
|
||||
|
||||
- **业务数据**(user_id / token / 二维码 / playlog_id)→ **位置参数**
|
||||
- **配置项**(client_id / region_id / timeout_ms)→ **关键字参数**,可省略走默认值
|
||||
|
||||
---
|
||||
|
||||
## 四、日志
|
||||
|
||||
### 4.1 两种模式
|
||||
|
||||
| 模式 | 输出内容 |
|
||||
|---|---|
|
||||
| **INFO**(默认) | 只展示 api 脚本的**运行情况和结果** |
|
||||
| **DEBUG** | 在 INFO 基础上,额外展示**向 api 发送的数据**和**api 返回的数据** |
|
||||
|
||||
切换只改 `config.py` 一行:
|
||||
|
||||
```python
|
||||
LOG_LEVEL = "info" # 只看流程
|
||||
LOG_LEVEL = "debug" # 连收发数据一起看
|
||||
```
|
||||
|
||||
### 4.2 运行提示
|
||||
|
||||
每个 api 函数工作时会提示开始,结束时提示结束:
|
||||
|
||||
```
|
||||
=== ParseQRCode 开始运行 ===
|
||||
[接口说明] 解析二维码并换取 userId 与 token。
|
||||
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
|
||||
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
|
||||
=== ParseQRCode 运行结束 ===
|
||||
```
|
||||
|
||||
DEBUG 模式额外输出:
|
||||
|
||||
```
|
||||
-> 请求 GetGameSettingApi
|
||||
URL : https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/83a5...
|
||||
请求头 : {"Content-Type":"application/json","Mai-Encoding":"1.55"}
|
||||
请求体 :
|
||||
{
|
||||
"placeId": 1000,
|
||||
"clientId": "TESTCLIENT1"
|
||||
}
|
||||
|
||||
<- 响应 GetGameSettingApi
|
||||
状态码 : 200
|
||||
耗时 : 312 ms
|
||||
响应体 :
|
||||
{
|
||||
"isAouAccession": false,
|
||||
"gameSetting": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 日志文件(两种形式同时存在、同时写入)
|
||||
|
||||
| 文件 | 模式 | 说明 |
|
||||
|---|---|---|
|
||||
| `logs/latest.log` | **覆盖**(`w`) | 只包含**当次**主脚本运行产出的日志,下次运行被覆盖 |
|
||||
| `logs/YYYY-MM-DD.log` | **追加**(`a`) | 存放**当日**产生的所有日志 |
|
||||
|
||||
每行都带 `[YYYY-MM-DD HH:MM:SS]` 时间戳。
|
||||
|
||||
### 4.4 主脚本怎么写
|
||||
|
||||
```python
|
||||
from api import setup_logging, teardown_logging, ParseQRCode, UserLogin
|
||||
|
||||
def main():
|
||||
setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录
|
||||
try:
|
||||
user_id, token = ParseQRCode(qr)
|
||||
login = UserLogin(user_id, token)
|
||||
finally:
|
||||
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
|
||||
```
|
||||
|
||||
**不需要**写装饰器,**不需要**自己埋点 —— `api/__init__.py` 在导入时就统一套好了日志外壳。
|
||||
|
||||
---
|
||||
|
||||
## 五、协议实现要点(全部有源码依据)
|
||||
|
||||
| 环节 | 依据 | 要点 |
|
||||
|---|---|---|
|
||||
| **URL 生成** | `Packet.cs:485` + `NetQuery.cs:14` | BASE_URL + md5(接口名 + "MaimaiChn" + "8bF76dE9") |
|
||||
| **请求封装** | `NetHttpClient.cs:262-300` | JSON → 原始 deflate → 0x78 0x9C + Adler32(大端) → AES-256-CBC |
|
||||
| **响应解封装** | `NetHttpClient.cs:355-380` | AES 解密 → 去 2 字节头 / 4 字节尾 → deflate → JSON |
|
||||
| **AES 参数** | `CipherAES.cs:11-12` | Key 32B / IV 16B / CBC / PKCS7 |
|
||||
| **请求头** | `NetHttpClient.cs:174-190` | Mai-Encoding: 1.55、Content-Encoding: deflate、UA = 混淆名#userId |
|
||||
| **number 头** | `Packet.cs:187-189` + `PingChecker.cs:227` | = Ping 心跳的往返毫秒(`GameManager.PingMsec`),**只由 Ping 刷新**;除 Ping 外每个接口都带 |
|
||||
| **Cookie** | `Packet.cs:190-193,228-234` | ★ **UserLoginApi 响应会下发 `Set-Cookie: JSESSIONID=...`**,之后每个带 userId 的请求都会带上;`requests.Session` 自动处理,但**必须复用同一个 client**(`get_default_client()`) |
|
||||
| **playSpecial** | `VOExtensions.cs:846-855` + `GameManager.cs:332-378` | `位反转32(Random(1,1037933)*2069 + CalcSpecialNum)`;标准 ACA 机台 `CalcSpecialNum = 1024 = 0x400` |
|
||||
| **扫码签名** | 技术规范 §4 | SHA256(chipID + timestamp + KEY) 大写;**不带 Content-Type** |
|
||||
| **loginDateTime** | `PacketUserLogin.cs:29` | 「本地墙上时间当 UTC」= 标准 Unix 秒 + 时区偏移 |
|
||||
|
||||
### ★ 一个必须注意的坑:Accept-Encoding 必须为空
|
||||
|
||||
`requests` 默认发 `Accept-Encoding: gzip, deflate`。本协议响应体是 AES 密文(二进制),
|
||||
服务端若据此做内容编码会返回**空 body**。C# 端不需要是因为 `HttpWebRequest`
|
||||
默认 `AutomaticDecompression = None`。所以 `maimai_net.py` 固定置空该头。
|
||||
|
||||
---
|
||||
|
||||
## 六、API 清单
|
||||
|
||||
### 6.1 会话链路(6 个)
|
||||
|
||||
| 脚本 | 函数 | 对应接口 |
|
||||
|---|---|---|
|
||||
| `chime.py` | `ParseQRCode(qr, *, chip_id)` | aimedb /wc_aime/api/get_data |
|
||||
| `gamesettings.py` | `GetGameSettings(*, place_id, client_id)` | GetGameSettingApi |
|
||||
| `ping.py` | `Ping(*, client_id)` | Ping |
|
||||
| `userpreview.py` | `UserPreview(user_id, token, *, ...)` | GetUserPreviewApi |
|
||||
| `userlogin.py` | `UserLogin(user_id, token, *, region_id, place_id)` | UserLoginApi |
|
||||
| `userlogout.py` | `UserLogout(user_id, token, login_dt, *, logout_type)` | UserLogoutApi |
|
||||
|
||||
### 6.2 上传成绩(2 个)
|
||||
|
||||
| 脚本 | 函数 | 说明 |
|
||||
|---|---|---|
|
||||
| `upsert_user_all.py` | `UpsertUserAll(user_id, playlog_id, *, login_date_time, auth_date_time, token, snapshot, extra_data, play, ...)` | ★ **一个函数跑完整条结算链路**:抓快照 → 组包 → 前置接口 → 自检 → 60 秒冷却 → 发包 → 落库回查。唯一有副作用的接口 |
|
||||
| `upsert_user_all.py` | `derive_play_characters(snapshot)` | 从服务器快照推「本局 5 只角色 + 游玩前等级」,免得手填的 `config.PLAY_CHARACTERS` 过期 |
|
||||
| `upsert_user_all.py` | `load_extra_data(extra)` / `merge_extra_data(user_all, extra)` | 把外部 JSON(dict / JSON 串 / 文件路径)自动匹配插入 36 节点 |
|
||||
| `upsert_user_all.py` | `verify_upsert_landing(user_id, snapshot, play)` | 发包后重新拉服务器数据,确认真的落库 |
|
||||
| `upsert_snapshot.py` | `fetch_upsert_snapshot(user_id, ...)` | 一键抓结算基线快照(`UpsertUserAll` 内部会按需自动调) |
|
||||
| `upsert_user_chargelog.py` | `UpsertUserChargelog(user_id, charge_id, login_date_time, ...)` | ★ **购买功能票**(UpsertUserChargelogApi):自动取当前票券/档案、算 validDate 与 price、闸门、发包 |
|
||||
| `upsert_user_chargelog.py` | `calc_valid_date(now)` / `ticket_price(id)` / `ticket_name(id)` | 有效期算法(-4h+90d)、票的 creditNum、票名 |
|
||||
|
||||
### 6.3 取数(一个接口一个脚本)
|
||||
|
||||
| 脚本 | 函数 | 对应接口 |
|
||||
|---|---|---|
|
||||
| `userdata.py` | UserData | GetUserDataApi |
|
||||
| `user_extend.py` | UserExtend | GetUserExtendApi |
|
||||
| `user_option.py` | UserOption | GetUserOptionApi |
|
||||
| `user_character.py` | UserCharacter | GetUserCharacterApi |
|
||||
| `user_ghost.py` | UserGhost | GetUserGhostApi |
|
||||
| `user_map.py` | UserMap / UserMapAll | GetUserMapApi |
|
||||
| `user_login_bonus.py` | UserLoginBonus / UserLoginBonusAll | GetUserLoginBonusApi |
|
||||
| `user_rating.py` | UserRating | GetUserRatingApi |
|
||||
| `user_item.py` | UserItem / UserItemAll | GetUserItemApi |
|
||||
| `user_music.py` | UserMusicDetail / UserMusicDetailAll | GetUserMusicApi |
|
||||
| `user_course.py` | UserCourse / UserCourseAll | GetUserCourseApi |
|
||||
| `user_charge.py` | UserCharge | GetUserChargeApi |
|
||||
| `user_favorite.py` | UserFavorite / UserFavoriteAll | GetUserFavoriteApi |
|
||||
| `user_activity.py` | UserActivity | GetUserActivityApi |
|
||||
| `user_mission.py` | UserMissionData | GetUserMissionDataApi |
|
||||
| `user_intimate.py` | UserIntimate | GetUserIntimateApi |
|
||||
| `user_shop_stock.py` | UserShopStock | GetUserShopStockApi |
|
||||
| `user_favorite_item.py` | UserFavoriteItem / UserFavoriteItemAll | GetUserFavoriteItemApi |
|
||||
| `user_rival.py` | UserRivalData / UserRivalMusic(All) | GetUserRivalDataApi / GetUserRivalMusicApi |
|
||||
| `user_friend_bonus.py` | UserFriendBonus | GetUserFriendBonusApi |
|
||||
| `user_kaleidx_scope.py` | UserKaleidxScope / GameKaleidxScope | GetUserKaleidxScopeApi / GetGameKaleidxScopeApi |
|
||||
| `user_region.py` | UserRegion | GetUserRegionApi |
|
||||
| `user_recommend_music.py` | UserRecommendRateMusic / UserRecommendSelectMusic | GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi |
|
||||
| `user_new_item.py` | UserNewItemList | GetUserNewItemListApi |
|
||||
| `game_ranking.py` | GetGameRanking | GetGameRankingApi |
|
||||
| `game_event.py` | GetGameEvent | GetGameEventApi |
|
||||
| `game_charge.py` | GetGameCharge | GetGameChargeApi |
|
||||
| `game_weekly_data.py` | GetGameWeeklyData | GetGameWeeklyDataApi |
|
||||
| `game_tournament_info.py` | GetGameTournamentInfo | GetGameTournamentInfoApi |
|
||||
| `game_ng_music_id.py` | GetGameNgMusicId | GetGameNgMusicIdApi |
|
||||
|
||||
---
|
||||
|
||||
## 七、新增一个 api 要做什么
|
||||
|
||||
1. 在 `api/` 下新建脚本,定义一个能传参的函数
|
||||
2. 函数首行写 docstring(会变成日志的 `[接口说明]`)
|
||||
3. 用 `from .log_setup import log_info, log_request, log_response` 记日志
|
||||
4. 用 `from .maimai_net import request_dict` 发包
|
||||
5. 在 `api/__init__.py` 里**导入**并**加进 `_API_FUNCS`** ← 漏了这步就不会有运行提示
|
||||
|
||||
---
|
||||
|
||||
## 八、相关文档
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `docs/上传成绩开发文档.md` | UpsertUserAll 的 36 节点 / 110 字段 / 差分语义 / 真机对照 |
|
||||
| `docs/主脚本编写指南.md` | ★ **写自己的主脚本**:三条铁律 / 最小骨架 / 四个流程模板 / 常见错误 |
|
||||
| `docs/购买功能票流程.md` | ★ 登录 → 买票 → 登出:接口结构 / 源码流程 / validDate 算法 / 待抓包确认项 |
|
||||
| `docs/UpsertUserAll踩坑清单.md` | ★ **28 条坑的逐条记录**:现象 / 根因 / 源码依据 / 正确做法 / 自检项 + 发包前 Checklist |
|
||||
| `docs/实跑验证记录.md` | ★ **真实跑通记录**:returnCode=1 + 回读确认 playCount 1260→1261,含完整时间线与落库证据 |
|
||||
| `docs/真机Hook对照报告.md` | ★ MelonLoader Mod 真机抓包(70 请求)与本实现的逐项对照:number / Cookie / CalcSpecialNum / 剩余差异说明 |
|
||||
| `docs/UpsertUserAll实跑对比.md` | 逐字段对比明细(110 字段 × 真机 vs 本工程) |
|
||||
| `docs/UpsertUserAll落库排查报告.md` | 「returnCode=1 但没落库」的根因分析 |
|
||||
| `docs/日志与主脚本指南.md` | 日志模块完整参考 + 主脚本写法 |
|
||||
| `docs/舞萌DX_扫码登录协议技术规范.md` | 扫码登录协议(上一代整理) |
|
||||
| `source/2026-10-05.log` | 真机网络日志(本工程最重要的判据) |
|
||||
|
||||
---
|
||||
|
||||
*新增 api 后请同步更新第六节清单。*
|
||||
Reference in New Issue
Block a user