孤儿分支,只含 docs/。内容包括: - 项目总览 / 主脚本编写指南 / 日志指南 - 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条) - 真机 Hook 对照报告 / 实跑验证记录 - 购买功能票流程(含被服务端拒绝的排查过程) · 扫码登录协议技术规范
20 KiB
上传成绩(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:
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(这是刻意设计的防呆)。
"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):
nsrc.Except(osrc)退化成全字段逐字段比较 → 任一字段变了整条就回传compare委托只用于算位串,不参与剔除- 位串
"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 的日期处理有三个容易搞错的点:
// 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 字段):
{"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:
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:
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 秒)
闸门已放行,开始上传成绩 ...
UpsertUserAll(..., confirm=True) # 默认按 config 的 60 秒
UpsertUserAll(..., confirm=True, cooldown_sec=0) # 调试时跳过
wait_send_cooldown(60, label="上传成绩") # 也可单独调用
九、完整用法
9.1 ★ 推荐写法:一个 UpsertUserAll 就够了
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 想手工控制每一步(进阶)
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
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,
即「本局不推进地图、不给角色加经验」,对应节点回传空数组:
"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。
先记住最要命的五条:
- 服务端返回
returnCode=1不代表数据被应用 —— 唯一验收标准是发包后回查数值有没有变 userData是整表覆盖语义 —— 原样回传旧值 = 什么都没发生(这是「回码成功但没落库」的头号成因)- 差分节点必须反映本局变化 ——
userMusicDetailList/userMapList/userCharacterList全空 = 服务端认为本局无变化 playlogId/loginDateTime必须逐字节等于UserLoginApi的返回值userWeeklyData/user2pPlaylog是对象不是数组;userChargeList是整表回传不是差分
十三、验证方式
python selftest.py
覆盖 196 项零网络检查,含:URL 混淆(对真机 md5 值)、加解密往返、
36 节点结构与形态、110 字段完整性、差分算法、收藏夹三值位串、日期语义、
副作用闸门、位串自动补齐、快照归一化、配置常量、
derive_play_characters 角色推导、extra_data 四种输入形态与自动补位串、
number 头 / Cookie 复用等。