# 上传成绩(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 复用等。