docs: WaHappy 开发文档
孤儿分支,只含 docs/。内容包括: - 项目总览 / 主脚本编写指南 / 日志指南 - 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条) - 真机 Hook 对照报告 / 实跑验证记录 - 购买功能票流程(含被服务端拒绝的排查过程) · 扫码登录协议技术规范
This commit is contained in:
@@ -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 复用等。
|
||||
Reference in New Issue
Block a user