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

508 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 上传成绩(UpsertUserAll)开发文档
> 对应 `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 复用等。