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

20 KiB
Raw Blame History

上传成绩(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):

  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 的日期处理有三个容易搞错的点:

// 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。

先记住最要命的五条:

  1. 服务端返回 returnCode=1 不代表数据被应用 —— 唯一验收标准是发包后回查数值有没有变
  2. userData 是整表覆盖语义 —— 原样回传旧值 = 什么都没发生(这是「回码成功但没落库」的头号成因)
  3. 差分节点必须反映本局变化 —— userMusicDetailList/userMapList/userCharacterList 全空 = 服务端认为本局无变化
  4. playlogId / loginDateTime 必须逐字节等于 UserLoginApi 的返回值
  5. userWeeklyData / user2pPlaylog 是对象不是数组;userChargeList 是整表回传不是差分

十三、验证方式

python selftest.py

覆盖 196 项零网络检查,含:URL 混淆(对真机 md5 值)、加解密往返、 36 节点结构与形态、110 字段完整性、差分算法、收藏夹三值位串、日期语义、 副作用闸门、位串自动补齐、快照归一化、配置常量、 derive_play_characters 角色推导、extra_data 四种输入形态与自动补位串、 number 头 / Cookie 复用等。