Compare commits

..
3 Commits
Author SHA1 Message Date
Txiaochen 3e30dffb31 v0版本完成:新增 4 篇详细文档(README 精简内容的落点)
README 为了防冗余做了精简,被移出的详细内容写成文档放这里:

- 功能票使用说明.md      买票/用票的完整原理、报文依据、playCredit 公式、实测记录
- 账号转生说明.md        reincarnate 完整流程、分批规则(避开 2 首)、
                          续关登录依据、登出 type=1 依据、playlog 合成、迁移范围
- 常见坑与注意事项.md    二维码 / 账号状态 / 功能票 / 副作用 / 日志 / 网络 /
                          不入库清单 + .gitignore 行尾注释的坑
- netdelivery说明.md     DLI 协议、amdaemon.exe 字符串出处、用法与实测结论
2026-10-07 11:23:47 +08:00
Txiaochen ddacc97b18 docs: 新增《购买功能票_存档与交接》
买票功能暂停,归档全部有用信息以便将来恢复:
- 协议事实(源码依据 + 真机 328 字节抓包原文)
- 完整实验时间线(8 次失败)与 12 项已排除假设
- ★ 同一账号对照实验证明差异在客户端(机台能买、脚本不能买)
- 别人的可用实现(截图转录)+ 12 项差异对照 + 6 个疑惑
- 账号风险提示、恢复工作的步骤与命令
2026-10-06 16:49:41 +08:00
Txiaochen 41909b9169 docs: WaHappy 开发文档
孤儿分支,只含 docs/。内容包括:
- 项目总览 / 主脚本编写指南 / 日志指南
- 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条)
- 真机 Hook 对照报告 / 实跑验证记录
- 购买功能票流程(含被服务端拒绝的排查过程)
  · 扫码登录协议技术规范
2026-10-06 14:42:32 +08:00
72 changed files with 35133 additions and 11697 deletions
-7
View File
@@ -1,7 +0,0 @@
# 统一换行符,避免 Windows/Linux 来回转换把每个文件都标成 modified
* text=auto
*.py text eol=lf
*.md text eol=lf
*.cs text eol=crlf
*.csproj text eol=crlf
*.json text eol=lf
-26
View File
@@ -1,26 +0,0 @@
# ---- 运行产物 ----
logs/
__pycache__/
*.pyc
*.pyo
# ---- 本地查证用(含 SEGA 二进制 / 反编译代码 / 真机日志,不入库)----
source/
# ---- 私有配置:只提交 config.py.example,不提交 config.py ----
config.py
# ---- 文档单独放 docs 分支,主分支不含 ----
docs/
# ---- .NET 构建产物 ----
gamehook/bin/
gamehook/obj/
# ---- 编辑器 / 系统 ----
.vs/
.vscode/
.idea/
*.user
Thumbs.db
Desktop.ini
-287
View File
@@ -1,287 +0,0 @@
<h1 align="center">WaHappy</h1>
<p align="center">
<b>舞萌DX 的游戏助手</b><br>
宗旨:让舞萌DX的玩家能够更好地游玩游戏,获得快乐。
</p>
<p align="center">AI真的很好用你知道吗</p>
---
## 这是什么
WaHappy 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
它不修改游戏、不注入进程,只是把游戏客户端做的事用脚本重做一遍:
扫码登录 → 取数据 → 上传成绩 → 登出。
> 协议实现全部来自反编译源码与真机网络日志的双向验证,
> 每个关键常量在代码注释里都标了**源码行号**。
---
## 当前状态:v0,**未完工**
**v0 目前只是一个占位版本,请以「能不能用」而不是「有多少代码」来判断它。**
| 模块 | 状态 |
|---|---|
| **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 |
| `buy_ticket.py` | ⚠️ **不可用** —— 购买功能票接口疑似已被服务端拉黑,见下方警告 |
| `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** |
| **文档** | 📄 在 **`docs` 分支**(`mai` 分支不含)—— 开发过程、踩坑记录、实跑验证,内容比代码本身还多 |
### ⚠️ 关于购买功能票
`buy_ticket.py`(`UpsertUserChargelogApi`)在**报文与真机逐字节一致**的情况下
仍被服务端拒绝(`returnCode=0`),之后该账号**疑似被拉黑,暂时无法再购买功能票**。
**请勿轻易重试该接口。** 完整排查过程见
[购买功能票流程](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/购买功能票流程.md) 第六节。
---
## 一个教训:别把一件事分段交给 AI
v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结果:
| 项目 | Token 消耗 |
|---|---|
| **v0 有效开发** | **384M+** |
| 含中途废弃的分支项目 | **1137.5M** |
**这 1137.5M 里有一大半是浪费掉的。**
真正的教训是:
> **不要把一个东西分段交给 AI,而是直接设定一个目标,然后让它自己跑完。**
分段交接会让 AI 反复丢失上下文、重复劳动、在已经解决的问题上打转 ——
而一个清晰的目标 + 足够长的执行窗口,反而更快也更省。
---
## 项目框架
```
WaHappy/
├── config.py 运行时可配置项(密钥 / 机台 / 歌曲数据 / 日志级别)
├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出
├── buy_ticket.py 主脚本:购买功能票(★ 当前不可用)
├── selftest.py 零网络自检(236 项,判据 = 源码 + 真机日志)
│
├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本)
│ ├── __init__.py 统一导出 + 日志自动跟踪
│ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
│ ├── log_setup.py 日志(INFO / DEBUG 双模式)
│ ├── upsert_user_all.py 上传成绩(36 节点 / 110 字段 / 差分)
│ ├── upsert_snapshot.py 结算基线快照
│ └── ... 40+ 个接口脚本
│
├── logs/ 日志输出(latest.log + 日期.log)★ 不入库
└── source/ 反编译源码 ★ 不入库,需自备
```
**设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。
主脚本只负责编排,日志和协议细节都在 `api/` 内部。
> **`docs/` 在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs),`source/` 与 `logs/` 不入库。**
>
> `source/` 是反编译源码,**需要自备**。
### 分支说明
| 分支 | 内容 |
|---|---|
| **`mai`** | 主分支 —— 代码(`api/` / `upsert.py` / `config.py` / `selftest.py` / `gamehook/`) |
| **`docs`** | 孤儿分支 —— 只有 `docs/`,开发文档与踩坑记录 |
两个分支**没有共同的提交历史**,各自独立。
---
## 快速开始
### 1. 装依赖
```bash
pip install requests pycryptodome
```
### 2. ★ 先填 `config.py`
仓库里只有模板 `config.py.example`,先复制一份:
```bash
cp config.py.example config.py # Linux / macOS
copy config.py.example config.py # Windows
```
然后**必须**填好 **`config.py` 第二节「机台与店铺标识」的 6 个值**:
| 变量 | 填什么 |
|---|---|
| `KEYCHIP_ID` | 你的机台 Keychip(**完整形式**,带连字符,形如 `XXXX-XXXXXXXXXXX`) |
| `CLIENT_ID` | Keychip **短值**(去掉连字符取前 11 位) |
| `REGION_ID` | 你所在区域 ID |
| `PLACE_ID` | 你所在店铺 ID |
| `PLACE_NAME` | 店铺名(游戏服务器不返回,只能手填) |
| `REGION_NAME` | 区域名 |
> ⚠️ **这 6 个值留空或填错,发出去的请求带的就是别人的机台身份,服务端会直接拒绝。**
>
> `config.py` 已在 `.gitignore` 里 —— 它是你的私有配置,**不要提交**。
### 3. 跑自检
```bash
python selftest.py # 零网络自检,应该 236 项全绿(不依赖你的 config)
```
### upsert.py 用法
```bash
# 预检:登录 + 抓快照 + 组包,不发包(零副作用,建议先跑这个)
python upsert.py "<二维码>"
# 真实上传一局成绩(★ 有副作用,不可撤销)
python upsert.py "<二维码>" --send
# 常用开关
python upsert.py "<二维码>" --send --debug # 日志里带完整收发报文
python upsert.py "<二维码>" --send --cooldown 0 # 跳过发包前 60 秒冷却
python upsert.py "<二维码>" --send --no-verify # 跳过上传后的落库回查
```
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`,不用动任何代码。**
> 二维码从机台的「扫码登录」界面获取,**有效期 10 分钟**。
### 额外塞节点:`--extra-data`
真机报文的 36 个节点绝大多数能由快照 + 本局演算自动推出来,
但偶尔想手工覆盖某几个(比如本局拿到的新 KaleidxScope 门),就用 `--extra-data`:
```bash
# ① 从文件读(★ 推荐 —— 不用跟 shell 引号搏斗)
python upsert.py "<二维码>" --send --extra-data extra.json
# ② 直接写 JSON 字符串
python upsert.py "<二维码>" --send --extra-data '{"isNewMapList":"1"}'
# ③ 多个来源,后给的覆盖先给的
python upsert.py "<二维码>" --send --extra-data base.json --extra-data patch.json
```
`extra.json` 长这样:
```json
{
"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"
}
```
| 规则 | 说明 |
|---|---|
| 键 | 必须是 `upsertUserAll` 的 **36 个节点**之一(含 11 条 `isNew*` 位串),写错会报错并列出可用节点名 |
| 值形态 | 按节点形态强制转换,传错立刻报错 |
| 位串 | 给了数组没给位串 → 按长度补 `"0"` 并提示。**新增条目请显式传 `"1"`**(`"0"` = 存量更新) |
| 优先级 | 最高,覆盖快照演算结果 |
| 校验时机 | ★ **扫码之前**就校验 —— JSON 写错直接退出,不浪费二维码 |
> ⚠ 在 PowerShell / CMD 里写内联 JSON 很容易被吞掉双引号,
> **建议一律用文件**(`--extra-data extra.json`)。
---
## 主脚本怎么写
**核心原则:不要为每个 api 调用写日志。** 日志已经在 `api/` 里了。
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
from api import (setup_logging, teardown_logging,
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType,
UpsertUserAll)
from config import REGION_ID, PLACE_ID
def main() -> int:
setup_logging("我的脚本") # ← 只需要这一句
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
user_id, token = ParseQRCode(sys.argv[1])
session.update(user_id=user_id, token=token)
UserPreview(user_id, token) # 顶号保护
login = UserLogin(user_id, token,
region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
# ★ 一个函数跑完整条结算链路
UpsertUserAll(user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
dry_run=False, confirm=True)
return 0
finally:
if session["logged_in"]: # ★ 一定要保证登出
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
teardown_logging()
if __name__ == "__main__":
sys.exit(main())
```
三条铁律:
1. **不要写日志** —— 每个 api 函数会自动打印 `=== 函数名 开始运行 ===` / `[运行结果]`
2. **一定要登出** —— 账号卡在 `isLogin=True` 时下次登录会 `returnCode=100`
3. **写接口要显式开闸门** —— 只写 `dry_run=False` 不够,必须同时给 `confirm=True`
完整指南见 [主脚本编写指南](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/主脚本编写指南.md)。
---
## 将来
v0 只是起点,后续可能会加:
- **网页控制** —— 用浏览器代替命令行操作
- 更多游戏功能(任务 / 地图 / 收藏夹 / 排行榜……)
- 更完整的真机验证覆盖
---
## 特别鸣谢
- **志成🍥** —— 提供脱壳 DLL
- **WorkBuddyAI**([workbuddy.ai](https://www.workbuddy.ai/))—— 提供 AI 模型
- **DeepSeek V4.1 Flash** —— 主要代码编写
- **NVIDIA NIM**([build.nvidia.com/models](https://build.nvidia.com/models))、
**AMD**([developer.amd.com.cn/radeon/tokenfactory](https://developer.amd.com.cn/radeon/tokenfactory))、
**Kilo**([kilo.ai](https://kilo.ai/))—— 提供 AI 模型
- **eaquira**([git.fragrance.moe/Fragrance/eaquira](https://git.fragrance.moe/Fragrance/eaquira))—— 思路启发
- **神秘群U** —— 私有科技的 Web 启发(暂无)
- **香水所有群友** —— 和谐帮助
*(还有什么以后补充)*
---
## 免责声明
本项目仅供**学习与协议研究**使用。使用本工具产生的任何后果由使用者自行承担。
请遵守游戏服务条款,不要用于破坏游戏公平性。
-497
View File
@@ -1,497 +0,0 @@
# -*- coding: utf-8 -*-
"""
api 包 —— 统一导出
================================================================================
主脚本只需要 from api import ... 就能拿到全部功能。
★ 本文件在导入时就给下面 _API_FUNCS 里列出的每个函数套上「日志外壳」,
所以主脚本拿到的已经是带日志的版本 —— 调用时会自动产出:
=== ParseQRCode 开始运行 ===
[接口说明] 解析二维码并换取 userId 与 token。
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
=== ParseQRCode 运行结束 ===
主脚本**不需要**写装饰器,也不需要自己埋点。
--------------------------------------------------------------------------------
目录结构(一个 api 一个脚本)
--------------------------------------------------------------------------------
基础设施
log_setup.py 统一日志(INFO / DEBUG 双模式)
maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
user_getter_common.py 取数脚本公共底座(请求 VO / _get / 翻页)
会话链路
chime.py 扫码登录 -> userId + token
gamesettings.py 取游戏设置
ping.py 心跳探活
userpreview.py 用户预览
userlogin.py 正式登录 -> loginId + loginDateTime
userlogout.py 登出
上传成绩
upsert_user_all.py ★ 上传成绩(唯一有副作用的接口)
upsert_snapshot.py 结算快照编排(统一调用下面的取数脚本)
取数(一个接口一个脚本)
userdata.py GetUserDataApi
user_extend.py GetUserExtendApi
user_option.py GetUserOptionApi
user_character.py GetUserCharacterApi
user_ghost.py GetUserGhostApi
user_map.py GetUserMapApi
user_login_bonus.py GetUserLoginBonusApi
user_rating.py GetUserRatingApi
user_item.py GetUserItemApi
user_music.py GetUserMusicApi
user_course.py GetUserCourseApi
user_charge.py GetUserChargeApi
user_favorite.py GetUserFavoriteApi
user_activity.py GetUserActivityApi
user_mission.py GetUserMissionDataApi
user_intimate.py GetUserIntimateApi
user_shop_stock.py GetUserShopStockApi
user_favorite_item.py GetUserFavoriteItemApi
user_rival.py GetUserRivalDataApi / GetUserRivalMusicApi
user_friend_bonus.py GetUserFriendBonusApi
user_kaleidx_scope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
user_region.py GetUserRegionApi
user_recommend_music.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
user_new_item.py GetUserNewItemListApi
game_ranking.py GetGameRankingApi
game_event.py GetGameEventApi
game_charge.py GetGameChargeApi
game_weekly_data.py GetGameWeeklyDataApi
game_tournament_info.py GetGameTournamentInfoApi
game_ng_music_id.py GetGameNgMusicIdApi
"""
from .log_setup import (
# 会话
setup_logging,
teardown_logging,
# 自动跟踪
autowrap,
get_called_apis,
# 模式
get_level,
set_level,
is_debug,
LEVEL_INFO,
LEVEL_DEBUG,
# 装饰器 / 兼容
api_log,
get_logger,
# 流程日志(INFO 可见)
log_info,
log_step,
log_result,
log_warning,
log_error,
log_section,
log_field,
log_debug,
# 收发数据日志(DEBUG 专属)
log_request,
log_response,
log_payload,
)
# ---------------------------------------------------------------------------
# 1) 网络核心
# ---------------------------------------------------------------------------
from .maimai_net import (
MaimaiNetClient,
MaimaiNetError,
get_default_client,
close_default_client,
request,
request_dict,
request_vo,
obfuscate_api_name,
build_api_url,
build_user_agent,
adler32,
aes_encrypt,
aes_decrypt,
deflate_compress,
deflate_decompress,
vo_to_json,
vo_from_json,
local_utc_offset_seconds,
login_date_time_now,
EmptyRequestVO,
UserIdRequestVO,
ReturnCodeVO,
)
# ---------------------------------------------------------------------------
# 2) 会话链路
# ---------------------------------------------------------------------------
from .chime import ParseQRCode
from .gamesettings import (
GetGameSettings,
GameSetting,
GameSettingRequestVO,
GameSettingResponseVO,
is_maintenance,
is_ping_disabled,
)
from .ping import Ping
from .userpreview import (
UserPreview,
UserPreviewRequestVO,
UserPreviewResponseVO,
)
from .userlogin import (
UserLogin,
UserLoginRequestVO,
UserLoginResponseVO,
)
from .userlogout import (
UserLogout,
LogoutType,
DelayLog,
RequestAPI,
UserLogoutRequestVO,
UserLogoutRequest2VO,
UserLogoutResponseVO,
create_empty_delay_log,
create_delay_log_from_stats,
DELAY_LOG_SLOTS,
)
# ---------------------------------------------------------------------------
# 3) 上传成绩
# ---------------------------------------------------------------------------
from .upsert_user_all import (
UpsertUserAll,
PlayData,
build_play_from_config,
derive_play_characters,
UserAllRequestVO,
UpsertResponseVO,
UpsertUserAllError,
ConfirmRequiredError,
PatchError,
# 节点结构
NODE_TYPES,
NODE_ORDER,
BITS_TO_ARRAY,
ARRAY_TO_BITS,
DIFF_PRIMARY_KEYS,
MAY_BE_EMPTY_NODES,
DEFAULT_EMPTY_NODES,
# 组包辅助
empty_node,
empty_user_all,
coerce_node_value,
sync_bits_to_arrays,
build_delta,
build_favorite_delta,
build_user_all,
build_user_playlog,
build_user_game_playlog,
build_post_play_user_data,
validate_user_playlog,
validate_before_send,
apply_play_effects,
# ★ 额外 JSON 合并 / 落库回查
load_extra_data,
merge_extra_data,
verify_upsert_landing,
default_user_weekly_data,
default_user_2p_playlog,
calc_play_special,
normalize_snapshot,
wait_send_cooldown,
# 字段表
PLAYLOG_FIELDS,
PLAYLOG_BOOL_FIELDS,
PLAYLOG_STRING_FIELDS,
JUDGE_TYPES,
JUDGE_BOXES,
USER_DATA_DROP_KEYS,
USER_OPTION_DROP_KEYS,
USER_CHARACTER_KEEP_KEYS,
USER_MUSIC_DETAIL_DROP_KEYS,
USER_CHARGE_DROP_KEYS,
USER_MISSION_DROP_KEYS,
)
from .upsert_snapshot import (
fetch_upsert_snapshot,
save_snapshot,
load_snapshot,
summarize_snapshot,
DEFAULT_SNAPSHOT_NODES,
)
from .upsert_user_chargelog import (
UpsertUserChargelog,
ChargelogConfirmError,
ChargelogGuardError,
UserChargeVO,
UserChargelogVO,
UserChargelogRequestVO,
ticket_name,
ticket_price,
calc_valid_date,
parse_wall_time,
now_cst,
)
# ---------------------------------------------------------------------------
# 4) 取数公共底座
# ---------------------------------------------------------------------------
from .user_getter_common import (
EmptyRequestVO as GetterEmptyRequestVO,
UserIdRequestVO as GetterUserIdRequestVO,
UserIdPageRequestVO,
UserLoginBonusRequestVO,
UserItemRequestVO,
UserMusicRequestVO,
UserCourseRequestVO,
UserFavoriteRequestVO,
UserFavoriteItemRequestVO,
UserShopStockRequestVO,
UserRivalRequestVO,
UserRivalMusicRequestVO,
fetch_all_pages,
ITEM_KINDS,
ITEM_NEXT_INDEX_FACTOR,
FAVORITE_ITEM_KINDS,
RIVAL_MUSIC_LEVELS,
)
# ---------------------------------------------------------------------------
# 5) 取数脚本(一个接口一个脚本)
# ---------------------------------------------------------------------------
from .userdata import UserData
from .user_extend import UserExtend
from .user_option import UserOption
from .user_character import UserCharacter
from .user_ghost import UserGhost
from .user_map import UserMap, UserMapAll
from .user_login_bonus import UserLoginBonus, UserLoginBonusAll
from .user_rating import UserRating
from .user_item import UserItem, UserItemAll
from .user_music import UserMusicDetail, UserMusicDetailAll
from .user_course import UserCourse, UserCourseAll
from .user_charge import UserCharge
from .user_favorite import UserFavorite, UserFavoriteAll
from .user_activity import UserActivity
from .user_mission import UserMissionData
from .user_intimate import UserIntimate
from .user_shop_stock import UserShopStock
from .user_favorite_item import (
UserFavoriteItem,
UserFavoriteItemAll,
FAVORITE_ITEM_KIND_MUSIC,
FAVORITE_ITEM_KIND_RIVAL,
)
from .user_rival import UserRivalData, UserRivalMusic, UserRivalMusicAll
from .user_friend_bonus import UserFriendBonus, extract_gift_points
from .user_kaleidx_scope import UserKaleidxScope, GameKaleidxScope
from .user_region import UserRegion
from .user_recommend_music import UserRecommendRateMusic, UserRecommendSelectMusic
from .user_new_item import UserNewItemList, UserNewItemListRequestVO
from .game_ranking import GetGameRanking, GameRankingRequestVO
from .game_event import GetGameEvent, GameEventRequestVO
from .game_charge import GetGameCharge, GameChargeRequestVO
from .game_weekly_data import GetGameWeeklyData
from .game_tournament_info import GetGameTournamentInfo
from .game_ng_music_id import GetGameNgMusicId
# ---------------------------------------------------------------------------
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
# ---------------------------------------------------------------------------
_API_FUNCS = (
# --- 会话链路 ---
"ParseQRCode",
"GetGameSettings",
"Ping",
"UserPreview",
"UserLogin",
"UserLogout",
# --- 上传成绩 ---
"UpsertUserAll",
"build_play_from_config",
"derive_play_characters",
"merge_extra_data",
"verify_upsert_landing",
"build_user_all",
# --- 购买功能票 ---
"UpsertUserChargelog",
"ticket_name",
"ticket_price",
"calc_valid_date",
"build_user_playlog",
"build_user_game_playlog",
"validate_user_playlog",
"validate_before_send",
"apply_play_effects",
"build_post_play_user_data",
"build_delta",
"build_favorite_delta",
"normalize_snapshot",
"fetch_upsert_snapshot",
"summarize_snapshot",
"UserNewItemList",
# --- 取数:单接口 ---
"UserData", # GetUserDataApi
"UserExtend", # GetUserExtendApi
"UserOption", # GetUserOptionApi
"UserCharacter", # GetUserCharacterApi
"UserGhost", # GetUserGhostApi
"UserMap", # GetUserMapApi(单页)
"UserMapAll", # GetUserMapApi(全量)
"UserLoginBonus", # GetUserLoginBonusApi(单页)
"UserLoginBonusAll", # GetUserLoginBonusApi(全量)
"UserRating", # GetUserRatingApi
"UserItem", # GetUserItemApi(单类)
"UserItemAll", # GetUserItemApi(全量)
"UserMusicDetail", # GetUserMusicApi(单页)
"UserMusicDetailAll", # GetUserMusicApi(全量)
"UserCourse", # GetUserCourseApi(单页)
"UserCourseAll", # GetUserCourseApi(全量)
"UserCharge", # GetUserChargeApi
"UserFavorite", # GetUserFavoriteApi(单类)
"UserFavoriteAll", # GetUserFavoriteApi(全量)
"UserActivity", # GetUserActivityApi
"UserMissionData", # GetUserMissionDataApi
"UserIntimate", # GetUserIntimateApi
"UserShopStock", # GetUserShopStockApi
"UserFavoriteItem", # GetUserFavoriteItemApi(单页)
"UserFavoriteItemAll", # GetUserFavoriteItemApi(全量)
"UserRivalData", # GetUserRivalDataApi
"UserRivalMusic", # GetUserRivalMusicApi(单页)
"UserRivalMusicAll", # GetUserRivalMusicApi(全量)
"UserFriendBonus", # GetUserFriendBonusApi
"UserKaleidxScope", # GetUserKaleidxScopeApi
"GameKaleidxScope", # GetGameKaleidxScopeApi
"UserRegion", # GetUserRegionApi
"UserRecommendRateMusic", # GetUserRecommendRateMusicApi
"UserRecommendSelectMusic", # GetUserRecommendSelectMusicApi
"UserNewItemList", # GetUserNewItemListApi
# --- 取数:游戏侧 ---
"GetGameRanking", # GetGameRankingApi
"GetGameEvent", # GetGameEventApi
"GetGameCharge", # GetGameChargeApi
"GetGameWeeklyData", # GetGameWeeklyDataApi
"GetGameTournamentInfo", # GetGameTournamentInfoApi
"GetGameNgMusicId", # GetGameNgMusicIdApi
)
# ---------------------------------------------------------------------------
# 7) 导入即包装:保证 from api import X 拿到的是带日志的版本
# ---------------------------------------------------------------------------
autowrap(globals(), _API_FUNCS)
# ---------------------------------------------------------------------------
# 8) 统一导出
# ---------------------------------------------------------------------------
__all__ = [
# ---- 日志:会话 ----
"setup_logging", "teardown_logging",
# ---- 日志:自动跟踪 ----
"autowrap", "get_called_apis",
# ---- 日志:模式 ----
"get_level", "set_level", "is_debug", "LEVEL_INFO", "LEVEL_DEBUG",
# ---- 日志:装饰器 / 兼容 ----
"api_log", "get_logger",
# ---- 日志:流程 ----
"log_info", "log_step", "log_result", "log_warning", "log_error",
"log_section", "log_field",
# ---- 日志:收发数据 ----
"log_debug", "log_request", "log_response", "log_payload",
# ---- 网络核心 ----
"MaimaiNetClient", "MaimaiNetError", "get_default_client", "close_default_client",
"request", "request_dict", "request_vo",
"obfuscate_api_name", "build_api_url", "build_user_agent", "adler32",
"aes_encrypt", "aes_decrypt", "deflate_compress", "deflate_decompress",
"vo_to_json", "vo_from_json",
"local_utc_offset_seconds", "login_date_time_now",
"EmptyRequestVO", "UserIdRequestVO", "ReturnCodeVO",
# ---- 会话链路 ----
"ParseQRCode",
"GetGameSettings", "GameSetting", "GameSettingRequestVO", "GameSettingResponseVO",
"is_maintenance", "is_ping_disabled",
"Ping",
"UserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO",
"UserLogin", "UserLoginRequestVO", "UserLoginResponseVO",
"UserLogout", "LogoutType", "DelayLog", "RequestAPI",
"UserLogoutRequestVO", "UserLogoutRequest2VO", "UserLogoutResponseVO",
"create_empty_delay_log", "create_delay_log_from_stats", "DELAY_LOG_SLOTS",
# ---- 上传成绩 ----
"UpsertUserAll", "PlayData", "build_play_from_config", "derive_play_characters",
"load_extra_data", "merge_extra_data", "verify_upsert_landing",
# ---- 购买功能票 ----
"UpsertUserChargelog", "ChargelogConfirmError", "ChargelogGuardError",
"UserChargeVO", "UserChargelogVO", "UserChargelogRequestVO",
"ticket_name", "ticket_price", "calc_valid_date", "parse_wall_time", "now_cst",
"UserAllRequestVO", "UpsertResponseVO",
"UpsertUserAllError", "ConfirmRequiredError", "PatchError",
"NODE_TYPES", "NODE_ORDER", "BITS_TO_ARRAY", "ARRAY_TO_BITS",
"DIFF_PRIMARY_KEYS", "MAY_BE_EMPTY_NODES", "DEFAULT_EMPTY_NODES",
"empty_node", "empty_user_all", "coerce_node_value", "sync_bits_to_arrays",
"build_delta", "build_favorite_delta", "build_user_all",
"build_user_playlog", "build_user_game_playlog", "build_post_play_user_data",
"validate_user_playlog", "validate_before_send", "apply_play_effects",
"default_user_weekly_data", "default_user_2p_playlog", "calc_play_special",
"normalize_snapshot", "wait_send_cooldown",
"PLAYLOG_FIELDS", "PLAYLOG_BOOL_FIELDS", "PLAYLOG_STRING_FIELDS",
"JUDGE_TYPES", "JUDGE_BOXES",
"USER_DATA_DROP_KEYS", "USER_OPTION_DROP_KEYS", "USER_CHARACTER_KEEP_KEYS",
"USER_MUSIC_DETAIL_DROP_KEYS", "USER_CHARGE_DROP_KEYS", "USER_MISSION_DROP_KEYS",
# ---- 快照编排 ----
"fetch_upsert_snapshot", "save_snapshot", "load_snapshot",
"summarize_snapshot", "DEFAULT_SNAPSHOT_NODES",
# ---- 取数公共底座 ----
"GetterEmptyRequestVO", "GetterUserIdRequestVO", "UserIdPageRequestVO",
"UserLoginBonusRequestVO", "UserItemRequestVO", "UserMusicRequestVO",
"UserCourseRequestVO", "UserFavoriteRequestVO", "UserFavoriteItemRequestVO",
"UserShopStockRequestVO", "UserRivalRequestVO", "UserRivalMusicRequestVO",
"fetch_all_pages",
"ITEM_KINDS", "ITEM_NEXT_INDEX_FACTOR", "FAVORITE_ITEM_KINDS", "RIVAL_MUSIC_LEVELS",
# ---- 取数脚本 ----
"UserData", "UserExtend", "UserOption", "UserCharacter", "UserGhost",
"UserMap", "UserMapAll",
"UserLoginBonus", "UserLoginBonusAll",
"UserRating",
"UserItem", "UserItemAll",
"UserMusicDetail", "UserMusicDetailAll",
"UserCourse", "UserCourseAll",
"UserCharge",
"UserFavorite", "UserFavoriteAll",
"UserActivity", "UserMissionData", "UserIntimate", "UserShopStock",
"UserFavoriteItem", "UserFavoriteItemAll",
"FAVORITE_ITEM_KIND_MUSIC", "FAVORITE_ITEM_KIND_RIVAL",
"UserRivalData", "UserRivalMusic", "UserRivalMusicAll",
"UserFriendBonus", "extract_gift_points",
"UserKaleidxScope", "GameKaleidxScope",
"UserRegion",
"UserRecommendRateMusic", "UserRecommendSelectMusic",
"UserNewItemList", "UserNewItemListRequestVO",
"GetGameRanking", "GameRankingRequestVO",
"GetGameEvent", "GameEventRequestVO",
"GetGameCharge", "GameChargeRequestVO",
"GetGameWeeklyData", "GetGameTournamentInfo", "GetGameNgMusicId",
]
-300
View File
@@ -1,300 +0,0 @@
# -*- coding: utf-8 -*-
"""
chime.py —— 扫码登录(二维码 -> userId + token)
================================================================================
功能:把微信二维码串(或二维码 URL)交给 aimedb 服务器,换取 userId 与 token。
对应接口:POST https://ai.sys-allnet.cn/wc_aime/api/get_data
--------------------------------------------------------------------------------
协议依据
--------------------------------------------------------------------------------
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
CCommGetUserData_Create(strGameID, strChipID, strCommonKey,
strQRData, strTitleKey, serverURLIndex)
strGameID = "MAID"
strChipID = KeychipID
strQRData = 二维码串[20:] <- 见 CCommQRCode.EraseHashIdentifier()
strTitleKey= "SDGB"
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommQRCode.cs
IsValid() : 前缀 "SGWC"(Chime.WechatID) + 偏移 4 处为 gameID
GetTimeStamp() : [8:20] 12 位 yymmddHHMMss
EraseHashIdentifier() : [20:] 64 位载荷
source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250-255
KeychipID = AMDaemon.System.KeychipId.Value
TitleKey = AMDaemon.System.GameId
GameID = "MAID"
docs/舞萌DX_扫码登录协议技术规范.md
- 签名 key = SHA-256(chipID + timestamp + SIGN_KEY) 大写 hex
- ★ 请求头【绝不能】带 Content-Type,否则服务端返回 errorID=57
- qrCode 字段传的是 64 位载荷,不是完整 84 字符串
--------------------------------------------------------------------------------
调用示例
--------------------------------------------------------------------------------
from api import ParseQRCode
user_id, token = ParseQRCode("SGWCMAID2610022219463A...")
# 也可以直接传二维码 URL
user_id, token = ParseQRCode("https://wq.wahlap.net/qrcode/req/MAID261002....html?l=...")
"""
from __future__ import annotations
import hashlib
import http.client
import json
import re
import ssl
from datetime import datetime
from typing import Tuple
from urllib.parse import urlparse
from config import (
AIMEDB_HOST,
AIMEDB_PATH,
AIMEDB_PORT,
KEYCHIP_ID,
QR_OPEN_GAME_ID,
QR_SIGN_KEY,
QR_TITLE_KEY,
QR_USER_AGENT,
)
from .log_setup import log_debug, log_error, log_info, log_request, log_response
# =============================================================================
# 二维码格式常量(CCommQRCode.cs + 技术规范)
# =============================================================================
QR_PREFIX = "SGWC" # Chime.WechatID
QR_GAME_ID = "MAID"
QR_PREFIX_LEN = 20 # SGWC(4) + MAID(4) + timestamp(12)
QR_PAYLOAD_LEN = 64
QR_TOTAL_LEN = QR_PREFIX_LEN + QR_PAYLOAD_LEN # 84
#: 默认机台 chipID
#:
#: ★★ 必须用**完整形式**(带连字符),不是短值 —— 这是个容易搞错的点:
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
#: KeychipID = AMDaemon.System.KeychipId.Value; // 完整形式
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:255
#: Chime.Instance.Init(GameID, KeychipID, _chimeDevice, TitleKey);
#: 而游戏服务器那边用的是短值:
#: source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
#: clientId = AMDaemon.System.KeychipId.ShortValue; // 短值
#:
#: 即:aimedb 扫码用完整 KeychipId,游戏服务器用 ShortValue。
#: docs 分支的《扫码登录协议技术规范》§3.4 里也有对应的抓包验证向量。
#: token 与 chipID 绑定,用错会导致后续接口「returnCode=1 但不落库」。
DEFAULT_CHIP_ID = KEYCHIP_ID
# =============================================================================
# 内部工具
# =============================================================================
def _extract_qr_from_url(url: str) -> str:
"""
从二维码 URL 提取完整 84 字符串。
URL 形如:
https://wq.wahlap.net/qrcode/req/MAID2610022219463A....html?l=...&t=...&d=...
取 /qrcode/req/ 与 .html 之间的 80 字符,前面补 "SGWC"。
"""
path = urlparse(url).path
match = re.search(r"/qrcode/req/([^/]+)\.html", path)
if not match:
raise ValueError("URL 路径格式不符合预期,无法提取二维码数据:" + url)
return QR_PREFIX + match.group(1)
def _normalize_qr(qr_input: str) -> str:
"""URL 或字符串 -> 标准 84 字符二维码串(含格式校验)。"""
qr = (qr_input or "").strip()
if qr.startswith("http://") or qr.startswith("https://"):
log_debug("输入是 URL,先提取二维码字符串")
qr = _extract_qr_from_url(qr)
if len(qr) != QR_TOTAL_LEN:
raise ValueError(
"二维码长度应为 " + str(QR_TOTAL_LEN) + " 字符,实际 " + str(len(qr)) + " 字符:" + qr
)
if not qr.startswith(QR_PREFIX):
raise ValueError("二维码必须以 '" + QR_PREFIX + "' 开头:" + qr)
if qr[4:8] != QR_GAME_ID:
raise ValueError(
"二维码 GameID 应为 '" + QR_GAME_ID + "',实际 '" + qr[4:8] + "':" + qr
)
if not all(c in "0123456789ABCDEF" for c in qr[QR_PREFIX_LEN:]):
raise ValueError("二维码载荷必须为大写十六进制:" + qr)
log_debug(
"二维码校验通过:timestamp=" + qr[8:20]
+ " payload_len=" + str(len(qr[QR_PREFIX_LEN:]))
)
return qr
def _build_body(qr_string: str, chip_id: str) -> Tuple[bytes, str, str]:
"""
构造请求体。
签名(技术规范 §4.1):
key = SHA-256(chipID + timestamp + SIGN_KEY).hexdigest().upper()
★ 拼接里不含 qrCode、不含 titlekey
qrCode 字段(CCommQRCode.EraseHashIdentifier):
qr_string[20:] 即 64 位载荷
返回:
(body_bytes, timestamp, key)
"""
timestamp = datetime.now().strftime("%y%m%d%H%M%S")
key = hashlib.sha256((chip_id + timestamp + QR_SIGN_KEY).encode()).hexdigest().upper()
body_dict = {
"chipID": chip_id,
"openGameID": QR_OPEN_GAME_ID,
"key": key,
"qrCode": qr_string[QR_PREFIX_LEN:],
"timestamp": timestamp,
"titlekey": QR_TITLE_KEY,
}
body_bytes = json.dumps(body_dict, separators=(",", ":")).encode("utf-8")
return body_bytes, timestamp, key
def _send(body_bytes: bytes) -> dict:
"""
发送 HTTPS POST 并解析 JSON 响应。
★ 请求头只放 Connection / User-Agent / Content-Length。
绝不能加 Content-Type —— 加了会得到 errorID=57(技术规范 §3.3)。
"""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
headers = {
"Connection": "Keep-Alive",
"User-Agent": QR_USER_AGENT,
"Content-Length": str(len(body_bytes)),
# ★ 此处刻意不加 Content-Type
}
conn = http.client.HTTPSConnection(AIMEDB_HOST, AIMEDB_PORT, context=ctx, timeout=20)
try:
conn.request("POST", AIMEDB_PATH, body=body_bytes, headers=headers)
resp = conn.getresponse()
text = resp.read().decode("utf-8")
status = resp.status
finally:
conn.close()
log_response(status=status, body=text, api_name="aimedb/get_data")
try:
return json.loads(text)
except Exception as exc:
raise RuntimeError("aimedb 响应不是合法 JSON:" + text[:300]) from exc
def _parse(resp: dict) -> Tuple[int, str]:
"""解析响应,返回 (user_id, token)。"""
error_id = resp.get("errorID", -1)
user_id = resp.get("userID", -1)
token = resp.get("token", "") or ""
if error_id != 0:
raise RuntimeError(
"扫码登录失败:errorID=" + str(error_id) + ",响应=" + json.dumps(resp, ensure_ascii=False)
+ "\n 提示:errorID=57 通常是二维码已失效/已被使用,或请求头带了 Content-Type"
)
if user_id == -1 or not token:
raise RuntimeError(
"扫码登录失败:userID=" + str(user_id) + ", token='" + token + "'"
)
return int(user_id), str(token)
# =============================================================================
# 公开 API
# =============================================================================
def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int, str]:
"""
解析二维码并换取 userId 与 token。
参数:
qr_input: 84 字符二维码串(SGWCMAID...),或二维码 URL
chip_id: 机台 Keychip 短值(可选,默认取 config.CLIENT_ID)
返回:
(user_id, token) 元组,可直接传给 UserPreview / UserLogin 等
异常:
ValueError: 二维码格式不合法
RuntimeError: 服务器返回错误或网络异常
"""
log_info("解析二维码登录:chip_id=" + chip_id)
qr_string = _normalize_qr(qr_input)
body_bytes, timestamp, key = _build_body(qr_string, chip_id)
log_request(
url="https://" + AIMEDB_HOST + ":" + str(AIMEDB_PORT) + AIMEDB_PATH,
body={
"chipID": chip_id,
"openGameID": QR_OPEN_GAME_ID,
"key": key,
"qrCode": qr_string[QR_PREFIX_LEN:],
"timestamp": timestamp,
"titlekey": QR_TITLE_KEY,
},
headers={"Connection": "Keep-Alive", "User-Agent": QR_USER_AGENT,
"Content-Length": str(len(body_bytes))},
api_name="aimedb/get_data",
)
log_info("发送登录请求:timestamp=" + timestamp + " host=" + AIMEDB_HOST + AIMEDB_PATH)
resp = _send(body_bytes)
user_id, token = _parse(resp)
log_info("扫码登录成功:user_id=" + str(user_id) + " token=" + token[:8] + "...(长度 "
+ str(len(token)) + ")")
return user_id, token
# =============================================================================
# 单独运行(调试)
# =============================================================================
if __name__ == "__main__":
import sys
from .log_setup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("chime_test")
try:
if len(sys.argv) < 2:
print("用法: python -m api.chime <二维码串或URL> [chip_id]")
return 1
chip = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_CHIP_ID
user_id, token = ParseQRCode(sys.argv[1], chip_id=chip)
print("")
print("user_id =", user_id)
print("token =", token)
return 0
except Exception as exc:
log_error("扫码登录失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
-53
View File
@@ -1,53 +0,0 @@
# -*- coding: utf-8 -*-
"""
game_charge.py —— 取游戏计费配置(GetGameChargeApi)
================================================================================
取服务器下发的计费档位。
对应接口:GetGameChargeApi
请求体 :{"isAll": false}(GameChargeRequestVO)
响应 :{length, gameChargeList:[{orderId,chargeId,price,startDate,endDate}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameChargeRequestVO.cs
source/2026-10-05.log:9 真机请求 {"isAll":false}
调用示例:
from api import GetGameCharge
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
@dataclass
class GameChargeRequestVO:
"""请求 VO(对应 GameChargeRequestVO)。"""
isAll: bool = False
def GetGameCharge(
*,
is_all: bool = False,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取游戏计费配置(对应 GetGameChargeApi)。
返回:
原始响应 dict,含 gameChargeList
"""
log_info("取游戏计费配置:isAll=" + str(is_all))
return request_dict(
"GetGameChargeApi", GameChargeRequestVO(isAll=is_all),
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-60
View File
@@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
"""
game_event.py —— 取游戏活动(GetGameEventApi)
================================================================================
取服务器下发的活动列表。
对应接口:GetGameEventApi
请求体 :{"type":1, "isAllEvent":true}(GameEventRequestVO)
响应 :{type, length, gameEventList:[{type,id,startDate,endDate}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameEventRequestVO.cs
source/2026-10-05.log:5 真机请求 {"type":1,"isAllEvent":true}
调用示例:
from api import GetGameEvent
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
@dataclass
class GameEventRequestVO:
"""请求 VO(对应 GameEventRequestVO)。"""
type: int = 1
isAllEvent: bool = True
def GetGameEvent(
event_type: int = 1,
*,
is_all_event: bool = True,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取游戏活动列表(对应 GetGameEventApi)。
参数:
event_type: 活动类型(真机用 1)
is_all_event: 是否取全部活动(真机 True)
返回:
原始响应 dict,含 gameEventList
"""
log_info("取游戏活动:type=" + str(event_type) + " isAllEvent=" + str(is_all_event))
return request_dict(
"GetGameEventApi",
GameEventRequestVO(type=event_type, isAllEvent=is_all_event),
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-45
View File
@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
"""
game_ng_music_id.py —— 取禁用曲目列表(GetGameNgMusicIdApi)
================================================================================
取服务器下发的 NG(不可游玩)曲目 ID 列表。
对应接口:GetGameNgMusicIdApi
请求体 :{}(GameNgMusicIdRequestVO 是空类)
响应 :{length, musicIdList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameNgMusicIdRequestVO.cs(空类)
source/2026-10-05.log:11
调用示例:
from api import GetGameNgMusicId
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
def GetGameNgMusicId(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取禁用曲目列表(对应 GetGameNgMusicIdApi)。
返回:
原始响应 dict,含 musicIdList
"""
log_info("取禁用曲目列表")
return request_dict(
"GetGameNgMusicIdApi", {},
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-56
View File
@@ -1,56 +0,0 @@
# -*- coding: utf-8 -*-
"""
game_ranking.py —— 取游戏排行(GetGameRankingApi)
================================================================================
取服务器下发的排行榜数据。
对应接口:GetGameRankingApi
请求体 :{"type": <排行类型>}(GameRankingRequestVO.type 是 long)
响应 :{type, gameRankingList:[{id,point,userName}], gameRankingInstantList}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameRankingRequestVO.cs
source/2026-10-05.log:3 真机请求 {"type":1}
调用示例:
from api import GetGameRanking
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
@dataclass
class GameRankingRequestVO:
"""请求 VO(对应 GameRankingRequestVO)。"""
type: int = 1
def GetGameRanking(
ranking_type: int = 1,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取游戏排行(对应 GetGameRankingApi)。
参数:
ranking_type: 排行类型(真机用 1)
返回:
原始响应 dict,含 gameRankingList
"""
log_info("取游戏排行:type=" + str(ranking_type))
return request_dict(
"GetGameRankingApi", GameRankingRequestVO(type=ranking_type),
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-45
View File
@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
"""
game_tournament_info.py —— 取大会信息(GetGameTournamentInfoApi)
================================================================================
取服务器下发的大会/赛事信息。
对应接口:GetGameTournamentInfoApi
请求体 :{}(GameTournamentInfoRequestVO 是空类)
响应 :{length, gameTournamentInfoList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameTournamentInfoRequestVO.cs(空类)
source/2026-10-05.log:7
调用示例:
from api import GetGameTournamentInfo
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
def GetGameTournamentInfo(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取大会信息(对应 GetGameTournamentInfoApi)。
返回:
原始响应 dict,含 gameTournamentInfoList
"""
log_info("取大会信息")
return request_dict(
"GetGameTournamentInfoApi", {},
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-45
View File
@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
"""
game_weekly_data.py —— 取游戏周常配置(GetGameWeeklyDataApi)
================================================================================
取服务器下发的周常周期信息。
对应接口:GetGameWeeklyDataApi
请求体 :{}(GameWeeklyRequestVO 是空类)
响应 :{gameWeeklyData:{missionCategory, updateDate, beforeDate}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameWeeklyRequestVO.cs(空类)
source/2026-10-05.log:13
调用示例:
from api import GetGameWeeklyData
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
def GetGameWeeklyData(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取游戏周常配置(对应 GetGameWeeklyDataApi)。
返回:
原始响应 dict,含 gameWeeklyData
"""
log_info("取游戏周常配置")
return request_dict(
"GetGameWeeklyDataApi", {},
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-175
View File
@@ -1,175 +0,0 @@
# -*- coding: utf-8 -*-
"""
gamesettings.py —— 获取游戏设置(GetGameSettingApi)
================================================================================
功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。
对应接口:GetGameSettingApi
请求体 :{"placeId": <店铺ID>, "clientId": "<机台短值>"}
source/Assembly-CSharp/Net.VO.Mai2/GameSettingRequestVO.cs
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:21-24
★ 真机调用时机:会话最开始、Ping 之前(source/2026-10-05.log:1)。
调用示例:
from api import GetGameSettings
resp = GetGameSettings()
print(resp.gameSetting.isMaintenance, resp.gameSetting.requestInterval)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from config import CLIENT_ID, PLACE_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict
# =============================================================================
# VO(字段对照 source/Assembly-CSharp/Net.VO.Mai2/GameSetting.cs 与真机响应)
# =============================================================================
@dataclass
class GameSetting:
"""游戏运行时配置(对应 Net.VO.Mai2.GameSetting)。"""
isMaintenance: bool = False
requestInterval: int = 0
rebootStartTime: str = ""
rebootEndTime: str = ""
rebootInterval: int = 0
movieUploadLimit: int = 0
movieStatus: int = 0
movieServerUri: str = ""
deliverServerUri: str = ""
oldServerUri: str = ""
usbDlServerUri: str = ""
maxCountRivalMusic: int = 0
replicationDelayLimit: int = 0
exclusionStartTime: str = ""
exclusionEndTime: str = ""
pingDisable: bool = False
packetTimeout: int = 0
packetTimeoutLong: int = 0
packetRetryCount: int = 0
userDataDlErrTimeout: int = 0
userDataDlErrRetryCount: int = 0
userDataDlErrSamePacketRetryCount: int = 0
userDataUpSkipTimeout: int = 0
userDataUpSkipRetryCount: int = 0
iconPhotoDisable: bool = False
uploadPhotoDisable: bool = False
photoDisableArea: Optional[str] = None
collectDelayInfoDisable: bool = False
maxCountMusic: int = 0
maxCountItem: int = 0
nameEntryDisable: bool = False
packetRecreateCount: int = 0
@dataclass
class GameSettingRequestVO:
"""请求 VO(对应 GameSettingRequestVO)。"""
placeId: int = 0
clientId: str = ""
@dataclass
class GameSettingResponseVO:
"""响应 VO(对应 GameSettingResponseVO)。"""
isAouAccession: bool = False
gameSetting: GameSetting = field(default_factory=GameSetting)
# =============================================================================
# 公开 API
# =============================================================================
def GetGameSettings(
*,
place_id: int = PLACE_ID,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> GameSettingResponseVO:
"""
获取游戏设置(服务器下发的运行时配置)。
参数:
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
client_id: 机台短值(可选,默认 config.CLIENT_ID)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
GameSettingResponseVO
"""
log_info("获取游戏设置:place_id=" + str(place_id) + " client_id=" + client_id)
raw = request_dict(
"GetGameSettingApi",
GameSettingRequestVO(placeId=place_id, clientId=client_id),
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
gs_raw = raw.get("gameSetting") or {}
known = {f for f in GameSetting.__dataclass_fields__}
gs = GameSetting(**{k: v for k, v in gs_raw.items() if k in known})
resp = GameSettingResponseVO(
isAouAccession=bool(raw.get("isAouAccession", False)),
gameSetting=gs,
)
if gs.isMaintenance:
log_warning("服务器处于维护状态(isMaintenance=True)")
if gs.pingDisable:
log_info("服务器已禁用心跳(pingDisable=True)")
log_info(
"游戏设置获取完成:isMaintenance=" + str(gs.isMaintenance)
+ " requestInterval=" + str(gs.requestInterval)
+ " packetTimeout=" + str(gs.packetTimeout)
+ " packetTimeoutLong=" + str(gs.packetTimeoutLong)
+ " pingDisable=" + str(gs.pingDisable)
)
return resp
def is_maintenance(settings: GameSettingResponseVO) -> bool:
"""便捷判断:服务器是否处于维护状态。"""
return bool(settings.gameSetting.isMaintenance)
def is_ping_disabled(settings: GameSettingResponseVO) -> bool:
"""便捷判断:是否已禁用心跳。"""
return bool(settings.gameSetting.pingDisable)
# =============================================================================
# 单独运行(调试)
# =============================================================================
if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("gamesettings_test")
try:
resp = GetGameSettings()
gs = resp.gameSetting
print("")
for name in GameSetting.__dataclass_fields__:
print(" " + name.ljust(34) + "= " + str(getattr(gs, name)))
return 0
except Exception as exc:
from .log_setup import log_error
log_error("获取游戏设置失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
-704
View File
@@ -1,704 +0,0 @@
# -*- coding: utf-8 -*-
"""
log_setup.py —— 统一日志模块(INFO / DEBUG 双模式)
================================================================================
【两种模式】
INFO (默认) 只展示 api 脚本的运行情况和结果:
=== ParseQRCode 开始运行 ===
[接口说明] ...
[调用参数] ...
[运行结果] ...
=== ParseQRCode 运行结束 ===
DEBUG 在 INFO 基础上,额外展示「向 api 发送的数据」和「api 返回的数据」:
-> 请求 GetUserPreviewApi
URL : https://...
请求头 : {...}
请求体 : {...JSON 全文...}
<- 响应 GetUserPreviewApi
状态码 : 200
响应体 : {...JSON 全文...}
【模式从哪来】
优先级: setup_logging(level=...) 显式传参 > config.py 的 LOG_LEVEL > INFO
日常只改 config.py 一行:
LOG_LEVEL = "info" # 只看流程
LOG_LEVEL = "debug" # 连收发数据一起看
【日志文件(两种形式同时存在、同时写入)】
logs/latest.log 覆盖模式("w")—— 只包含【当次】主脚本运行产出的日志,
下一次运行主脚本时被覆盖
logs/YYYY-MM-DD.log 追加模式("a")—— 存放【当日】产生的所有日志
每行都带 [YYYY-MM-DD HH:MM:SS] 时间戳。
【主脚本怎么用】
from api import setup_logging, teardown_logging, ParseQRCode, UserLogin
def main():
setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录
try:
user_id, token = ParseQRCode(qr) # 自动产出「开始运行 / 运行结束」
login = UserLogin(user_id, token)
finally:
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
api 函数不需要自己写 print,也不需要装饰器 —— api/__init__.py 在导入时就
统一套上了日志外壳(见 autowrap)。
【api 脚本怎么用】
from api import log_info, log_debug, log_request, log_response
def UserPreview(user_id, token, ...):
# 函数的第一行 docstring 会成为日志里的「[接口说明]」
# 例如: '''获取用户预览信息'''
log_info(f"请求用户预览 user_id={user_id}") # 可选,想额外记话时才写
log_request(url=url, body=req, headers=hdr, api_name="GetUserPreviewApi")
raw = _http_post(...)
log_response(status=200, body=raw, headers=hdr, api_name="GetUserPreviewApi")
return parsed
"""
from __future__ import annotations
import atexit
import functools
import json
import os
import platform
import time
from datetime import datetime
from typing import Any, Callable, Iterable, Optional
# =============================================================================
# 常量与全局状态
# =============================================================================
LEVEL_INFO = "info"
LEVEL_DEBUG = "debug"
_LEVEL_RANK = {LEVEL_INFO: 20, LEVEL_DEBUG: 10}
# 项目根目录(本文件在 api/ 下,根目录是上一级)
_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_LOGS_DIR = os.path.join(_ROOT_DIR, "logs")
_SEPARATOR = "=" * 66
_INDENT = " "
# 全局状态
_session_started = False
_session_script_name = ""
_current_level = LEVEL_INFO
_value_max = 0
_date_log_path: Optional[str] = None
_latest_log_path: Optional[str] = None
_date_log_file = None
_latest_log_file = None
# 被自动跟踪的 api 函数名(供横幅展示)
_tracked_api_names: list = []
# 本次会话实际调用过的 api(按首次调用顺序)
_called_apis: list = []
# =============================================================================
# 模式判定
# =============================================================================
def _normalize_level(level: Any) -> str:
"""把各种写法的模式名归一化成 'info' / 'debug'。"""
if level is None:
return LEVEL_INFO
text = str(level).strip().lower()
if text in ("debug", "dbg", "verbose", "详细", "调试"):
return LEVEL_DEBUG
return LEVEL_INFO
def _config_level() -> str:
"""从 config.py 读 LOG_LEVEL;读不到就用 INFO。"""
try:
import config # type: ignore
return _normalize_level(getattr(config, "LOG_LEVEL", LEVEL_INFO))
except Exception:
return LEVEL_INFO
def _config_value_max() -> int:
"""从 config.py 读 LOG_VALUE_MAX(0 = 不截断)。"""
try:
import config # type: ignore
return int(getattr(config, "LOG_VALUE_MAX", 0) or 0)
except Exception:
return 0
def get_level() -> str:
"""返回当前日志模式('info' / 'debug')。"""
return _current_level
def is_debug() -> bool:
"""当前是否 DEBUG 模式。"""
return _LEVEL_RANK.get(_current_level, 20) <= _LEVEL_RANK[LEVEL_DEBUG]
def set_level(level: Any) -> str:
"""运行时切换模式(一般不用,改 config.py 即可)。返回切换后的模式。"""
global _current_level
_current_level = _normalize_level(level)
return _current_level
# =============================================================================
# 日志文件
# =============================================================================
def _ensure_logs_dir() -> None:
os.makedirs(_LOGS_DIR, exist_ok=True)
def _get_date_log_path() -> str:
return os.path.join(_LOGS_DIR, datetime.now().strftime("%Y-%m-%d") + ".log")
def _get_latest_log_path() -> str:
return os.path.join(_LOGS_DIR, "latest.log")
def _write_files(message: str) -> None:
"""把一段文本写进两个日志文件(每行带时间戳)。"""
timestamp = datetime.now().strftime("[%Y-%m-%d %H:%M:%S]")
lines = message.splitlines() or [""]
payload = "".join(timestamp + line + "\n" for line in lines)
for handle in (_date_log_file, _latest_log_file):
if handle:
try:
handle.write(payload)
handle.flush()
except Exception:
pass
def _emit(message: str = "", *, console: bool = True) -> None:
"""统一出口:控制台 + 两个日志文件。"""
if console:
try:
print(message)
except Exception:
try:
print(message.encode("utf-8", "replace").decode("utf-8", "replace"))
except Exception:
pass
_write_files(message)
def _close_log_files() -> None:
global _date_log_file, _latest_log_file
for name in ("_date_log_file", "_latest_log_file"):
handle = globals()[name]
if handle:
try:
handle.close()
except Exception:
pass
globals()[name] = None
# =============================================================================
# 自动跟踪:给 api 函数套日志外壳
# =============================================================================
#
# 目标:主脚本只写 setup_logging("名字") + 业务代码,
# 调用的每个 api 函数自动产出「===函数名 开始运行=== / ===函数名 运行结束===」,
# 调用方不必自己写装饰器,也不必自己埋点。
#
# 原理:api/__init__.py 在导入完各 api 脚本后调用 autowrap(globals(), _API_FUNCS),
# 把命名空间里的函数替换成带日志的版本 —— 于是 from api import ParseQRCode
# 拿到的已经是包装后的函数。
def _describe(func: Callable) -> str:
"""取函数 docstring 首行,作为「[接口说明]」。"""
doc = (func.__doc__ or "").strip()
if not doc:
return ""
return doc.splitlines()[0].strip()
def _make_tracked(func: Callable) -> Callable:
"""生成带日志外壳的包装函数。"""
name = func.__name__
summary = _describe(func)
@functools.wraps(func)
def tracked(*args, **kwargs) -> Any:
_note_api_call(name)
_emit("")
_emit("=== " + name + " 开始运行 ===")
if summary:
_emit(_INDENT + "[接口说明] " + summary)
# 调用参数:DEBUG 展开多行 JSON,INFO 记单行摘要
if args:
if is_debug():
_emit(_INDENT + "[位置参数]")
_emit(_fmt_pretty(list(args)))
else:
_emit(_INDENT + "[位置参数] " + ", ".join(_fmt(a) for a in args))
if kwargs:
shown = {k: v for k, v in kwargs.items() if v is not None}
if shown:
if is_debug():
_emit(_INDENT + "[关键字参数]")
_emit(_fmt_pretty(shown))
else:
_emit(_INDENT + "[关键字参数] "
+ ", ".join(k + "=" + _fmt(v) for k, v in shown.items()))
t0 = time.monotonic()
try:
result = func(*args, **kwargs)
except BaseException as exc:
ms = int((time.monotonic() - t0) * 1000)
_emit(_INDENT + "[运行结果] 异常 " + type(exc).__name__ + ": "
+ str(exc) + "(耗时 " + str(ms) + " ms)")
_emit("=== " + name + " 运行结束 ===")
_emit("")
raise
ms = int((time.monotonic() - t0) * 1000)
_emit(_INDENT + "[运行结果] 成功 " + _fmt_result(result)
+ "(耗时 " + str(ms) + " ms)")
if is_debug() and result is not None:
_emit(_INDENT + "[返回数据]")
_emit(_fmt_pretty(result))
_emit("=== " + name + " 运行结束 ===")
_emit("")
return result
tracked.__mai_logged__ = True # type: ignore[attr-defined]
tracked.__mai_original__ = func # type: ignore[attr-defined]
return tracked
def autowrap(namespace: dict, names: Iterable[str]) -> list:
"""
给命名空间里的函数套上日志外壳(导入时调用,幂等)。
参数:
namespace: 目标命名空间,通常是 api/__init__.py 的 globals()
names: 要包装的函数名序列
返回:
实际完成包装的函数名列表
"""
wrapped = []
for name in names:
obj = namespace.get(name)
if obj is None or not callable(obj):
continue
if getattr(obj, "__mai_logged__", False):
wrapped.append(name)
continue
try:
namespace[name] = _make_tracked(obj)
wrapped.append(name)
except Exception:
continue
for n in wrapped:
if n not in _tracked_api_names:
_tracked_api_names.append(n)
return wrapped
def _note_api_call(name: str) -> None:
"""记录主脚本调用过的 api(去重,保留首次调用顺序)。"""
if name not in _called_apis:
_called_apis.append(name)
def get_called_apis() -> list:
"""返回本次会话实际调用过的 api 名称(按首次调用顺序)。"""
return list(_called_apis)
# =============================================================================
# 会话:初始化 / 收尾
# =============================================================================
def setup_logging(script_name: str, level: Any = None) -> str:
"""
初始化日志会话(主脚本开头调用一次)。
做三件事:
1. 确定日志模式(level 显式传参 > config.LOG_LEVEL > INFO)
2. 打开两个日志文件:日期日志(追加)+ latest.log(覆盖)
3. 打印会话横幅
参数:
script_name: 脚本名,显示在横幅上
level: 日志模式;None(默认)= 读 config.py 的 LOG_LEVEL
返回:
实际生效的模式('info' / 'debug')
"""
global _session_started, _session_script_name, _value_max
global _date_log_path, _latest_log_path, _date_log_file, _latest_log_file
set_level(level if level is not None else _config_level())
_value_max = _config_value_max()
if _session_started:
return _current_level
_ensure_logs_dir()
_session_script_name = script_name
_date_log_path = _get_date_log_path()
_latest_log_path = _get_latest_log_path()
_called_apis.clear()
_date_log_file = open(_date_log_path, "a", encoding="utf-8") # 追加:当日全部
_latest_log_file = open(_latest_log_path, "w", encoding="utf-8") # 覆盖:仅当次
_print_banner(script_name)
_session_started = True
return _current_level
def _print_banner(script_name: str) -> None:
mode_text = "DEBUG(含收发数据)" if is_debug() else "INFO(仅运行情况与结果)"
lines = [
_SEPARATOR,
_INDENT + "日志会话开始 —— " + script_name,
_SEPARATOR,
_INDENT * 2 + "主脚本 : " + script_name,
_INDENT * 2 + "开始时间 : " + datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
_INDENT * 2 + "日志模式 : " + mode_text,
_INDENT * 2 + "Python : " + platform.python_version()
+ " (" + platform.python_implementation() + " " + platform.machine() + ")",
_INDENT * 2 + "工作目录 : " + os.getcwd(),
_INDENT * 2 + "已挂载 api : " + str(len(_tracked_api_names))
+ " 个(自动跟踪,主脚本无需写日志代码)",
_INDENT * 2 + "当次日志 : " + str(_latest_log_path),
_INDENT * 2 + "日期日志 : " + str(_date_log_path),
_SEPARATOR,
]
_emit("\n".join(lines))
def teardown_logging() -> None:
"""结束日志会话(主脚本结尾调用;不调用也会在退出时自动收尾)。"""
global _session_started
if not _session_started:
return
lines = [_SEPARATOR, _INDENT + "本次运行调用的 api", _SEPARATOR]
if _called_apis:
for i, name in enumerate(_called_apis, 1):
lines.append(_INDENT * 2 + str(i).rjust(2) + ". " + name)
else:
lines.append(_INDENT * 2 + "(未调用任何 api)")
lines.append(_SEPARATOR)
lines.append(_INDENT + "日志会话结束 —— " + _session_script_name)
lines.append(_SEPARATOR)
_emit("\n".join(lines))
_close_log_files()
_session_started = False
# =============================================================================
# 值格式化
# =============================================================================
def _truncate(text: str) -> str:
if _value_max and len(text) > _value_max:
return text[:_value_max] + "...(共 " + str(len(text)) + " 字符)"
return text
def _fmt(value: Any) -> str:
"""把一个值渲染成紧凑单行文本。"""
if isinstance(value, str):
return _truncate(value)
if value is None or isinstance(value, (int, float, bool)):
return repr(value)
if isinstance(value, (dict, list, tuple)):
try:
return _truncate(json.dumps(value, ensure_ascii=False,
separators=(",", ":"), default=str))
except Exception:
return _truncate(repr(value))
if hasattr(value, "__dict__"):
try:
data = {k: v for k, v in vars(value).items() if not k.startswith("_")}
return _truncate(json.dumps(data, ensure_ascii=False,
separators=(",", ":"), default=str))
except Exception:
return _truncate(repr(value))
return _truncate(repr(value))
def _to_plain(value: Any) -> Any:
"""把 dataclass / 普通对象递归转成可 JSON 化的结构。"""
if value is None or isinstance(value, (int, float, bool, str)):
return value
if isinstance(value, dict):
return {k: _to_plain(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_to_plain(v) for v in value]
if hasattr(value, "__dataclass_fields__"):
return {k: _to_plain(getattr(value, k, None)) for k in value.__dataclass_fields__}
if hasattr(value, "__dict__"):
return {k: _to_plain(v) for k, v in vars(value).items() if not k.startswith("_")}
return str(value)
def _fmt_pretty(value: Any, indent: str = " ") -> str:
"""把 dict / list / VO 渲染成多行缩进的 JSON。DEBUG 下完整输出,不折叠。"""
data = _to_plain(value)
if isinstance(data, str):
stripped = data.strip()
if stripped[:1] in ("{", "["):
try:
data = json.loads(stripped)
except Exception:
return "\n".join(indent + line for line in data.splitlines())
try:
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
except Exception:
text = repr(data)
if _value_max and len(text) > _value_max:
text = text[:_value_max] + "\n" + indent + "...(共 " + str(len(text)) + " 字符,已截断)"
return "\n".join(indent + line for line in text.splitlines())
def _fmt_result(result: Any) -> str:
"""结果的紧凑摘要(INFO 用)。"""
if isinstance(result, list):
return "list(" + str(len(result)) + " 项)"
if isinstance(result, tuple):
return "tuple(" + str(len(result)) + " 项)"
if isinstance(result, dict):
return "dict(" + str(len(result)) + " 键)"
if isinstance(result, str):
return repr(result) if len(result) <= 60 else "str(" + str(len(result)) + " 字符)"
if hasattr(result, "__dataclass_fields__"):
return type(result).__name__ + "(" + str(len(result.__dataclass_fields__)) + " 字段)"
return repr(result)
# =============================================================================
# 流程日志(INFO 可见)
# =============================================================================
def log_info(message: str) -> None:
"""普通信息 —— 相当于「会进日志文件的 print」。"""
_emit(str(message))
def log_step(title: str, *, index: int = 0, total: int = 0) -> None:
"""记一个运行步骤:log_step("扫码登录", index=1, total=5) -> [步骤 1/5] 扫码登录"""
if index and total:
_emit("[步骤 " + str(index) + "/" + str(total) + "] " + title)
elif index:
_emit("[步骤 " + str(index) + "] " + title)
else:
_emit("[步骤] " + title)
def log_result(message: Any) -> None:
"""记一条运行结果。"""
_emit("[结果] " + (message if isinstance(message, str) else _fmt(message)))
def log_warning(message: str) -> None:
"""警告(不影响流程,但需要留意)。"""
_emit("[警告] " + str(message))
def log_error(message: str) -> None:
"""错误。"""
_emit("[错误] " + str(message))
def log_section(title: str) -> None:
"""记一个分节(带分隔线),用于主脚本分段。"""
_emit("\n" + "-" * 60 + "\n" + title + "\n" + "-" * 60)
def log_field(name: str, value: Any, *, debug_only: bool = False) -> None:
"""
记一个「字段: 值」;DEBUG 下 dict / list / VO 自动多行展开。
log_field("user_id", 12345)
log_field("请求体", {"userId": 12345}, debug_only=True) # 只在 DEBUG 出现
"""
if debug_only and not is_debug():
return
if is_debug() and (isinstance(value, (dict, list)) or hasattr(value, "__dataclass_fields__")):
_emit(name + ":")
_emit(_fmt_pretty(value))
return
_emit(name + ": " + _fmt(value))
def log_debug(message: Any) -> None:
"""调试信息 —— 仅 DEBUG 模式输出。"""
if not is_debug():
return
_emit("[调试] " + _fmt(message))
# =============================================================================
# 收发数据日志(DEBUG 专属)—— api 脚本用它记录「发送的数据 / 返回的数据」
# =============================================================================
def log_request(
url: str = "",
body: Any = None,
headers: Any = None,
*,
api_name: str = "",
) -> None:
"""
记录一次「向 api 发送的数据」(仅 DEBUG 模式输出)。
参数:
url: 完整请求地址
body: 请求体(dict / VO / JSON 字符串),展开成 JSON 全文
headers: 请求头(dict)
api_name: 接口名
"""
if not is_debug():
return
_emit("")
_emit("-> 请求 " + api_name if api_name else "-> 请求")
if url:
_emit(" URL : " + str(url))
if headers:
_emit(" 请求头 : " + _fmt(headers))
if body is not None:
_emit(" 请求体 :")
_emit(_fmt_pretty(body))
def log_response(
status: Any = None,
body: Any = None,
headers: Any = None,
*,
api_name: str = "",
elapsed_ms: Optional[int] = None,
) -> None:
"""
记录一次「api 返回的数据」(仅 DEBUG 模式输出)。
参数:
status: HTTP 状态码
body: 响应体(JSON 字符串 / dict / VO)
headers: 响应头(dict)
api_name: 接口名
elapsed_ms: 耗时(毫秒)
"""
if not is_debug():
return
_emit("")
_emit("<- 响应 " + api_name if api_name else "<- 响应")
if status is not None:
_emit(" 状态码 : " + str(status))
if elapsed_ms is not None:
_emit(" 耗时 : " + str(elapsed_ms) + " ms")
if headers:
_emit(" 响应头 : " + _fmt(headers))
if body is not None:
_emit(" 响应体 :")
_emit(_fmt_pretty(body))
def log_payload(label: str, data: Any) -> None:
"""记录任意报文 / 数据结构(仅 DEBUG 模式输出)。"""
if not is_debug():
return
_emit("")
_emit(" " + label + " :")
_emit(_fmt_pretty(data))
# =============================================================================
# 兼容:装饰器与 logger 对象
# =============================================================================
def api_log(func: Callable) -> Callable:
"""
api 函数装饰器 —— 与自动跟踪等价(重复包装会被跳过)。
一般【不需要】手写:api/__init__.py 的 autowrap 已经统一套好外壳。
这里保留是为了让 api 脚本单独运行(python -m api.xxx)时也能记录。
"""
if getattr(func, "__mai_logged__", False):
return func
return _make_tracked(func)
class _Logger:
"""兼容旧写法的 logger 对象;新代码直接用模块级函数即可。"""
def info(self, message: str) -> None:
log_info(message)
def warning(self, message: str) -> None:
log_warning(message)
def error(self, message: str) -> None:
log_error(message)
def debug(self, message: Any) -> None:
log_debug(message)
def step(self, title: str, **kwargs) -> None:
log_step(title, **kwargs)
def result(self, message: Any) -> None:
log_result(message)
_logger_instance = _Logger()
def get_logger() -> _Logger:
"""获取全局 logger 实例(等价于直接用模块级函数)。"""
return _logger_instance
# =============================================================================
# 退出时自动收尾
# =============================================================================
def _auto_teardown() -> None:
try:
if _session_started:
teardown_logging()
else:
_close_log_files()
except Exception:
pass
atexit.register(_auto_teardown)
-773
View File
@@ -1,773 +0,0 @@
# -*- coding: utf-8 -*-
"""
maimai_net.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
================================================================================
本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。
--------------------------------------------------------------------------------
一、协议依据(全部来自 source/ 反编译源码)
--------------------------------------------------------------------------------
1) 服务器基址与 API 路径
source/Assembly-CSharp/Manager/OperationManager.cs:225,338
ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
source/Assembly-CSharp/Net.Packet/Packet.cs:116
Client = NetHttpClient.Create(BaseUrl + Obfuscator(query.Api))
source/Assembly-CSharp/Net.Packet/Packet.cs:485
Obfuscator(s) = md5_hex_lower(s + "8bF76dE9")
source/Assembly-CSharp/Net.VO/NetQuery.cs:14
Api = api + "MaimaiChn"
=> 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "8bF76dE9")
已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "8bF76dE9")
= 83a5d5a20b062c5c2ad817460b8f7f76
(source/2026-10-05.log:1 的请求就是打到这个尾段)
2) 请求体封装(三段:JSON -> deflate -> AES)
source/Assembly-CSharp/Net/NetHttpClient.cs:262-300 RequestCallback
a. DeflateStream(CompressionMode.Compress) 压缩 JSON 字节
b. 手工拼 zlib 头 0x78 0x9C + 压缩数据 + Adler32(4 字节大端)
c. CipherAES.Encrypt(...) 整体 AES 加密
source/Assembly-CSharp/Net/CipherAES.cs:11-30
AES-256-CBC / PKCS7 / Key 32B / IV 16B
3) 响应体解封装(逆过程)
source/Assembly-CSharp/Net/NetHttpClient.cs:355-380 Decrypt + Decompress
a. AES 解密
b. 去掉前 2 字节 zlib 头与后 4 字节 Adler32
c. Deflate 解压 -> UTF-8 JSON
4) 请求头
source/Assembly-CSharp/Net/NetHttpClient.cs:174-190 Request()
Content-Type : application/json
charset : UTF-8
Mai-Encoding : 1.55 (DefaultHttpHeaderEncryption)
Content-Encoding: deflate (有请求体时)
User-Agent : <混淆后的API名>#<userId 或 clientId>
(source/Assembly-CSharp/Net/NetPacketUtil.cs:12-19)
number : <ping 毫秒>(非 Ping 接口;Packet.cs:189)
★ number 的语义:GameManager.PingMsec,只由 Ping 心跳包刷新
(PingChecker.cs:227 <- PacketPing.cs:34 的 sw.ElapsedMilliseconds)。
真机整局 53 个业务请求的 number 恒为同一个值(实测 63)。
所以调用方**应当先跑一次 Ping()**,之后的请求才会带上真实 number;
没跑过时 number = 0,与真机开机后第一屏的行为一致。
★ Cookie:★ UserLoginApi 的响应会下发
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
(Packet.cs:228-234 只在 UserLogin 响应上把它收进 CookieContainer,
之后 Packet.cs:190-193 每个带 userId 的请求都挂回去)。
Ping 等接口的响应头里没有 Set-Cookie,所以必须跑过登录才会拿到。
这是**真实的会话粘滞**,必须带着。
requests.Session 会自动收下并自动带上,无需额外代码 ——
但前提是**全程复用同一个 MaimaiNetClient**(用 get_default_client())。
--------------------------------------------------------------------------------
二、★ 一个必须注意的坑:Accept-Encoding 必须为空
--------------------------------------------------------------------------------
requests 默认会发 Accept-Encoding: gzip, deflate。
本协议响应体是 AES 密文(二进制),服务端若据此做内容编码,会返回空 body。
实测:不发该头 -> HTTP 200 / 0 字节;显式置空 -> 正常返回。
C# 端不需要是因为 HttpWebRequest 默认 AutomaticDecompression = None。
所以这里固定 Accept-Encoding: ""。
--------------------------------------------------------------------------------
三、对外接口
--------------------------------------------------------------------------------
MaimaiNetClient 客户端类(连接复用 + Cookie 管理)
get_default_client() 取全局默认客户端
request(...) 便捷函数:发一次请求并返回原始 JSON 字符串
request_vo(...) 便捷函数:发一次请求并返回映射后的 dataclass
加解密工具:aes_encrypt / aes_decrypt / deflate_compress / deflate_decompress
协议工具 :obfuscate_api_name / build_api_url / adler32
"""
from __future__ import annotations
import hashlib
import json
import time
import zlib
from dataclasses import dataclass, field, is_dataclass
from typing import Any, Dict, List, Optional, Type, TypeVar
from urllib.parse import urljoin
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from config import (
AES_KEY,
AES_IV,
BASE_URL,
CLIENT_ID,
MAI_ENCODING,
OBFUSCATE_PARAM,
TIMEOUT_LONG_MS,
TIMEOUT_MS,
)
from .log_setup import log_debug, log_error, log_request, log_response
# =============================================================================
# 协议常量
# =============================================================================
#: API 名后缀(NetQuery.cs:14 Api = api + "MaimaiChn")
API_SUFFIX = "MaimaiChn"
#: HTTP 头(NetHttpClient.cs:174-190)
HEADER_CONTENT_TYPE = "application/json"
HEADER_CHARSET = "UTF-8"
HEADER_CONTENT_ENCODING = "deflate"
#: AES 参数
AES_BLOCK_SIZE = 16
T = TypeVar("T")
# =============================================================================
# 一、协议工具
# =============================================================================
def obfuscate_api_name(api_name: str) -> str:
"""
对逻辑接口名做 MD5 混淆,得到 URL 尾段(对应 Packet.Obfuscator)。
逻辑名 "GetUserPreviewApi" -> "GetUserPreviewApiMaimaiChn"
-> md5(上面 + "8bF76dE9")
参数:
api_name: 逻辑接口名,**不要**自己带 MaimaiChn 后缀
返回:
32 位小写十六进制字符串
"""
full_name = api_name if api_name.endswith(API_SUFFIX) else api_name + API_SUFFIX
return hashlib.md5((full_name + OBFUSCATE_PARAM).encode("utf-8")).hexdigest()
def build_api_url(api_name: str) -> str:
"""构建完整的请求 URL。"""
return urljoin(BASE_URL, obfuscate_api_name(api_name))
def build_user_agent(api_name: str, user_id: int = 0, client_id: str = CLIENT_ID) -> str:
"""
构建 User-Agent(对应 NetPacketUtil.GetUserAgent)。
userId != 0 -> "<混淆API名>#<userId>"
userId == 0 -> "<混淆API名>#<clientId>"
"""
tail = user_id if user_id else client_id
return obfuscate_api_name(api_name) + "#" + str(tail)
def adler32(data: bytes) -> int:
"""
计算 Adler-32 校验和(对应 NetHttpClient.Adler32:437-452)。
★ 注意与 zlib.adler32 的区别:C# 端是自己实现的,
返回值是 (b << 16) + a,与 zlib.adler32 一致,这里保留原实现以便对照。
"""
a = 1
b = 0
for byte in data:
a = (a + byte) % 65521
b = (b + a) % 65521
return (b << 16) | a
# =============================================================================
# 二、加解密 / 压缩
# =============================================================================
def aes_encrypt(data: bytes) -> bytes:
"""AES-256-CBC 加密(PKCS7 填充)。对应 CipherAES.Encrypt。"""
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
return cipher.encrypt(pad(data, AES_BLOCK_SIZE))
def aes_decrypt(data: bytes) -> bytes:
"""AES-256-CBC 解密(PKCS7 去填充)。对应 CipherAES.Decrypt。"""
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
return unpad(cipher.decrypt(data), AES_BLOCK_SIZE)
def deflate_compress(data: bytes) -> bytes:
"""
原始 deflate 压缩 + 手工拼 zlib 头与 Adler32。
对应 NetHttpClient.cs:262-300 RequestCallback:
memoryStream.WriteByte(120); // 0x78
memoryStream.WriteByte(156); // 0x9C
... 原始 deflate 数据 ...
... Adler32 大端 4 字节 ...
★ C# 的 DeflateStream 产生的是**原始 deflate**(无 zlib 头),
所以 Python 侧要用 compressobj(wbits=-zlib.MAX_WBITS)。
"""
compressor = zlib.compressobj(wbits=-zlib.MAX_WBITS)
compressed = compressor.compress(data) + compressor.flush()
checksum = adler32(data)
return bytes([0x78, 0x9C]) + compressed + checksum.to_bytes(4, "big")
def deflate_decompress(data: bytes) -> bytes:
"""
去掉 zlib 头与 Adler32 后做原始 deflate 解压。
对应 NetHttpClient.cs:355-380 Decompress:
_temporaryStream.Position = 2; // 跳过 2 字节头
_temporaryStream.SetLength(_temporaryStream.Length - 4); // 去掉 4 字节校验
"""
if len(data) < 6:
return b""
return zlib.decompress(data[2:-4], -zlib.MAX_WBITS)
# =============================================================================
# 三、真机时间戳
# =============================================================================
def local_utc_offset_seconds() -> int:
"""取本机时区相对 UTC 的偏移秒数(东八区 = +28800)。"""
offset = -time.timezone
if time.daylight and time.localtime().tm_isdst > 0:
offset = -time.altzone
return int(offset)
def login_date_time_now() -> int:
"""
生成真机 UserLoginApi 的 loginDateTime。
★ 真机算法(source/Assembly-CSharp/MAI2/DateTime.cs + PacketUserLogin.cs:29):
MAI2.DateTime.Now 返回 DateTime.UtcNow + LocalOffset,Kind = Unspecified;
new DateTimeOffset(该 Unspecified 值) 会再按本地时区加一次偏移,
最终 ToUnixTimeSeconds() = 标准 Unix 秒 + 本地时区偏移。
东八区即 now + 28800。
★ 为什么必须一致:
PacketUserLogin.cs:43 会把请求值回填进响应,
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
与登录时不一致会被服务端拒绝。
"""
return int(time.time()) + local_utc_offset_seconds()
# =============================================================================
# 四、VO 序列化 / 反序列化
# =============================================================================
def vo_to_json(obj: Any) -> str:
"""
把请求对象序列化成紧凑 JSON(对应 VOSerializer.Serialize)。
规则:
- dataclass -> 按字段声明顺序,跳过值为 None 的字段
- 带 __dict__ 的对象 -> 同上
- dict / 其他 -> 直接 json.dumps
"""
if obj is None:
return "{}"
if is_dataclass(obj) and not isinstance(obj, type):
data = {k: getattr(obj, k) for k in obj.__dataclass_fields__}
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
if isinstance(obj, dict):
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
if hasattr(obj, "__dict__"):
data = {k: v for k, v in vars(obj).items() if v is not None and not k.startswith("_")}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
def _json_default(value: Any) -> Any:
"""json.dumps 的兜底转换:dataclass / 普通对象 -> dict。"""
if is_dataclass(value) and not isinstance(value, type):
return {k: getattr(value, k) for k in value.__dataclass_fields__}
if hasattr(value, "__dict__"):
return {k: v for k, v in vars(value).items() if not k.startswith("_")}
return str(value)
def _convert_value(value: Any, target_type: Any) -> Any:
"""按目标类型递归转换嵌套 dataclass。"""
if value is None:
return None
if isinstance(target_type, type) and is_dataclass(target_type) and isinstance(value, dict):
kwargs = {}
for fname, fdef in target_type.__dataclass_fields__.items():
if fname in value:
kwargs[fname] = _convert_value(value[fname], fdef.type)
return target_type(**kwargs)
origin = getattr(target_type, "__origin__", None)
if origin is list and isinstance(value, list):
args = getattr(target_type, "__args__", None)
if args and isinstance(args[0], type) and is_dataclass(args[0]):
return [_convert_value(v, args[0]) for v in value]
return value
return value
def vo_from_json(cls: Type[T], json_str: str) -> T:
"""
把响应 JSON 反序列化成 dataclass(对应 VOSerializer.Deserialize)。
★ 只填充类里声明过的字段 —— 服务端多给的字段会被忽略,不会报错。
"""
data = json.loads(json_str)
if not isinstance(data, dict):
return data # type: ignore[return-value]
if is_dataclass(cls):
kwargs = {}
for fname, fdef in cls.__dataclass_fields__.items():
if fname in data:
kwargs[fname] = _convert_value(data[fname], fdef.type)
return cls(**kwargs)
obj = cls() # type: ignore[call-arg]
for k, v in data.items():
if hasattr(obj, k):
setattr(obj, k, v)
return obj
# =============================================================================
# 五、网络客户端
# =============================================================================
class MaimaiNetError(RuntimeError):
"""网络层 / 协议层错误。"""
class MaimaiNetClient:
"""
舞萌DX 游戏服务器客户端。
一次完整请求的流程:
请求方向:VO -> 紧凑 JSON -> 原始 deflate -> 0x78 0x9C + Adler32 -> AES-256-CBC -> HTTP POST
响应方向:HTTP Body -> AES 解密 -> 去 2 字节头 / 4 字节尾 -> deflate 解压 -> JSON -> VO
Cookie 由 requests.Session 自动管理;登录接口返回的 Set-Cookie 会被保存,
后续请求自动带上(对应 C# 的 CookieContainer,Packet.cs:183-190)。
"""
def __init__(
self,
base_url: str = BASE_URL,
timeout_ms: int = TIMEOUT_MS,
verify_ssl: bool = False,
) -> None:
self.base_url = base_url.rstrip("/") + "/"
self.timeout = timeout_ms / 1000.0
self.verify_ssl = verify_ssl
self.session = requests.Session()
#: ★ number 头的值(Packet.cs:189 GameManager.PingMsec)。
#: 只由 **Ping 心跳包**更新(对应 PingChecker.cs:227 PingSuccess),
#: 普通业务请求不会改动它 —— 真机整局 53 个请求的 number 恒为 63。
self.ping_msec: int = 0
if not verify_ssl:
try:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except Exception:
pass
# -------------------------------------------------------------------------
# 内部
# -------------------------------------------------------------------------
def _build_headers(
self,
api_name: str,
user_id: int = 0,
client_id: str = CLIENT_ID,
has_body: bool = True,
ping_msec: Optional[int] = None,
) -> Dict[str, str]:
"""
构建请求头(对应 NetHttpClient.Request:174-190 + Packet.ProcImpl:166-176)。
★ 关于 number 头:
source/Assembly-CSharp/Net.Packet/Packet.cs:187-194
if (!IsPingPacket())
{
Client.AddHeader("number", GameManager.PingMsec.ToString());
if (Query.UserId != 0L) { Client.AddCookie(Query.UserId); }
}
即**除 Ping 外的每个接口**都会带一个 number 头,值是本机到服务器的
ping 毫秒数(GameManager.PingMsec)。
★ number 的来源(源码链路,逐条可查):
Main/PingChecker.cs:166 StartPacket(new PacketPing(PingSuccess, PingError))
Net.Packet.Mai2/PacketPing.cs:34
_onDone((int)sw.ElapsedMilliseconds)
→ 值 = **Ping 包的往返毫秒**
Main/PingChecker.cs:227 GameManager.PingMsec = pingMsec
所以 number **只由 Ping 心跳刷新**,普通业务请求不会改它。
实测(gamehook 抓真机一局 70 个请求):
#1 ~ #10 number = 0 ← 还没跑过 Ping
#11 ~ #17 Ping 心跳(不带 number 头)
#18 ~ #70 number = 63 ← Ping 往返 63ms,此后恒定
本类在 request() 里对 Ping 包做同样的事:把往返毫秒写进 self.ping_msec。
★ 关于 Cookie(Packet.cs:190-193 + 228-234):
Client.AddCookie(Query.UserId) 把「登录时服务端 Set-Cookie 存下来的
CookieContainer」挂到本次请求上(NetHttpClient.cs:170-177)。
★ 实测 **UserLoginApi 的响应头里确实有**:
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
而 Ping 等接口的响应头里没有 Set-Cookie(只有 Date / Content-Type /
Content-Length / Connection / Pragma / Cache-Control)。
所以「登录前没有 Cookie、登录后一直带着 JSESSIONID」正是真机行为。
Cookie 由 requests.Session 自动管理:收到就存、后续自动带 —— 无需额外代码,
但**必须全程复用同一个 MaimaiNetClient**,否则会丢掉 JSESSIONID。
"""
headers = {
"Content-Type": HEADER_CONTENT_TYPE,
"charset": HEADER_CHARSET,
"Mai-Encoding": MAI_ENCODING,
"User-Agent": build_user_agent(api_name, user_id, client_id),
# ★ 必须显式置空,见模块 docstring 第二节
"Accept-Encoding": "",
}
if has_body:
headers["Content-Encoding"] = HEADER_CONTENT_ENCODING
# ★ number 头(Packet.cs:189):除 Ping 外的接口都要带
if not api_name.startswith("Ping"):
value = self.ping_msec if ping_msec is None else ping_msec
headers["number"] = str(int(value))
return headers
@property
def last_ping_msec(self) -> int:
"""number 头的当前值(= 最近一次 Ping 的往返毫秒)。兼容旧字段名。"""
return self.ping_msec
def _encrypt_request(self, json_str: str) -> bytes:
"""JSON -> deflate(zlib头+Adler32) -> AES。"""
return aes_encrypt(deflate_compress(json_str.encode("utf-8")))
def _decrypt_response(self, encrypted: bytes) -> str:
"""AES -> 去头尾 -> deflate -> UTF-8 字符串。"""
return deflate_decompress(aes_decrypt(encrypted)).decode("utf-8")
# -------------------------------------------------------------------------
# 公开
# -------------------------------------------------------------------------
def request(
self,
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> str:
"""
发送一次请求,返回**原始 JSON 响应字符串**。
参数:
api_name: 逻辑接口名(如 "GetUserPreviewApi",不要带 MaimaiChn)
request_vo: 请求体对象(dataclass / dict / None 表示空对象 {})
user_id: 用户 ID(0 表示未登录请求;影响 User-Agent 与 Cookie)
client_id: 客户端 ID
timeout_ms: 超时覆盖(毫秒)
extra_headers: 额外请求头(如 number)
返回:
响应 JSON 字符串
异常:
MaimaiNetError: 网络异常 / 空响应 / 解密或解压失败
"""
url = urljoin(self.base_url, obfuscate_api_name(api_name))
timeout = (timeout_ms or self.timeout * 1000) / 1000.0
request_json = vo_to_json(request_vo)
body = self._encrypt_request(request_json)
headers = self._build_headers(api_name, user_id, client_id, has_body=True)
if extra_headers:
headers.update(extra_headers)
# ★ DEBUG 模式:记录「向 api 发送的数据」
log_request(url=url, body=request_json, headers=headers, api_name=api_name)
t0 = time.monotonic()
try:
response = self.session.post(
url,
data=body,
headers=headers,
timeout=timeout,
verify=self.verify_ssl,
)
except requests.exceptions.RequestException as exc:
log_error("接口 " + api_name + " 请求失败:" + str(exc))
raise MaimaiNetError("接口 " + api_name + " 请求失败:" + str(exc)) from exc
elapsed_ms = int((time.monotonic() - t0) * 1000)
# ★ 只有 Ping 包会刷新 number(对应 PingChecker.cs:224-229 PingSuccess)。
# 普通业务包不动它,否则 number 会随每个包的耗时抖动,与真机不符。
if api_name.startswith("Ping"):
self.ping_msec = elapsed_ms
content = response.content
# 0 字节响应:请求未进入业务层
if len(content) == 0:
detail = (
"接口 " + api_name + " 返回 HTTP " + str(response.status_code)
+ ",响应体为 0 字节(空响应)。\n"
" 含义:服务端没有返回任何内容,请求很可能未进入业务层。\n"
" 排查:userId/token 是否有效、接口名与请求体字段是否与源码一致、"
"Accept-Encoding 是否被置空。\n"
" 响应头:" + repr(dict(response.headers))
)
log_error(detail)
raise MaimaiNetError(detail)
try:
response_json = self._decrypt_response(content)
except ValueError as exc:
detail = (
"接口 " + api_name + " 响应解密失败(HTTP " + str(response.status_code)
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
" 含义:服务器返回了内容,但不是合法的 AES 密文,或长度不是 16 的倍数。"
)
log_error(detail)
raise MaimaiNetError(detail) from exc
except zlib.error as exc:
detail = (
"接口 " + api_name + " 响应解压失败(HTTP " + str(response.status_code)
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
" 含义:AES 解密成功但 deflate 解压失败,响应不是预期的压缩格式"
"(可能是网关/防火墙返回的页面)。"
)
log_error(detail)
raise MaimaiNetError(detail) from exc
if not response_json:
detail = (
"接口 " + api_name + " 返回空明文(HTTP " + str(response.status_code)
+ ",密文 " + str(len(content)) + " 字节,解密后为空串)。\n"
" 含义:链路与加密都正常,但服务端没有返回内容。\n"
" 排查:userId / token 是否失效、keychip 与该卡是否匹配、该卡是否正在游戏中。"
)
log_error(detail)
raise MaimaiNetError(detail)
# ★ DEBUG 模式:记录「api 返回的数据」
try:
shown_body: Any = json.loads(response_json)
except Exception:
shown_body = response_json
log_response(
status=response.status_code,
body=shown_body,
headers={k: v for k, v in response.headers.items()},
api_name=api_name,
elapsed_ms=elapsed_ms,
)
return response_json
def request_vo(
self,
api_name: str,
request_vo: Any,
response_cls: Type[T],
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> T:
"""发送请求并把响应映射成 dataclass。"""
raw = self.request(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
return vo_from_json(response_cls, raw)
def request_dict(
self,
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""
发送请求并返回**原始响应 dict**。
★ 取数脚本一律用这个:upsertUserAll 需要响应原文,
映射成 dataclass 会丢掉服务端多给的字段。
"""
raw = self.request(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
data = json.loads(raw)
return data if isinstance(data, dict) else {"_data": data}
def close(self) -> None:
"""关闭底层会话。"""
try:
self.session.close()
except Exception:
pass
def __enter__(self) -> "MaimaiNetClient":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
# =============================================================================
# 六、全局默认客户端
# =============================================================================
_default_client: Optional[MaimaiNetClient] = None
def get_default_client() -> MaimaiNetClient:
"""获取(必要时创建)全局默认客户端 —— 连接与 Cookie 会自动复用。"""
global _default_client
if _default_client is None:
_default_client = MaimaiNetClient()
return _default_client
def close_default_client() -> None:
"""关闭全局默认客户端(一般不需要手动调用)。"""
global _default_client
if _default_client is not None:
_default_client.close()
_default_client = None
def request(
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> str:
"""便捷函数:用默认客户端发一次请求,返回原始 JSON 字符串。"""
net = client or get_default_client()
return net.request(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
def request_dict(
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""便捷函数:用默认客户端发一次请求,返回原始响应 dict。"""
net = client or get_default_client()
return net.request_dict(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
def request_vo(
api_name: str,
request_vo: Any,
response_cls: Type[T],
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> T:
"""便捷函数:用默认客户端发一次请求,返回映射后的 dataclass。"""
net = client or get_default_client()
return net.request_vo(
api_name, request_vo, response_cls,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
# =============================================================================
# 七、通用请求 / 响应 VO
# =============================================================================
@dataclass
class EmptyRequestVO:
"""空请求体 —— 对应 C# 里没有任何字段的 RequestVO(如 PingRequestVO)。"""
@dataclass
class UserIdRequestVO:
"""只有 userId 的请求体(对应 UserDetailRequestVO / UserCharacterRequestVO 等)。"""
userId: int
@dataclass
class ReturnCodeVO:
"""通用「只有 returnCode」的响应体。"""
returnCode: int = 0
# =============================================================================
# 单独运行:协议自检(不发业务请求)
# =============================================================================
if __name__ == "__main__":
print("=== maimai_net 协议自检 ===")
print("BASE_URL :", BASE_URL)
print("GetGameSettingApi :", build_api_url("GetGameSettingApi"))
print(" 期望尾段 : 83a5d5a20b062c5c2ad817460b8f7f76")
print("User-Agent 示例 :", build_user_agent("UserLoginApi", 12345678))
sample = '{"userId":12345678,"token":"abc"}'
packed = deflate_compress(sample.encode("utf-8"))
print("deflate 头/尾 :", packed[:2].hex(), packed[-4:].hex(),
" Adler32 =", hex(adler32(sample.encode("utf-8"))))
assert deflate_decompress(packed).decode("utf-8") == sample
enc = aes_encrypt(packed)
assert aes_decrypt(enc) == packed
print("AES 往返 : OK(密文", len(enc), "字节,16 的倍数:", len(enc) % 16 == 0, ")")
print("自检通过")
-95
View File
@@ -1,95 +0,0 @@
# -*- coding: utf-8 -*-
"""
ping.py —— 心跳探活(PingMaimaiChn)
================================================================================
功能:向游戏服务器发一次心跳,确认链路可达。
对应接口:PingMaimaiChn
请求体 :{}(source/Assembly-CSharp/Net.VO.Mai2/PingRequestVO.cs 是空类)
响应 :{"result": "Pong"}
★ 接口名(容易写错,注意):
source/Assembly-CSharp/Net.Packet.Mai2/PacketPing.cs:22
new NetQuery<PingRequestVO, PingResponseVO>("Ping", 0uL);
客户端传的逻辑名是 **"Ping"**,NetQuery.cs:14 再补上 "MaimaiChn" 后缀,
所以混淆用的全名是 "PingMaimaiChn":
md5("PingMaimaiChn" + "8bF76dE9") = 3bae8c1162b3148abcfc38a89c03e3e7
本模块传 "Ping" 即可,obfuscate_api_name 会自动补后缀。
(Packet.cs:96 用 Query.Api.StartsWith("Ping") 判断是否心跳包,
也是基于补完后缀的 "PingMaimaiChn"。)
★ 为什么要跑它(不只是探活):
Packet.cs:189 非 Ping 接口都会带 HTTP 头 number = GameManager.PingMsec
PingChecker.cs:227 GameManager.PingMsec = Ping 包的往返毫秒
所以 **Ping 是 number 头的唯一来源**。先 Ping 一次,后面的业务请求才会
带上真实 number(真机实测 63ms);不 Ping 则 number = 0,
与真机开机后第一屏(还没跑过心跳)的行为一致。
MaimaiNetClient 在收到 Ping 响应后会自动把往返毫秒记进 client.ping_msec。
调用示例:
from api import Ping
if Ping():
print("服务器可达")
"""
from __future__ import annotations
from typing import Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict
def Ping(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> bool:
"""
心跳探活。
参数:
client_id: 机台短值(可选)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
True = 服务器返回 "Pong"
"""
log_info("发送心跳探活")
raw = request_dict(
"Ping", {},
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
result = raw.get("result", "")
ok = (result == "Pong")
if ok:
log_info("心跳正常:result=Pong")
else:
log_warning("心跳返回异常:result=" + str(result))
return ok
if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("ping_test")
try:
ok = Ping()
print("")
print("服务器可达:" + str(ok))
return 0 if ok else 1
finally:
teardown_logging()
raise SystemExit(_main())
-319
View File
@@ -1,319 +0,0 @@
# -*- coding: utf-8 -*-
"""
upsert_snapshot.py —— 结算快照编排(统一调用各取数脚本)
================================================================================
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(userdata.py / user_map.py …)。
--------------------------------------------------------------------------------
为什么需要快照
--------------------------------------------------------------------------------
服务器对 userData 是**整表覆盖**语义,对差分节点是**只回传变化项**语义。
要把本局的变化正确表达出来,必须先有一份「登录时的服务器状态」作基线,
再把本局的影响叠加到基线上 —— 这就是快照的作用。
真机对照(source/2026-10-05.log):
| 节点 | Get 侧(基线) | Upsert 侧(本局后) |
|---------------------|-------------------|----------------------|
| userData.playCount | 1257 | 1258 (+1) |
| userMapList | 104 条 | 仅 1 条(distance 变)|
| userMusicDetailList | 739 组 | 仅 1 条(playCount+1)|
| userItemList | 1070 条 | [](无变化) |
--------------------------------------------------------------------------------
调用示例
--------------------------------------------------------------------------------
from api import fetch_upsert_snapshot
snapshot = fetch_upsert_snapshot(user_id, token=token)
# 之后直接传给 UpsertUserAll(snapshot=snapshot, ...)
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Sequence
from config import CLIENT_ID, PLACE_ID, PLAY_SHOP_ITEM_IDS, REGION_ID, TIMEOUT_MS
from .log_setup import log_debug, log_info, log_warning
from .maimai_net import MaimaiNetClient
# 各取数脚本(一个接口一个脚本)
from .userdata import UserData
from .user_extend import UserExtend
from .user_option import UserOption
from .user_character import UserCharacter
from .user_ghost import UserGhost
from .user_map import UserMapAll
from .user_login_bonus import UserLoginBonusAll
from .user_rating import UserRating
from .user_item import UserItemAll
from .user_music import UserMusicDetailAll
from .user_course import UserCourseAll
from .user_charge import UserCharge
from .user_favorite import UserFavoriteAll
from .user_activity import UserActivity
from .user_mission import UserMissionData
from .user_intimate import UserIntimate
from .user_shop_stock import UserShopStock
from .user_favorite_item import UserFavoriteItemAll
from .user_kaleidx_scope import UserKaleidxScope
#: 默认要抓的节点(顺序即抓取顺序)
#: ★ 只列 UpsertUserAll 真正需要的;纯展示类接口(排行/活动/大会…)不在此列。
DEFAULT_SNAPSHOT_NODES: tuple = (
"userData",
"userExtend",
"userOption",
"userCharacterList",
"userMapList",
"userLoginBonusList",
"userRatingList",
"userItemList",
"userMusicDetailList",
"userCourseList",
"userChargeList",
"userFavoriteList",
"userActivityList",
"userMissionDataList",
"userIntimateList",
"userShopItemStockList",
"userFavoritemusicList",
"userKaleidxScopeList",
"userGhost",
)
def fetch_upsert_snapshot(
user_id: int,
*,
token: str = "",
client_id: str = CLIENT_ID,
place_id: int = PLACE_ID,
region_id: int = REGION_ID,
shop_item_ids: Optional[Sequence[int]] = None,
nodes: Optional[Sequence[str]] = None,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
strict: bool = False,
) -> Dict[str, Any]:
"""
抓取结算所需的全部基线数据。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(可选,多数取数接口不需要)
client_id: 机台短值
place_id: 店铺 ID
region_id: 区域 ID
shop_item_ids: 商店商品清单(可选,默认 config.PLAY_SHOP_ITEM_IDS)
nodes: 要抓的节点(可选,默认 DEFAULT_SNAPSHOT_NODES)
timeout_ms: 超时毫秒
client: 可复用的 MaimaiNetClient
strict: True = 任一接口失败就抛异常;
False(默认)= 记录失败并跳过,保证链路不崩
返回:
{
"userData": <GetUserDataApi 原始响应>,
"userExtend": <GetUserExtendApi 原始响应>,
"userMapList": {"userMapList": [...]}, # 已摊平/翻页
"userMusicDetailList": {"userMusicList": [...]},# 已摊平成单条
...
"_failed": [ {"node":.., "error":..}, ... ]
}
这个结构可以直接传给 UpsertUserAll(snapshot=...),也可 json 落盘。
"""
wanted = list(nodes) if nodes else list(DEFAULT_SNAPSHOT_NODES)
log_info("抓取结算快照:user_id=" + str(user_id) + " 节点数=" + str(len(wanted)))
raw: Dict[str, Any] = {}
failed: List[Dict[str, str]] = []
def _call(node: str, fn, *args, **kwargs):
"""逐节点容错:单个接口失败不影响整体。"""
try:
raw[node] = fn(*args, **kwargs)
return True
except Exception as exc:
failed.append({"node": node, "error": type(exc).__name__ + ": " + str(exc)})
log_warning("节点 " + node + " 抓取失败:" + type(exc).__name__ + ": " + str(exc))
if strict:
raise
return False
common = dict(client_id=client_id, timeout_ms=timeout_ms, client=client)
# ---- 逐个抓取(顺序与 DEFAULT_SNAPSHOT_NODES 一致)----
if "userData" in wanted:
_call("userData", UserData, user_id, **common)
if "userExtend" in wanted:
_call("userExtend", UserExtend, user_id, **common)
if "userOption" in wanted:
_call("userOption", UserOption, user_id, **common)
if "userCharacterList" in wanted:
_call("userCharacterList", UserCharacter, user_id, **common)
if "userMapList" in wanted:
_call("userMapList", UserMapAll, user_id, **common)
if "userLoginBonusList" in wanted:
_call("userLoginBonusList", UserLoginBonusAll, user_id, **common)
if "userRatingList" in wanted:
_call("userRatingList", UserRating, user_id, **common)
if "userItemList" in wanted:
_call("userItemList", UserItemAll, user_id, **common)
if "userMusicDetailList" in wanted:
_call("userMusicDetailList", UserMusicDetailAll, user_id, **common)
if "userCourseList" in wanted:
_call("userCourseList", UserCourseAll, user_id, **common)
if "userChargeList" in wanted:
_call("userChargeList", UserCharge, user_id, **common)
if "userFavoriteList" in wanted:
# ★ 该接口额外带一份**原始** itemKind 副本(_raw),
# 供 UpsertUserAll 生成 isNewFavoriteList 位串用
# —— 真机 itemKind=10 回的是 null,正是它产生了位串里的 "1"。
if _call("userFavoriteList", UserFavoriteAll, user_id, **common):
fav_raw = (raw.get("userFavoriteList") or {}).get("_raw")
if fav_raw:
raw["_raw_favorite"] = fav_raw
if "userActivityList" in wanted:
_call("userActivityList", UserActivity, user_id, **common)
if "userMissionDataList" in wanted:
# ★ 该接口的响应里同时带着 userWeeklyData(周界),
# 它是 Upsert 侧 userWeeklyData 节点的来源,会被自动提取。
_call("userMissionDataList", UserMissionData, user_id, **common)
if "userIntimateList" in wanted:
_call("userIntimateList", UserIntimate, user_id, **common)
if "userShopItemStockList" in wanted:
ids = list(shop_item_ids) if shop_item_ids is not None else list(PLAY_SHOP_ITEM_IDS)
if ids:
_call("userShopItemStockList", UserShopStock, user_id,
shop_item_id_list=ids, **common)
else:
log_debug("未配置 PLAY_SHOP_ITEM_IDS,跳过商店库存节点")
if "userFavoritemusicList" in wanted:
_call("userFavoritemusicList", UserFavoriteItemAll, user_id, 1, **common)
if "userKaleidxScopeList" in wanted:
_call("userKaleidxScopeList", UserKaleidxScope, user_id, **common)
if "userGhost" in wanted:
_call("userGhost", UserGhost, user_id, **common)
raw["_failed"] = failed
log_info("结算快照抓取完成:成功 " + str(len(raw) - 1) + " 个节点,失败 "
+ str(len(failed)) + " 个")
return raw
def save_snapshot(snapshot: Dict[str, Any], path: str) -> str:
"""把快照写到 JSON 文件(便于离线复用 / 人工核对)。"""
with open(path, "w", encoding="utf-8") as fp:
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
log_info("快照已保存:" + path)
return path
def load_snapshot(path: str) -> Dict[str, Any]:
"""从 JSON 文件读回快照。"""
with open(path, "r", encoding="utf-8") as fp:
data = json.load(fp)
log_info("快照已加载:" + path)
return data
def summarize_snapshot(snapshot: Dict[str, Any]) -> Dict[str, int]:
"""
统计快照各节点的条数,便于快速核对(返回 {节点名: 条数})。
条数含义:
userData 这类单元素数组 -> 1
userMusicDetailList 这类列表 -> 摊平后的条数
"""
out: Dict[str, int] = {}
for node, value in (snapshot or {}).items():
if node.startswith("_"):
continue
if isinstance(value, list):
out[node] = len(value)
elif isinstance(value, dict):
# 从响应里挑出列表
found = False
for key, sub in value.items():
if isinstance(sub, list):
out[node] = len(sub)
found = True
break
if not found:
out[node] = 1
else:
out[node] = 0
return out
# =============================================================================
# 单独运行(调试)
# =============================================================================
if __name__ == "__main__":
import sys
from .log_setup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("upsert_snapshot_test")
try:
if len(sys.argv) < 2:
print("用法: python -m api.upsert_snapshot <user_id> [--token TOKEN] [--save 路径]")
return 1
user_id = int(sys.argv[1])
token = ""
save_path = ""
args = sys.argv[2:]
i = 0
while i < len(args):
if args[i] == "--token" and i + 1 < len(args):
token = args[i + 1]
i += 2
elif args[i] == "--save" and i + 1 < len(args):
save_path = args[i + 1]
i += 2
else:
i += 1
snap = fetch_upsert_snapshot(user_id, token=token)
summary = summarize_snapshot(snap)
print("")
for node, count in summary.items():
print(" " + node.ljust(28) + "= " + str(count) + " 条")
if save_path:
save_snapshot(snap, save_path)
return 0
except Exception as exc:
log_error("抓取快照失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
File diff suppressed because it is too large Load Diff
-531
View File
@@ -1,531 +0,0 @@
# -*- coding: utf-8 -*-
r"""
upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
================================================================================
功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。
对应接口:UpsertUserChargelogApi
请求体 :UserChargelogRequestVO(4 字段,顺序照 C# 声明)
source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs
public ulong userId;
public UserChargelog userChargelog;
public UserCharge userCharge;
public long loginDateTime;
响应 :{returnCode, apiName}
--------------------------------------------------------------------------------
一、真机的购买流程(源码逐行可查)
--------------------------------------------------------------------------------
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
├─ ticket.ticketKind.id == 1(付费票)
├─ 从 OperationManager.GetGameChargeDataList() 找该 chargeId 的 price
│ (= GetGameChargeApi 的响应)
├─ TicketManager.cs:207 SetBuyPaidTicketData(playerId, ticketId)
│ ├─ ChargeList 里已有该 chargeId -> stock++、更新 purchaseDate / validDate
│ └─ 没有 -> 新增一条 stock=1
└─ TicketManager.cs:234 RequestUserCharge(playerId)
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
│ -> 取**购买后**(stock > 0)的那条 UserCharge
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
│ -> chargeId = ticketId
│ price = DataManager.GetTicket(ticketId).creditNum ★ 不是 money price
│ purchaseDate = TimeManager.GetNowDateString()
│ playCount = 玩家总游玩次数
│ playerRating = 玩家 Rating
│ placeId / regionId / clientId = 当前机台
└─ 发 UpsertUserChargelogApi
★ 成功后 TicketManager.cs:255 RequestSuccess 会把本地 stock 再 --,
因为那张票马上就被这一局用掉了。本模块只负责「买」,
所以默认传的是**购买后**的库存(现有 + 1)。
--------------------------------------------------------------------------------
二、票的有效期算法
--------------------------------------------------------------------------------
Manager/TicketManager.cs:564
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
真机验证(source/2026-10-05.log:123 的 userChargeList):
chargeId=5 purchaseDate="2026-10-04 18:22:10"
-> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02
-> validDate="2027-01-02 04:00:00" ✅ 完全吻合
--------------------------------------------------------------------------------
三、ticket.creditNum 从哪来
--------------------------------------------------------------------------------
游戏本地主数据:
<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
已抄进 config.TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
--------------------------------------------------------------------------------
四、安全闸门
--------------------------------------------------------------------------------
本接口会**真实写入一条购买记录**,所以默认不发包:
UpsertUserChargelog(...) -> 抛 ChargelogConfirmError
UpsertUserChargelog(..., dry_run=True) -> 只构造报文
UpsertUserChargelog(..., confirm=True) -> 真实发送
全局开关:config.UPSERT_CHARGELOG_CONFIRM(默认 False)
--------------------------------------------------------------------------------
五、调用示例
--------------------------------------------------------------------------------
from api import setup_logging, ParseQRCode, UserLogin, UserLogout,
LogoutType, UpsertUserChargelog
setup_logging("买票")
user_id, token = ParseQRCode(qr)
login = UserLogin(user_id, token)
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
UserLogout(user_id, token, login.loginDateTime, logout_type=LogoutType.TestIn)
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Union
import config
from config import (
CLIENT_ID,
PLACE_ID,
REGION_ID,
TICKET_CREDIT_NUM,
TICKET_NAMES,
TICKET_VALID_DAYS,
TICKET_VALID_HOUR_OFFSET,
TIMEOUT_MS,
UPSERT_CHARGELOG_CONFIRM,
)
from .log_setup import log_debug, log_error, log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict
from .upsert_user_all import UpsertResponseVO, resolve_api_func
class ChargelogConfirmError(RuntimeError):
"""有副作用的接口被安全闸门拦下(此时**未发包**)。"""
class ChargelogGuardError(RuntimeError):
"""
安全检查未通过(此时**未发包**)。
★ 目前唯一的触发条件:**账号当前已经持有功能票**。
真机行为:`UseTicketPrepare` 只在「本局要用这张票」时才走购买分支,
而一张票买到手后会立刻被本局用掉(`RequestSuccess` 里 stock--)。
所以正常状态下 `userChargeList` 里的 stock 全是 0。
如果账号**已经持有**一张功能票还去发购买请求,
服务端看到的是「重复购买 / 状态矛盾」,有**封号风险**。
所以本模块默认硬拦。
"""
# =============================================================================
# 一、VO(字段顺序照 source/Assembly-CSharp/Net.VO.Mai2/*.cs 声明)
# =============================================================================
@dataclass
class UserChargeVO:
"""UserCharge —— 一张票的持有状态(UserCharge.cs,4 字段)。"""
chargeId: int = 0
stock: int = 0
purchaseDate: str = ""
validDate: str = ""
@dataclass
class UserChargelogVO:
"""UserChargelog —— 一条购买记录(UserChargelog.cs,8 字段)。"""
chargeId: int = 0
price: int = 0
purchaseDate: str = ""
playCount: int = 0
playerRating: int = 0
placeId: int = 0
regionId: int = 0
clientId: str = ""
@dataclass
class UserChargelogRequestVO:
"""请求 VO(UserChargelogRequestVO.cs,4 字段,顺序照声明)。"""
userId: int
userChargelog: UserChargelogVO
userCharge: UserChargeVO
loginDateTime: int = 0
# =============================================================================
# 二、工具
# =============================================================================
def ticket_name(charge_id: int) -> str:
"""取票名(只用于日志)。"""
return TICKET_NAMES.get(int(charge_id), "未知票(" + str(charge_id) + ")")
def ticket_price(charge_id: int, override: Optional[int] = None) -> int:
"""
取 userChargelog.price —— 即 ticket.creditNum。
依据 VOExtensions.cs:410 result.price = ticket.creditNum;
数据来源 config.TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
"""
if override is not None:
return int(override)
if int(charge_id) not in TICKET_CREDIT_NUM:
log_warning("config.TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
+ " —— 按 0 处理,建议补进配置")
return 0
return int(TICKET_CREDIT_NUM[int(charge_id)])
#: 北京时间(CST = UTC+8)
#: 依据 Manager/TimeManager.cs:9 CSToffset = new TimeSpan(8, 0, 0)
CST = timezone(timedelta(hours=8))
def now_cst(now: Optional[datetime] = None) -> datetime:
"""
取「北京时间现在」;传了 now 就用它(视作已是北京时间)。
★ 为什么要显式 +8 而不是 datetime.now():
TimeManager.GetNowTime() 返回 **DateTime.UtcNow**,
GetDateString() 再 Add(CSToffset) —— 即游戏里的日期串**恒是北京时间**,
与本机时区无关。脚本跑在非东八区机器上时 datetime.now() 会错。
"""
if now is not None:
return now
return datetime.now(timezone.utc).astimezone(CST)
def _now_string(now: Optional[datetime] = None) -> str:
"""
TimeManager.GetNowDateString() 的等价物。
依据 Manager/TimeManager.cs:7,62-70:
DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f"
GetDateString(unix) = GetDateTime(unix).ToString("yyyy-MM-dd HH:mm:ss.f")
★★ 注意末尾那个 **.f**(一位小数)—— 真机报文长这样:
"purchaseDate":"2026-10-06 13:20:10.0"
少写这个 ".0" 服务端会直接 returnCode=0(实测踩过)。
★ 而 validDate 用的是另一套格式(见 calc_valid_date),**不带**小数。
"""
d = now_cst(now)
return d.strftime("%Y-%m-%d %H:%M:%S") + "." + str(d.microsecond // 100000)
def calc_valid_date(
now: Optional[datetime] = None,
*,
hour_offset: int = TICKET_VALID_HOUR_OFFSET,
days: int = TICKET_VALID_DAYS,
) -> str:
"""
算票的有效期字符串。
依据 TicketManager.cs:562-565:
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
★ 注意这里用的是**显式格式串**(没有 .f),所以结果形如
"2027-01-04 04:00:00" —— 与 purchaseDate 的 "…10.0" 不同。
"""
base = now_cst(now)
d = (base + timedelta(hours=hour_offset) + timedelta(days=days)).date()
return d.strftime("%Y-%m-%d") + " 04:00:00"
def parse_wall_time(text: Any) -> Optional[datetime]:
"""
解析墙上时间串,兼容两种格式:
"2026-10-06 13:20:10.0" ← TimeManager.DateTimeFormat(一位小数,真机报文用这个)
"2026-10-06 13:20:10" ← 无小数(服务端回给 GetUserChargeApi 的是这个)
解析不出来返回 None。
"""
s = str(text or "").strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
return None
def _find_charge(charge_list: Any, charge_id: int) -> Optional[Dict[str, Any]]:
"""在 GetUserChargeApi 的 userChargeList 里找指定 chargeId。"""
if not isinstance(charge_list, list):
return None
for c in charge_list:
if isinstance(c, dict) and int(c.get("chargeId", -1) or -1) == int(charge_id):
return c
return None
# =============================================================================
# 三、主函数
# =============================================================================
def UpsertUserChargelog(
user_id: int,
charge_id: int,
login_date_time: int,
*,
charge_list: Optional[List[Dict[str, Any]]] = None,
game_charge_list: Optional[List[Dict[str, Any]]] = None,
user_data: Optional[Dict[str, Any]] = None,
stock: Optional[int] = None,
price: Optional[int] = None,
purchase_date: Optional[str] = None,
valid_date: Optional[str] = None,
play_count: Optional[int] = None,
player_rating: Optional[int] = None,
place_id: int = PLACE_ID,
region_id: int = REGION_ID,
client_id: str = CLIENT_ID,
guard_no_stock: bool = True,
guard_purchasable: bool = True,
confirm: Optional[bool] = None,
dry_run: bool = False,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Union[UserChargelogRequestVO, UpsertResponseVO]:
"""
购买一张功能票(UpsertUserChargelogApi,★ 有副作用)。
参数:
user_id: 用户 ID(必填)
charge_id: 要买的票 ID(必填,= GetGameChargeApi 里的 chargeId)
★ 付费票是 2/3/4/5/6(config.PAID_TICKET_IDS)
login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime)
charge_list: 当前票券列表(可选)。不传则自动调 GetUserChargeApi。
用来算「购买后库存」和是否已有该票。
user_data: 当前 userData(可选)。不传则自动调 GetUserDataApi。
用来填 playCount / playerRating。
stock: ★ 购买后该票的库存;不传 = 现有 + 1
price: ★ userChargelog.price;不传 = config.TICKET_CREDIT_NUM[charge_id]
purchase_date: 购买时刻字符串;不传 = 现在
valid_date: 有效期字符串;不传 = 现在-4h+90d(见 calc_valid_date)
play_count: 覆盖 playCount(不传则取 userData.playCount)
player_rating: 覆盖 playerRating(不传则取 userData.playerRating)
guard_purchasable: ★ 默认 True = **只允许买 GetGameChargeApi 列出来的票**
(不在清单里就抛 ChargelogGuardError,未发包)。
依据 TicketManager.cs:137-150 —— 真机也是先在
GetGameChargeDataList() 里找到该 chargeId 才发购买请求,
找不到直接 return false。服务端对未上架的票会 returnCode=0。
实测:本机台只上架 chargeId=2/3,买 5 会失败。
game_charge_list: 可购买清单(可选)。不传则自动调 GetGameChargeApi。
guard_no_stock: ★ 默认 True = **账号当前若已持有任何功能票就直接拒绝购买**
(抛 ChargelogGuardError,未发包)。
理由见 ChargelogGuardError 的 docstring —— 有封号风险。
确认要强行购买才传 False。
confirm: 副作用闸门;None 时取 config.UPSERT_CHARGELOG_CONFIRM
dry_run: True = 只构造并返回报文,绝不发包
返回:
dry_run=True -> UserChargelogRequestVO
dry_run=False -> UpsertResponseVO
"""
if not login_date_time:
raise ValueError("缺少 login_date_time:必须传 UserLoginApi 的 loginDateTime"
"(PacketUploadUserChargelog.cs:23)")
name = ticket_name(charge_id)
log_info("购买功能票:user_id=" + str(user_id) + " chargeId=" + str(charge_id)
+ "(" + name + ")dry_run=" + str(dry_run))
if int(charge_id) not in tuple(config.PAID_TICKET_IDS):
log_warning("chargeId=" + str(charge_id) + " 不在 config.PAID_TICKET_IDS="
+ str(tuple(config.PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
# ---------- 1. 取当前状态 ----------
if charge_list is None:
log_info("未传 charge_list,自动抓取当前票券 ...")
raw = resolve_api_func("UserCharge")(
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
charge_list = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
if user_data is None:
log_info("未传 user_data,自动抓取当前档案 ...")
raw_ud = resolve_api_func("UserData")(
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
user_data = (raw_ud.get("userData") if isinstance(raw_ud, dict) else None) or {}
# ---------- 1.4 ★ 安全检查:这张票当前**上架了吗** ----------
# 依据 TicketManager.cs:137-150:真机先在 GetGameChargeDataList() 里找该
# chargeId,找到才 RequestUserCharge,找不到直接 return false(不买)。
# 服务端对未上架的票会 returnCode=0 —— 实测买 chargeId=5 就是这种情况。
if guard_purchasable:
if game_charge_list is None:
log_info("未传 game_charge_list,自动抓取可购买清单 ...")
_gc = resolve_api_func("GetGameCharge")(
client_id=client_id, timeout_ms=timeout_ms, client=client)
game_charge_list = (_gc.get("gameChargeList")
if isinstance(_gc, dict) else None) or []
avail = [int(g.get("chargeId")) for g in (game_charge_list or [])
if isinstance(g, dict) and g.get("chargeId") is not None]
log_info("当前可购买 chargeId:" + str(avail))
if int(charge_id) not in avail:
msg = ("安全检查未通过:chargeId=" + str(charge_id) + "(" + name
+ ")**不在当前可购买清单里**。\n"
" 可购买:" + (str(avail) if avail else "(空)") + "\n"
" 依据 TicketManager.cs:137-150 —— 真机也只买 GetGameChargeApi\n"
" 列出来的票,找不到就不发购买请求。\n"
" 服务端对未上架的票会 returnCode=0(实测过)。\n"
" 确需强行购买请传 guard_purchasable=False。")
log_error(msg)
raise ChargelogGuardError(msg)
log_info("安全检查通过:chargeId=" + str(charge_id) + " 在可购买清单里")
# ---------- 1.5 ★ 安全检查:账号当前是否已持有功能票 ----------
held = [c for c in (charge_list or [])
if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
if held:
desc = "、".join(
"chargeId=" + str(c.get("chargeId")) + "(" + ticket_name(c.get("chargeId"))
+ ") stock=" + str(c.get("stock")) + " validDate=" + str(c.get("validDate"))
for c in held)
log_warning("★ 账号当前已持有 " + str(len(held)) + " 张功能票:" + desc)
if guard_no_stock:
msg = ("安全检查未通过:账号当前**已持有功能票**(" + desc + "),拒绝再次购买。\n"
" 原因:真机只在「本局要用这张票」时才购买,且买到手会立刻用掉"
"(stock--),正常状态 stock 恒为 0。\n"
" 已经持票还发购买请求,服务端看到的是重复购买,有**封号风险**。\n"
" 确需强行购买请传 guard_no_stock=False。")
log_error(msg)
raise ChargelogGuardError(msg)
log_warning("guard_no_stock=False —— 已跳过该检查,继续购买")
else:
log_info("安全检查通过:账号当前没有持有任何功能票")
existing = _find_charge(charge_list, charge_id)
old_stock = int(existing.get("stock", 0) or 0) if existing else 0
if stock is None:
stock = old_stock + 1
stock = int(stock)
if price is None:
price = ticket_price(charge_id)
price = int(price)
if play_count is None:
play_count = int(user_data.get("playCount", 0) or 0)
if player_rating is None:
player_rating = int(user_data.get("playerRating", 0) or 0)
now = now_cst()
if not purchase_date:
purchase_date = _now_string(now)
if not valid_date:
# ★ 真机里 purchaseDate 与 validDate 都来自同一个 DateTime.Now
# (TicketManager.cs:216-219 与 :564),所以调用方若显式给了 purchaseDate,
# 有效期就该从**那个时刻**推,而不是从现在推。
base = parse_wall_time(purchase_date)
if base is None:
log_warning("purchase_date 解析不了(应为 'YYYY-MM-DD HH:MM:SS[.f]'),"
"validDate 改按「现在」计算:" + str(purchase_date))
base = now
valid_date = calc_valid_date(base)
log_info(" 票名 : " + name)
log_info(" 库存 : " + str(old_stock) + " -> " + str(stock))
log_info(" price : " + str(price) + "(= ticket.creditNum)")
log_info(" purchaseDate: " + purchase_date)
log_info(" validDate : " + valid_date)
# ---------- 2. 构造请求 ----------
req = UserChargelogRequestVO(
userId=int(user_id),
userChargelog=UserChargelogVO(
chargeId=int(charge_id),
price=price,
purchaseDate=purchase_date,
playCount=int(play_count),
playerRating=int(player_rating),
placeId=int(place_id),
regionId=int(region_id),
clientId=client_id,
),
userCharge=UserChargeVO(
chargeId=int(charge_id),
stock=stock,
purchaseDate=purchase_date,
validDate=valid_date,
),
loginDateTime=int(login_date_time),
)
# ---------- 3. dry-run ----------
if dry_run:
log_info("[dry-run] 已构造购买报文,未发包")
log_debug("dry-run 报文:" + __import__("json").dumps(
req, ensure_ascii=False, default=str))
return req
# ---------- 4. 副作用闸门 ----------
effective = UPSERT_CHARGELOG_CONFIRM if confirm is None else bool(confirm)
if not effective:
msg = ("UpsertUserChargelogApi 会**真实写入一条购买记录**(不可撤销)。"
"本脚本默认不放行。确认要发请传 confirm=True,"
"或改 config.UPSERT_CHARGELOG_CONFIRM = True。"
"只想看报文请传 dry_run=True。")
log_error(msg)
raise ChargelogConfirmError(msg)
# ---------- 5. 发包 ----------
log_info("闸门已放行,开始上传购买记录 ...")
raw = request_dict(
"UpsertUserChargelogApi", req,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
resp = UpsertResponseVO(
returnCode=int(raw.get("returnCode", 0) or 0),
apiName=str(raw.get("apiName", "") or ""),
)
if resp.returnCode != 1:
msg = ("购买失败:returnCode=" + str(resp.returnCode)
+ " apiName=" + resp.apiName)
log_error(msg)
raise RuntimeError(msg)
log_info("购买成功:returnCode=" + str(resp.returnCode) + " apiName=" + resp.apiName)
log_info(" 票名 " + name + " 库存 " + str(old_stock) + " -> " + str(stock))
return resp
# =============================================================================
# 四、单独运行(调试)
# =============================================================================
if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("chargelog_test")
try:
log_info("构造一张 2 倍券的购买报文(不发包)")
req = UpsertUserChargelog(
12345678, 2, 1791159576,
charge_list=[], user_data={"playCount": 1260, "playerRating": 15000},
dry_run=True,
)
log_info("userChargelog = " + str(req.userChargelog))
log_info("userCharge = " + str(req.userCharge))
log_info("validDate 算法自检:")
log_info(" 2026-10-04 18:22:10 买 5 倍券 -> "
+ calc_valid_date(datetime(2026, 10, 4, 18, 22, 10))
+ "(真机是 2027-01-02 04:00:00)")
return 0
finally:
teardown_logging()
raise SystemExit(_main())
-48
View File
@@ -1,48 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_activity.py —— 取用户活动记录(GetUserActivityApi)
================================================================================
取玩家的活动/成就记录(playList 与 musicList 两个列表)。
对应接口:GetUserActivityApi
请求体 :UserIdRequestVO
响应 :{userActivity:{playList:[...], musicList:[...]}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserActivityRequestVO.cs
★ 形状特例(36 个 Upsert 节点里唯一一个 Get 与 Upsert 形状不同的):
Get 侧 :单个对象 userActivity = {playList, musicList}
Upsert 侧:**单元素数组** userActivityList = [ {playList, musicList} ]
不是把两个数组首尾相接摊平。
调用示例:
from api import UserActivity
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserActivity(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户活动记录(对应 GetUserActivityApi)。
返回:
原始响应 dict,含 userActivity 子对象
"""
log_info("取用户活动记录:user_id=" + str(user_id))
return _get("GetUserActivityApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-46
View File
@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_character.py —— 取用户角色列表(GetUserCharacterApi)
================================================================================
取玩家所有角色的等级与觉醒度(真机 312 条)。
对应接口:GetUserCharacterApi
请求体 :UserIdRequestVO
响应 :{userId, length, userCharacterList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserCharacterRequestVO.cs
★ 回传 Upsert 时只保留 characterId / level / awakening / useCount 四个字段
(真机 source/2026-10-05.log:123 的 userCharacterList 就是这 4 个键)。
调用示例:
from api import UserCharacter
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserCharacter(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户角色列表(对应 GetUserCharacterApi)。
返回:
原始响应 dict,含 userCharacterList
"""
log_info("取用户角色列表:user_id=" + str(user_id))
return _get("GetUserCharacterApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-45
View File
@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_charge.py —— 取用户票券(GetUserChargeApi)
================================================================================
取玩家的票券持有情况(真机 3 条)。
对应接口:GetUserChargeApi
请求体 :UserIdRequestVO
响应 :{userId, length, userChargeList:[{chargeId,stock,purchaseDate,validDate,extNum1}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserChargeRequestVO.cs
★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。
调用示例:
from api import UserCharge
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserCharge(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户票券(对应 GetUserChargeApi)。
返回:
原始响应 dict,含 userChargeList
"""
log_info("取用户票券:user_id=" + str(user_id))
return _get("GetUserChargeApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-71
View File
@@ -1,71 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_course.py —— 取用户课题模式成绩(GetUserCourseApi)
================================================================================
取玩家的 COURSE(课题)模式通关记录(真机 16 条)。
对应接口:GetUserCourseApi
请求体 :UserCourseRequestVO(userId + nextIndex,**没有 maxCount**)
响应 :{userId, length, nextIndex, userCourseList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserCourseRequestVO.cs
source/2026-10-05.log:55 请求体就是 {"userId":..,"nextIndex":0}
调用示例:
from api import UserCourse
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserCourseRequestVO, fetch_all_pages
def UserCourse(
user_id: int,
*,
next_index: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户课题模式成绩(单页,对应 GetUserCourseApi)。
返回:
原始响应 dict,含 userCourseList
"""
log_info("取用户课题成绩:user_id=" + str(user_id) + " next_index=" + str(next_index))
return _get(
"GetUserCourseApi",
UserCourseRequestVO(userId=user_id, nextIndex=next_index),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def UserCourseAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户课题成绩(自动翻页取全)。
返回:
{"userCourseList": [...]}
"""
log_info("取用户课题成绩(全量):user_id=" + str(user_id))
items = fetch_all_pages(
"GetUserCourseApi", user_id, "userCourseList",
body_factory=lambda ni: UserCourseRequestVO(userId=user_id, nextIndex=ni or 0),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("课题成绩获取完成:" + str(len(items)) + " 条")
return {"userCourseList": items}
-46
View File
@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_extend.py —— 取用户扩展设置(GetUserExtendApi)
================================================================================
取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。
对应接口:GetUserExtendApi
请求体 :UserIdRequestVO
响应 :{userId, userExtend:{...}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserExtendRequestVO.cs
★ 其中 categoryIndex(曲种索引)与 userPlaylog 的 extNum4 必须一致
(真机两处都是 2022),组包时要注意。
调用示例:
from api import UserExtend
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserExtend(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户扩展设置(对应 GetUserExtendApi)。
返回:
原始响应 dict,含 userExtend 子对象
"""
log_info("取用户扩展设置:user_id=" + str(user_id))
return _get("GetUserExtendApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-102
View File
@@ -1,102 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_favorite.py —— 取用户收藏(GetUserFavoriteApi)
================================================================================
取玩家各类收藏夹(称号 / 头像 / 相框 / 搭档 / 背景…)。
对应接口:GetUserFavoriteApi
请求体 :UserFavoriteRequestVO(userId + itemKind)
响应 :{userId, userFavorite:{itemKind, itemIdList:[...]}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFavoriteRequestVO.cs
source/2026-10-05.log:59-68 真机依次请求 itemKind = 3,1,2,10,11
★ 回传 Upsert 时 userFavoriteList 是**恒 5 条**的完整数组(不做剔除),
位串 isNewFavoriteList 是三值 "0"/"1"/"2"。
调用示例:
from api import UserFavorite, UserFavoriteAll
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
def UserFavorite(
user_id: int,
item_kind: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户某一类收藏(对应 GetUserFavoriteApi)。
参数:
user_id: 用户 ID(必填)
item_kind: 收藏类别(必填):3=头像 1=称号 2=名牌 10=搭档 11=相框
返回:
原始响应 dict,含 userFavorite
"""
log_info("取用户收藏:user_id=" + str(user_id) + " itemKind=" + str(item_kind))
return _get(
"GetUserFavoriteApi",
UserFavoriteRequestVO(userId=user_id, itemKind=item_kind),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def UserFavoriteAll(
user_id: int,
*,
item_kinds: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户全部收藏(按真机顺序请求 5 个类别)。
参数:
item_kinds: 类别列表(可选,默认 FAVORITE_ITEM_KINDS = (3,1,2,10,11))
返回:
{"userFavoriteList": [{itemKind, itemIdList}, ...]},恒 5 条
"""
kinds = list(item_kinds) if item_kinds else list(FAVORITE_ITEM_KINDS)
log_info("取用户全部收藏:user_id=" + str(user_id) + " kinds=" + str(kinds))
result: List[Dict[str, Any]] = []
raw_items: List[Dict[str, Any]] = []
for kind in kinds:
resp = _get(
"GetUserFavoriteApi",
UserFavoriteRequestVO(userId=user_id, itemKind=kind),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
fav = resp.get("userFavorite") or {}
if not fav:
log_warning("itemKind=" + str(kind) + " 未返回 userFavorite,按空处理")
fav = {"itemKind": kind, "itemIdList": []}
# ★ 真机里 itemKind=10 的响应回的是 {"itemKind":null,"itemIdList":[]}(服务端怪癖)。
# 保留一份**原始**副本供 Upsert 差分用 —— 位串里的 "1" 正是它产生的。
raw_items.append(dict(fav))
# 回传用的副本按请求值补齐 itemKind,避免报文里出现 null
filled = dict(fav)
if not filled.get("itemKind"):
filled["itemKind"] = kind
result.append(filled)
log_info("收藏获取完成:" + str(len(result)) + " 类")
return {"userFavoriteList": result, "_raw": raw_items}
-92
View File
@@ -1,92 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
================================================================================
取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。
对应接口:GetUserFavoriteItemApi
请求体 :UserFavoriteItemRequestVO(userId + kind + nextIndex + maxCount=100
+ isAllFavoriteItem)
响应 :{userId, kind, length, nextIndex, userFavoriteItemList:[{orderId,id}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFavoriteItemRequestVO.cs
source/2026-10-05.log:93,107 真机 kind=2 与 kind=1 各一次
★ 字段名是 kind(不是 itemKind),别写错。
调用示例:
from api import UserFavoriteItem, UserFavoriteItemAll
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserFavoriteItemRequestVO, fetch_all_pages
#: kind 取值
FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲
FAVORITE_ITEM_KIND_RIVAL = 2 # 收藏对手
def UserFavoriteItem(
user_id: int,
kind: int = FAVORITE_ITEM_KIND_MUSIC,
*,
next_index: int = 0,
is_all_favorite_item: bool = False,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户收藏项(单页,对应 GetUserFavoriteItemApi)。
参数:
user_id: 用户 ID(必填)
kind: 1=收藏乐曲 2=收藏对手(默认 1)
返回:
原始响应 dict,含 userFavoriteItemList
"""
log_info("取用户收藏项:user_id=" + str(user_id) + " kind=" + str(kind)
+ " next_index=" + str(next_index))
return _get(
"GetUserFavoriteItemApi",
UserFavoriteItemRequestVO(
userId=user_id, kind=kind, nextIndex=next_index,
maxCount=100, isAllFavoriteItem=is_all_favorite_item,
),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def UserFavoriteItemAll(
user_id: int,
kind: int = FAVORITE_ITEM_KIND_MUSIC,
*,
is_all_favorite_item: bool = False,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户收藏项(自动翻页取全)。
返回:
{"userFavoriteItemList": [...]}
"""
log_info("取用户收藏项(全量):user_id=" + str(user_id) + " kind=" + str(kind))
items = fetch_all_pages(
"GetUserFavoriteItemApi", user_id, "userFavoriteItemList",
body_factory=lambda ni: UserFavoriteItemRequestVO(
userId=user_id, kind=kind, nextIndex=ni or 0,
maxCount=100, isAllFavoriteItem=is_all_favorite_item),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("收藏项获取完成:" + str(len(items)) + " 条")
return {"userFavoriteItemList": items}
-64
View File
@@ -1,64 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi)
================================================================================
取玩家本次可领取的好友里程(getMiles)。
对应接口:GetUserFriendBonusApi
请求体 :UserIdRequestVO
响应 :{returnCode, userId, getMiles}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFriendBonusRequestVO.cs
source/2026-10-05.log:111 真机响应 {"returnCode":1,"userId":..,"getMiles":90}
调用示例:
from api import UserFriendBonus, extract_gift_points
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserFriendBonus(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户好友里程(对应 GetUserFriendBonusApi)。
返回:
原始响应 dict:{"returnCode":1, "userId":.., "getMiles":..}
"""
log_info("取用户好友里程:user_id=" + str(user_id))
resp = _get("GetUserFriendBonusApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
if int(resp.get("returnCode", 0) or 0) != 1:
log_warning("好友里程接口返回 returnCode=" + str(resp.get("returnCode")))
return resp
def extract_gift_points(friend_bonus: Dict[str, Any]) -> int:
"""
从好友里程响应里取出 getMiles(本次可领取的里程数)。
参数:
friend_bonus: UserFriendBonus 的返回值
返回:
getMiles 整数(取不到则为 0)
"""
try:
return int((friend_bonus or {}).get("getMiles", 0) or 0)
except Exception:
return 0
-245
View File
@@ -1,245 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_getter_common.py —— 各取数脚本的公共底座
================================================================================
只提供三样东西,取数逻辑都在各自的脚本里:
1. 各接口的请求 VO(严格对应 source/.../Net.VO.Mai2/*RequestVO.cs)
2. _get() 发一次请求并返回**原始响应 dict**
3. fetch_all_pages() 自动翻页
--------------------------------------------------------------------------------
★ 为什么单独抽出来:各接口的请求体并不统一
--------------------------------------------------------------------------------
权威依据 source/Assembly-CSharp/Net.VO.Mai2/ 下各自的 XxxRequestVO.cs:
GameKaleidxScopeRequestVO : 空类,一个字段都没有
UserDetailRequestVO : 只有 userId
UserMapRequestVO : userId + nextIndex + maxCount = 1000
UserLoginBonusRequestVO : userId + nextIndex + maxCount = 20
UserItemRequestVO : userId + nextIndex + maxCount = 100
UserMusicRequestVO : userId + nextIndex + maxCount = 50
UserFavoriteRequestVO : userId + itemKind
UserFavoriteItemRequestVO : userId + kind + nextIndex + maxCount = 100 + isAllFavoriteItem
UserShopStockRequestVO : userId + shopItemIdList
UserRivalMusicRequestVO : userId + rivalId + nextIndex + userRivalMusicLevelList
发错字段名或漏字段,服务端会返回异常或空数据。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_debug, log_warning
from .maimai_net import MaimaiNetClient, get_default_client
# =============================================================================
# 请求 VO(逐字段对应 C# 的 XxxRequestVO)
# =============================================================================
@dataclass
class EmptyRequestVO:
"""空请求体(对应 GameKaleidxScopeRequestVO —— 它确实没有任何字段)。"""
@dataclass
class UserIdRequestVO:
"""只有 userId(对应 UserDetailRequestVO / UserCharacterRequestVO / UserOptionRequestVO 等)。"""
userId: int
@dataclass
class UserIdPageRequestVO:
"""通用分页(对应 UserMapRequestVO,maxCount 默认 1000)。"""
userId: int
nextIndex: int = 0
maxCount: int = 1000
@dataclass
class UserLoginBonusRequestVO:
"""对应 UserLoginBonusRequestVO(maxCount 默认 20)。"""
userId: int
nextIndex: int = 0
maxCount: int = 20
@dataclass
class UserItemRequestVO:
"""对应 UserItemRequestVO(maxCount 默认 100)。"""
userId: int
nextIndex: int = 0
maxCount: int = 100
@dataclass
class UserMusicRequestVO:
"""对应 UserMusicRequestVO(maxCount 默认 50)。"""
userId: int
nextIndex: int = 0
maxCount: int = 50
@dataclass
class UserCourseRequestVO:
"""对应 UserCourseRequestVO(只有 userId + nextIndex,**没有** maxCount)。"""
userId: int
nextIndex: int = 0
@dataclass
class UserFavoriteRequestVO:
"""对应 UserFavoriteRequestVO(按 itemKind 分次请求)。"""
userId: int
itemKind: int = 0
@dataclass
class UserFavoriteItemRequestVO:
"""对应 UserFavoriteItemRequestVO(注意字段名是 kind,不是 itemKind)。"""
userId: int
kind: int = 1
nextIndex: int = 0
maxCount: int = 100
isAllFavoriteItem: bool = False
@dataclass
class UserShopStockRequestVO:
"""对应 UserShopStockRequestVO。"""
userId: int
shopItemIdList: List[int] = field(default_factory=list)
@dataclass
class UserRivalRequestVO:
"""对应 UserRivalRequestVO。"""
userId: int
rivalId: int = 0
@dataclass
class UserRivalMusicRequestVO:
"""对应 UserRivalMusicRequestVO(level 列表固定 6 档)。"""
userId: int
rivalId: int = 0
nextIndex: int = 0
userRivalMusicLevelList: List[Dict[str, int]] = field(
default_factory=lambda: [{"level": lv} for lv in (0, 1, 2, 3, 4, 10)]
)
# =============================================================================
# 常量
# =============================================================================
#: GetUserItemApi 的 itemKind 取值(对应 Net.VO.Mai2/ItemKind.cs)
#: 客户端在 StateUserDownload.cs 里依次下载这些 kind
ITEM_KINDS: tuple = (1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 14)
#: ★ GetUserItemApi 首页 nextIndex 的哨兵系数。
#: 依据 source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
#: Create(GetNetQuery(userId, itemId * 10000000000L));
#: 传 0 会被服务端拒绝。真机日志 source/2026-10-05.log:32 也是 10000000000。
ITEM_NEXT_INDEX_FACTOR: int = 10000000000
#: GetUserFavoriteApi 的 itemKind(真机顺序固定 3/1/2/10/11,见 source/2026-10-05.log:59-68)
FAVORITE_ITEM_KINDS: tuple = (3, 1, 2, 10, 11)
#: GetUserRivalMusicApi 的 level 档位(真机 source/2026-10-05.log:101)
RIVAL_MUSIC_LEVELS: tuple = (0, 1, 2, 3, 4, 10)
# =============================================================================
# 内部工具
# =============================================================================
def _get(
api_name: str,
body: Any,
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
发一次请求,返回**原始响应 dict**(不做 VO 映射,保留服务端全部字段)。
★ 取数脚本一律走这里:upsertUserAll 需要响应原文,
映射成 dataclass 会丢掉服务端多给的字段。
"""
net = client or get_default_client()
raw = net.request(
api_name, body,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS,
)
if isinstance(raw, str):
return json.loads(raw) if raw else {}
return raw or {}
def fetch_all_pages(
api_name: str,
user_id: int,
list_key: str,
*,
body_factory: Optional[Callable[[Optional[int]], Any]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
max_pages: int = 200,
) -> List[Any]:
"""
自动翻页抓取某个列表接口的全部条目。
分页约定(各 Packet 的 Proc 里都是这个逻辑):
nextIndex == 0 -> 没有更多数据
nextIndex != 0 -> 下一页的起始索引
参数:
api_name: 逻辑接口名
list_key: 响应里承载列表的键名(如 "userMapList")
body_factory: 可选;接收 next_index 返回请求 VO 的工厂。
★ 首页会以 next_index=None 调用它 —— 这是为了支持
GetUserItemApi 这类「首发要带哨兵值」的接口
(首包 nextIndex = itemKind * 1e10,不能是 0)。
max_pages: 翻页上限(防御性)
返回:
合并后的全部条目
"""
all_items: List[Any] = []
next_index: Optional[int] = None
for page in range(max_pages):
if body_factory is not None:
body = body_factory(next_index)
else:
body = UserIdPageRequestVO(userId=user_id, nextIndex=next_index or 0)
resp = _get(api_name, body, user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
items = resp.get(list_key) or []
if isinstance(items, dict):
items = [items]
all_items.extend(items)
next_index = int(resp.get("nextIndex") or 0)
log_debug(
api_name + " 第 " + str(page + 1) + " 页:本页 " + str(len(items))
+ " 条,累计 " + str(len(all_items)) + " 条,nextIndex=" + str(next_index)
)
if not next_index:
break
else:
log_warning(api_name + " 超过 " + str(max_pages) + " 页仍未结束,已中止翻页")
return all_items
-47
View File
@@ -1,47 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_ghost.py —— 取用户幽灵数据(GetUserGhostApi)
================================================================================
取玩家的「幽灵」对战记录(真机 15 条)。
对应接口:GetUserGhostApi
请求体 :UserIdRequestVO
响应 :{length, userGhostList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserGhostRequestVO.cs
★ Upsert 侧 userGhost 默认留空:源码 VOExtensions.cs:235 是
dst.userGhost = (!src.MyGhost.NeedSave) ? null : new UserGhost[1] {...};
只有「本局产生了幽灵」才回传,纯网络脚本复刻不出幽灵数据。
调用示例:
from api import UserGhost
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserGhost(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户幽灵数据(对应 GetUserGhostApi)。
返回:
原始响应 dict,含 userGhostList
"""
log_info("取用户幽灵数据:user_id=" + str(user_id))
return _get("GetUserGhostApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-43
View File
@@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
================================================================================
取玩家与各搭档的亲密度(真机 2 条)。
对应接口:GetUserIntimateApi
请求体 :UserIdRequestVO
响应 :{userId, length, userIntimateList:[{partnerId,intimateLevel,intimateCountRewarded}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserIntimateRequestVO.cs
调用示例:
from api import UserIntimate
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserIntimate(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户搭档亲密度(对应 GetUserIntimateApi)。
返回:
原始响应 dict,含 userIntimateList
"""
log_info("取用户搭档亲密度:user_id=" + str(user_id))
return _get("GetUserIntimateApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-124
View File
@@ -1,124 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_item.py —— 取用户道具(GetUserItemApi)
================================================================================
取玩家各类道具的库存(真机按 kind 分 12 次请求)。
对应接口:GetUserItemApi
请求体 :UserItemRequestVO(userId + nextIndex + maxCount=100)
响应 :{userId, length, nextIndex, itemKind, userItemList:[{itemKind,itemId,stock,isValid}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserItemRequestVO.cs
--------------------------------------------------------------------------------
★★ 首发 nextIndex 必须带哨兵值(最容易踩的坑)
--------------------------------------------------------------------------------
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
public PacketGetUserItem(ulong userId, long itemId, ...)
{
Create(GetNetQuery(userId, itemId * 10000000000L)); // <- 首包哨兵
}
// Proc(): nextIndex != 0 时继续翻页
真机日志 source/2026-10-05.log:32 也是 nextIndex=10000000000。
传 0 会被服务端拒绝(HTTP 500)。
★ 请求体里**没有 itemKind 字段** —— 类别是靠 nextIndex 的哨兵值表达的:
第 1 页 nextIndex = kind * 1e10
后续翻页沿用响应回的 nextIndex
调用示例:
from api import UserItem, UserItemAll
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_debug, log_info, log_warning
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR
def UserItem(
user_id: int,
item_kind: int,
*,
next_index: Optional[int] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户某一类道具(对应 GetUserItemApi)。
参数:
user_id: 用户 ID(必填)
item_kind: 道具类别(必填):1=名牌 2=称号 3=头像 4=礼物 5=乐曲 …
next_index: 分页索引;**None(默认)表示首页**,会自动换算成
item_kind * 10000000000 的哨兵值。
返回:
原始响应 dict,含 userItemList
"""
if next_index is None:
next_index = item_kind * ITEM_NEXT_INDEX_FACTOR
log_info("取用户道具:user_id=" + str(user_id) + " item_kind=" + str(item_kind)
+ " nextIndex=" + str(next_index))
return _get(
"GetUserItemApi",
UserItemRequestVO(userId=user_id, nextIndex=next_index, maxCount=100),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def UserItemAll(
user_id: int,
*,
item_kinds: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户全部道具(按 ITEM_KINDS 逐个类别抓取,并自动翻页)。
参数:
item_kinds: 类别列表(可选,默认 ITEM_KINDS)
返回:
{"userItemList": [...]},已合并所有类别与分页
"""
kinds = list(item_kinds) if item_kinds else list(ITEM_KINDS)
log_info("取用户全部道具:user_id=" + str(user_id) + " kinds=" + str(kinds))
merged: List[Dict[str, Any]] = []
for kind in kinds:
next_index: Optional[int] = kind * ITEM_NEXT_INDEX_FACTOR
page = 0
while next_index:
page += 1
resp = _get(
"GetUserItemApi",
UserItemRequestVO(userId=user_id, nextIndex=next_index, maxCount=100),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
items = resp.get("userItemList") or []
if isinstance(items, dict):
items = [items]
merged.extend(items)
next_index = int(resp.get("nextIndex") or 0)
log_debug("item_kind=" + str(kind) + " 第 " + str(page) + " 页:"
+ str(len(items)) + " 条,nextIndex=" + str(next_index))
if page >= 200:
log_warning("item_kind=" + str(kind) + " 翻页超过 200 页,已中止")
break
log_info("道具获取完成:" + str(len(merged)) + " 条")
return {"userItemList": merged}
-75
View File
@@ -1,75 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_kaleidx_scope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
================================================================================
对应接口:
GetUserKaleidxScopeApi 请求体 UserIdRequestVO(userId)
响应 {userId, userKaleidxScopeList:[...]}
GetGameKaleidxScopeApi 请求体 **空对象 {}**(GameKaleidxScopeRequestVO 没有任何字段)
响应 {gameKaleidxScopeList:[{gateId, phaseId}]}
依据:
source/Assembly-CSharp/Net.VO.Mai2/UserKaleidxScopeRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/GameKaleidxScopeRequestVO.cs(空类)
source/Assembly-CSharp/Net.VO.Mai2/GameKaleidxScopeResponseVO.cs
source/2026-10-05.log:15,119
★★ 重要区分(曾导致 userData 取空)
--------------------------------------------------------------------------------
GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。
玩家档案 userData 的正确来源是 GetUserDataApi(见 userdata.py)。
真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的
两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId,
多发字段会让服务端返回空内容。
调用示例:
from api import UserKaleidxScope, GameKaleidxScope
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, EmptyRequestVO, UserIdRequestVO
def UserKaleidxScope(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取玩家 KALEIDX 门进度(对应 GetUserKaleidxScopeApi)。
返回:
原始响应 dict,含 userKaleidxScopeList
"""
log_info("取玩家 KALEIDX 门进度:user_id=" + str(user_id))
return _get("GetUserKaleidxScopeApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
def GameKaleidxScope(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取全服 KALEIDX 门开放进度(对应 GetGameKaleidxScopeApi)。
★ 请求体是空对象 {} —— 该 RequestVO 一个字段都没有,**不要传 userId**。
返回:
原始响应 dict,含 gameKaleidxScopeList
"""
log_info("取全服 KALEIDX 门进度")
return _get("GetGameKaleidxScopeApi", EmptyRequestVO(), 0,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-76
View File
@@ -1,76 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
================================================================================
取玩家的登录奖励领取进度(真机 38 条,分两页)。
对应接口:GetUserLoginBonusApi
请求体 :UserLoginBonusRequestVO(userId + nextIndex + maxCount=20)
响应 :{userId, length, nextIndex, userLoginBonusList:[{bonusId,point,isCurrent,isComplete}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserLoginBonusRequestVO.cs
★ 回传 Upsert 时只回传 isCurrent=true 的那一条(本次签到的那条)。
调用示例:
from api import UserLoginBonus, UserLoginBonusAll
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserLoginBonusRequestVO, fetch_all_pages
def UserLoginBonus(
user_id: int,
*,
next_index: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户登录奖励(单页,对应 GetUserLoginBonusApi)。
参数:
next_index: 分页起始索引(0 = 首页)
返回:
原始响应 dict,含 userLoginBonusList
"""
log_info("取用户登录奖励:user_id=" + str(user_id) + " next_index=" + str(next_index))
return _get(
"GetUserLoginBonusApi",
UserLoginBonusRequestVO(userId=user_id, nextIndex=next_index, maxCount=20),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def UserLoginBonusAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户登录奖励(自动翻页取全)。
返回:
{"userLoginBonusList": [...]}
"""
log_info("取用户登录奖励(全量):user_id=" + str(user_id))
items = fetch_all_pages(
"GetUserLoginBonusApi", user_id, "userLoginBonusList",
body_factory=lambda ni: UserLoginBonusRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=20),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("登录奖励获取完成:" + str(len(items)) + " 条")
return {"userLoginBonusList": items}
-77
View File
@@ -1,77 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_map.py —— 取用户地图进度(GetUserMapApi)
================================================================================
取玩家各地图的推进距离(真机 104 条)。
对应接口:GetUserMapApi
请求体 :UserMapRequestVO(userId + nextIndex + maxCount=1000)
响应 :{userId, length, nextIndex, userMapList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMapRequestVO.cs
★ Get 侧下发 100+ 条;Upsert 只回传「本次距离变化」的那 1 条
(真机:mapId=550003 的 distance 1424000 -> 1435000)。
调用示例:
from api import UserMap / UserMapAll
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdPageRequestVO, fetch_all_pages
def UserMap(
user_id: int,
*,
next_index: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户地图进度(单页,对应 GetUserMapApi)。
参数:
next_index: 分页起始索引(0 = 首页)
返回:
原始响应 dict,含 userMapList
"""
log_info("取用户地图进度:user_id=" + str(user_id) + " next_index=" + str(next_index))
return _get(
"GetUserMapApi",
UserIdPageRequestVO(userId=user_id, nextIndex=next_index, maxCount=1000),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def UserMapAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户地图进度(自动翻页取全)。
返回:
{"userMapList": [...]}
"""
log_info("取用户地图进度(全量):user_id=" + str(user_id))
items = fetch_all_pages(
"GetUserMapApi", user_id, "userMapList",
body_factory=lambda ni: UserIdPageRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=1000),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("地图进度获取完成:" + str(len(items)) + " 条")
return {"userMapList": items}
-48
View File
@@ -1,48 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi)
================================================================================
取玩家的任务进度与周常数据(真机 6 条任务)。
对应接口:GetUserMissionDataApi
请求体 :UserIdRequestVO
响应 :{userId, userWeeklyData:{...}, userMissionDataList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMissionRequestVO.cs
★ 一个接口同时下发两个 Upsert 节点:
userMissionDataList 任务列表
userWeeklyData 周常数据(★ 是**对象**不是数组)
★ 两者回传 Upsert 时都要丢掉各自的 userId 字段。
调用示例:
from api import UserMissionData
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserMissionData(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户任务与周数据(对应 GetUserMissionDataApi)。
返回:
原始响应 dict:{"userId":.., "userWeeklyData":{..}, "userMissionDataList":[..]}
"""
log_info("取用户任务与周数据:user_id=" + str(user_id))
return _get("GetUserMissionDataApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-90
View File
@@ -1,90 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_music.py —— 取用户曲目成绩(GetUserMusicApi)
================================================================================
取玩家全部曲目的历史成绩归档(真机 739 组)。
对应接口:GetUserMusicApi
请求体 :UserMusicRequestVO(userId + nextIndex + maxCount=50)
响应 :{userId, nextIndex, userMusicList:[{userMusicDetailList:[...], length}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMusicRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/UserMusic.cs(外层按曲分组)
source/Assembly-CSharp/Net.VO.Mai2/UserMusicDetail.cs(内层按难度)
★ 响应是**两层嵌套**:外层每元素是一首歌,内层的 userMusicDetailList 才是
按 (musicId, level) 的成绩条目。取数时要摊平。
★ 回传 Upsert 时只回传本局玩的那一条(playCount +1),其余不动。
调用示例:
from api import UserMusicDetail, UserMusicDetailFlat
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserMusicRequestVO, fetch_all_pages
def UserMusicDetail(
user_id: int,
*,
next_index: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户曲目成绩(单页,对应 GetUserMusicApi)。
参数:
next_index: 分页起始索引(0 = 首页)
返回:
原始响应 dict,含 userMusicList(两层嵌套)
"""
log_info("取用户曲目成绩:user_id=" + str(user_id) + " next_index=" + str(next_index))
return _get(
"GetUserMusicApi",
UserMusicRequestVO(userId=user_id, nextIndex=next_index, maxCount=50),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def UserMusicDetailAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户曲目成绩(自动翻页取全,已摊平成单层)。
返回:
{"userMusicList": [ {"musicId":..,"level":..,"playCount":..}, ... ]}
每元素是**单条 (musicId, level) 成绩**,不是按曲分组的嵌套结构。
"""
log_info("取用户曲目成绩(全量):user_id=" + str(user_id))
groups = fetch_all_pages(
"GetUserMusicApi", user_id, "userMusicList",
body_factory=lambda ni: UserMusicRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=50),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
flat: List[Dict[str, Any]] = []
for group in groups:
if isinstance(group, dict):
details = group.get("userMusicDetailList") or []
if isinstance(details, dict):
details = [details]
flat.extend(details)
log_info("曲目成绩获取完成:" + str(len(flat)) + " 条(原始 "
+ str(len(groups)) + " 组)")
return {"userMusicList": flat}
-84
View File
@@ -1,84 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_new_item.py —— 取新增道具(GetUserNewItemListApi)
================================================================================
上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。
对应接口:GetUserNewItemListApi
请求体 :UserNewItemListRequestVO(userId + version + userData[] + userPlaylogList[])
响应 :{userId, userItemList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserNewItemListRequestVO.cs
source/2026-10-05.log:121 真机请求带 userData 与 userPlaylogList
★ 这是**结算之后**才调的接口,用于回查本局获得的新道具。
★ 请求体里的 userData / userPlaylogList 需要传,内容与 UpsertUserAll 一致。
调用示例:
from api import UserNewItemList
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLAY_VERSION_INT, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
@dataclass
class UserNewItemListRequestVO:
"""请求 VO(对应 UserNewItemListRequestVO)。"""
userId: int
version: int = PLAY_VERSION_INT
userData: List[Any] = field(default_factory=list)
userPlaylogList: List[Any] = field(default_factory=list)
def UserNewItemList(
user_id: int,
*,
version: int = PLAY_VERSION_INT,
user_data: Optional[List[Any]] = None,
user_playlog_list: Optional[List[Any]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取本次新增道具(对应 GetUserNewItemListApi)。
★ 这是**结算流程的前置接口**,真机在 UpsertUserAll 之前约 8 秒调用
(source/2026-10-05-new.log: 19:22:53 -> 19:23:02)。
★★ 传入的 user_data 必须是**游玩后**的档案,不是登录时的旧值。
真机实证(source/2026-10-05-new.log 的该请求):
userData[0].playCount = 1258 (登录时是 1258,本局后 1259)
userData[0].point = 4460 (登录时是 4110,本局后 4460)
userData[0].lastPlayDate = "2026-10-05 19:22:53.0"
即:**与 UpsertUserAll 里的 userData 完全一致**。
所以正确用法是先把本局演算跑完,再拿演算后的 userData 传进来。
参数:
user_id: 用户 ID(必填)
version: ROM 版本整数(默认取 config.PLAY_VERSION_INT)
user_data: 玩家档案数组(通常传 [游玩后的 userData])
user_playlog_list: 本局成绩数组(通常传 [playlog])
返回:
原始响应 dict,含 userItemList
"""
log_info("取新增道具:user_id=" + str(user_id) + " version=" + str(version))
return request_dict(
"GetUserNewItemListApi",
UserNewItemListRequestVO(
userId=user_id, version=version,
userData=list(user_data or []),
userPlaylogList=list(user_playlog_list or []),
),
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-47
View File
@@ -1,47 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_option.py —— 取用户游戏设置(GetUserOptionApi)
================================================================================
取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。
对应接口:GetUserOptionApi
请求体 :UserIdRequestVO
响应 :userOption 的字段直接平铺在响应顶层
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserOptionRequestVO.cs
★ 形态提醒:Get 侧响应是**平铺**的(顶层直接就是 optionKind/noteSpeed/...),
而回传 Upsert 时要包成单元素数组 userOption:[{...}]。
裁剪时要丢掉 tempoVolume(真机 Upsert 报文里没有这个字段)。
调用示例:
from api import UserOption
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserOption(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户游戏设置(对应 GetUserOptionApi)。
返回:
原始响应 dict,设置字段平铺在顶层
"""
log_info("取用户游戏设置:user_id=" + str(user_id))
return _get("GetUserOptionApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-46
View File
@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_rating.py —— 取用户 Rating 明细(GetUserRatingApi)
================================================================================
取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。
对应接口:GetUserRatingApi
请求体 :UserIdRequestVO
响应 :{userId, userRating:{rating, ratingList, newRatingList, nextRatingList,
nextNewRatingList, udemae}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserRatingRequestVO.cs
★ 回传 Upsert 时是**单元素数组** userRatingList:[{...}],不是对象。
调用示例:
from api import UserRating
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserRating(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户 Rating 明细(对应 GetUserRatingApi)。
返回:
原始响应 dict,含 userRating 子对象
"""
log_info("取用户 Rating 明细:user_id=" + str(user_id))
return _get("GetUserRatingApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-64
View File
@@ -1,64 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_recommend_music.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
================================================================================
对应接口:
GetUserRecommendRateMusicApi 请求体 UserIdRequestVO
响应 {userId, userRecommendRateMusicIdList:[...]}
GetUserRecommendSelectMusicApi 请求体 UserIdRequestVO
响应 {userId, userRecommendSelectionMusicIdList:[...]}
依据:
source/Assembly-CSharp/Net.VO.Mai2/UserRecommendRateMusicRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/UserRecommendSelectionMusicRequestVO.cs
source/2026-10-05.log:79,81
调用示例:
from api import UserRecommendRateMusic, UserRecommendSelectMusic
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserRecommendRateMusic(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取推荐上分曲目(对应 GetUserRecommendRateMusicApi)。
返回:
原始响应 dict,含 userRecommendRateMusicIdList
"""
log_info("取推荐上分曲目:user_id=" + str(user_id))
return _get("GetUserRecommendRateMusicApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
def UserRecommendSelectMusic(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取推荐选曲(对应 GetUserRecommendSelectMusicApi)。
返回:
原始响应 dict,含 userRecommendSelectionMusicIdList
"""
log_info("取推荐选曲:user_id=" + str(user_id))
return _get("GetUserRecommendSelectMusicApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-44
View File
@@ -1,44 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_region.py —— 取用户地区游玩统计(GetUserRegionApi)
================================================================================
取玩家在各地区的游玩次数。
对应接口:GetUserRegionApi
请求体 :UserIdRequestVO
响应 :{userId, length, userRegionList:[{regionId,playCount,created}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserRegionRequestVO.cs
source/2026-10-05.log:77
调用示例:
from api import UserRegion
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserRegion(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户地区游玩统计(对应 GetUserRegionApi)。
返回:
原始响应 dict,含 userRegionList
"""
log_info("取用户地区统计:user_id=" + str(user_id))
return _get("GetUserRegionApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-128
View File
@@ -1,128 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_rival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
================================================================================
取玩家的对手名单及其曲目成绩。
对应接口:
GetUserRivalDataApi 请求体 UserRivalRequestVO(userId + rivalId)
响应 {userId, userRivalData:{rivalId, rivalName}}
GetUserRivalMusicApi 请求体 UserRivalMusicRequestVO(userId + rivalId + nextIndex
+ userRivalMusicLevelList)
响应 {userId, rivalId, length, nextIndex, userRivalMusicList}
依据:
source/Assembly-CSharp/Net.VO.Mai2/UserRivalRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/UserRivalMusicRequestVO.cs
source/2026-10-05.log:95,101
★ 对手 ID 需要调用方提供(先由 GetUserFavoriteItemApi 的 kind=2 取到)。
调用示例:
from api import UserRivalData, UserRivalMusic
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import (
_get,
UserRivalRequestVO,
UserRivalMusicRequestVO,
RIVAL_MUSIC_LEVELS,
fetch_all_pages,
)
def UserRivalData(
user_id: int,
rival_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取对手资料(对应 GetUserRivalDataApi)。
参数:
user_id: 用户 ID(必填)
rival_id: 对手 ID(必填)
返回:
原始响应 dict,含 userRivalData
"""
log_info("取对手资料:user_id=" + str(user_id) + " rival_id=" + str(rival_id))
return _get(
"GetUserRivalDataApi",
UserRivalRequestVO(userId=user_id, rivalId=rival_id),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def UserRivalMusic(
user_id: int,
rival_id: int,
*,
next_index: int = 0,
levels: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取对手曲目成绩(单页,对应 GetUserRivalMusicApi)。
参数:
user_id: 用户 ID(必填)
rival_id: 对手 ID(必填)
next_index: 分页索引(0 = 首页)
levels: 难度档位列表(可选,默认 0/1/2/3/4/10)
返回:
原始响应 dict,含 userRivalMusicList
"""
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
log_info("取对手曲目成绩:user_id=" + str(user_id) + " rival_id=" + str(rival_id)
+ " levels=" + str(lv))
return _get(
"GetUserRivalMusicApi",
UserRivalMusicRequestVO(
userId=user_id, rivalId=rival_id, nextIndex=next_index,
userRivalMusicLevelList=[{"level": x} for x in lv],
),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def UserRivalMusicAll(
user_id: int,
rival_id: int,
*,
levels: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取对手曲目成绩(自动翻页取全)。
返回:
{"userRivalMusicList": [...]}
"""
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
log_info("取对手曲目成绩(全量):user_id=" + str(user_id) + " rival_id=" + str(rival_id))
items = fetch_all_pages(
"GetUserRivalMusicApi", user_id, "userRivalMusicList",
body_factory=lambda ni: UserRivalMusicRequestVO(
userId=user_id, rivalId=rival_id, nextIndex=ni or 0,
userRivalMusicLevelList=[{"level": x} for x in lv]),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("对手曲目获取完成:" + str(len(items)) + " 条")
return {"userRivalMusicList": items}
-58
View File
@@ -1,58 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi)
================================================================================
取玩家在各商店商品的已兑换次数。
对应接口:GetUserShopStockApi
请求体 :UserShopStockRequestVO(userId + shopItemIdList)
响应 :{userId, userShopStockList:[{shopItemId,tradeCount}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserShopStockRequestVO.cs
source/2026-10-05.log:117 真机请求体带 100+ 个 shopItemId
★ shopItemIdList 需要调用方提供(来自游戏 Master 数据,脚本无法自行推导)。
不传时按空列表发送,服务端返回空 userShopStockList —— 这是合法的。
调用示例:
from api import UserShopStock
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLAY_SHOP_ITEM_IDS, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserShopStockRequestVO
def UserShopStock(
user_id: int,
*,
shop_item_id_list: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户商店库存(对应 GetUserShopStockApi)。
参数:
user_id: 用户 ID(必填)
shop_item_id_list: 商品 ID 清单(可选,默认取 config.PLAY_SHOP_ITEM_IDS)
返回:
原始响应 dict,含 userShopStockList
"""
ids = list(shop_item_id_list) if shop_item_id_list is not None \
else list(PLAY_SHOP_ITEM_IDS)
log_info("取用户商店库存:user_id=" + str(user_id) + " 商品数=" + str(len(ids)))
return _get(
"GetUserShopStockApi",
UserShopStockRequestVO(userId=user_id, shopItemIdList=ids),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
-58
View File
@@ -1,58 +0,0 @@
# -*- coding: utf-8 -*-
"""
userdata.py —— 取用户全量数据(GetUserDataApi)
================================================================================
取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。
对应接口:GetUserDataApi
请求体 :UserIdRequestVO(只有 userId)
响应 :{userId, userData:{86 键}, banState}
依据 :source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserData.cs:17
source/Assembly-CSharp/Net.VO.Mai2/UserDetailResponseVO.cs
★ banState 在 Get 侧是**顶层**字段(UserDetailResponseVO.cs:15),
回传 Upsert 时要搬进 userData 内部(真机 source/2026-10-05.log:123 的 userData 里有 banState)。
⚠ 常见误区:有资料认为 userData 由 GetGameKaleidxScopeApi 下发。
但 GameKaleidxScopeResponseVO.cs 里只有 gameKaleidxScopeList 字段,根本没有 userData。
真机日志里那一次「GetGameKaleidxScope 的响应带 userData」,是抓包工具按时间相邻
记录造成的假象(两条响应被合并显示)。
调用示例:
from api import UserData
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserData(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户全量数据(对应 GetUserDataApi)。
参数:
user_id: 用户 ID(必填)
client_id: 机台短值(可选)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
原始响应 dict:{"userId":.., "userData":{..}, "banState":..}
"""
log_info("取用户全量数据:user_id=" + str(user_id))
return _get("GetUserDataApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-222
View File
@@ -1,222 +0,0 @@
# -*- coding: utf-8 -*-
"""
userlogin.py —— 用户正式登录(UserLoginApi)
================================================================================
功能:创建登录会话,返回 loginId / loginDateTime / 登录天数统计。
loginId 与 loginDateTime 是后续结算上报与登出的**必需输入**。
对应接口:UserLoginApi
请求体 :10 个字段,见 source/Assembly-CSharp/Net.VO.Mai2/UserLoginRequestVO.cs
source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:21-31
响应 :UserLoginResponseVO(returnCode=1 表示成功)
--------------------------------------------------------------------------------
★ 关于 loginDateTime(与后续接口强相关,务必理解)
--------------------------------------------------------------------------------
真机取的是「本地墙上时间被当作 UTC 时刻」的秒数,即
loginDateTime = 标准 Unix 时间戳 + 本地时区偏移
东八区即 now + 28800。
成因:MAI2.DateTime.cs 里 DateTime.UtcNow + LocalOffset 得到 Kind=Unspecified
的值,PacketUserLogin.cs:29 再用 new DateTimeOffset(该值) 又加一次本地偏移。
影响:PacketUserLogin.cs:43 会把请求值回填响应;
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
与登录时不一致会被服务端拒绝。
本模块默认按真机算法生成;需要手动指定时传 login_date_time。
★ 关于 dateTime 与 loginDateTime 的区别
dateTime = ALL.Net 认证时刻(Auth.AuthTime,标准 Unix 秒)
loginDateTime= 本次登录时刻(上面的特殊算法)
两者都出现在 UserLoginRequestVO 里,但只有 loginDateTime 会被后续接口复用。
调用示例:
from api import UserLogin
login = UserLogin(12345678, "TESTTOKEN0000000000", region_id=1, place_id=1000)
print(login.loginId, login.loginDateTime)
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_LONG_MS
from .log_setup import log_debug, log_error, log_info
from .maimai_net import (
MaimaiNetClient,
local_utc_offset_seconds,
login_date_time_now,
request_dict,
)
@dataclass
class UserLoginRequestVO:
"""请求 VO(对应 UserLoginRequestVO,字段顺序照 C# 声明)。"""
userId: int
accessCode: str = ""
regionId: int = 0
placeId: int = 0
clientId: str = ""
dateTime: int = 0
loginDateTime: int = 0
isContinue: bool = False
genericFlag: int = 0
token: str = ""
@dataclass
class UserLoginResponseVO:
"""
响应 VO(对应 UserLoginResponseVO)。
★ 后两个字段是**客户端回填**的,服务端不返回:
loginDateTime —— PacketUserLogin.cs:43 用请求值回填,
UpsertUserAll / UserLogout 都复用它
dateTime —— 本次请求发出的 ALL.Net 认证时刻,
UpsertUserAll 的 userData.dateTime 要用它
(真机 source/2026-10-05.log:123 两处都是 1791134329)
"""
returnCode: int = 0
lastLoginDate: str = ""
loginCount: int = 0
consecutiveLoginCount: int = 0
loginId: int = 0
loginDateTime: int = 0
token: str = ""
dateTime: int = 0
def UserLogin(
user_id: int,
token: str,
*,
access_code: str = "",
region_id: int = REGION_ID,
place_id: int = PLACE_ID,
client_id: str = CLIENT_ID,
is_continue: bool = False,
generic_flag: int = 0,
date_time: Optional[int] = None,
login_date_time: Optional[int] = None,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> UserLoginResponseVO:
"""
用户正式登录(创建登录会话)。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(必填,来自 ParseQRCode)
access_code: 访问码(可选,通常为空)
region_id: 区域 ID(可选,默认 config.REGION_ID)
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
client_id: 机台短值(可选)
is_continue: 是否续玩(可选)
generic_flag: 通用标志位(可选)
date_time: ALL.Net 认证时刻(可选,默认当前 Unix 秒)
login_date_time: 本次登录时刻(可选,默认按真机算法生成)
timeout_ms: 超时毫秒(可选,默认 60 秒)
client: 可复用的 MaimaiNetClient(可选)
返回:
UserLoginResponseVO(loginDateTime 已按真机行为回填)
异常:
RuntimeError: returnCode != 1
"""
log_info(
"用户正式登录:user_id=" + str(user_id)
+ " region_id=" + str(region_id) + " place_id=" + str(place_id)
)
now = int(time.time())
if date_time is None:
date_time = now
if login_date_time is None:
login_date_time = login_date_time_now()
log_debug(
"时间戳:dateTime=" + str(date_time) + " loginDateTime=" + str(login_date_time)
+ "(本地时区偏移 " + str(local_utc_offset_seconds()) + " 秒)"
)
raw = request_dict(
"UserLoginApi",
UserLoginRequestVO(
userId=user_id,
accessCode=access_code,
regionId=region_id,
placeId=place_id,
clientId=client_id,
dateTime=date_time,
loginDateTime=login_date_time,
isContinue=is_continue,
genericFlag=generic_flag,
token=token,
),
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_LONG_MS, client=client,
)
known = {f for f in UserLoginResponseVO.__dataclass_fields__}
resp = UserLoginResponseVO(**{k: v for k, v in raw.items() if k in known})
if resp.returnCode != 1:
msg = "登录失败:returnCode=" + str(resp.returnCode)
log_error(msg)
raise RuntimeError(msg)
# ★ 回填 loginDateTime(对应 PacketUserLogin.cs:43)
# 服务端不返回该字段,客户端用请求值回填;后续 UpsertUserAll / UserLogout 复用。
if not resp.loginDateTime:
resp.loginDateTime = login_date_time
log_debug("服务端未返回 loginDateTime,按真机行为回填为 " + str(login_date_time))
# ★ 回填 dateTime:本次请求实际发出的 ALL.Net 认证时刻。
# UpsertUserAll 的 userData.dateTime 要用它(真机两处一致)。
resp.dateTime = date_time
log_info(
"用户登录成功:loginId=" + str(resp.loginId)
+ " loginDateTime=" + str(resp.loginDateTime)
+ " dateTime=" + str(resp.dateTime)
+ " lastLoginDate=" + resp.lastLoginDate
+ " loginCount=" + str(resp.loginCount)
+ " consecutiveLoginCount=" + str(resp.consecutiveLoginCount)
)
return resp
if __name__ == "__main__":
import sys
from .log_setup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("userlogin_test")
try:
if len(sys.argv) < 3:
print("用法: python -m api.userlogin <user_id> <token> [region_id] [place_id]")
return 1
rid = int(sys.argv[3]) if len(sys.argv) > 3 else REGION_ID
pid = int(sys.argv[4]) if len(sys.argv) > 4 else PLACE_ID
lg = UserLogin(int(sys.argv[1]), sys.argv[2], region_id=rid, place_id=pid)
print("")
for name in UserLoginResponseVO.__dataclass_fields__:
print(" " + name.ljust(24) + "= " + str(getattr(lg, name)))
return 0
except Exception as exc:
log_error("用户登录失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
-254
View File
@@ -1,254 +0,0 @@
# -*- coding: utf-8 -*-
"""
userlogout.py —— 用户登出(UserLogoutApi)
================================================================================
功能:结束登录会话,可选上报本次会话的网络统计(delayLog)。
对应接口:UserLogoutApi
请求体 :两种形态,见 source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogout.cs:22-63
type == 1 (Logout) -> UserLogoutRequest2VO(含 delayLog)
其他 type -> UserLogoutRequestVO (不含 delayLog)
--------------------------------------------------------------------------------
登出类型(source/Assembly-CSharp/Net.VO.Mai2/LogoutType.cs)
--------------------------------------------------------------------------------
None = 0
Logout = 1 正常登出(带 delayLog 统计)
Cancel = 2 取消 / 强制登出指定座席(NetPacketUtil.cs:47)
Error = 3 错误强制登出(GameMain.cs:91)
TestIn = 4 测试登入 / 顶号(GameMain.cs:104)
Quit = 5 退出时强制登出,可释放残留会话(GameMainObject.cs:200)
★ 真机会话结束时用的是 type=4(TestIn),见 source/2026-10-05.log:125。
★ delayLog 结构(source/Assembly-CSharp/Net.VO.Mai2/DelayLog.cs)
request 是定长 26 的数组(DelayLog.cs:17 new RequestAPI[26]),
每个元素 {count, size, msec, retry}。
调用示例:
from api import UserLogout, LogoutType
UserLogout(12345678, token, login.loginDateTime,
logout_type=LogoutType.TestIn, region_id=1, place_id=1000)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_MS
from .log_setup import log_debug, log_error, log_info
from .maimai_net import MaimaiNetClient, request_dict
#: DelayLog.request 的定长(DelayLog.cs:17)
DELAY_LOG_SLOTS = 26
class LogoutType:
"""登出类型(对应 Net.VO.Mai2.LogoutType)。"""
None_ = 0
Logout = 1 # 正常登出(带 delayLog)
Cancel = 2 # 取消 / 强制登出
Error = 3 # 错误强制登出
TestIn = 4 # 测试登入 / 顶号(真机会话结束用这个)
Quit = 5 # 退出强制登出
@dataclass
class RequestAPI:
"""单个接口的统计(对应 Net.VO.Mai2/RequestAPI.cs)。"""
count: int = 0
size: int = 0
msec: int = 0
retry: int = 0
@dataclass
class DelayLog:
"""会话网络统计(对应 Net.VO.Mai2/DelayLog.cs)。"""
dlRequests: int = 0
dlSize: int = 0
dlRetry: int = 0
loginMsec: int = 0
saveMsec: int = 0
reductionMusic: int = 0
reductionItem: int = 0
request: List[RequestAPI] = field(
default_factory=lambda: [RequestAPI() for _ in range(DELAY_LOG_SLOTS)]
)
@dataclass
class UserLogoutRequestVO:
"""简单登出请求 VO(对应 UserLogoutRequestVO)。"""
userId: int
accessCode: str = ""
regionId: int = 0
placeId: int = 0
clientId: str = ""
loginDateTime: int = 0
type: int = 0
@dataclass
class UserLogoutRequest2VO:
"""完整登出请求 VO(对应 UserLogoutRequest2VO,含 delayLog)。"""
userId: int
accessCode: str = ""
regionId: int = 0
placeId: int = 0
clientId: str = ""
loginDateTime: int = 0
type: int = 0
delayLog: Optional[DelayLog] = None
@dataclass
class UserLogoutResponseVO:
"""响应 VO(对应 UserLogoutResponseVO)。"""
returnCode: int = 0
def create_empty_delay_log() -> DelayLog:
"""创建全零的 DelayLog(26 个槽位)。"""
return DelayLog()
def create_delay_log_from_stats(
requests_stats: Optional[List[Dict[str, Any]]] = None,
*,
dl_requests: int = 0,
dl_size: int = 0,
dl_retry: int = 0,
login_msec: int = 0,
save_msec: int = 0,
reduction_music: int = 0,
reduction_item: int = 0,
) -> DelayLog:
"""
用统计数据构造 DelayLog。
参数:
requests_stats: 每个接口的统计列表,元素形如
{"count":1,"size":2090,"msec":73,"retry":0}
按 DelayLog.request 的槽位顺序填入(最多 26 个)
"""
dl = DelayLog(
dlRequests=dl_requests, dlSize=dl_size, dlRetry=dl_retry,
loginMsec=login_msec, saveMsec=save_msec,
reductionMusic=reduction_music, reductionItem=reduction_item,
)
for i, stat in enumerate(requests_stats or []):
if i >= DELAY_LOG_SLOTS:
break
dl.request[i] = RequestAPI(
count=int(stat.get("count", 0)),
size=int(stat.get("size", 0)),
msec=int(stat.get("msec", 0)),
retry=int(stat.get("retry", 0)),
)
return dl
def UserLogout(
user_id: int,
token: str,
login_date_time: int,
*,
logout_type: int = LogoutType.TestIn,
access_code: str = "",
region_id: int = REGION_ID,
place_id: int = PLACE_ID,
client_id: str = CLIENT_ID,
delay_log: Optional[DelayLog] = None,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> UserLogoutResponseVO:
"""
用户登出。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(必填)
login_date_time: 登录时刻(必填,取自 UserLogin 的 loginDateTime)
logout_type: 登出类型(可选,默认 4=TestIn,与真机一致)
access_code: 访问码(可选)
region_id: 区域 ID(可选)
place_id: 店铺 ID(可选)
client_id: 机台短值(可选)
delay_log: 会话统计(可选;仅 logout_type=1 时随报文发出)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
UserLogoutResponseVO
异常:
RuntimeError: returnCode != 1
"""
log_info(
"用户登出:user_id=" + str(user_id) + " logout_type=" + str(logout_type)
+ " loginDateTime=" + str(login_date_time)
)
if logout_type == LogoutType.Logout:
body: Any = UserLogoutRequest2VO(
userId=user_id, accessCode=access_code,
regionId=region_id, placeId=place_id, clientId=client_id,
loginDateTime=login_date_time, type=logout_type,
delayLog=delay_log if delay_log is not None else create_empty_delay_log(),
)
log_debug("使用完整登出报文(含 delayLog)")
else:
body = UserLogoutRequestVO(
userId=user_id, accessCode=access_code,
regionId=region_id, placeId=place_id, clientId=client_id,
loginDateTime=login_date_time, type=logout_type,
)
log_debug("使用简单登出报文(不含 delayLog)")
raw = request_dict(
"UserLogoutApi", body,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
resp = UserLogoutResponseVO(returnCode=int(raw.get("returnCode", 0) or 0))
if resp.returnCode != 1:
msg = "登出失败:returnCode=" + str(resp.returnCode)
log_error(msg)
raise RuntimeError(msg)
log_info("用户登出成功:returnCode=" + str(resp.returnCode))
return resp
if __name__ == "__main__":
import sys
from .log_setup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("userlogout_test")
try:
if len(sys.argv) < 4:
print("用法: python -m api.userlogout <user_id> <token> <loginDateTime> "
"[logout_type] [region_id] [place_id]")
return 1
lt = int(sys.argv[4]) if len(sys.argv) > 4 else LogoutType.TestIn
rid = int(sys.argv[5]) if len(sys.argv) > 5 else REGION_ID
pid = int(sys.argv[6]) if len(sys.argv) > 6 else PLACE_ID
resp = UserLogout(int(sys.argv[1]), sys.argv[2], int(sys.argv[3]),
logout_type=lt, region_id=rid, place_id=pid)
print("")
print("returnCode =", resp.returnCode)
return 0
except Exception as exc:
log_error("用户登出失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
-156
View File
@@ -1,156 +0,0 @@
# -*- coding: utf-8 -*-
"""
userpreview.py —— 用户预览(GetUserPreviewApi)
================================================================================
功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。
对应接口:GetUserPreviewApi
请求体 :{"userId":.., "segaIdAuthKey":"", "token":"..", "clientId":".."}
source/Assembly-CSharp/Net.VO.Mai2/UserPreviewRequestVO.cs
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserPreview.cs:20-25
响应 :见 UserPreviewResponseVO(23 字段,含 errorId)
★ 真机调用时机:扫码拿到 userId/token 之后、UserLoginApi 之前
(source/2026-10-05.log:23)。
★ 客户端行为:isLogin=True 时拒绝继续登录,避免顶号
(source/Assembly-CSharp/Process.Entry.State/ConfirmPlay.cs:151)。
调用示例:
from api import UserPreview
pv = UserPreview(12345678, "TESTTOKEN0000000000")
print(pv.userName, pv.playerRating)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict
@dataclass
class UserPreviewRequestVO:
"""请求 VO(对应 UserPreviewRequestVO)。"""
userId: int
segaIdAuthKey: str = ""
token: str = ""
clientId: str = ""
@dataclass
class UserPreviewResponseVO:
"""
响应 VO(对应 UserPreviewResponseVO + 真机响应字段)。
★ 字段顺序按 source/Assembly-CSharp/Net.VO.Mai2/UserPreviewResponseVO.cs
与 source/2026-10-05.log:24 的真机响应合并。
"""
userId: int = 0
userName: str = ""
isLogin: bool = False
lastGameId: Optional[str] = None
lastRomVersion: str = ""
lastDataVersion: str = ""
lastLoginDate: str = ""
lastPlayDate: str = ""
playerRating: int = 0
nameplateId: int = 0
iconId: int = 0
trophyId: int = 0
partnerId: int = 0
frameId: int = 0
dispRate: int = 0
totalAwake: int = 0
isNetMember: int = 0
dailyBonusDate: str = ""
headPhoneVolume: Optional[int] = None
isInherit: bool = False
banState: int = 0
errorId: int = 0
def UserPreview(
user_id: int,
token: str,
*,
client_id: str = CLIENT_ID,
sega_id_auth_key: str = "",
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> UserPreviewResponseVO:
"""
获取用户预览信息。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(必填,来自 ParseQRCode)
client_id: 机台短值(可选)
sega_id_auth_key: SEGA ID 鉴权 key(可选,通常为空)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
UserPreviewResponseVO
"""
log_info("获取用户预览:user_id=" + str(user_id) + " client_id=" + client_id)
raw = request_dict(
"GetUserPreviewApi",
UserPreviewRequestVO(
userId=user_id,
segaIdAuthKey=sega_id_auth_key,
token=token,
clientId=client_id,
),
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
known = {f for f in UserPreviewResponseVO.__dataclass_fields__}
resp = UserPreviewResponseVO(**{k: v for k, v in raw.items() if k in known})
if resp.errorId != 0:
log_warning("服务器返回 errorId=" + str(resp.errorId))
if resp.banState != 0:
log_warning("账号可能被封禁:banState=" + str(resp.banState))
if resp.isLogin:
log_warning("账号处于已登录状态(isLogin=True),继续登录会顶号")
log_info(
"用户预览获取完成:userName=" + resp.userName
+ " playerRating=" + str(resp.playerRating)
+ " isLogin=" + str(resp.isLogin)
+ " isNetMember=" + str(resp.isNetMember)
)
return resp
if __name__ == "__main__":
import sys
from .log_setup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("userpreview_test")
try:
if len(sys.argv) < 3:
print("用法: python -m api.userpreview <user_id> <token> [client_id]")
return 1
cid = sys.argv[3] if len(sys.argv) > 3 else CLIENT_ID
pv = UserPreview(int(sys.argv[1]), sys.argv[2], client_id=cid)
print("")
for name in UserPreviewResponseVO.__dataclass_fields__:
print(" " + name.ljust(18) + "= " + str(getattr(pv, name)))
return 0
except Exception as exc:
log_error("获取用户预览失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
-259
View File
@@ -1,259 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
buy_ticket.py —— 主脚本范例:扫码登录 → 购买功能票 → 登出
================================================================================
一条完整的「买票」链路:
ParseQRCode -> UserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」)
2. 调 GetUserDataApi 取 playCount / playerRating
3. 按 TicketManager.cs:564 算 validDate(现在 -4h +90d,取日期补 04:00:00)
4. 查 config.TICKET_CREDIT_NUM 得到 userChargelog.price(= ticket.creditNum)
5. 闸门 → 发包
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python buy_ticket.py <二维码> # 预检:只看报文,不发包
python buy_ticket.py <二维码> --list # 只列当前票券和可买的票
python buy_ticket.py <二维码> --charge-id 3 # 指定票(默认 5 = 5倍券)
python buy_ticket.py <二维码> --charge-id 2 --send # ★ 真实购买
python buy_ticket.py <二维码> --charge-id 2 --send --debug
python buy_ticket.py <二维码> --charge-id 2 --send --skip-entry # 跳过入场数据下载
付费票(config.PAID_TICKET_IDS):
2 = 2倍チケット creditNum 1
3 = 3倍チケット creditNum 2
4 = 4倍チケット creditNum 3
5 = 5倍チケット creditNum 4
6 = 6倍チケット creditNum 5
★ 真实购买会写入一条购买记录(不可撤销)。不传 --send 时只构造报文。
★ 二维码有**时效性**(**有效期 10 分钟**),过期后 aimedb 返回 errorID=2
—— **不是按次数计量的**,重新生成即可。
★ 购买前会先查一次功能票;**账号已持有功能票时直接拒绝购买**(重复购买有封号风险)。
"""
from __future__ import annotations
import sys
import time
from api import (
setup_logging,
teardown_logging,
log_error,
log_info,
ParseQRCode,
UserPreview,
UserLogin,
UserLogout,
LogoutType,
UpsertUserChargelog,
ChargelogConfirmError,
ChargelogGuardError,
UserCharge,
GetGameCharge,
ticket_name,
fetch_upsert_snapshot,
summarize_snapshot,
Ping,
)
from config import PLACE_ID, REGION_ID
def show_charges(user_id, label):
"""
★ 调 GetUserChargeApi 打印当前票券状态,并返回 stock > 0 的那些。
★★ 为什么跑前跑后都要查:
真机只在「本局要用这张票」时才购买,买到手会立刻被用掉(stock--),
所以正常状态 stock 恒为 0。
**已经持票还去买,服务端看到的是重复购买,有封号风险。**
所以买之前必须确认没有持票;买之后要确认状态符合预期。
"""
raw = UserCharge(user_id)
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
if not lst:
log_info(" (空)")
for c in lst:
cid = c.get("chargeId")
stock = int(c.get("stock", 0) or 0)
log_info(" chargeId=" + str(cid) + " " + ticket_name(cid)
+ " stock=" + str(stock)
+ " purchaseDate=" + str(c.get("purchaseDate"))
+ " validDate=" + str(c.get("validDate"))
+ (" ← ★ 持有中" if stock > 0 else ""))
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
log_info("[" + label + "] 持有中的功能票:" + str(len(held)) + " 张")
return held
def parse_args(argv):
opts = {"qr": "", "charge_id": 5, "send": False, "list": False,
"debug": False, "skip_entry": False, "wait_before": 30.0}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--list":
opts["list"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--skip-entry":
opts["skip_entry"] = True
elif a == "--wait-before" and i + 1 < len(argv):
opts["wait_before"] = float(argv[i + 1])
i += 1
elif a == "--charge-id" and i + 1 < len(argv):
opts["charge_id"] = int(argv[i + 1])
i += 1
elif not a.startswith("--") and not opts["qr"]:
opts["qr"] = a
i += 1
return opts
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["qr"]:
print(__doc__)
return 1
if opts["debug"]:
import config as _cfg
_cfg.LOG_LEVEL = "debug"
setup_logging("buy_ticket")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---- ★★ 最早的前置检查:这张票当前**上架了吗** ----
# ★ GetGameChargeApi 不需要登录(user_id=0),所以放在**扫码之前**,
# 不满足就直接退出 —— 不浪费二维码,也不产生任何登录会话。
# 依据 TicketManager.cs:137-150:真机也只买 GetGameChargeApi 列出来的票。
shop = GetGameCharge().get("gameChargeList") or []
avail = [int(g.get("chargeId")) for g in shop
if isinstance(g, dict) and g.get("chargeId") is not None]
log_info("当前可购买 chargeId:" + str(avail))
for g in shop:
log_info(" chargeId=" + str(g.get("chargeId"))
+ " " + ticket_name(g.get("chargeId"))
+ " money price=" + str(g.get("price"))
+ " 有效期 " + str(g.get("startDate")) + " ~ " + str(g.get("endDate")))
if opts["charge_id"] not in avail:
log_error("chargeId=" + str(opts["charge_id"])
+ "(" + ticket_name(opts["charge_id"])
+ ")**不在当前可购买清单里** —— 连登录都不做,直接退出。")
log_error(" 原因:服务端对未上架的票会 returnCode=0(实测过)。")
log_error(" 想买请用清单里的 chargeId:" + str(avail))
return 4
# ---- ★ 心跳:决定后续所有请求的 number 头 ----
# 真机序列里 Ping 在 [11],紧接着才是 GetUserPreviewApi [12]。
# 不跑的话 number 恒为 "0"(真机是 75)。
Ping()
user_id, token = ParseQRCode(opts["qr"])
session.update(user_id=user_id, token=token)
preview = UserPreview(user_id, token)
if preview.isLogin:
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
# ---- ★ 复刻真机的「入场」序列:登录后先把用户数据全量拉一遍 ----
# 真机 13:19:45 登录 -> 13:19:46~13:19:51 拉 47 个节点
# -> 13:20:10 才发 UpsertUserChargelogApi
# 本脚本原来 login 后直接买,跳过了这一段。
if not opts["skip_entry"]:
snap = fetch_upsert_snapshot(user_id, token=token,
place_id=PLACE_ID, region_id=REGION_ID)
_sum = summarize_snapshot(snap)
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
+ str(len(snap.get("_failed") or [])) + " 个")
# ---- ★★ 跑前:必须确认账号当前**没有**持有功能票 ----
held_before = show_charges(user_id, "购买前")
aborted = False
if held_before:
log_error("账号当前已持有功能票,**拒绝购买**(重复购买有封号风险):")
for c in held_before:
log_error(" chargeId=" + str(c.get("chargeId"))
+ " " + ticket_name(c.get("chargeId"))
+ " stock=" + str(c.get("stock"))
+ " validDate=" + str(c.get("validDate")))
log_error("请先在机台把票用掉,或等它过期后再来。")
aborted = True
else:
# ---- ★ 买票前等待(贴近真机节奏:真机 login 13:19:45 -> 买票 13:20:10,间隔 25s)----
if opts["wait_before"] > 0:
log_info("买票前等待 " + str(opts["wait_before"]) + " 秒 ...")
time.sleep(opts["wait_before"])
log_info("等待结束,开始购买")
# ---- 购买 ----
# ★ 一个函数搞定:取当前票券/档案 -> 安全检查 -> 算 validDate/price -> 闸门 -> 发包
UpsertUserChargelog(
user_id, opts["charge_id"], login.loginDateTime,
dry_run=not opts["send"],
confirm=opts["send"],
guard_no_stock=True,
place_id=PLACE_ID, region_id=REGION_ID,
)
# ---- ★★ 跑后:无论有没有真发包,都再查一次 ----
# (中止时也查,确认状态确实没被动过)
held_after = show_charges(user_id, "购买后")
if aborted:
log_info("已按安全检查中止,**未发包**;上面是中止后的复查结果")
if len(held_after) == len(held_before):
log_info("复查确认:票券状态未发生变化 ✅")
else:
log_warning("复查发现票券数量变了 —— 请人工确认")
return 3
if not opts["send"]:
log_info("[dry-run] 未发包。想真实购买请加 --send(★ 会写入购买记录)")
return 0
if held_after:
log_info("购买后持有 " + str(len(held_after)) + " 张票(购买成功)")
else:
log_warning("购买后没有查到持有中的票 —— 服务端可能没落库,"
"或票被立刻消耗了")
return 0
except ChargelogGuardError:
return 3
except SystemExit:
raise
except ChargelogConfirmError:
return 5
except Exception as exc:
log_error(type(exc).__name__ + ": " + str(exc))
return 1
finally:
if session["logged_in"]:
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
teardown_logging()
if __name__ == "__main__":
sys.exit(main())
-461
View File
@@ -1,461 +0,0 @@
# -*- coding: utf-8 -*-
"""
config.py —— 舞萌DX 纯网络脚本工程 · 运行时可配置项
================================================================================
★★ 这是**模板文件**。第一次使用请先复制一份:
cp config.py.example config.py # Linux / macOS
copy config.py.example config.py # Windows
然后**必须**填好第二节「机台与店铺标识」的 6 个值,否则脚本发出去的请求
带的是别人的机台身份,服务端会拒绝。
★ config.py 已在 .gitignore 里 —— 它属于你的私有配置,不要提交。
================================================================================
本文件集中存放**用户可能需要修改**的内容,其余协议常量写死在各自脚本内。
分六块:
1. 网络与加密常量(AES 密钥、协议版本、服务器地址)
2. 机台与店铺标识(Keychip、区域、店铺)
3. 扫码登录常量(aimedb)
4. ★ UpsertUserAll 即将上传的歌曲数据(默认取 source/2026-10-05.log 真机样本)
5. UpsertUserAll 安全闸门
6. 日志级别
权威来源全部标注在注释里,改之前请先看依据。
"""
# =============================================================================
# 一、网络与加密常量
# =============================================================================
# --- 游戏服务器(Maimai2Servlet)---
# 依据:source/Assembly-CSharp/Manager/OperationManager.cs:225,338
# _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
# 实测:source/2026-10-05.log:1
BASE_URL = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/"
# --- AES 参数 ---
# 依据:source/Assembly-CSharp/Net/CipherAES.cs:11-12
# AesKey = "FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W" (32 字节 -> AES-256)
# AesIV = "F>;24DjU9W6ZsRH[" (16 字节)
AES_KEY = b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W"
AES_IV = b"F>;24DjU9W6ZsRH["
# --- API 名混淆盐 ---
# 依据:source/Assembly-CSharp/Net.Packet/Packet.cs:44 ObfuscateParam = "8bF76dE9"
# 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
# "MaimaiChn" 后缀来自 source/Assembly-CSharp/Net.VO/NetQuery.cs:14 Api = api + "MaimaiChn"
OBFUSCATE_PARAM = "8bF76dE9"
# --- 报文版本标识 ---
# 依据:source/Assembly-CSharp/Net/NetHttpClient.cs:69 DefaultHttpHeaderEncryption = "1.55"
# (请求头 Mai-Encoding 的值;1.56 ROM 实测仍发 1.55)
MAI_ENCODING = "1.55"
# --- 超时(毫秒)---
# 依据:source/Assembly-CSharp/Net/NetConfig.cs:7-11
TIMEOUT_MS = 10000 # 普通接口
TIMEOUT_LONG_MS = 60000 # 登录 / 结算上传
# =============================================================================
# 二、机台与店铺标识
# =============================================================================
# Keychip ID(机台唯一标识,完整格式带连字符)
# ★★ 这是你自己的机台身份,不要外传;填错服务端会拒。
# 依据:source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
# KeychipID = AMDaemon.System.KeychipId.Value
KEYCHIP_ID = "" # ★★ 必填:形如 "XXXX-XXXXXXXXXXX"(带连字符)
# 客户端 ID(Keychip 短值,去掉连字符取前 11 位)
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
# clientId = AMDaemon.System.KeychipId.ShortValue
# 实测:真机日志里 clientId 恒等于 Keychip 短值
CLIENT_ID = "" # ★★ 必填:Keychip 短值,去掉连字符取前 11 位
# 区域 ID / 店铺 ID
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:24-25
# regionId = Auth.RegionCode ; placeId = Auth.LocationId
# 实测:真机日志里的 regionId / placeId 与机台所在地一致
REGION_ID = 0 # ★★ 必填:你所在区域 ID
PLACE_ID = 0 # ★★ 必填:你所在店铺 ID
# 店铺名 / 区域名
#
# ★ 为什么必须手工填:**游戏服务器没有任何接口返回店铺名**。
# 逐行扫过 source/2026-10-05.log 全部响应:
# - GetGameSettingApi 只回 gameSetting,无店名
# - GetUserDataApi 的 lastPlaceName 回的是 null
# - GetUserRegionApi 只回 regionId/playCount/created
# 店铺名来自机台本地 AMDaemon:
# source/AMDaemon.NET/AMDaemon/Allnet/Auth.cs:33 LocationName <- allnet_Auth_getLocationName()
# 是开机时 ALL.Net 认证下发的,客户端本地就有,不会向游戏服务器索取。
#
# 取值示例(真机 UpsertUserAll 报文):
# userPlaylogList[0].placeName = "<你的店铺名>"
# userData[0].lastRegionName = "<你的区域名>"
PLACE_NAME = "" # ★★ 必填:店铺名(游戏服务器不返回,只能手填)
REGION_NAME = "" # ★★ 必填:区域名
# =============================================================================
# 三、扫码登录常量(aimedb)
# =============================================================================
#
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
# + docs/舞萌DX_扫码登录协议技术规范.md
# aimedb 服务器
AIMEDB_HOST = "ai.sys-allnet.cn"
AIMEDB_PORT = 443
AIMEDB_PATH = "/wc_aime/api/get_data"
# 签名密钥(含前导 X,32 字符)
# 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致
QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
# 固定字段
QR_OPEN_GAME_ID = "MAID"
QR_TITLE_KEY = "SDGB"
QR_USER_AGENT = "WC_AIME_LIB"
# =============================================================================
# 三之二、★ 功能票(Ticket)主数据
# =============================================================================
#
# 依据:游戏本地主数据
# D:\SDGB2026\Package\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
# 源码侧 Manager.MaiStudio.TicketData.creditNum
#
# ★ 为什么脚本需要它:
# UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是
# **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price:
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410
# result.price = ticket.creditNum;
#
# ticketKind 含义(Ticket.xml 里的枚举):
# 1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
TICKET_CREDIT_NUM = {
0: 0,
2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1
10005: 0, 10105: 0, 10205: 0, # 活动票
11001: 0, # 免费 1.5 倍券
30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票
}
#: 票名(只用于日志好看,不影响协议)
TICKET_NAMES = {
0: "使用しない",
2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット",
10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2",
10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット",
30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票",
}
#: 付费票(ticketKind=1)—— 只有这些能在机台购买
PAID_TICKET_IDS = (2, 3, 4, 5, 6)
#: 买票闸门(和 UpsertUserAll 一样,默认不放行)
UPSERT_CHARGELOG_CONFIRM = False
#: 票有效期算法参数
#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564
#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10
#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00" ✅
TICKET_VALID_HOUR_OFFSET = -4
TICKET_VALID_DAYS = 90
# =============================================================================
# 四、★ UpsertUserAll 即将上传的歌曲数据
# =============================================================================
#
# ★ 为什么放在 config:
# 一局游玩的成绩是**唯一无法由脚本自行产生**的输入 ——
# 纯网络脚本复刻不出游玩过程,服务端要的就是这份原始成绩。
# 换歌 / 换成绩只改这里,不用动任何 api 脚本。
#
# ★ 默认值 = source/2026-10-05.log:123 的**真机跑通报文**(UpsertUserAllApi 请求)
# 即 userPlaylogList[0] 逐字段照抄。
#
# ★ 两个"歌曲数据"的区别(别混):
# userPlaylogList 本局**原始判定输入** —— 服务端据此算分、写战绩表
# userMusicDetailList 按 (musicId, level) 的**历史归档** —— 本局那条 playCount +1
# 下面 PLAY_* 同时喂给这两处,保证一致。
# --- 曲目与难度 ---
PLAY_MUSIC_ID = 143 # 曲目 ID
PLAY_MUSIC_LEVEL = 4 # 0=Basic 1=Advanced 2=Expert 3=Master 4=Re:Master
PLAY_TRACK_NO = 1 # 本局第几首
# --- 版本号(同一版本两种写法,两处都要)---
PLAY_VERSION_INT = 1056000 # 整数形式,用于 userPlaylogList[].version(1.56.00)
PLAY_VERSION_STR = "1.56.00" # 字符串形式,用于 userGamePlaylogList[].version
# --- 成绩 ---
PLAY_ACHIEVEMENT = 0 # 达成率 ×10000(0 = 本局失败)
PLAY_DELUXSCORE = 0 # DX 分数
PLAY_SCORE_RANK = 0 # 评级档(0 = 无)
PLAY_IS_CLEAR = False # 是否 Clear
PLAY_MAX_COMBO = 0
PLAY_TOTAL_COMBO = 655 # 该谱面 note 总数(= 各类型判定数之和)
PLAY_MAX_SYNC = 0
PLAY_TOTAL_SYNC = 0
PLAY_COMBO_STATUS = 0 # 0=无 1=全连
PLAY_SYNC_STATUS = 0
PLAY_FAST_COUNT = 0
PLAY_LATE_COUNT = 0
# --- 判定明细 {类型: {判定位: 次数}} ---
# 真机样本是一局**失败**的成绩(全 Miss)
PLAY_JUDGE_COUNTS = {
"tap": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 441},
"hold": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 19},
"slide": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 192},
"touch": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 0},
"break": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 3},
}
# --- 评级(本局前后)---
PLAY_BEFORE_RATING = 15000
PLAY_AFTER_RATING = 15000
PLAY_BEFORE_GRADE = 0
PLAY_AFTER_GRADE = 0
PLAY_AFTER_GRADE_RANK = 1
PLAY_BEFORE_DELUX_RATING = 15000
PLAY_AFTER_DELUX_RATING = 15000
# --- 曲种索引 ---
# ★ 必须与 userExtend.categoryIndex 一致(真机两处都是 2022)
PLAY_CATEGORY_INDEX = 2022
PLAY_MUSIC_INDEX = 10 # userExtend.musicIndex
PLAY_EXTRA_FLAG = 1721 # userExtend.extraFlag
# --- 本局使用的角色快照(characterId1..5 / Level / Awakening)---
#
# ★ 语义:**进入游玩时**的快照值,不要加经验 —— 这一点极易搞错。
# (结算后的等级在 upsertUserAll.userCharacterList 里,那处才 +经验)
# 真机实证(source/2026-10-05.log:123 同一份报文):
# userPlaylogList[0].characterLevel1..5 = 3800/4455/134/134/134 ← 快照(游玩前)
# upsertUserAll.userCharacterList = 3801/4456/135/135/135 ← 结算后
#
# ★★ 通常**不用改这里**:main.py 会调用 derive_play_characters(snapshot),
# 直接从服务器刚下发的 userData.charaSlot + userCharacterList 推出游玩前等级,
# 并覆盖本配置。只有拿不到快照(离线组包)时才回退到这里。
#
# 默认值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级
PLAY_CHARACTERS = [
{"characterId": 395, "level": 3800, "awakening": 5},
{"characterId": 101, "level": 4455, "awakening": 5},
{"characterId": 400402, "level": 134, "awakening": 3},
{"characterId": 400401, "level": 134, "awakening": 3},
{"characterId": 200401, "level": 134, "awakening": 3},
]
# --- 本局消耗 ---
# ★ playCredit 语义(VOExtensions.cs:838-845):
# playCredit = Credit.GameCostEnoughPlay()(投币成本) + TicketManager.GetTicketCredit(useTicketId)
# 即"这一局总共折合多少 credit",不是余额、不是曲目数。真机样本 = 1。
PLAY_CREDIT = 1
# ★ -1 = 不使用票。依据 TicketManager.cs:47 初始化 _useTicketId[i] = -1;
# GetTicketCredit(-1) 里 GetTicket(-1) 返回 null -> 返回 0,即不用票不折算 credit。
PLAY_USE_TICKET_ID = -1
PLAY_MODE = 0 # 0=通常
PLAY_IS_TUTORIAL = False
PLAY_IS_EVENT_MODE = False
PLAY_IS_NEW_FREE = False
PLAY_IS_FREEDOM_MODE = False
# --- 机台本地计数器(★ 无法从服务器推导,只能手填)---
#
# 依据:source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:881
# result.playCount = Backup.localParameter.playCount
# 这是机台**本地**的游玩计数器,任何 Get 接口都不会返回它,
# 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。
# 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。
# 真机实测:19:21 那一局 = 9,22:31 那一局 = 10。
PLAY_LOCAL_PLAY_COUNT = 8
# --- ★ playSpecial 里的 "CalcSpecialNum"(机台硬件标志位)---
#
# 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:846-855
#
# playSpecial = 位反转32( Random.Range(1,1037933) * 2069 + CalcSpecialNum() )
#
# CalcSpecialNum() 是**按位或**出来的机台特征掩码,逐位含义(源码原文):
# 0x001 SpecialAP (测试/特殊 AP)
# 0x002 IsAutoPlay()
# 0x004 Win32.getRawInputDeviceCount 的 keyboard > 0
# 0x008 同上 mouse > 0
# 0x010 config.IsAutoPlay / IsDebugDisp / IsAllOpen
# 0x020 AMDaemon.System.IsDevelop
# 0x040 !SystemInfo.deviceName.Contains("ACA") ← 真机是 ACA,故该位为 0
# 0x080 !graphicsDeviceName.Contains("GeForce GTX") ← 真机是 GTX,故该位为 0
# 0x100 !processorType.Contains("Core(TM) i3-8100") ← 真机是 i3-8100,故该位为 0
# 0x400 无条件置位(源码 GameManager.cs:375) ← ★ 恒为 1
#
# ★ 真机实测(gamehook 抓到 CalcSpecialNum 调用):
# [CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False)
# 1024 = 0x400,即除"无条件置位"那一位外全部为 0 —— 正好是一台标准 ACA 机台。
#
# ★ 反推验证:真机 playSpecial = 892014272
# 位反转32(892014272) = 57726124 = 27900 * 2069 + 1024
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
#
# 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。
PLAY_SPECIAL_NUM = 1024
# --- 用户总游玩次数增量 ---
#
# ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。
# 想复刻真机的「+1」就改成 1。
#
# 为什么默认改成 0:
# playCount 是账号的**总游玩次数**,是玩家可见的统计值;
# 本工程做「成绩上传」时不需要改动它,保持与上传前一致更安全。
# ★ 改成 0 之后的副作用:「落库回查」不能再靠 playCount 是否 +1 判断,
# 会自动改用 lastPlayDate 是否更新来判断(见 verify_upsert_landing)。
PLAY_USER_PLAY_COUNT_GAIN = 0
# --- 曲目历史游玩次数增量 ---
#
# ★ 默认 0 = 本局**不改动**该曲目的历史游玩次数。
# ★★ 注意语义:是「条目照传、次数不变」,**不是**「不回传这个节点」。
# 实测(同一份代码、同一账号,唯一差别就是这个节点):
# userMusicDetailList 回传 1 条 -> returnCode=1 且 playCount 真的 +1(落库)
# userMusicDetailList 留空 -> returnCode=1 但 playCount 纹丝不动(**没落库**)
# 所以「不想让乐曲 playCount 增加」必须靠把**增量**设成 0,
# 而不是把整个节点清空 —— 后者会让整包被服务端静默丢弃。
#
# 想复刻真机的「该谱面 playCount +1」就改成 1。
PLAY_MUSIC_PLAY_COUNT_GAIN = 0
# --- 地图进度增量 ---
#
# ★ 默认 0 = **本局不推进地图**,userMapList 回传空数组。
# 想复刻真机的"地图 +11000"就改成 11000(真机样本:mapId=550003 的
# distance 1424000 -> 1435000)。
#
# 依据:source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs 的地图导出逻辑
# —— 没变化的条目不会进差分结果。
PLAY_MAP_DISTANCE_GAIN = 0
# --- 组队角色等级增量 ---
#
# ★ 默认 0 = **本局不给角色加经验**,userCharacterList 回传空数组。
# 想复刻真机的"组队 5 只各 +1 级"就改成 1(真机样本 4455->4456 等)。
#
# ★ 注意:设成 0 时 userCharacterList 差集为空 -> 该节点不回传,
# 这是**预期的**,不是错误;validate_before_send 也只在你把增量设成非 0
# 却又没产出条目时才告警。
PLAY_CHARACTER_EXP_GAIN = 0
# --- ★ 关于 userGetPointList / userLoginBonusList(留空即可)---
#
# 这两个节点是**登录奖励发放**才产生的,普通游玩结算不需要回传:
#
# userGetPointList(里程/点数明细)
# 真机 39 次 UpsertUserAll 样本里,**25 次为空(64%)**;
# 只有发生登录奖励/任务奖励时才非空(如 getKind=1 point=200)。
# 留空 => 服务端不累加 point,账户点数保持不变。
#
# userLoginBonusList(签到奖励)
# 同理,只有本次登录触发新签到才回传那一条(isCurrent=true)。
# 留空 => isNewLoginBonusList 也是空串。
#
# ★ 连带影响(正常现象):留空后 userData.point 保持基线值不变。
#
# 源码参考(如需还原发放行为):
# Manager/UserData.cs:57-62 enum MaiMileGetKind { Mission=1, FriendBonus=2, Present=3 }
# Manager/UserData.cs:352-367 AddMissionPointLog / AddFriendBonusPointLog
# --- 店铺库存查询清单(不填则跳过该节点)---
PLAY_SHOP_ITEM_IDS = []
# =============================================================================
# 五、UpsertUserAll 安全闸门
# =============================================================================
#
# ★ 这是**唯一有副作用**的接口:会把本局成绩真实写进服务器,不可撤销。
# 保持 False(默认),调用时必须显式传 confirm=True 才真正发包。
# 改成 True 会永久放行(不建议)。
UPSERT_CONFIRM = False
# 真实发包前的冷却等待(秒)
#
# ★ 语义:**每次**真实发送 UpsertUserAll 之前,都固定等待这么多秒(含首次)。
# 目的:让上报节奏贴近真机 —— 真机从登录到结算上报约 80 秒,
# 不会登录后立刻上报(source/2026-10-05.log:25 登录 → :123 上报,间隔 81 秒)。
#
# ★ 只作用于**真实发送**(confirm=True);dry_run 不等待。
# ★ 冷却发生在「发包前自检」之后,所以自检有问题时会先看到告警,不必白等。
# ★ 调试时想跳过等待:把这里改成 0,或调用时传 cooldown_sec=0。
UPSERT_SEND_COOLDOWN_SEC = 60
# =============================================================================
# 六、日志级别
# =============================================================================
#
# "info" 只输出 api 脚本的运行情况与结果:
# === XxxApi 开始运行 ===
# === XxxApi 运行结束 ===
#
# "debug" 在上面基础上,额外输出向 api 发送的数据与 api 返回的数据:
# → 请求 XxxApi URL / 请求头 / 请求体 JSON 全文
# ← 响应 XxxApi 状态码 / 耗时 / 响应头 / 响应体 JSON 全文
#
LOG_LEVEL = "info"
# 单条日志里内嵌长文本的截断长度(0 = 不截断)
LOG_VALUE_MAX = 0
# =============================================================================
# 导出
# =============================================================================
__all__ = [
# 网络与加密
"BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM", "MAI_ENCODING",
"TIMEOUT_MS", "TIMEOUT_LONG_MS",
# 机台与店铺
"KEYCHIP_ID", "CLIENT_ID", "REGION_ID", "PLACE_ID", "PLACE_NAME", "REGION_NAME",
# 功能票
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
"UPSERT_CHARGELOG_CONFIRM", "TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
# 扫码登录
"AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH", "QR_SIGN_KEY",
"QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT",
# 歌曲数据
"PLAY_MUSIC_ID", "PLAY_MUSIC_LEVEL", "PLAY_TRACK_NO",
"PLAY_VERSION_INT", "PLAY_VERSION_STR",
"PLAY_ACHIEVEMENT", "PLAY_DELUXSCORE", "PLAY_SCORE_RANK", "PLAY_IS_CLEAR",
"PLAY_MAX_COMBO", "PLAY_TOTAL_COMBO", "PLAY_MAX_SYNC", "PLAY_TOTAL_SYNC",
"PLAY_COMBO_STATUS", "PLAY_SYNC_STATUS", "PLAY_FAST_COUNT", "PLAY_LATE_COUNT",
"PLAY_JUDGE_COUNTS",
"PLAY_BEFORE_RATING", "PLAY_AFTER_RATING",
"PLAY_BEFORE_GRADE", "PLAY_AFTER_GRADE", "PLAY_AFTER_GRADE_RANK",
"PLAY_BEFORE_DELUX_RATING", "PLAY_AFTER_DELUX_RATING",
"PLAY_CATEGORY_INDEX", "PLAY_MUSIC_INDEX", "PLAY_EXTRA_FLAG",
"PLAY_CHARACTERS", "PLAY_CREDIT", "PLAY_USE_TICKET_ID", "PLAY_MODE",
"PLAY_IS_TUTORIAL", "PLAY_IS_EVENT_MODE", "PLAY_IS_NEW_FREE", "PLAY_IS_FREEDOM_MODE",
"PLAY_LOCAL_PLAY_COUNT", "PLAY_SPECIAL_NUM",
"PLAY_USER_PLAY_COUNT_GAIN",
"PLAY_MUSIC_PLAY_COUNT_GAIN", "PLAY_MAP_DISTANCE_GAIN", "PLAY_CHARACTER_EXP_GAIN",
"PLAY_SHOP_ITEM_IDS",
# 闸门
"UPSERT_CONFIRM", "UPSERT_SEND_COOLDOWN_SEC",
# 日志
"LOG_LEVEL", "LOG_VALUE_MAX",
]
+487
View File
@@ -0,0 +1,487 @@
# UpsertUserAll 实跑数据 vs 真机日志 逐条对比
> **本文档数据来源**
> - **实跑侧**:用你提供的二维码真实跑通了「扫码 → 预览 → 登录 → 抓快照 → 构造 UpsertUserAll → 登出」,
> 报文由 `api/upsert_user_all.py` 生成(**dry-run,未发包**)
> - **真机侧**:`source/2026-10-05.log:123` 的 UpsertUserAllApi 请求报文
>
> **结论速览**
>
> | 维度 | 结果 |
> |---|---|
> | 顶层字段 | 7/7 结构一致 |
> | 36 个节点 | **形态 36/36 全部一致** |
> | 11 条位串 | **11/11 全部一致**(含 `isNewFavoriteList="22212"`) |
> | userData 字段 | 76/82 命中(6 处差异全部可解释,见 §5) |
> | userPlaylog 字段 | 106/110 命中(4 处差异全部可解释,见 §6) |
---
## 一、本次实跑记录
| 项目 | 值 |
|---|---|
| 二维码时间戳 | `261005175231`(2026-10-05 17:52:31) |
| user_id | 12345678 |
| 昵称 / Rating | ☆Astral☆ / 15000 |
| loginId | **7693120247791879616** |
| loginDateTime | **1791222938** |
| dateTime(Auth.AuthTime) | **1791194138** |
| 快照节点 | 19 个全部成功(`_failed: []`) |
| 是否发包 | **否**(dry_run=True) |
### 快照抓到的条数(对比真机日志的 Get 侧)
| 节点 | 本次实跑 | 真机 2026-10-05 | 说明 |
|---|---:|---:|---|
| userCharacterList | 312 | 312 | 一致 |
| userMapList | 104 | 104 | 一致 |
| userLoginBonusList | 38 | 38 | 一致 |
| userItemList | 1070 | 1070 | 一致 |
| userMusicDetailList | **1037** | 739(组数) | 实跑已摊平成单条,真机是 739 组嵌套 |
| userCourseList | 16 | 16 | 一致 |
| userChargeList | 3 | 3 | 一致 |
| userFavoriteList | 5 | 5 | 一致 |
| userMissionDataList | 6 | 6 | 一致 |
| userIntimateList | 2 | 2 | 一致 |
| userGhost | 15 | 15 | 一致 |
> ★ `userMusicDetailList` 1037 是**摊平后**的 (musicId, level) 条目数;真机日志的 739 是**按曲分组**的组数。
> 两者不是同一口径,摊平后 739 组共 1037 条。
---
## 二、顶层结构对比
```jsonc
// 实跑产出(api/upsert_user_all.py 生成)
{
"userId": 12345678,
"playlogId": 7693120247791879616, // = UserLoginApi 的 loginId
"isEventMode": false,
"isFreePlay": false,
"loginDateTime": 1791222938, // = UserLoginApi 的 loginDateTime
"userPlaylogList": [ { ...110 字段... } ],
"upsertUserAll": { ...36 节点... }
}
```
| 字段 | 真机 | 本次实跑 | 判定 |
|---|---|---|---|
| `userId` | 12345678 | 12345678 | 一致 |
| `playlogId` | 7692848101484135861 | 7693120247791879616 | **不同**(不同次登录,正常) |
| `isEventMode` | false | false | 一致 |
| `isFreePlay` | false | false | 一致 |
| `loginDateTime` | 1791159576 | 1791222938 | **不同**(不同次登录,正常) |
| `userPlaylogList` | 1 条 | 1 条 | 一致 |
| `upsertUserAll` | 36 节点 | 36 节点 | 一致 |
> `playlogId` / `loginDateTime` 两次必然不同 —— 它们是**本次登录会话**产生的,
> 不是固定值。关键是它们与**本次** `UserLoginApi` 的返回值一致(已核对)。
---
## 三、36 个节点逐节点对比
| # | 节点 | 真机 | 实跑 | 形态 |
|---|---|---|---:|---|
| 1 | `userData` | 1 条 | 1 条 | 一致 |
| 2 | `userExtend` | 1 条 | 1 条 | 一致 |
| 3 | `userOption` | 1 条 | 1 条 | 一致 |
| 4 | `userCharacterList` | 5 条 | 5 条 | 一致 |
| 5 | `userGhost` | 0 条 | 0 条 | 一致 |
| 6 | `userMapList` | 1 条 | 1 条 | 一致 |
| 7 | `userLoginBonusList` | 0 条 | 0 条 | 一致 |
| 8 | `userRatingList` | 1 条 | 1 条 | 一致 |
| 9 | `userItemList` | 0 条 | 0 条 | 一致 |
| 10 | `userMusicDetailList` | 1 条 | 1 条 | 一致 |
| 11 | `userCourseList` | 0 条 | 0 条 | 一致 |
| 12 | `userFriendSeasonRankingList` | 0 条 | 0 条 | 一致 |
| 13 | `userChargeList` | **3 条** | **3 条** | 一致(整表回传) |
| 14 | `userFavoriteList` | **5 条** | **5 条** | 一致(恒 5 条) |
| 15 | `userActivityList` | 1 条 | 1 条 | 一致 |
| 16 | `userMissionDataList` | 6 条 | 6 条 | 一致 |
| 17 | `userWeeklyData` | 对象 | 对象 | 一致 |
| 18 | `userGamePlaylogList` | 1 条 | 1 条 | 一致 |
| 19 | `user2pPlaylog` | 对象 | 对象 | 一致 |
| 20 | `userIntimateList` | 0 条 | 0 条 | 一致 |
| 21 | `userShopItemStockList` | 0 条 | 0 条 | 一致 |
| 22 | `userGetPointList` | 0 条 | 0 条 | 一致 |
| 23 | `userTradeItemList` | 0 条 | 0 条 | 一致 |
| 24 | `userFavoritemusicList` | 0 条 | 0 条 | 一致 |
| 25 | `userKaleidxScopeList` | 0 条 | 0 条 | 一致 |
| 26-36 | 11 条 `isNew*` 位串 | — | — | 见下节 |
**形态命中 36/36。**
---
## 四、11 条位串对比(全部一致)
| 位串 | 真机 | 实跑 | 一致 |
|---|---|---|---|
| `isNewCharacterList` | `"00000"` | `"00000"` | ✅ |
| `isNewMapList` | `"0"` | `"0"` | ✅ |
| `isNewLoginBonusList` | `""` | `""` | ✅ |
| `isNewItemList` | `""` | `""` | ✅ |
| `isNewMusicDetailList` | `"0"` | `"0"` | ✅ |
| `isNewCourseList` | `""` | `""` | ✅ |
| **`isNewFavoriteList`** | **`"22212"`** | **`"22212"`** | ✅ |
| `isNewFriendSeasonRankingList` | `""` | `""` | ✅ |
| `isNewUserIntimateList` | `""` | `""` | ✅ |
| `isNewFavoritemusicList` | `""` | `""` | ✅ |
| `isNewKaleidxScopeList` | `""` | `""` | ✅ |
> **`isNewFavoriteList = "22212"` 是本次最值得注意的一处**:
> 第 4 位是 `"1"`(而不是 `"2"`),因为服务器对 `itemKind=10`(搭档)的 Get 响应
> 回的是 `{"itemKind":null, "itemIdList":[]}` —— 服务端怪癖。
> 实现上用**未归一化的原始响应**作差分基线,才能复现出这个 `"1"`。
---
## 五、userData[0] 逐字段对比
真机 82 字段,实跑 82 字段,**命中 76/82**。
### 5.1 一致的关键字段(节选)
| 字段 | 真机 | 实跑 | 说明 |
|---|---|---|---|
| `userName` | ☆Astral☆ | ☆Astral☆ | 一致 |
| `playerRating` | 15000 | 15000 | 一致 |
| `point` / `totalPoint` | 4110 / 52620 | 4110 / 52620 | 一致 |
| `iconId` / `plateId` / `titleId` | 559501 / 458001 / 55502 | 同 | 一致 |
| `charaSlot` | [395,101,400402,400401,200401] | 同 | 一致 |
| `lastGameId` | "SDGB" | "SDGB" | 一致(兜底填充) |
| `accessCode` | "" | "" | 一致(兜底填充) |
| `lastPlaceId` / `lastPlaceName` | 1000 / 测试店铺… | 同 | 一致 |
| `lastRegionId` / `lastRegionName` | 5 / 测试区域 | 同 | 一致 |
| `lastClientId` | "TESTCLIENT1" | "TESTCLIENT1" | 一致 |
| `lastPlayMode` | 0 | 0 | 一致(归 0) |
| `banState` | 0 | 0 | 一致(已搬进 userData 内部) |
| `lastRomVersion` | "1.56.00" | "1.56.00" | 一致 |
### 5.2 六处差异(全部可解释)
| 字段 | 真机 | 实跑 | 原因 |
|---|---|---|---|
| `playCount` | 1258 | **1259** | 基线不同:真机基线 1257,实跑基线 1258(真机那局已写入)。**增量都是 +1** ✅ |
| `currentPlayCount` | 186 | **187** | 同上,增量都是 +1 ✅ |
| `dateTime` | 1791134329 | **1791194138** | 本次登录的 Auth.AuthTime(不同次登录) ✅ |
| `lastLoginDate` | 2026-10-05 00:19:37.0 | **2026-10-05 17:55:38.0** | 本次登录时刻 ✅ |
| `lastPlayDate` | 2026-10-05 00:20:57.0 | **2026-10-05 17:55:38.0** | 本次游玩时刻 ✅ |
| `eventWatchedDate` | 2026-10-05 00:19:37.0 | **2026-10-05 17:55:38.0** | 本次登录时刻 ✅ |
> **验证方式**:把实跑的 `playCount` 减 1 得 1258,正好等于本次快照基线;
> 真机的 1258 减 1 得 1257,等于真机快照基线。**两边的增量逻辑完全一致。**
### 5.3 实跑 userData 全文(节选关键段)
```json
{
"accessCode": "",
"userName": "☆Astral☆",
"playCount": 1259,
"currentPlayCount": 187,
"eventWatchedDate": "2026-10-05 17:55:38.0",
"lastGameId": "SDGB",
"lastLoginDate": "2026-10-05 17:55:38.0",
"lastPlayDate": "2026-10-05 17:55:38.0",
"lastPlayCredit": 1,
"lastPlayMode": 0,
"lastPlaceId": 1000,
"lastPlaceName": "测试店铺",
"lastRegionId": 5,
"lastRegionName": "测试区域",
"lastClientId": "TESTCLIENT1",
"lastCountryCode": "CHN",
"dateTime": 1791194138,
"banState": 0
}
```
---
## 六、userPlaylogList[0] 逐字段对比(110 字段)
**命中 106/110。**
### 6.1 四字段差异(全部可解释)
| 字段 | 真机 | 实跑 | 原因 |
|---|---|---|---|
| `playlogId` | 7692848101484135861 | 7693120247791879616 | 本次登录的 loginId ✅ |
| `loginDate` | 1791130777 | 1791194138 | 标准 Unix 秒 = loginDateTime − 28800 ✅ |
| `playDate` | "2026-10-04" | "2026-10-05" | **日志日**(7 点前算前一天)——真机游玩在 00:20 所以回退到 10-04;实跑在 17:55 不回退 ✅ |
| `userPlayDate` | "2026-10-05 00:20:30.0" | "2026-10-05 17:55:38.0" | 游玩时刻 ✅ |
> **`playDate` 的差异恰好验证了算法正确性**:
> 真机在 00:20 游玩 → `playDate` 回退为前一天 `2026-10-04`;
> 实跑在 17:55 → `playDate` 保持 `2026-10-05`。
> 这正是 `TimeManager.ToLogDateTime`(`TimeManager.cs:77-85`,7 点分界)的行为。
### 6.2 完全一致的 106 个字段(节选关键项)
| 字段 | 值 | 说明 |
|---|---|---|
| `userId` | 0 | 恒为 0 |
| `version` | 1056000 | 1.56.00 |
| `placeId` / `placeName` | 1000 / 测试店铺… | 一致 |
| `musicId` / `level` | 143 / 4 | 一致 |
| `trackNo` | 1 | 一致 |
| `playerNum` | 1 | 一致 |
| `characterId1..5` | 395,101,400402,400401,200401 | 一致 |
| `characterLevel1..5` | 3800,4455,134,134,134 | 一致(**游玩前快照,不加经验**) |
| `achievement` / `deluxscore` | 0 / 0 | 一致 |
| `totalCombo` | 655 | 一致 |
| `tapMiss` / `holdMiss` / `slideMiss` / `breakMiss` | 441 / 19 / 192 / 3 | 一致 |
| `isTap` / `isHold` / `isSlide` / `isTouch` / `isBreak` | true,true,true,false,true | 一致 |
| `isClear` | false | 一致 |
| `trialPlayAchievement` | -1 | 一致 |
| `extNum4` | 2022 | 一致(= categoryIndex) |
| `extBool1/2/3` | false | 一致(**bool 而非 0**) |
| `vsUserName` / `playedUserName1..3` | "" | 一致(**空串而非 0**) |
---
## 七、关键节点逐条示例
### 7.1 `userMusicDetailList`(差分:只回传本局那条)
**真机**
```json
[
{ "musicId": 143, "level": 4, "playCount": 8, "achievement": 0,
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0,
"scoreRank": 0, "extNum1": 0 }
]
```
**实跑**
```json
[
{ "musicId": 143, "level": 4, "playCount": 9, "achievement": 0,
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0,
"scoreRank": 0, "extNum1": 0 }
]
```
**对比**:结构、字段、键序完全一致;`playCount` 真机 7→8、实跑 8→9,
**都是基线 +1**。Get 侧有 1037 条,这里只回传本局那 1 条 —— 差分生效。
---
### 7.2 `userMapList`(差分:只回传变化那条)
**真机**
```json
[
{ "mapId": 550003, "distance": 1435000, "isLock": false,
"isClear": false, "isComplete": false, "unlockFlag": 0 }
]
```
**实跑**
```json
[
{ "mapId": 550003, "distance": 1446000, "isLock": false,
"isClear": false, "isComplete": false, "unlockFlag": 0 }
]
```
**对比**:`mapId` 相同(都是当前 `selectMapId`);
真机 1424000→1435000(+11000),实跑 1435000→1446000(+11000),
**增量完全一致**。Get 侧有 104 条,只回传 1 条。
---
### 7.3 `userCharacterList`(差分:组队 5 只各 +1 级)
**真机**
```json
[
{ "characterId": 101, "level": 4456, "awakening": 5, "useCount": 0 },
{ "characterId": 395, "level": 3801, "awakening": 5, "useCount": 0 },
{ "characterId": 200401, "level": 135, "awakening": 3, "useCount": 0 },
{ "characterId": 400401, "level": 135, "awakening": 3, "useCount": 0 },
{ "characterId": 400402, "level": 135, "awakening": 3, "useCount": 0 }
]
```
**实跑**
```json
[
{ "characterId": 395, "useCount": 0, "level": 3802, "awakening": 5 },
{ "characterId": 101, "useCount": 0, "level": 4457, "awakening": 5 },
{ "characterId": 400402, "useCount": 0, "level": 136, "awakening": 3 },
{ "characterId": 400401, "useCount": 0, "level": 136, "awakening": 3 },
{ "characterId": 200401, "useCount": 0, "level": 136, "awakening": 3 }
]
```
**对比**:
- 条目数都是 **5**(只回传组队角色,Get 侧有 312 条)
- 每只都是 **基线 +1 级**:真机 4455→4456、3800→3801、134→135;实跑 4456→4457、3801→3802、135→136
- 字段集合一致(`characterId`/`level`/`awakening`/`useCount`)
- 唯一差别是**键序**(真机按 characterId 升序,实跑按 charaSlot 顺序)—— JSON 对象键序不影响语义
---
### 7.4 `userChargeList`(★ 整表回传,不做差分)
**真机**(3 条)与**实跑**(3 条)**完全相同**:
```json
[
{ "chargeId": 2, "stock": 0, "purchaseDate": "2026-07-14 18:33:37", "validDate": "2026-10-12 04:00:00" },
{ "chargeId": 3, "stock": 0, "purchaseDate": "2026-10-02 10:49:23", "validDate": "2026-12-31 04:00:00" },
{ "chargeId": 5, "stock": 0, "purchaseDate": "2026-10-04 18:22:10", "validDate": "2027-01-02 04:00:00" }
]
```
**对比**:Get 侧 3 条 → Upsert 侧也是 3 条。
说明 `userChargeList` 是**整表回传**,**不能**当差分节点处理
(否则会被误收敛成空数组)。实跑已正确处理。
---
### 7.5 `userFavoriteList`(★ 恒 5 条,位串 "22212")
**真机**
```json
[
{ "itemKind": 3, "itemIdList": [559501] },
{ "itemKind": 1, "itemIdList": [] },
{ "itemKind": 2, "itemIdList": [] },
{ "itemKind": 10, "itemIdList": [] },
{ "itemKind": 11, "itemIdList": [] }
]
```
位串:`"22212"`
**实跑**
```json
[
{ "itemKind": 3, "itemIdList": [559501] },
{ "itemKind": 1, "itemIdList": [] },
{ "itemKind": 2, "itemIdList": [] },
{ "itemKind": 10, "itemIdList": [] },
{ "itemKind": 11, "itemIdList": [] }
]
```
位串:`"22212"`
**对比**:**逐字节一致**,包括位串。
第 4 位 `"1"` 的来源:服务器对 `itemKind=10` 回的是 `{"itemKind":null}`,
差分基线查不到该 kind → 位 `"1"`。
---
### 7.6 `userWeeklyData`(★ 对象,非数组)
**真机**
```json
{ "lastLoginWeek": "2026-09-28 04:00:00", "beforeLoginWeek": "2026-09-21 04:00:00", "friendBonusFlag": true }
```
**实跑**
```json
{ "lastLoginWeek": "2026-10-05 04:00:00", "beforeLoginWeek": "2026-09-28 04:00:00", "friendBonusFlag": true }
```
**对比**:结构与键完全一致。周界值差一周是因为**本次登录已跨周**
(实跑时服务器 `GetGameWeeklyDataApi` 下发的 `updateDate` 已是 2026-10-05)。
该值取自服务器下发的 `userWeeklyData`,不是本地推导。
---
### 7.7 `user2pPlaylog`(★ 对象,非数组)
真机与实跑**完全一致**:
```json
{ "userId1": 0, "userId2": 0, "userName1": "", "userName2": "",
"regionId": 0, "placeId": 0, "user2pPlaylogDetailList": [] }
```
---
### 7.8 `userGamePlaylogList`
**真机**
```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 }]
```
**实跑**
```json
[{ "playlogId": 7693120247791879616, "version": "1.56.00",
"playDate": "2026-10-05 17:55:38.0", "playMode": 0, "useTicketId": -1,
"playCredit": 1, "playTrack": 1, "clientId": "TESTCLIENT1",
"isPlayTutorial": false, "isEventMode": false, "isNewFree": false,
"playCount": 8, "playSpecial": 16417894, "playOtherUserId": 0 }]
```
**对比**:13 个字段名与类型完全一致。
- `playlogId` / `playDate` 是本次会话值(正常)
- `playSpecial` 两边都是**随机位掩码**(真机 55951502,实跑 16417894),
按 `VOExtensions.cs:846-855` 的算法生成,每次不同 —— 正确
- `useTicketId=-1`(不使用票)、`playCredit=1`、`playCount=8` 全部命中
---
## 八、差异汇总与判定
| 类别 | 差异项 | 判定 |
|---|---|---|
| 顶层 | `playlogId`、`loginDateTime` | ✅ 正常(每次登录不同) |
| userData | `playCount`、`currentPlayCount` | ✅ 正常(基线不同,**增量都是 +1**) |
| userData | `dateTime`、`lastLoginDate`、`lastPlayDate`、`eventWatchedDate` | ✅ 正常(本次会话时刻) |
| playlog | `playlogId`、`loginDate`、`playDate`、`userPlayDate` | ✅ 正常(本次会话时刻 + 日志日算法) |
| 结构 | 无 | ✅ **36 节点形态、11 位串全部一致** |
**没有任何一处是「实现错误」导致的差异。**
---
## 九、本次实跑发现并修复的 3 个 Bug
| # | 问题 | 根因 | 修复 |
|---|---|---|---|
| 1 | `userData.dateTime` 为 `null` | `UserLogin` 没有回填它实际发出的 `dateTime` | `UserLoginResponseVO` 增加 `dateTime` 字段并在登录时回填 |
| 2 | `userFavoriteList` 为空、位串为 `""` | 取数脚本返回的字典用**节点名**作键(`userFavoriteList`),而原始响应用 `userFavorite`;提取逻辑只认后者 | 提取时增加按节点名兜底 |
| 3 | `userWeeklyData` 周界比真机晚一周 | 用了本地推导而非服务器下发值 | 优先从 `GetUserMissionDataApi` 响应的 `userWeeklyData` 取(`UserMissionResponseVO.cs:10`) |
修复后重跑,**结构差异归零**。
---
## 十、怎么复现这次对比
```powershell
# 1) 实跑(dry-run,不发包)—— 把二维码换成你自己的
python upsert.py "<二维码URL>"
# 2) 只看构造出的报文(不联网)
python -c "import json;d=json.load(open('upsert_payload.json',encoding='utf-8'));print(json.dumps(d['upsertUserAll']['userData'],ensure_ascii=False,indent=2))"
# 3) 零网络自检(129 项)
python selftest.py
```
> **注意**:本次实跑**没有真实发送** UpsertUserAll。
> 要真实上传需显式加 `--send`(会把成绩写进服务器,不可撤销)。
---
## 十一、相关文件
| 文件 | 说明 |
|---|---|
| `api/upsert_user_all.py` | 上传成绩实现(36 节点 / 110 字段 / 差分) |
| `api/upsert_snapshot.py` | 结算快照编排 |
| `docs/上传成绩开发文档.md` | 字段级开发文档 |
| `source/2026-10-05.log:123` | 真机报文(本文档对比基准) |
| `docs/UpsertUserAll实跑对比.md` | 本文档 |
+175
View File
@@ -0,0 +1,175 @@
# UpsertUserAll 落库排查报告
> **背景**:`python upsert.py "<二维码>" --send` 修复闸门后能真实发包,
> 服务端返回 `returnCode=1`,但**数据未写入服务器**。
> 本文档记录已完成的全部排查与结论。
---
## 一、结论速览
| 项 | 状态 |
|---|---|
| 闸门放行 | 已修复,`returnCode=1` |
| 报文与真机对齐 | **非预期差异归零** |
| 数据落库 | **未落库**(`point`/`playCount` 回查不变) |
| 根因 | **未确定**,已排除 6 类可能 |
---
## 二、已修复的 5 个 Bug
### Bug 1:`upsert.py` 漏传 `confirm`(你遇到的直接原因)
`upsert.py` 原来只传 `dry_run=not opts["send"]`,但 `UpsertUserAll` 的规则是
`confirm=None` 时取 `config.UPSERT_CONFIRM`(默认 `False`),所以照样被拦。
```python
# 修复前
dry_run=not opts["send"],
# 修复后
dry_run=not opts["send"],
confirm=bool(opts["send"]), # 关键
```
### Bug 2:`userExtend` 缺 2 个字段
| | Get 侧 | Upsert 侧 |
|---|---|---|
| 键数 | 14 | **16** |
| 差 | — | `isPhotoAgree`、`isGotoCodeRead` |
这两个字段**只在 Upsert 侧出现**,必须补上。
### Bug 3:`userRating.udemae` 有 7 个冗余大写字段
服务器 Get 响应里 `npcWinNum` 与 `NpcWinNum` **并存**(23 键):
```json
// Get 侧(source/2026-10-05.log:87)
{ "npcWinNum": 0, ..., "NpcWinNum": 0, "NpcLoseNum": 0, ... } // 23 键
// Upsert 侧(source/2026-10-05.log:123)
{ "npcWinNum": 0, ... } // 16 键
```
源码 `Net.VO.Mai2/UserUdemae.cs` 只声明了**小写的 16 个**,所以大写那批是
服务端冗余输出,回传时必须裁掉。修复前请求体比真机大 119 字节。
### Bug 4:`userCharacterList` 排序错
| | 顺序 |
|---|---|
| `charaSlot` | [395, 101, 400402, 400401, 200401] |
| 真机 `userCharacterList` | **[101, 395, 200401, 400401, 400402]**(按 characterId 升序) |
回传时按 `characterId` 升序,不是按 `charaSlot` 顺序。
### Bug 5:扫码用的 chipID 形式错
| 用途 | 正确值 | 源码依据 |
|---|---|---|
| aimedb 扫码 | `XXXX-XXXXXXXXXXX`(**完整**) | `ChimeReaderManager.cs:250` `KeychipId.Value` |
| 游戏服务器 | `TESTCLIENT1`(短值) | `PacketGetGameSetting.cs:23` `KeychipId.ShortValue` |
我原来扫码也用了短值。已按源码修正为完整形式。
---
## 三、报文对齐最终状态
```
顶层键 : 7/7
36 节点形态 : 36/36
userData : 82/82 键
userPlaylog : 110/110 键
userExtend : 16/16 键
udemae : 16/16 键
非预期差异 : 0
请求体大小 : 17158 vs 17161 字节(差 3 字节,来自数值位数)
```
**深度递归对比(逐字段、逐元素)后,除「不同次登录必然不同」的值外,零差异。**
---
## 四、已排除的可能(6 类)
| # | 假设 | 验证方式 | 结果 |
|---|---|---|---|
| 1 | 时序问题(写入有延迟) | 发送后 T+0 / T+20s / T+45s 三次回查 | 都不变 |
| 2 | 差分节点为空导致「看不出变化」 | 直接改绝对值(`point` 4110→4111) | 仍不写 |
| 3 | 字段缺失/多余 | 深度递归键集合 + 值对比 | 已归零 |
| 4 | `number` 请求头缺失 | 补上 `Packet.cs:170` 要求的头 | 仍不写 |
| 5 | `playSpecial` 值不对 | 查源码 `CalcSpecialNum` = 硬件标志位 | 非根因 |
| 6 | 缺少 `GetUserNewItemList` 前置 | 完整复刻真机 121→123 流程 | 仍不写 |
### 关于第 2 项的探测细节
这是最关键的一次探测 —— 直接 `patch` 把 `userData.point` 从 4110 改成 4111 发上去:
```
baseline point = 4110
上传成绩成功:returnCode=1
T+ 0s point = 4110
T+20s point = 4110
T+45s point = 4110
```
**连最简单的整数字段都不写**,说明不是「数据不对」而是「整包没被接受」。
---
## 五、当前判断
服务端**收下了**请求(返回正确的 `apiName`),但**没有应用**。
可能的方向(未验证):
1. **账号/机台绑定校验** —— token 可能绑定了特定 keychip,而 `XXXX-XXXXXXXXXXX`
只是文档里的示例值,不是**你实际机台**的 keychip。
若服务端校验「token 对应的机台 ≠ 请求里的 chipID」,就会静默拒收。
2. **需要真实游玩产生的会话状态** —— 真机的 Upsert 发生在**真实游玩之后**,
服务端可能记录了「本次 loginId 下有一局真实游玩」才允许结算。
纯网络脚本没有这个前置状态。
3. **服务端侧的风控/审计** —— 真机与脚本的 TLS 指纹、请求节奏等特征不同。
---
## 六、需要你提供的信息
要继续定位,最有价值的是:
| # | 信息 | 为什么关键 |
|---|---|---|
| 1 | **你机台的真实 Keychip ID** | 若 `XXXX-XXXXXXXXXXX` 不是你的机台,方向 1 就成立 |
| 2 | 该机台在 `keychip.csv` 里的对应行 | 可确认 keychip 与店铺/区域的绑定关系 |
| 3 | 真机跑一局后立刻导出 Upsert 报文 | 可拿同账号同机台的最新报文做 1:1 对比 |
> `source/keychip.csv` 有 229KB 全量机台对照表,如果你能告诉我机台所在的
> 店铺 ID,我可以从中查出正确的 keychip。
---
## 七、本次排查的正面成果
即使尚未落库,这次排查也修掉了 **5 个真实 Bug**,其中 3 个(`userExtend` 缺字段、
`udemae` 冗余字段、`userCharacterList` 排序)是**只有做深度值对比才能发现**的:
- 修复前:报文与真机有 11 处非预期差异
- 修复后:**0 处**
自检 129/129 仍全绿。
---
## 八、复现方式
```powershell
# 1) 真实发送 + 落库验证(会写服务器,不可撤销)
python upsert.py "<二维码>" --send
# 2) 零网络自检
python selftest.py
```
+670
View File
@@ -0,0 +1,670 @@
# UpsertUserAll 踩坑清单
> **这份文档只讲「坑」** —— 每一个都是我实际踩过、并且用真机数据或反编译源码验证过的。
> 想看接口全貌请读 [`上传成绩开发文档.md`](上传成绩开发文档.md);
> 想看真实跑通证据请读 [`实跑验证记录.md`](实跑验证记录.md)。
>
> ---
> **判据来源**(全部可查):
>
> | 来源 | 内容 |
> |---|---|
> | `source/2026-10-05.log:123` | 真机 00:20 会话的 UpsertUserAll 报文 |
> | `source/2026-10-05-new.log:123` | 真机 19:21 会话的 UpsertUserAll 报文 |
> | `MaimaiNetSpy/raw/068_*.json` | 真机 22:31 会话的 UpsertUserAll 报文(MelonLoader hook 抓) |
> | `source/Assembly-CSharp/` | 反编译源码(每个坑都标了行号) |
>
> **三个真机会话的横向对照**是本文最有力的工具 —— 很多坑只有横向比才看得出来。
---
## 零、先记住这句话
> ### ★ 服务端返回 `returnCode=1` 只代表「报文格式合法」,**不代表数据被应用**。
「`returnCode=1` 但 `playCount` 纹丝不动」是这条路线上**最主要的失败模式**,
而且它有**至少 4 个完全不同的成因**(坑 1 / 2 / 3 / 4)。所以:
**唯一的验收标准是「发包后重新拉一次服务器数据,看数字有没有动」。**
`upsert.py --send` 已经内置了这个回查(第 7.5 步),会打印 `【落库成功】` 或 `【疑似未落库】`。
不要只看 `returnCode`。
---
## 一、致命坑:会让「回码成功但什么都没发生」的
### 坑 1:`userData` 是**整表覆盖**语义,不是「只写你改的字段」
**现象**:把 `GetUserDataApi` 拿到的 `userData` 原样塞进 Upsert 发出去,`returnCode=1`,
回查 `playCount` / `point` **完全不变**。
**根因**:服务端对 `userData` 这个单元素数组是**整表覆盖**。
你回传旧值 = 覆盖成旧值 = 「看起来什么都没发生」。
**正确做法**:必须先按本局演算出**新值**再整表回传。
| 字段 | Get 侧(登录时) | 必须回传的 Upsert 侧 |
|---|---|---|
| `playCount` | 1257 | **1258**(+1) |
| `currentPlayCount` | 185 | **186**(+1) |
| `lastPlayDate` | 2026-10-04 18:46:31 | **2026-10-05 00:20:57.0**(本局上报时刻) |
| `lastPlayMode` | 2 | **0** |
| `lastLoginDate` | 2026-10-04 18:34:22 | **2026-10-05 00:19:37.0**(本次登录时刻) |
| `lastPlaceId` | 0 | **1000** |
| `lastRegionId` | 11 | **5** |
| `dateTime` | null | **1791134329**(登录请求里的 dateTime) |
| `lastGameId` | null | **"SDGB"** |
| `accessCode` | null | **""** |
对应实现:`apply_play_effects()`(`api/upsert_user_all.py`)。
**自检**:`validate_before_send()` 会检查
`playCount` / `currentPlayCount` 是否为 0、`dateTime` 是否为 0、
`lastPlayDate` / `lastLoginDate` 是否为空。
---
### 坑 2:差分节点全空 = 服务端认为「本局什么都没变」★ 已用 A/B 实验证实
**现象**:`userData` 改对了,但 `userMusicDetailList` / `userMapList` / `userCharacterList`
全是 `[]`,回码成功但战绩表没多一条。
**根因**:这些节点是**差分语义** —— 只回传「本局发生变化的条目」。
全空等于告诉服务端「没有任何条目变化」。
#### ★★ 2026-10-06 的 A/B 对照实验(决定性证据)
同一天、同一账号、同一份代码,**唯一差别是 `userMusicDetailList` 空不空**:
| 时间 | `userMusicDetailList` | 其他差分节点 | `userData.playCount` | 结果 |
|---|---|---|---|---|
| 12:52 | **`[]`** | 都空 | 1261 → 1261 | ❌ `returnCode=1` 但**没落库** |
| 12:57 | **1 条**(次数不变) | 都空 | 1261 → **1262** | ✅ **落库** |
两次的 `returnCode` 都是 1、`apiName` 都是 `com.sega.maimai.api.UpsertUserAllApi`,
**唯一变量就是这个节点**。
> **推论(已实测)**:
> 只要 `userPlaylogList` 里有本局成绩,`userMusicDetailList` 就**必须**回传本局那条。
> 「有 playlog 却没有任何曲目明细」会被服务端当成无效包**静默丢弃**。
>
> 注意这里的关键是**节点非空**,不是**次数必须 +1** ——
> 回传条目但 `playCount` 保持不变(+0)同样能落库(12:57 那次就是)。
> 所以「不想让乐曲 playCount 增加」应该把增量设成 0,
> **而不是**把整个节点清空。见 `config.PLAY_MUSIC_PLAY_COUNT_GAIN`。
**真机对照**(同一账号 Get 侧 vs Upsert 侧):
| 节点 | Get 侧条数 | Upsert 侧条数 | 是差分吗 |
|---|---:|---:|---|
| `userMusicDetailList` | 739 组 | **1** | 是(本局那条 playCount +1) |
| `userMapList` | 104 | **1** | 是(选中地图 distance +11000) |
| `userCharacterList` | 312 | **5** | 是(组队 5 只各 +1 级) |
| `userItemList` | 1070 | **0** | 是(无变化 → 空) |
| `userCourseList` | 16 | **0** | 是(无变化 → 空) |
| `userChargeList` | 3 | **3** | ★ **否!整表回传** |
**正确做法**:`build_user_all()` 用 `DIFF_PRIMARY_KEYS` 对每个差分节点算 (新 − 基线)。
**★ 反例警告 1**:`userChargeList` **不是差分节点**,真机原样发了全部 3 条。
如果把它当差分处理(且本局没买票),会被误收敛成 `[]` —— 这是我踩过的具体坑。
**★ 反例警告 2(本次新增)**:差分收敛会把「和基线完全相同」的条目剔掉。
`PLAY_MUSIC_PLAY_COUNT_GAIN = 0` 时本局那条和基线一样,会被剔光 → 节点变空 → 整包失效。
所以 `build_user_all()` 里对这个节点做了**兜底**:
`apply_play_effects` 会把本局那条暂存到 `_played_music_entry`,
差分收敛后如果节点空了就再塞回去,并把 `isNewMusicDetailList` 置 `"0"`(存量更新)。
---
### 坑 3:`playlogId` / `loginDateTime` 必须**逐字节等于** `UserLoginApi` 的返回值
**现象**:`playlogId` 自己造一个、或用了上一局的 `loginId` → 服务端静默拒收。
**根因**(`PacketUpsertUserAll.cs:31`):
这两个值**不是本接口产生的**,而是登录会话的标识。服务端拿它们找会话,
找不到就不应用。
**正确做法**:
```python
login = UserLogin(user_id, token, ...) # 先登录
UpsertUserAll(user_id, login.loginId, # ★ 必须是它
login_date_time=login.loginDateTime, # ★ 必须是它
auth_date_time=login.dateTime, # userData.dateTime 用这个
...)
```
**★ 容易混的三个时间**:
| 变量 | 是什么 | 用在哪 |
|---|---|---|
| `login.loginDateTime` | 「本地墙上时间当 UTC」(= 标准 Unix 秒 + 28800) | 顶层 `loginDateTime` 字段 |
| `login.dateTime` | 登录**请求**里发的 `Auth.AuthTime` | `userData.dateTime` |
| `loginDate`(playlog 里) | **标准 Unix 秒** | `userPlaylogList[].loginDate` |
**中途重新登录必须重取这两个值** —— 换了会话还发旧 `loginId` 一定失败。
**自检**:`validate_before_send()` 会检查 `playlogId` / `loginDateTime` 是否为 0。
---
### 坑 4:`confirm` 参数漏传 —— 闸门根本没开,但你以为开了
**现象**:`upsert.py --send` 跑完显示「上传成功」,其实**一个包都没发**。
**根因**:`UpsertUserAll` 的闸门规则是
`confirm=None` 时取 `config.UPSERT_CONFIRM`(默认 `False`)。
所以只写 `dry_run=False` **不够**,必须同时显式给 `confirm=True`。
```python
# ✗ 错的:dry_run=False 但 confirm 没给 → 走 config 的 False → 被拦
UpsertUserAll(..., dry_run=False)
# ✓ 对的
UpsertUserAll(..., dry_run=False, confirm=True)
```
**这是「排查了半天以为服务端不收,其实是自己没发」的经典案例。**
---
## 二、结构坑:报文对不上
### 坑 5:`userWeeklyData` / `user2pPlaylog` 是**对象**,不是数组
36 个节点里有 **2 个** JSON 形态是 `{}` 而不是 `[]`:
```jsonc
"userWeeklyData": { "lastLoginWeek": "...", "beforeLoginWeek": "...", "friendBonusFlag": true }
"user2pPlaylog" : { "userId1": 0, ..., "user2pPlaylogDetailList": [] }
```
传成数组会被 `coerce_node_value()` 直接抛 `PatchError`(刻意的防呆)。
**四种形态总览**:
| 类别 | 数量 | JSON 形态 |
|---|---:|---|
| 单元素数组(userData/userExtend/userOption/userRatingList) | 4 | `[{...}]` |
| **对象** | 2 | `{...}` ★ |
| 普通数组 | 19 | `[...]` |
| **位串** | 11 | `"0101..."` |
4 + 2 + 19 + 11 = 36。
---
### 坑 6:11 条位串的长度**必须**等于对应数组的条数
`isNew*` 与同名数组一一对应。长度不符 → 服务端误判「哪些是新增」。
`build_user_all()` 末尾用 `sync_bits_to_arrays()` 自动补齐,但如果你手工 `patch` 了数组,
位串也要跟着对。
**收藏夹是特例**(见坑 12):位串取值不是 `0/1` 而是 `1/2`。
---
### 坑 7:`userExtend` 在 Upsert 侧**多出 2 个字段**
| | Get 侧 | Upsert 侧 |
|---|---:|---:|
| 键数 | 14 | **16** |
| 多出 | — | `isPhotoAgree`、`isGotoCodeRead` |
这两个字段**只在 Upsert 侧出现**(Get 响应里没有),必须手工补上,
否则报文比真机少 2 个键。
对应实现:`USER_EXTEND_EXTRA_KEYS`。
---
### 坑 8:`userRating.udemae` 有一批**首字母大写的重复字段**,必须裁掉
**现象**:`GetUserRatingApi` 的响应里 `npcWinNum` 和 `NpcWinNum` **同时存在**(23 键)。
```jsonc
// Get 侧(source/2026-10-05.log:87)—— 23 键
{ "npcWinNum": 0, ..., "NpcWinNum": 0, "NpcLoseNum": 0, "MaxLoseNum": 0, ... }
// Upsert 侧(source/2026-10-05.log:123)—— 只有小写 16 键
{ "npcWinNum": 0, ... }
```
**根因**:源码 `Net.VO.Mai2/UserUdemae.cs` 只声明了小写的 16 个字段,
大写那批是**服务端的冗余输出**。
**影响**:不裁掉的话 ① 报文里出现源码不存在的字段 ② 请求体比真机大 **119 字节**。
对应实现:`USER_RATING_UDEMAE_DROP_KEYS`。
> ★ 这个坑**只有做深度递归值对比才能发现** —— 光看键数或报文长度是看不出来的。
---
### 坑 9:`userCharacterList` 要按 `characterId` **升序**,不是 `charaSlot` 顺序
| | 顺序 |
|---|---|
| `userData.charaSlot` | `[395, 101, 400402, 400401, 200401]` |
| 真机 `userCharacterList` | `[101, 395, 200401, 400401, 400402]` |
**两者顺序不同** —— 回传时按 `characterId` 升序排。
对应实现:`grown.sort(key=lambda c: int(c.get("characterId", 0)))`。
---
### 坑 10:字段裁剪 —— Get 侧有的,Upsert 侧不一定有
| 节点 | 要丢掉的字段 |
|---|---|
| `userData` | `friendCode`、`nameplateId`、`trophyId`、`cmLastEmoneyCredit`、`cmLastEmoneyBrand` |
| `userOption` | `tempoVolume` |
| `userCharacterList` | 只保留 `characterId`/`level`/`awakening`/`useCount` |
| `userMusicDetailList` | `extNum2` |
| `userChargeList` | `extNum1` |
| `userMissionDataList` / `userWeeklyData` | `userId` |
### 坑 10.1:`banState` 的位置会「漂移」
- **Get 侧**:`banState` 是 `userData` 的**同级顶层**字段
- **Upsert 侧**:它被**移进了 `userData` 内部**
`_extract_from_get_responses()` 会自动搬运,但如果你自己手工拼快照,别忘了。
---
## 三、字段值坑:结构对了但值错了
### 坑 11:`characterLevelN` 是**游玩前**等级,不要加经验
**源码**(`VOExtensions.cs:1361`):
```csharp
result.characterLevel1 = (int)gameScore.CharaLevel[0];
```
`gameScore.CharaLevel` 是**本次开始游玩时**灌进去的快照,整局不变。
**三个真机会话的三方对照**(这是唯一能定论的方式):
| 会话 | Get 侧 `userCharacterList` | `userPlaylogList[].characterLevel` | Upsert 侧 `userCharacterList` |
|---|---|---|---|
| 00:20 | 3800/4455/134 | **3800/4455/134** | 3801/4456/135 |
| 19:21 | 3801/4456/135 | **3801/4456/135** | 3802/4457/136 |
| 22:31 | 3802/4457/136 | **3802/4457/136** | 3803/4458/137 |
**结论:`playlog.characterLevel` 恒等于 Get 侧(游玩前),
`upsertUserAll.userCharacterList` 恒为 +1(游玩后)。**
> ⚠ 我一度写成 `level + character_exp_gain`,因为只看了单个会话、
> 把「Get 侧」和「Upsert 侧」的数值记混了。**横向比三个会话立刻暴露。**
**推荐做法**:别手填 `config.PLAY_CHARACTERS`,直接用
`derive_play_characters(snapshot)` 从服务器快照推(`upsert.py` 已接上)。
账号一升级手填值就过期,会出现「playlog 与 userCharacterList 对不上」的诡异现象。
---
### 坑 12:收藏夹**不做剔除**,且位串取值是 `1/2` 不是 `0/1`
`build_favorite_delta()` 复刻 `VOExtensions.cs:345-367`:
- **恒返回完整 5 条**(不做差分剔除)
- 位串按真机 `"22212"` 反推:`"2"` = 服务器侧有该 itemKind,`"1"` = 查不到
**★ 必须用未归一化的原始 Get 响应作基线**:
真机里 `itemKind=10`(搭档)的 Get 响应回的是 `{"itemKind": null}`,
正因为查不到才产生那个 `"1"`。
但 `user_favorite.py` 会「补 itemKind」,补完之后就看不出原始 null 了。
**两个真机会话都是 `"22212"`** —— 第 4 位(itemKind=10)**稳定为 "1"**,
说明服务端对该 kind 恒回 null。
**实现上的处理**:
- 有原始响应 → 用它建索引,自然得到 `"22212"`
- 只有归一化基线 → 传 `assume_missing=FAVORITE_MISSING_KINDS`(= `(10,)`)兜底
---
### 坑 13:`loginDate` 是**标准 Unix 秒**,不是 `loginDateTime`
```
loginDateTime = 1791239482 (「墙上时间当 UTC」= 标准 Unix 秒 + 28800)
loginDate = 1791210683 (标准 Unix 秒)
```
**三个真机会话的实际差值**:
| 会话 | `loginDateTime` | `loginDate` | 差 |
|---|---|---|---|
| 00:20 | 1791159576 | 1791130777 | **28799** |
| 19:21 | 1791228113 | 1791199315 | **28798** |
| 22:31 | 1791239482 | 1791210683 | **28799** |
**注意不是整 28800!** 原因:`loginDate` 来自
`TimeManager.GetUnixTime(src.Detail.LastLoginDate)`(`VOExtensions.cs:1290`),
而 `lastLoginDate` 是客户端在**登录响应处理完那一刻**写的,比 `loginDateTime` 晚 1~2 秒。
本工程用 `loginDateTime − 28800`,**稳定差 1~2 秒**。
服务端两次都接受了 → 它**不对这几个时刻做等式校验**。知道这一点可以省掉一轮无谓排查。
---
### 坑 14:`playDate` 是**日志日**(7 点分界),不是自然日
源码 `TimeManager.cs:77-85`:
```csharp
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); // ★ 7 点前回退一天
return result;
}
```
真机验证:00:20 那一局的 `userPlaylogList[0].playDate = "2026-10-04"`
(自然日是 10-05,因为 00:20 < 07:00 所以回退)。19:21 那局则是 `"2026-10-05"`。
**`playDate` 是日期串(`YYYY-MM-DD`),`userPlayDate` 才是完整时间串**,别搞混。
---
### 坑 15:`lastLoginDate` 与 `lastPlayDate` 是**两个不同的时刻**
**现象**:所有时间字段都填成 `loginDateTime` → 数值上「能用」,但和真机对不上。
**真机规律**(三个会话全部成立):
| 字段 | 语义 | 00:20 会话 | 19:21 会话 | 22:31 会话 |
|---|---|---|---|---|
| `userData.lastLoginDate` | **登录**时刻 | 00:19:37.0 | 19:21:55.0 | 22:31:23.0 |
| `userData.eventWatchedDate` | **与 lastLoginDate 同值** | 00:19:37.0 | 19:21:55.0 | 22:31:23.0 |
| `userData.lastPlayDate` | **上报**时刻 | 00:20:57.0 | 19:23:02.0 | 22:32:21.0 |
| `userGamePlaylogList[0].playDate` | **与 lastPlayDate 同值** | 00:20:57.0 | 19:23:02.0 | 22:32:21.0 |
| `userPlaylogList[0].userPlayDate` | **游玩**时刻(更早) | 00:20:30.0 | 19:22:36.0 | 22:31:59.0 |
即:登录 → 游玩 → 上报是三个时刻,真机分别是
「loginDateTime +1~2s」「userPlayDate」「上报那一刻」。
本工程把「游玩/上报时刻」抽成参数 `play_date_time`(不传则退回 `login_date_time`),
`upsert.py` 传 `login_date_time_now()`。
---
### 坑 16:`userGetPointList` / `userLoginBonusList` 是**登录奖励**才产生的
**真机证据**:
| 会话 | `userData.point` | `userGetPointList` | `userLoginBonusList` |
|---|---|---|---|
| 00:20 | 4110 | `[]` | `[]` |
| 19:21 | 4460 | `[{"getKind":1,"point":200},{"getKind":2,"point":150}]` | `[{"bonusId":61,"point":1,"isCurrent":true,...}]` |
| 22:31 | 4460 | `[]` | `[]` |
**规律**:这两个节点非空 ⟺ 本次登录触发了奖励发放。
留空则 `point` 保持基线值不变 —— 这是「不发放点数」的**必然结果,不是 bug**。
**★ 排查陷阱**:如果你把 `point` 手工 +200 但 `userGetPointList` 留空,
会出现「数字对不上」的矛盾。要么两个一起改,要么两个都不动。
对应实现:`DEFAULT_EMPTY_NODES`。
---
### 坑 17:`userGamePlaylogList[].playCount` 是**机台本地计数器**,服务器不返回
真机值:00:20 会话 = **8**、19:21 = **9**、22:31 = **10**(每打一局 +1)。
源码 `VOExtensions.cs:881`:`result.playCount = Backup.localParameter.playCount`。
**任何 Get 接口都不返回它** —— 既推不出来,服务端也不会纠正。
填错只是统计数字不对,不影响落库。放在 `config.PLAY_LOCAL_PLAY_COUNT`。
---
### 坑 18:`playSpecial` 不是 0,是一枚**随机位掩码**
源码 `VOExtensions.cs:846-855`:
```csharp
int max = 1037933;
int num2 = UnityEngine.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; }
result.playSpecial = num3;
```
**`CalcSpecialNum()` 是机台硬件标志位掩码**(`GameManager.cs:332-378`),
逐位含义:`SpecialAP`、`IsAutoPlay()`、键鼠数量、`IsDevelop`、
以及三条**否定条件**(不含 "ACA" / 不含 "GeForce GTX" / 不含 "Core(TM) i3-8100"),
最后 `num |= 0x400` **无条件置位**。
**真机实测**:`[CalcSpecialNum] = 1024`(= 0x400,标准 ACA 机台其余位恰好全 0)。
**反推验证**(这一步很有说服力):
```
真机 playSpecial = 892014272
位反转32(892014272) = 57726124 = 27900 × 2069 + 1024
└───┬───┘ └─┬─┘
随机系数 余数 = CalcSpecialNum ✅
```
配置项:`config.PLAY_SPECIAL_NUM = 1024`。
> 注意位反转后可能超过 int32 变成负数 —— 真机 19:21 那局就是 `-603697438`,这是正常的。
---
### 坑 19:`extBool1/2/3` 是 bool 但**名字不带 `is` 前缀**
用 `name.startswith("is")` 判断布尔字段会**漏掉这三个**,
导致序列化成 `0` 而不是 `false`。
同理字符串字段(`vsUserName`、`playedUserName1..3`、`placeName`、`playDate`、`userPlayDate`)
默认值必须是 `""` 而不是 `0`。
对应实现:显式集合 `PLAYLOG_BOOL_FIELDS` / `PLAYLOG_STRING_FIELDS`。
---
### 坑 20:110 个字段**一个都不能省**
把真机报文的 `userPlaylogList[0]` 与 `Net.VO.Mai2/UserPlaylog.cs` 做双向集合比对:
| 方向 | 差异 |
|---|---|
| C# 声明但报文未出现 | **0** |
| 报文有但 C# 未声明 | **0** |
**能精简的是「填写的内容要真实」,而不是删字段。**
---
## 四、网络层坑
### 坑 21:`number` 头只由 **Ping 心跳**刷新,不是每个请求都更新
**真机 70 个请求里它只变过一次**:
```
#1 ~ #10 number = 0 ← 还没跑过心跳
#11 ~ #17 Ping 包(按 Packet.cs:187 **不带** number 头)
#18 ~ #70 number = 63 ← 心跳往返 63ms,此后 53 个业务请求恒定
```
**源码链路**:
```
PingChecker.cs:166 StartPacket(new PacketPing(PingSuccess, PingError))
PacketPing.cs:34 _onDone((int)sw.ElapsedMilliseconds) ← 值 = Ping 包往返毫秒
PingChecker.cs:227 GameManager.PingMsec = pingMsec
Packet.cs:189 Client.AddHeader("number", GameManager.PingMsec.ToString())
```
**我原来的错法**:每个请求结束后把「本请求耗时」写进 `ping_msec`,
于是 number 随每个包抖动。已改成只有 Ping 包更新。
**调用方注意**:`upsert.py` 在取完服务器设置后加了一步 `Ping()`,否则 number 恒为 0。
---
### 坑 22:Cookie —— 登录响应**会**下发 `JSESSIONID`,而且必须带着
**这个坑我踩了两次(先误判、后自我推翻)**:
1. hook 日志里显示 `Cookie : userId=12345678` —— 这是我 hook **自己拼的假串**
(`gamehook/Main.cs:196-199` 的 `_cookie = "userId=" + userId`),不是真实 Cookie 头。
2. 于是我用 `Ping` 探针去验证,看到响应头里**没有 Set-Cookie**,
就得出「服务器从不下发 Cookie,无需处理」的结论 —— **这是错的**。
3. 后来跑完整登录链路,`UserLoginApi` 的响应头里明确有:
```
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
```
**源码**(`Packet.cs:228-234`):**只在 `UserLogin` 响应上**把 Set-Cookie 收进
`CookieContainer`;之后 `Packet.cs:190-193` 每个带 userId 的请求都挂回去。
**结论**:这是**真实的会话粘滞**。
`requests.Session` 会自动处理,**但前提是全程复用同一个 `MaimaiNetClient`**
(本工程各 api 函数在 `client=None` 时都走 `get_default_client()` 单例)。
**如果谁手动 new 一个 client 去发结算包,就会丢掉 JSESSIONID。**
> **教训**:用「只读探针」验证一件事时,要确认探针覆盖了完整链路 ——
> 我只探了 Ping,就下了关于整个服务器的结论。
---
### 坑 23:`Accept-Encoding` 必须**显式置空**
`requests` 默认发 `Accept-Encoding: gzip, deflate`。
本协议响应体是 AES 密文(二进制),服务端若据此做内容编码会返回**空 body**。
实测:不发该头 → HTTP 200 / 0 字节;显式置空 → 正常返回。
C# 端不需要是因为 `HttpWebRequest` 默认 `AutomaticDecompression = None`。
---
### 坑 24:扫码 chipID 用**完整形式**,游戏服务器用**短值**
| 用途 | 正确值 | 源码依据 |
|---|---|---|
| aimedb 扫码 | `XXXX-XXXXXXXXXXX`(**带连字符**) | `ChimeReaderManager.cs:250` `KeychipId.Value` |
| 游戏服务器 `clientId` | `TESTCLIENT1`(短值) | `PacketGetGameSetting.cs:23` `KeychipId.ShortValue` |
我原来扫码也用了短值,导致 aimedb 返回错误。
---
## 五、工程/流程坑
### 坑 25:不做**落库回查**,你永远不知道有没有写进去
`returnCode=1` 完全不可信(见坑 1~4)。**必须**发包后重新拉一次数据比对。
`upsert.py --send` 的第 7.5 步已经内置:
```
7.5 落库回查(重新拉服务器数据)
playCount 1260 -> 1261
currentPlayCount 188 -> 189
lastPlayDate 2026-10-05 22:32:21 -> 2026-10-06 01:28:04
musicId=143 level=4 playCount -> 11
【落库成功】userData.playCount 已 +1
```
**为什么在登出前回查**:同一个登录会话内 `GetUserDataApi` 立刻能读到新值,
不用再开一次会话(也就不会消耗第二个二维码)。
---
### 坑 26:报文体长度差异要**逐节点**看,不要只看总长
总长差几字节可能来自随机数位数(`playSpecial`),也可能来自真实的结构错误。
**逐节点比对字节数**才能定位:
```
节点 真机068 本次 差
userData 1985 1985 0
...
userGamePlaylogList 294 295 1 <<< 只有它差(playSpecial 随机数)
(36 节点合计) 14255 14256 1
```
**35 个节点 0 差异**才是「结构完全对齐」的证据。
---
### 坑 27:`--debug` 日志会很大
DEBUG 模式会完整打印每个请求的头 + 体 + 响应体,
一次结算流程(含 19 节点快照 + 739 组曲目分页)能到 **18 MB**。
只想看流程就别加 `--debug`;想减小体积调 `config.LOG_VALUE_MAX`(0 = 不截断)。
---
## 六、发包前 Checklist
按顺序过一遍,能挡掉上面绝大多数坑:
- [ ] **闸门**:`confirm=True`(或 `config.UPSERT_CONFIRM=True`)
- [ ] **会话**:`playlogId == login.loginId`,`loginDateTime == login.loginDateTime`
- [ ] **同会话**:中途没重新登录过(重登必须重取上面两个值)
- [ ] **快照**:`fetch_upsert_snapshot()` 抓了 19 个节点,失败 0 个
- [ ] **userData 演算**:`playCount`/`currentPlayCount` +1,`lastPlayDate` 是上报时刻,
`lastLoginDate`/`eventWatchedDate` 是登录时刻,`dateTime` 非 0
- [ ] **差分节点**:`userMusicDetailList` 至少 1 条(本局曲目战绩);
`userMapList` / `userCharacterList` 按 config 增量决定 ——
`PLAY_MAP_DISTANCE_GAIN` / `PLAY_CHARACTER_EXP_GAIN` **默认都是 0**,
此时它们为空是**预期行为**;只有把增量设成非 0 时才必须有条目
- [ ] **`userChargeList`** 是整表回传,没被当差分收敛成空
- [ ] **位串**:11 条长度等于对应数组条数;`isNewFavoriteList` 应为 `"22212"`
- [ ] **playlog**:110 个字段齐全;`characterLevelN` 是**游玩前**等级
- [ ] **playSpecial**:非 0,且余数 = `config.PLAY_SPECIAL_NUM`
- [ ] **网络**:跑过 `Ping()`(number 头),同一个 client(Cookie)
- [ ] **自检**:`validate_before_send()` 返回 0 个问题
- [ ] **回查**:发包后确认 `playCount` 真的 +1
---
## 六点五、关于 `userMapList` / `userCharacterList` 的默认空值
本工程默认:
```python
PLAY_MAP_DISTANCE_GAIN = 0 # 本局不推进地图 -> userMapList 回传 []
PLAY_CHARACTER_EXP_GAIN = 0 # 本局不给角色加经验 -> userCharacterList 回传 []
```
**这不是坑,是刻意的默认值** —— 因为「地图走多远」「角色升几级」取决于真实游玩,
纯网络脚本推不出来。想要真机那种 +11000 / +1 级,把这两个配置改回去即可。
★ 唯一要注意的连带影响:`validate_before_send()` 的「差分节点为空」告警
只在**增量非 0 却没产出条目**时才触发,所以默认值下不会刷无意义的告警。
---
## 七、这个清单是怎么攒出来的
| 阶段 | 手段 | 抓到的坑 |
|---|---|---|
| 初版实现 | 读源码 + 单个真机日志 | 坑 5、6、13、14、18、19、20、23、24 |
| 深度值对比 | 逐字段递归 diff 真机报文 vs 本工程 | 坑 7、8、9、10 |
| 落库失败排查 | 直接 `patch` 绝对值探测服务端行为 | 坑 1、2、3、4 |
| **MelonLoader hook 抓真机** | 在游戏里 hook 网络层抓完整会话 | 坑 11、12、15、16、17、21、22 |
| 真实跑通 | `--send` + 落库回查 | 坑 25、26、27 |
**最有效的一招是「横向比多个真机会话」** —— 坑 11(characterLevel 语义)、
坑 13(loginDate 差 28798/28799)、坑 15(两个时刻分离)都是单看一个会话看不出来、
一横向比就立刻暴露的。
第二有效的是 **MelonLoader hook** —— 它能看到 HTTP 头(`number`)、
能抓到 `CalcSpecialNum()` 的真实返回值,这些在纯日志里根本看不到。
+103
View File
@@ -0,0 +1,103 @@
# netdelivery.py 说明(ALL.Net DLI)
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里
> netdelivery 部分的**详细版**(含运行原理)。
---
## 一、DLI 是什么
**DLI = Download Instruction(配信指示書)。**
机台做完 ALL.Net 认证后,AMDaemon 会再问一次「我该下什么包」:
```
POST http://at.sys-allnet.cn/net/delivery/instruction
body = title_id=SDGB&title_ver=<版本>&client_id=<Keychip 短值>
→ result=1&uri=<patch 指示書 URL>|<option 指示書 URL>
```
指示書本身是个 INI(日文注释),真正要下的包写在 `INSTALLn=` 里:
```ini
[COMMON] INSTALL1=https://.../SDGB_1.56.00_..._1_1.55.01.app
[OPTIONAL] INSTALL1=https://.../SDGB_A031_..._0.opt
INSTALL2=https://.../SDGB_A005_..._0.opt
```
> ★ 只有 `INSTALLn=` 才是**要下载的包**;
> `EXISTn=` 是「本机应该已经有的文件」,只是文件名,不是下载项。
---
## 二、协议出处
全部来自 `source/amdaemon.exe` 的字符串(数字是文件内偏移):
| 字符串 | 偏移 |
|---|---|
| `http://at.sys-all.cn/net/initialize` | `@0x788170` |
| `http://at.sys-all.cn/net/delivery/instruction` | `@0x788198` |
| `title_id=%s&title_ver=%s&client_id=%s` | `@0x7882b8` |
| `ALWE_NETDELIVER_PHASE_GET_DLINST_INFO` | `@0x63bde0` |
| `ALWE_NETDELIVER_PHASE_APP_IMAGE_INFO` | `@0x63be08` |
| `ALWE_NETDELIVER_PHASE_OPT_IMAGE_INFO` | `@0x63be30` |
| `"Net deliver param - dli uri : %s."` | `@0x63c3f8` |
| `ALE_ABEX_STAT_DLINSTR_OK` / `_ERR_DLINSTR_NG` | `@0x638600` |
| `alAbEx_lite Ver 4.00.01` | `@0x784a31` |
协议是 ALL.Net **alAbEx v4**:AES-128-CBC 信封,明文是 `key=value&...`。
```
请求 = AES-CBC( 16 字节全 0 前缀 || 明文, IV = 全 0 )
响应 = 16 字节 IV || 密文
```
---
## 三、用法
```bash
python netdelivery.py # 用 config.py 里的机台查,然后**多选下载**
python netdelivery.py A63E01ABCDE # 指定 Keychip
python netdelivery.py --list # 只列清单 + 直链,不下载
python netdelivery.py --all # 全下(不交互)
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
python netdelivery.py --out D:\SDGB2026\update
python netdelivery.py --insecure # 关掉 TLS 校验(CDN 证书有问题时用)
```
交互选择支持:逗号/空格分隔、区间(`1-3`)、`a`=全选、`n`=全不选、`q`=退出。
下载带**断点续传**(`.part` 临时文件 + `Range`,下完改名)与进度/速率显示。
---
## 四、实测结论(2026-10-06)
| 结论 | 证据 |
|---|---|
| `title_ver` 决定拿哪份指示書 | `1.55` → patch 1.56 + opt A005;`1.56` → 仅 opt A031;`≤1.54` → `uri=null` |
| `client_id` **必须**是 11 位短值 | 发 15 位完整 Keychip → `result=-3`;发小写 → `result=-2` |
| 包可以断点续传 | 返回 `206` + `Content-Range` |
| 已下架的历史包会回 **504** | 这是华立 CDN 的状态,**不是脚本的问题** |
实测清单(`--ver 1.56`):
```
序号 类型 大小 来源段/键 文件名
[ 1] opt 7.25 MB COMMON/INSTALL1 SDGB_A031_20260909101347_0.opt
[ 2] opt 3.00 MB OPTIONAL/INSTALL1 SDGB_A005_20260610180958_0.opt
```
下载并校验字节数完全一致:`SDGB_A031...opt` = 7602176、`SDGB_A005...opt` = 3145728。
---
## 五、注意
- 默认下载到 `download/`(已加入 `.gitignore`,`.app` 动辄上 GB)。
- 需要同目录下的 `authv1.py` 提供 ALL.Net 协议层。
- 这是**只读**操作,不写任何账号数据。
@@ -0,0 +1,46 @@
{
"user_id": 12345678,
"login": {
"loginId": 7693120247791879616,
"loginDateTime": 1791222938,
"lastLoginDate": "2026-10-05 17:53:49",
"loginCount": 184,
"consecutiveLoginCount": 3,
"token": "HcXOwSdDwBSEPzngjdfp"
},
"auth_date_time": 1791194138,
"snapshot_summary": {
"userData": 1,
"userExtend": 1,
"userOption": 1,
"userCharacterList": 312,
"userMapList": 104,
"userLoginBonusList": 38,
"userRatingList": 1,
"userItemList": 1070,
"userMusicDetailList": 1037,
"userCourseList": 16,
"userChargeList": 3,
"userFavoriteList": 5,
"userActivityList": 1,
"userMissionDataList": 6,
"userIntimateList": 2,
"userFavoritemusicList": 27,
"userKaleidxScopeList": 7,
"userGhost": 15
},
"snapshot_failed": [],
"preview": {
"userName": "☆Astral☆",
"playerRating": 15000,
"isLogin": false,
"banState": 0,
"errorId": 0,
"lastLoginDate": "1970-01-01 00:00:00",
"lastPlayDate": "1970-01-01 00:00:00",
"totalAwake": 184,
"iconId": 559501,
"frameId": 0
},
"qr_run_at": "2026-10-05 17:53"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+626
View File
@@ -0,0 +1,626 @@
## 顶层字段对比
| 字段 | 真机 | 本次实跑 | 一致 |
|---|---|---|---|
| `userId` | 12345678 | 12345678 | 一致 |
| `playlogId` | 7692848101484135861 | 7693120247791879616 | **不同** |
| `isEventMode` | False | False | 一致 |
| `isFreePlay` | False | False | 一致 |
| `loginDateTime` | 1791159576 | 1791222938 | **不同** |
| `userPlaylogList` | 1 条 | 1 条 | 一致 |
| `upsertUserAll` | 36 节点 | 36 节点 | 一致 |
## 36 个节点逐节点对比
| # | 节点 | 真机 | 实跑 | 形态一致 |
|---|---|---|---|---|
| 1 | `userData` | 1 条 | 1 条 | 一致 |
| 2 | `userExtend` | 1 条 | 1 条 | 一致 |
| 3 | `userOption` | 1 条 | 1 条 | 一致 |
| 4 | `userCharacterList` | 5 条 | 5 条 | 一致 |
| 5 | `userGhost` | 0 条 | 0 条 | 一致 |
| 6 | `userMapList` | 1 条 | 1 条 | 一致 |
| 7 | `userLoginBonusList` | 0 条 | 0 条 | 一致 |
| 8 | `userRatingList` | 1 条 | 1 条 | 一致 |
| 9 | `userItemList` | 0 条 | 0 条 | 一致 |
| 10 | `userMusicDetailList` | 1 条 | 1 条 | 一致 |
| 11 | `userCourseList` | 0 条 | 0 条 | 一致 |
| 12 | `userFriendSeasonRankingList` | 0 条 | 0 条 | 一致 |
| 13 | `userChargeList` | 3 条 | 3 条 | 一致 |
| 14 | `userFavoriteList` | 5 条 | 5 条 | 一致 |
| 15 | `userActivityList` | 1 条 | 1 条 | 一致 |
| 16 | `userMissionDataList` | 6 条 | 6 条 | 一致 |
| 17 | `userWeeklyData` | 对象(3 键) | 对象(3 键) | 一致 |
| 18 | `userGamePlaylogList` | 1 条 | 1 条 | 一致 |
| 19 | `user2pPlaylog` | 对象(7 键) | 对象(7 键) | 一致 |
| 20 | `userIntimateList` | 0 条 | 0 条 | 一致 |
| 21 | `userShopItemStockList` | 0 条 | 0 条 | 一致 |
| 22 | `userGetPointList` | 0 条 | 0 条 | 一致 |
| 23 | `userTradeItemList` | 0 条 | 0 条 | 一致 |
| 24 | `userFavoritemusicList` | 0 条 | 0 条 | 一致 |
| 25 | `userKaleidxScopeList` | 0 条 | 0 条 | 一致 |
| 26 | `isNewCharacterList` | 位串 '00000' | 位串 '00000' | 一致 |
| 27 | `isNewMapList` | 位串 '0' | 位串 '0' | 一致 |
| 28 | `isNewLoginBonusList` | 位串 '' | 位串 '' | 一致 |
| 29 | `isNewItemList` | 位串 '' | 位串 '' | 一致 |
| 30 | `isNewMusicDetailList` | 位串 '0' | 位串 '0' | 一致 |
| 31 | `isNewCourseList` | 位串 '' | 位串 '' | 一致 |
| 32 | `isNewFavoriteList` | 位串 '22212' | 位串 '22212' | 一致 |
| 33 | `isNewFriendSeasonRankingList` | 位串 '' | 位串 '' | 一致 |
| 34 | `isNewUserIntimateList` | 位串 '' | 位串 '' | 一致 |
| 35 | `isNewFavoritemusicList` | 位串 '' | 位串 '' | 一致 |
| 36 | `isNewKaleidxScopeList` | 位串 '' | 位串 '' | 一致 |
## 11 条位串对比
| 位串 | 真机 | 实跑 | 一致 |
|---|---|---|---|
| `isNewCharacterList` | `00000` | `00000` | 一致 |
| `isNewMapList` | `0` | `0` | 一致 |
| `isNewLoginBonusList` | `` | `` | 一致 |
| `isNewItemList` | `` | `` | 一致 |
| `isNewMusicDetailList` | `0` | `0` | 一致 |
| `isNewCourseList` | `` | `` | 一致 |
| `isNewFavoriteList` | `22212` | `22212` | 一致 |
| `isNewFriendSeasonRankingList` | `` | `` | 一致 |
| `isNewUserIntimateList` | `` | `` | 一致 |
| `isNewFavoritemusicList` | `` | `` | 一致 |
| `isNewKaleidxScopeList` | `` | `` | 一致 |
## userData[0] 逐字段对比
| 字段 | 真机 | 实跑 | 一致 |
|---|---|---|---|
| `accessCode` | "" | "" | 一致 |
| `userName` | "☆Astral☆" | "☆Astral☆" | 一致 |
| `isNetMember` | 1 | 1 | 一致 |
| `point` | 4110 | 4110 | 一致 |
| `totalPoint` | 52620 | 52620 | 一致 |
| `iconId` | 559501 | 559501 | 一致 |
| `plateId` | 458001 | 458001 | 一致 |
| `titleId` | 55502 | 55502 | 一致 |
| `partnerId` | 1 | 1 | 一致 |
| `frameId` | 209504 | 209504 | 一致 |
| `selectMapId` | 550003 | 550003 | 一致 |
| `totalAwake` | 184 | 184 | 一致 |
| `gradeRating` | 0 | 0 | 一致 |
| `musicRating` | 15000 | 15000 | 一致 |
| `playerRating` | 15000 | 15000 | 一致 |
| `highestRating` | 15000 | 15000 | 一致 |
| `gradeRank` | 0 | 0 | 一致 |
| `classRank` | 1 | 1 | 一致 |
| `courseRank` | 10 | 10 | 一致 |
| `charaSlot` | [395, 101, 400402, 400401, 200401] | [395, 101, 400402, 400401, 200401] | 一致 |
| `charaLockSlot` | [395, 101, 0, 0, 0] | [395, 101, 0, 0, 0] | 一致 |
| `contentBit` | 2145488608 | 2145488608 | 一致 |
| `playCount` | 1258 | 1259 | **不同** |
| `currentPlayCount` | 186 | 187 | **不同** |
| `renameCredit` | 0 | 0 | 一致 |
| `mapStock` | 0 | 0 | 一致 |
| `eventWatchedDate` | "2026-10-05 00:19:37.0" | "2026-10-05 17:55:38.0" | **不同** |
| `lastGameId` | "SDGB" | "SDGB" | 一致 |
| `lastRomVersion` | "1.56.00" | "1.56.00" | 一致 |
| `lastDataVersion` | "1.55.05" | "1.55.05" | 一致 |
| `lastLoginDate` | "2026-10-05 00:19:37.0" | "2026-10-05 17:55:38.0" | **不同** |
| `lastPlayDate` | "2026-10-05 00:20:57.0" | "2026-10-05 17:55:38.0" | **不同** |
| `lastPlayCredit` | 1 | 1 | 一致 |
| `lastPlayMode` | 0 | 0 | 一致 |
| `lastPlaceId` | 1000 | 1000 | 一致 |
| `lastPlaceName` | "测试店铺" | "测试店铺" | 一致 |
| `lastAllNetId` | 0 | 0 | 一致 |
| `lastRegionId` | 5 | 5 | 一致 |
| `lastRegionName` | "测试区域" | "测试区域" | 一致 |
| `lastClientId` | "TESTCLIENT1" | "TESTCLIENT1" | 一致 |
| `lastCountryCode` | "CHN" | "CHN" | 一致 |
| `lastSelectEMoney` | 0 | 0 | 一致 |
| `lastSelectTicket` | 0 | 0 | 一致 |
| `lastSelectCourse` | 552304 | 552304 | 一致 |
| `lastCountCourse` | 0 | 0 | 一致 |
| `firstGameId` | "SDGB" | "SDGB" | 一致 |
| `firstRomVersion` | "1.41.00" | "1.41.00" | 一致 |
| `firstDataVersion` | "1.40.08" | "1.40.08" | 一致 |
| `firstPlayDate` | "2024-12-31 21:25:58" | "2024-12-31 21:25:58" | 一致 |
| `compatibleCmVersion` | "1.55.00" | "1.55.00" | 一致 |
| `dailyBonusDate` | "1970-01-01 08:00:00" | "1970-01-01 08:00:00" | 一致 |
| `dailyCourseBonusDate` | "2026-07-13 00:00:00" | "2026-07-13 00:00:00" | 一致 |
| `lastPairLoginDate` | "2026-10-04 18:05:18" | "2026-10-04 18:05:18" | 一致 |
| `lastTrialPlayDate` | "2026-10-03 21:07:50" | "2026-10-03 21:07:50" | 一致 |
| `playVsCount` | 0 | 0 | 一致 |
| `playSyncCount` | 0 | 0 | 一致 |
| `winCount` | 0 | 0 | 一致 |
| `helpCount` | 0 | 0 | 一致 |
| `comboCount` | 0 | 0 | 一致 |
| `totalDeluxscore` | 1869771 | 1869771 | 一致 |
| `totalBasicDeluxscore` | 1274 | 1274 | 一致 |
| `totalAdvancedDeluxscore` | 6566 | 6566 | 一致 |
| `totalExpertDeluxscore` | 470976 | 470976 | 一致 |
| `totalMasterDeluxscore` | 1236693 | 1236693 | 一致 |
| `totalReMasterDeluxscore` | 154262 | 154262 | 一致 |
| `totalSync` | 1112 | 1112 | 一致 |
| `totalBasicSync` | 5 | 5 | 一致 |
| `totalAdvancedSync` | 5 | 5 | 一致 |
| `totalExpertSync` | 425 | 425 | 一致 |
| `totalMasterSync` | 604 | 604 | 一致 |
| `totalReMasterSync` | 73 | 73 | 一致 |
| `totalAchievement` | 975613200 | 975613200 | 一致 |
| `totalBasicAchievement` | 2984753 | 2984753 | 一致 |
| `totalAdvancedAchievement` | 7755815 | 7755815 | 一致 |
| `totalExpertAchievement` | 315483810 | 315483810 | 一致 |
| `totalMasterAchievement` | 578226016 | 578226016 | 一致 |
| `totalReMasterAchievement` | 71162806 | 71162806 | 一致 |
| `playerOldRating` | 10927 | 10927 | 一致 |
| `playerNewRating` | 4606 | 4606 | 一致 |
| `banState` | 0 | 0 | 一致 |
| `friendRegistSkip` | 0 | 0 | 一致 |
| `dateTime` | 1791134329 | 1791194138 | **不同** |
真机 82 字段,实跑 82 字段,命中 76/82
## 差分节点明细
### `userMusicDetailList`
真机:
```json
[
{
"musicId": 143,
"level": 4,
"playCount": 8,
"achievement": 0,
"comboStatus": 0,
"syncStatus": 0,
"deluxscoreMax": 0,
"scoreRank": 0,
"extNum1": 0
}
]
```
实跑:
```json
[
{
"musicId": 143,
"level": 4,
"playCount": 9,
"achievement": 0,
"comboStatus": 0,
"syncStatus": 0,
"deluxscoreMax": 0,
"scoreRank": 0,
"extNum1": 0
}
]
```
### `userMapList`
真机:
```json
[
{
"mapId": 550003,
"distance": 1435000,
"isLock": false,
"isClear": false,
"isComplete": false,
"unlockFlag": 0
}
]
```
实跑:
```json
[
{
"mapId": 550003,
"distance": 1446000,
"isLock": false,
"isClear": false,
"isComplete": false,
"unlockFlag": 0
}
]
```
### `userCharacterList`
真机:
```json
[
{
"characterId": 101,
"level": 4456,
"awakening": 5,
"useCount": 0
},
{
"characterId": 395,
"level": 3801,
"awakening": 5,
"useCount": 0
},
{
"characterId": 200401,
"level": 135,
"awakening": 3,
"useCount": 0
},
{
"characterId": 400401,
"level": 135,
"awakening": 3,
"useCount": 0
},
{
"characterId": 400402,
"level": 135,
"awakening": 3,
"useCount": 0
}
]
```
实跑:
```json
[
{
"characterId": 395,
"useCount": 0,
"level": 3802,
"awakening": 5
},
{
"characterId": 101,
"useCount": 0,
"level": 4457,
"awakening": 5
},
{
"characterId": 400402,
"useCount": 0,
"level": 136,
"awakening": 3
},
{
"characterId": 400401,
"useCount": 0,
"level": 136,
"awakening": 3
},
{
"characterId": 200401,
"useCount": 0,
"level": 136,
"awakening": 3
}
]
```
### `userChargeList`
真机:
```json
[
{
"chargeId": 2,
"stock": 0,
"purchaseDate": "2026-07-14 18:33:37",
"validDate": "2026-10-12 04:00:00"
},
{
"chargeId": 3,
"stock": 0,
"purchaseDate": "2026-10-02 10:49:23",
"validDate": "2026-12-31 04:00:00"
},
{
"chargeId": 5,
"stock": 0,
"purchaseDate": "2026-10-04 18:22:10",
"validDate": "2027-01-02 04:00:00"
}
]
```
实跑:
```json
[
{
"chargeId": 2,
"stock": 0,
"purchaseDate": "2026-07-14 18:33:37",
"validDate": "2026-10-12 04:00:00"
},
{
"chargeId": 3,
"stock": 0,
"purchaseDate": "2026-10-02 10:49:23",
"validDate": "2026-12-31 04:00:00"
},
{
"chargeId": 5,
"stock": 0,
"purchaseDate": "2026-10-04 18:22:10",
"validDate": "2027-01-02 04:00:00"
}
]
```
### `userFavoriteList`
真机:
```json
[
{
"itemKind": 3,
"itemIdList": [
559501
]
},
{
"itemKind": 1,
"itemIdList": []
},
{
"itemKind": 2,
"itemIdList": []
},
{
"itemKind": 10,
"itemIdList": []
},
{
"itemKind": 11,
"itemIdList": []
}
]
```
实跑:
```json
[
{
"itemKind": 3,
"itemIdList": [
559501
]
},
{
"itemKind": 1,
"itemIdList": []
},
{
"itemKind": 2,
"itemIdList": []
},
{
"itemKind": 10,
"itemIdList": []
},
{
"itemKind": 11,
"itemIdList": []
}
]
```
## 两个对象节点
### `userWeeklyData`
真机:
```json
{
"lastLoginWeek": "2026-09-28 04:00:00",
"beforeLoginWeek": "2026-09-21 04:00:00",
"friendBonusFlag": true
}
```
实跑:
```json
{
"lastLoginWeek": "2026-09-28 04:00:00",
"beforeLoginWeek": "2026-09-21 04:00:00",
"friendBonusFlag": true
}
```
### `user2pPlaylog`
真机:
```json
{
"userId1": 0,
"userId2": 0,
"userName1": "",
"userName2": "",
"regionId": 0,
"placeId": 0,
"user2pPlaylogDetailList": []
}
```
实跑:
```json
{
"userId1": 0,
"userId2": 0,
"userName1": "",
"userName2": "",
"regionId": 0,
"placeId": 0,
"user2pPlaylogDetailList": []
}
```
## userGamePlaylogList
真机:
```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
}
]
```
实跑:
```json
[
{
"playlogId": 7693120247791879616,
"version": "1.56.00",
"playDate": "2026-10-05 17:55:38.0",
"playMode": 0,
"useTicketId": -1,
"playCredit": 1,
"playTrack": 1,
"clientId": "TESTCLIENT1",
"isPlayTutorial": false,
"isEventMode": false,
"isNewFree": false,
"playCount": 8,
"playSpecial": 16417894,
"playOtherUserId": 0
}
]
```
## userPlaylogList[0] 逐字段对比(110 字段)
| # | 字段 | 真机 | 实跑 | 一致 |
|---|---|---|---|---|
| 1 | `userId` | 0 | 0 | 一致 |
| 2 | `orderId` | 0 | 0 | 一致 |
| 3 | `playlogId` | 7692848101484135861 | 7693120247791879616 | **不同** |
| 4 | `version` | 1056000 | 1056000 | 一致 |
| 5 | `placeId` | 1000 | 1000 | 一致 |
| 6 | `placeName` | "测试店铺" | "测试店铺" | 一致 |
| 7 | `loginDate` | 1791130777 | 1791194138 | **不同** |
| 8 | `playDate` | "2026-10-04" | "2026-10-05" | **不同** |
| 9 | `userPlayDate` | "2026-10-05 00:20:30.0" | "2026-10-05 17:55:38.0" | **不同** |
| 10 | `type` | 0 | 0 | 一致 |
| 11 | `musicId` | 143 | 143 | 一致 |
| 12 | `level` | 4 | 4 | 一致 |
| 13 | `trackNo` | 1 | 1 | 一致 |
| 14 | `vsMode` | 0 | 0 | 一致 |
| 15 | `vsUserName` | "" | "" | 一致 |
| 16 | `vsStatus` | 0 | 0 | 一致 |
| 17 | `vsUserRating` | 0 | 0 | 一致 |
| 18 | `vsUserAchievement` | 0 | 0 | 一致 |
| 19 | `vsUserGradeRank` | 0 | 0 | 一致 |
| 20 | `vsRank` | 0 | 0 | 一致 |
| 21 | `playerNum` | 1 | 1 | 一致 |
| 22 | `playedUserId1` | 0 | 0 | 一致 |
| 23 | `playedUserName1` | "" | "" | 一致 |
| 24 | `playedMusicLevel1` | 0 | 0 | 一致 |
| 25 | `playedUserId2` | 0 | 0 | 一致 |
| 26 | `playedUserName2` | "" | "" | 一致 |
| 27 | `playedMusicLevel2` | 0 | 0 | 一致 |
| 28 | `playedUserId3` | 0 | 0 | 一致 |
| 29 | `playedUserName3` | "" | "" | 一致 |
| 30 | `playedMusicLevel3` | 0 | 0 | 一致 |
| 31 | `characterId1` | 395 | 395 | 一致 |
| 32 | `characterLevel1` | 3800 | 3800 | 一致 |
| 33 | `characterAwakening1` | 5 | 5 | 一致 |
| 34 | `characterId2` | 101 | 101 | 一致 |
| 35 | `characterLevel2` | 4455 | 4455 | 一致 |
| 36 | `characterAwakening2` | 5 | 5 | 一致 |
| 37 | `characterId3` | 400402 | 400402 | 一致 |
| 38 | `characterLevel3` | 134 | 134 | 一致 |
| 39 | `characterAwakening3` | 3 | 3 | 一致 |
| 40 | `characterId4` | 400401 | 400401 | 一致 |
| 41 | `characterLevel4` | 134 | 134 | 一致 |
| 42 | `characterAwakening4` | 3 | 3 | 一致 |
| 43 | `characterId5` | 200401 | 200401 | 一致 |
| 44 | `characterLevel5` | 134 | 134 | 一致 |
| 45 | `characterAwakening5` | 3 | 3 | 一致 |
| 46 | `achievement` | 0 | 0 | 一致 |
| 47 | `deluxscore` | 0 | 0 | 一致 |
| 48 | `scoreRank` | 0 | 0 | 一致 |
| 49 | `maxCombo` | 0 | 0 | 一致 |
| 50 | `totalCombo` | 655 | 655 | 一致 |
| 51 | `maxSync` | 0 | 0 | 一致 |
| 52 | `totalSync` | 0 | 0 | 一致 |
| 53 | `tapCriticalPerfect` | 0 | 0 | 一致 |
| 54 | `tapPerfect` | 0 | 0 | 一致 |
| 55 | `tapGreat` | 0 | 0 | 一致 |
| 56 | `tapGood` | 0 | 0 | 一致 |
| 57 | `tapMiss` | 441 | 441 | 一致 |
| 58 | `holdCriticalPerfect` | 0 | 0 | 一致 |
| 59 | `holdPerfect` | 0 | 0 | 一致 |
| 60 | `holdGreat` | 0 | 0 | 一致 |
| 61 | `holdGood` | 0 | 0 | 一致 |
| 62 | `holdMiss` | 19 | 19 | 一致 |
| 63 | `slideCriticalPerfect` | 0 | 0 | 一致 |
| 64 | `slidePerfect` | 0 | 0 | 一致 |
| 65 | `slideGreat` | 0 | 0 | 一致 |
| 66 | `slideGood` | 0 | 0 | 一致 |
| 67 | `slideMiss` | 192 | 192 | 一致 |
| 68 | `touchCriticalPerfect` | 0 | 0 | 一致 |
| 69 | `touchPerfect` | 0 | 0 | 一致 |
| 70 | `touchGreat` | 0 | 0 | 一致 |
| 71 | `touchGood` | 0 | 0 | 一致 |
| 72 | `touchMiss` | 0 | 0 | 一致 |
| 73 | `breakCriticalPerfect` | 0 | 0 | 一致 |
| 74 | `breakPerfect` | 0 | 0 | 一致 |
| 75 | `breakGreat` | 0 | 0 | 一致 |
| 76 | `breakGood` | 0 | 0 | 一致 |
| 77 | `breakMiss` | 3 | 3 | 一致 |
| 78 | `isTap` | true | true | 一致 |
| 79 | `isHold` | true | true | 一致 |
| 80 | `isSlide` | true | true | 一致 |
| 81 | `isTouch` | false | false | 一致 |
| 82 | `isBreak` | true | true | 一致 |
| 83 | `isCriticalDisp` | true | true | 一致 |
| 84 | `isFastLateDisp` | true | true | 一致 |
| 85 | `fastCount` | 0 | 0 | 一致 |
| 86 | `lateCount` | 0 | 0 | 一致 |
| 87 | `isAchieveNewRecord` | false | false | 一致 |
| 88 | `isDeluxscoreNewRecord` | false | false | 一致 |
| 89 | `comboStatus` | 0 | 0 | 一致 |
| 90 | `syncStatus` | 0 | 0 | 一致 |
| 91 | `isClear` | false | false | 一致 |
| 92 | `beforeRating` | 15000 | 15000 | 一致 |
| 93 | `afterRating` | 15000 | 15000 | 一致 |
| 94 | `beforeGrade` | 0 | 0 | 一致 |
| 95 | `afterGrade` | 0 | 0 | 一致 |
| 96 | `afterGradeRank` | 1 | 1 | 一致 |
| 97 | `beforeDeluxRating` | 15000 | 15000 | 一致 |
| 98 | `afterDeluxRating` | 15000 | 15000 | 一致 |
| 99 | `isPlayTutorial` | false | false | 一致 |
| 100 | `isEventMode` | false | false | 一致 |
| 101 | `isFreedomMode` | false | false | 一致 |
| 102 | `playMode` | 0 | 0 | 一致 |
| 103 | `isNewFree` | false | false | 一致 |
| 104 | `trialPlayAchievement` | -1 | -1 | 一致 |
| 105 | `extNum1` | 0 | 0 | 一致 |
| 106 | `extNum2` | 0 | 0 | 一致 |
| 107 | `extNum4` | 2022 | 2022 | 一致 |
| 108 | `extBool1` | false | false | 一致 |
| 109 | `extBool2` | false | false | 一致 |
| 110 | `extBool3` | false | false | 一致 |
命中 106/110
## 汇总
- userData 字段命中:76/82
- playlog 字段命中:106/110
- 节点形态命中:36/36
+507
View File
@@ -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 复用等。
+302
View File
@@ -0,0 +1,302 @@
# 主脚本编写指南
> 面向「想用这个工程写自己业务流程」的人。
> 目标:**你只写业务,日志和协议细节都交给 `api/`**。
---
## 一、三条铁律
### 1. 不要为每个 api 调用写日志
`api/` 里每个函数在导入时都被自动套了日志外壳(`log_setup.autowrap`),
调用时会自动打印:
```
=== UserLogin 开始运行 ===
[接口说明] 用户正式登录(开启本次登录会话)。
[位置参数] 12345678, 'TpzUKJM6x1d2iKUP5yLI'
[关键字参数] region_id=1, place_id=1000
[运行结果] 成功 UserLoginResponseVO(8 字段)(耗时 77 ms)
=== UserLogin 运行结束 ===
```
所以你**不需要**在调用前后写 `log_section` / `log_info`。
主脚本里唯一必需的日志语句只有开头结尾那两句:
```python
setup_logging("我的脚本") # 开头
...
teardown_logging() # 结尾(写在 finally 里)
```
### 2. 一定要保证登出
账号若停留在 `isLogin=True`,下次登录会返回 `returnCode=100`。
**用 `try/finally` 兜底**:
```python
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
...
login = UserLogin(user_id, token, ...)
session.update(login_dt=login.loginDateTime, logged_in=True)
...
UserLogout(...)
session["logged_in"] = False
finally:
if session["logged_in"]:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn)
```
### 3. 有副作用的接口要显式开闸门
`UpsertUserAll` / `UpsertUserChargelog` 这类写接口默认**不发包**,
必须显式传 `confirm=True`(或 `dry_run=True` 只看报文):
```python
UpsertUserAll(..., dry_run=True) # 只构造,不发
UpsertUserAll(..., dry_run=False, confirm=True) # 真发
```
> ⚠ 只写 `dry_run=False` **不够** —— `confirm=None` 时会去读
> `config.UPSERT_CONFIRM`(默认 `False`),照样被拦。
---
## 二、最小骨架
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
from api import (setup_logging, teardown_logging,
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType)
from config import REGION_ID, PLACE_ID
def main() -> int:
setup_logging("我的脚本")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
user_id, token = ParseQRCode(sys.argv[1])
session.update(user_id=user_id, token=token)
UserPreview(user_id, token) # 顶号保护
login = UserLogin(user_id, token,
region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
# ---- 你的业务写在这里 ----
UserLogout(user_id, token, login.loginDateTime,
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
session["logged_in"] = False
return 0
finally:
if session["logged_in"]:
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception:
pass
teardown_logging()
if __name__ == "__main__":
sys.exit(main())
```
---
## 三、日志
### 3.1 两种模式(改 config.py 一行)
| `LOG_LEVEL` | 输出 |
|---|---|
| `"info"`(默认) | 只展示 api 脚本的**运行情况和结果** |
| `"debug"` | 额外展示**向 api 发送的数据**和 **api 返回的数据**(含完整报文) |
临时切换(不改 config):
```bash
python upsert.py <二维码> --debug
```
> ★ DEBUG 会完整打印收发报文,一次结算流程能到 **十几 MB**。
> 只想看流程就别开;想瘦身调 `config.LOG_VALUE_MAX`。
### 3.2 两个日志文件(同时写)
| 文件 | 模式 | 内容 |
|---|---|---|
| `logs/latest.log` | 覆盖(`w`) | 只含**当次**主脚本运行 |
| `logs/YYYY-MM-DD.log` | 追加(`a`) | 当日**全部**日志 |
### 3.3 想记自己的话(可选)
```python
from api import log_info, log_warning, log_error, log_section, log_field
log_section("阶段标题") # 带分隔线
log_info("普通信息")
log_warning("要留意的情况")
log_error("出错了")
log_field("预览数据", preview) # INFO 一行概要;DEBUG 展开完整 JSON
```
### 3.4 嵌套调用也会自动记
`UpsertUserAll` 内部会调 `fetch_upsert_snapshot` / `UserNewItemList` / `UserData`,
这些**内部调用同样打印自己的开始/结束行**,日志层次完整嵌套:
```
=== UpsertUserAll 开始运行 ===
=== fetch_upsert_snapshot 开始运行 ===
...
=== fetch_upsert_snapshot 运行结束 ===
=== UserNewItemList 开始运行 ===
...
=== UserNewItemList 运行结束 ===
...
=== UpsertUserAll 运行结束 ===
```
---
## 四、参数约定
| 类型 | 位置 | 例子 |
|---|---|---|
| **业务数据**(必需) | 位置参数 | `user_id` / `token` / `二维码` / `playlog_id` / `charge_id` |
| **配置项**(可省) | 关键字参数 | `client_id` / `region_id` / `place_id` / `timeout_ms` / `client` |
**`client` 参数**:所有 api 都接受一个可选的 `MaimaiNetClient`。
不传时走全局单例 `get_default_client()`,**连接和 Cookie(JSESSIONID)自动复用**。
> ### ★ 不要自己 `MaimaiNetClient()` 新建去发任何带 userId 的包
>
> 源码 `Packet.cs:187-194`:**所有非 Ping 且 `userId != 0` 的请求都会
> `Client.AddCookie(Query.UserId)`** —— **登出也不例外**。
>
> ```csharp
> if (!IsPingPacket())
> {
> Client.AddHeader("number", GameManager.PingMsec.ToString());
> if (Query.UserId != 0L) { Client.AddCookie(Query.UserId); } // ← 登出也走这里
> }
> ```
>
> 那个 CookieContainer 是 **`UserLoginApi` 响应里的 `Set-Cookie: JSESSIONID=...`**
> 存下来的(`Packet.cs:228-234`)。
>
> **为什么对登出尤其要命**:登出请求体里**没有 token**,只有
> `userId` / `accessCode` / `regionId` / `placeId` / `clientId` /
> `loginDateTime` / `type` / `delayLog`。
> 服务端能用来定位「哪个会话」的,除了 `userId + loginDateTime`,就剩这个 JSESSIONID。
> **丢掉它有可能登出失败 → 账号卡在 `isLogin=True`(下次登录 returnCode=100)**。
>
> 结论:**全程复用同一个 client**(不传 `client=` 即默认单例,自动满足)。
---
## 五、四个典型流程模板
### 5.1 只登录(探活 / 查状态)
```python
user_id, token = ParseQRCode(qr)
UserPreview(user_id, token)
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
# ... 用 UserData / UserMusicDetailAll 等取数 ...
```
### 5.2 上传一局成绩(最常用)
```python
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
# ★ 一个函数跑完整条链路:抓快照 → 推角色 → 造成绩明细 → 前置接口
# → 组 36 节点 → 合并 extra_data → 自检 → 冷却 → 发包 → 落库回查
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token, # 让内部自动抓基线快照
dry_run=False, confirm=True,
)
```
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`**,不用动主脚本。
**想额外塞节点**用 `extra_data`:
```python
UpsertUserAll(..., extra_data={
"userKaleidxScopeList": [{"gateId": 1, "isGateFound": True, ...}],
"isNewKaleidxScopeList": "1",
})
```
### 5.3 买一张功能票
见 `buy_ticket.py` 与 `docs/购买功能票流程.md`。核心就一句:
```python
UpsertUserChargelog(user_id, charge_id=2,
login_date_time=login.loginDateTime,
dry_run=False, confirm=True)
```
### 5.4 只取数(不发任何写接口)
```python
from api import UserData, UserMusicDetailAll, UserRating
ud = UserData(user_id)
print(ud["userData"]["playCount"])
for m in (UserMusicDetailAll(user_id).get("userMusicList") or []):
if m.get("musicId") == 143:
print(m)
```
> 取数类 api 不需要 `confirm`,也没有副作用,可以随便调。
---
## 六、常见错误
| 现象 | 原因 | 解决 |
|---|---|---|
| 登录 `returnCode=100` | 账号卡在 `isLogin=True` | 上次没登出;等超时或用 `finally` 兜底 |
| `UpsertUserAll` 抛 `ConfirmRequiredError` | 只传了 `dry_run=False`,没传 `confirm=True` | 补 `confirm=True` |
| 扫码 `errorID=2` | 二维码**过期**了。它有**时效性**(**有效期 10 分钟**),**不是按次数计量的** | 重新生成一个 |
| `returnCode=1` 但数据没变 | 差分节点全空(见踩坑清单坑 2) | 检查 `PLAY_*` 增量配置 |
| 日志文件特别大 | 开了 DEBUG | 关掉 `--debug` 或调小 `LOG_VALUE_MAX` |
| `RuntimeError: 接口 xxx 返回 0 字节` | `Accept-Encoding` 被改过 | 不要动 `maimai_net` 的该头 |
---
## 七、自己写一个新 api
1. 在 `api/` 下新建脚本,定义一个能传参的函数
2. 函数首行写 docstring(会变成日志的 `[接口说明]`)
3. 用 `from .maimai_net import request_dict` 发包
4. 用 `from .log_setup import log_info` 记关键结果
5. **在 `api/__init__.py` 里导入并加进 `_API_FUNCS`** ← 漏了这步就没有开始/结束提示
---
## 八、相关文档
| 文件 | 内容 |
|---|---|
| `docs/项目总览.md` | 工程结构与 api 清单 |
| `docs/日志与主脚本指南.md` | 日志模块完整参考 |
| `docs/上传成绩开发文档.md` | UpsertUserAll 的 36 节点 / 110 字段 / 差分语义 |
| `docs/UpsertUserAll踩坑清单.md` | 28 条坑 + 发包前 Checklist |
| `docs/购买功能票流程.md` | 登录 → 买票 → 登出 |
| `upsert.py` | 主脚本范例(极简版) |
+173
View File
@@ -0,0 +1,173 @@
# 功能票使用说明(购买 / 消耗)
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里功能票部分的**详细版**。
> 购买接口的完整排查过程见 [购买功能票流程.md](购买功能票流程.md) 与
> [购买功能票_存档与交接.md](购买功能票_存档与交接.md)。
---
## 一、票的种类
机台**可购买**的是付费票(`ticketKind=1`,`api.PAID_TICKET_IDS = (2,3,4,5,6)`):
| chargeId | 票 | 上报的 `price`(= 票的 `creditNum`) |
|---|---|---|
| 2 | 2倍チケット | 1 |
| 3 | 3倍チケット | 2 |
| 5 | 5倍チケット | 4 |
> 完整表(含 4/6 倍与活动票)见 `api/upsert_user_chargelog.py` 的 `TICKET_CREDIT_NUM`。
>
> ⚠️ **不同店铺上架的票不一样。** 买之前先 `--list` 看一眼 ——
> 服务端对未上架的票会直接 `returnCode=0`(实测过)。
---
## 二、购买:`buy_ticket.py`
```bash
python buy_ticket.py "<二维码>" --list # 只列当前票券和可买的票
python buy_ticket.py "<二维码>" --charge-id 2 # 预检:只看报文
python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
```
| 参数 | 默认 | 说明 |
|---|---|---|
| `--charge-id N` | **5** | 买哪张票 |
| `--list` | — | 只列票券与可购买清单,不做任何写操作 |
| `--send` | 关 | ★ 真实发包 |
| `--debug` | 关 | 日志里带完整收发报文 |
| `--skip-entry` | 关 | 跳过登录后的「入场全量下载」 |
| `--wait-before N` | **30** | 买票前等待秒数(真机 login→买票是 26 秒) |
### 三道安全检查
1. **买之前**先查 `GetGameChargeApi` —— 目标票**不在当前可购买清单里就直接退出**,
连登录都不做(不浪费二维码)。
依据 `source/Assembly-CSharp/Manager/TicketManager.cs:137-150`。
2. **跑前查一次** `GetUserChargeApi`:**账号已经持有功能票时直接拒绝购买**
(重复购买有封号风险)。想清掉手里的票用 `use_ticket.py`。
3. **跑后再查一次**,确认票券状态符合预期。
### 报文形态
```json
{
"userId": <uid>,
"userChargelog": {
"chargeId": 2, "price": 1,
"purchaseDate": "2026-10-06 22:23:00.0",
"playCount": 1268, "playerRating": 15534,
"placeId": 1007, "regionId": 8, "clientId": "A63E01C3047"
},
"userCharge": {
"chargeId": 2, "stock": 1,
"purchaseDate": "2026-10-06 22:23:00.0",
"validDate": "2027-01-04 04:00:00"
},
"loginDateTime": 1791325354
}
```
| 字段 | 依据 |
|---|---|
| `price` | = `ticket.creditNum`(**不是** `GetGameChargeApi` 里的 money price)。`VOExtensions.cs:410` |
| `stock` | **购买后**的库存(原值 + 1) |
| `purchaseDate` | `TimeManager.DateTimeFormat` = `yyyy-MM-dd HH:mm:ss.f`,**带一位小数** |
| `validDate` | `TicketManager.cs:562-565`:`Now.AddHours(-4).AddDays(90).ToString("yyyy-MM-dd 04:00:00")`,**不带小数** |
> ⚠️ 这两个日期格式不一样 —— `purchaseDate` 带 `.f` 而 `validDate` 不带。
> 少写 `.0` 服务端直接 `returnCode=0`(踩过)。
---
## 三、消耗:`use_ticket.py`
```bash
python use_ticket.py "<二维码>" --list # 只列当前持有的票
python use_ticket.py "<二维码>" --ticket-id 2 # 预检:只看报文
python use_ticket.py "<二维码>" --ticket-id 2 --send # ★ 真实消耗
```
| 参数 | 默认 | 说明 |
|---|---|---|
| `--ticket-id N` | 第一张持有的 | 消耗哪一张 |
| `--list` | — | 只列票券,不做任何写操作 |
| `--send` | 关 | ★ 真实发包 |
| `--music-id N` / `--level N` | `config.PLAY_MUSIC_ID` / `PLAY_MUSIC_LEVEL` | 「这一局」打的歌 |
| `--skip-entry` | 关 | 跳过入场全量下载 |
| `--debug` | 关 | 日志里带完整收发报文 |
### 原理
`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList`
**格式完全相同**(`UserCharge.cs`:`chargeId` / `stock` / `purchaseDate` / `validDate`),
所以只要把该节点里目标票的 `stock` 减 1,再让 playlog 带上
`useTicketId` / `playCredit`,服务端就认为这张票被这一局用掉了。
**真机实证**(2026-10-06 22:23 那局正好用了 2 倍券):
```
userChargeList: chargeId=2 stock 1 -> 0 ← 票被消耗
userGamePlaylogList[0].useTicketId = 2 ← 用了哪张票
userGamePlaylogList[0].playCredit = 2
userPlaylogList[0].playCredit = 2
userData.lastPlayCredit = 2
(报文里**没有** isNewChargeList 这个节点)
```
`playCredit` 的算法(`VOExtensions.cs:838-845`):
```csharp
int num = Credit.GameCostEnoughPlay(); // 投币数,普通局 = 1
int useTicketId = TicketManager.GetUseTicketId(playerId);
int ticketCredit= TicketManager.GetTicketCredit(useTicketId); // = 票的 creditNum
int playCredit = num + ticketCredit;
```
2 倍券的 `creditNum = 1` → `playCredit = 1 + 1 = 2`,与抓包吻合。
### 实测记录(2026-10-07 00:24)
```
[消耗前] chargeId=2 2倍チケット stock=1 ← 持有中
选定要消耗的票:chargeId=2 creditNum=1 -> playCredit=1+1=2
userChargeList 里 chargeId=2 的 stock:1 -> 0
★ 上传成绩成功:returnCode=1
落库回查:
playCount 1269 -> 1269 (PLAY_USER_PLAY_COUNT_GAIN=0,不再 +1)
currentPlayCount 197 -> 197
lastPlayDate 2026-10-06 22:23:56 -> 2026-10-07 00:24:07 ← 落库成功
musicId=143 level=4 playCount -> 11 (曲目次数不变)
★ 票券已消耗:chargeId=2 不再持有 ✅
```
---
## 四、典型用法:票挂在手里,想再买一张
`buy_ticket.py` 检测到**已持票**会拒绝购买。想再买一张:
1. 用 `use_ticket.py --send` 把手里那张消耗掉,或
2. 直接在机台上打一局并选用它
然后再跑 `buy_ticket.py`。
---
## 五、购买接口曾经的坑(摘要)
`UpsertUserChargelogApi` 曾经一直返回 `returnCode=0`,报文与真机**逐字节一致**也照样失败。
**根因:请求头多发了 `Accept` 与 `Connection`。**
真机(Unity Mono 的 `HttpWebRequest`)实际只发这几个头:
```
number / Content-Type / User-Agent / charset / Mai-Encoding / Content-Encoding / Cookie
```
删掉这两个头之后一次通过(`stock` 0 → 1,落库确认)。
> 完整排查过程(12 项已排除假设、账号对照实验、真机抓包对比)见
> [购买功能票流程.md](购买功能票流程.md) 与 [购买功能票_存档与交接.md](购买功能票_存档与交接.md)。
+133
View File
@@ -0,0 +1,133 @@
# 实跑验证记录(真实发包 + 落库确认)
> 本文记录本工程**第一次真实跑通**「扫码登录 → 取数 → 上传成绩 → 落库回查 → 登出」的完整证据。
> 执行方式:`python upsert.py <二维码> --send`(等价于下面的驱动方式)。
---
## 一、结论
| 环节 | 结果 |
|---|---|
| 扫码登录 | ✅ user_id=12345678,token 获取成功 |
| 用户预览 | ✅ errorId=0、banState=0、isLogin=False |
| 正式登录 | ✅ returnCode=1,loginId=7693236821794227665 |
| 抓基线快照 | ✅ 19 个节点全部成功,失败 0 个 |
| 组包 + 发包前自检 | ✅ 0 问题 |
| 冷却 | ✅ 等待 60.0 秒(模拟真机节奏) |
| **上传成绩** | ✅ **returnCode=1** |
| **落库回查** | ✅ **userData.playCount 1260 → 1261** |
| 登出 | ✅ returnCode=1 |
**这是关键突破**:之前的失败模式是「returnCode=1 但没落库」。
本次是**回码成功 + 重新拉服务器数据确认数值真的变了**,两者同时成立。
---
## 二、时间线(2026-10-06 01:28)
```
01:28:01 扫码登录成功 user_id=12345678
01:28:01 用户登录成功 loginId=7693236821794227665
loginDateTime=1791250081 dateTime=1791221281
01:28:03 结算快照抓取完成 成功 19 个节点,失败 0 个
01:28:04 角色快照取自服务器 395:3803 / 101:4458 / 400402:137 / 400401:137 / 200401:137
01:28:04 发包前自检通过
01:28:04 上传成绩冷却 等待 60.0 秒后再发送(模拟真机节奏)
01:29:04 冷却结束 共等待 60.0 秒
01:29:04 上传成功 returnCode=1
01:29:04 落库回查:
playCount 1260 -> 1261
currentPlayCount 188 -> 189
lastPlayDate 2026-10-05 22:32:21 -> 2026-10-06 01:28:04
musicId=143 level=4 playCount -> 11
01:29:05 【落库成功】userData.playCount 已 +1
01:29:05 用户登出成功 returnCode=1
```
---
## 三、落库证据(上传后重新拉服务器数据)
上传成功后**不登出、立刻**调 `GetUserDataApi` + `GetUserMusicApi` 回读,服务器返回:
```json
{
"userData": {
"playCount": 1261,
"currentPlayCount": 189,
"lastPlayDate": "2026-10-06 01:28:04",
"lastLoginDate": "2026-10-06 01:28:01",
"eventWatchedDate": "2026-10-06 01:28:01",
"lastPlaceId": 0,
"point": 4460,
"charaSlot": [395, 101, 400402, 400401, 200401]
}
}
```
```
musicId=143 level=4 -> playCount = 11
```
对照**上传前**的基线:`playCount=1260`、`currentPlayCount=188`、`(143,4).playCount=10`。
三项全部 +1,**证明服务端真的把这份报文写进去了**。
同时确认:
- `point` 保持 4460 不变 —— 与「`userGetPointList` / `userLoginBonusList` 留空」的预期一致
- `lastLoginDate` = 登录时刻(01:28:01),`lastPlayDate` = 上报时刻(01:28:04),两者分离正确
- `charaSlot` 未被破坏
---
## 四、这次跑通依赖的关键实现点
1. **`number` 请求头**:先跑 `Ping()` 取得往返毫秒,之后所有请求带上(本次 47 ms)
2. **Cookie 复用**:全程同一个 `MaimaiNetClient`,`UserLoginApi` 的 `Set-Cookie: JSESSIONID` 自动带入后续请求
3. **userData 整表覆盖**:先抓 19 节点快照作基线,再按本局演算 +1 后整表回传
4. **差分节点只回传变化项**:`userMusicDetailList` 只回传 (143,4) 那条、`userMapList` 只回传 550003、
`userCharacterList` 只回传 5 只组队角色
5. **11 条位串与数组长度严格对齐**:`isNewFavoriteList="22212"` 等全部复刻真机
6. **`playSpecial` 用 `CalcSpecialNum=1024`**:与真机位模式一致
7. **`characterLevelN` 用游玩前等级**:playlog 3803/4458/137,userCharacterList 才是 3804/4459/138
---
## 五、复现方式
```bash
# 1) 预检(不发包,零副作用)
python upsert.py "<二维码>" --debug
# 2) 真实上传(★ 会把成绩写进服务器,不可撤销)
python upsert.py "<二维码>" --send
# 想跳过 60 秒冷却
python upsert.py "<二维码>" --send --cooldown 0
# 想跳过上传后的落库回查
python upsert.py "<二维码>" --send --no-verify
```
`--send` 会自动做落库回查:重新拉 `GetUserDataApi` + `GetUserMusicApi`,
对比 `playCount` 有没有 +1,并打印 `【落库成功】` 或 `【疑似未落库】`。
> ★ 建议先用 `--debug` 跑一次预检:DEBUG 日志会完整打印请求头、请求体、响应体,
> 便于逐字段核对。代价是日志文件会很大(本次 DEBUG 日志约 200 万行)。
---
## 六、账号状态变化(本次真实写入的内容)
| 项 | 上传前 | 上传后 |
|---|---|---|
| `userData.playCount` | 1260 | **1261** |
| `userData.currentPlayCount` | 188 | **189** |
| `userData.lastPlayDate` | 2026-10-05 22:32:21 | **2026-10-06 01:28:04** |
| `userMusicDetailList` (143, Re:Master) playCount | 10 | **11** |
| `userMapList` 550003 distance | 1457000 | **1468000**(+11000) |
| `userCharacterList` 5 只组队角色 | 3803/4458/137/137/137 | **3804/4459/138/138/138**(+1) |
| `userData.point` | 4460 | 4460(不变) |
写入的 `userPlaylogList[0]` 是 `musicId=143`(Re:Master)全 Miss 的失败局,
判定数 441/19/192/0/3,与 `config.PLAY_*` 一致。
+114
View File
@@ -0,0 +1,114 @@
# 常见坑与注意事项
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里被精简掉的
> 「常见坑与注意事项」章节的**详细版**。
---
## 一、二维码
| 项 | 说明 |
|---|---|
| 有效期 | **10 分钟**,过期后 aimedb 返回 `errorID=2` |
| 计量方式 | ★ **不是按次数计量的**,只是有时效;重新生成即可 |
| 来源 | 机台的「扫码登录」界面 |
| 每个脚本各要一次 | `reincarnate.py` 需要目标号的二维码;源号可以用 `--src-json` 免掉 |
---
## 二、账号状态
- **一定要登出。** 账号卡在 `isLogin=True` 时,下次登录会 `returnCode=100`。
本工程所有主脚本都在 `finally` 里兜底登出,异常也不会漏。
- 想确认状态可以看 `GetUserPreviewApi` 的 `isLogin` 字段。
- 登出类型:真机用 **type=1(Logout)**,见 [账号转生说明.md](账号转生说明.md) 第五节。
---
## 三、功能票
- 账号**已经持有**功能票时,`buy_ticket.py` **会拒绝购买**(重复购买有封号风险)。
- 想再买一张,先用 `use_ticket.py` 把手里那张用掉,或直接在机台上打一局选用它。
- 不同店铺上架的票不一样,买之前先 `--list`。
- 详见 [功能票使用说明.md](功能票使用说明.md)。
---
## 四、副作用
| 命令 | 后果 |
|---|---|
| 不带 `--send` | 只构造报文,**不发包**(安全,随便跑) |
| 带 `--send` | **真实写入服务器,不可撤销** |
涉及真实写入的脚本:`upsert.py` / `buy_ticket.py` / `use_ticket.py` / `reincarnate.py`。
---
## 五、日志
```
logs/latest.log 只含**当次**运行(下次覆盖)
logs/YYYY-MM-DD.log 当日全部日志(追加)
```
改 `config.py` 的 `LOG_LEVEL`:
| 值 | 效果 |
|---|---|
| `"info"` | 只展示 api 脚本的运行情况和结果 |
| `"debug"` | 额外展示向 api 发送的数据与 api 返回的数据 |
> 主脚本也可以加 `--debug` 临时开启,不用改 `config.py`。
> DEBUG 日志很大(一局能到十几 MB),`config.LOG_VALUE_MAX` 可以限制单值长度。
---
## 六、网络
- 服务器偶发超时 / 连接重置,**重试即可**(买票那次就是这么过的)。
- 所有请求默认 `timeout` 10 秒,登录与结算用 60 秒。
- `GetGameChargeApi` 偶尔会读超时,重跑一次通常就好。
---
## 七、不要提交的东西
```
config.py 你的机台身份,.gitignore 已忽略
data/ 用户数据快照
download/ .app / .opt 更新包
source/ 反编译源码(版权)
logs/ 运行日志
```
### ⚠️ `.gitignore` 的行尾注释不是注释
写:
```gitignore
data/ # 每账号一份用户数据快照
```
**是错的** —— `#` 只在**行首**才是注释,整行会被当成一个含空格和 `#` 的 pattern,
**匹配不到任何东西**。结果就是规则失效。
正确写法:
```gitignore
# 每账号一份用户数据快照
data/
```
> 这条坑踩过一次:`data/12570152.json`(604 KB 用户数据)被误提交进 git。
> 修复方式是 `git rm --cached` + `commit --amend` + `reflog expire` + `gc --prune=now`。
---
## 八、其它
- **换歌 / 换成绩只改 `config.py` 的 `PLAY_*`**,不用动任何代码。
- `config.py` 不入库,仓库里只有 `config.py.example` 模板;
第一次用要先 `copy config.py.example config.py` 并填好「机台与店铺标识」。
- `api/` 里每个脚本 = 一个接口,函数名与文件名都带 `Get`(如果 API 名里有 Get)。
- 想离线复现某次请求,用 `--debug` 抓报文 + `selftest.py` 里的对拍用例。
+380
View File
@@ -0,0 +1,380 @@
# 日志与主脚本指南
> 模块:`api/log_setup.py` | 模式:INFO / DEBUG
---
## 一、一句话理解
**api 调用会被自动识别并记录,主脚本不用写任何日志代码。**
主脚本要写的只有两件:
```python
setup_logging("脚本名") # 开头一句
... # 业务代码,照常调 api
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
```
---
## 二、自动日志长什么样
调用了 `ParseQRCode` 与 `UserPreview`,日志自动产出:
```
==================================================================
日志会话开始 —— main
==================================================================
主脚本 : main
开始时间 : 2026-10-05 17:37:42
日志模式 : INFO(仅运行情况与结果)
Python : 3.12.6 (CPython AMD64)
工作目录 : D:\SDGBSource\杂乱的api\wahappy-v0-goal
已挂载 api : 60 个(自动跟踪,主脚本无需写日志代码)
当次日志 : ...\logs\latest.log
日期日志 : ...\logs\2026-10-05.log
==================================================================
=== ParseQRCode 开始运行 ===
[接口说明] 解析二维码并换取 userId 与 token。
[位置参数] SGWCMAID2610022219463A297AF4CAB863406AA91ED2D29FF3BE20BF1113AC03095D97BD1573EDD691B6
解析二维码登录:chip_id=TESTCLIENT1
发送登录请求:timestamp=261005173742 host=ai.sys-allnet.cn/wc_aime/api/get_data
扫码登录成功:user_id=12345678 token=t3WikM5n...(长度 20)
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
=== ParseQRCode 运行结束 ===
=== UserPreview 开始运行 ===
[接口说明] 获取用户预览信息。
[位置参数] 12345678, 'TESTTOKEN0000000000'
[关键字参数] client_id='TESTCLIENT1'
获取用户预览:user_id=12345678 client_id=TESTCLIENT1
用户预览获取完成:userName=☆Astral☆ playerRating=15000 isLogin=False isNetMember=1
[运行结果] 成功 UserPreviewResponseVO(22 字段)(耗时 388 ms)
=== UserPreview 运行结束 ===
==================================================================
本次运行调用的 api
==================================================================
1. ParseQRCode
2. UserPreview
==================================================================
日志会话结束 —— main
==================================================================
```
自动带出来的东西:
| 自动内容 | 来源 |
|---|---|
| `===函数名 开始运行===` / `===函数名 运行结束===` | 导入时自动包装 |
| `[接口说明]` | 函数 docstring 首行 |
| `[位置参数]` / `[关键字参数]` | 本次调用传入的值 |
| `[运行结果]` | 成功摘要或异常类型,附耗时 |
| 会话结束的 **api 汇总清单** | 运行时记录调用顺序 |
---
## 三、两种模式
| 模式 | 你能看到什么 |
|---|---|
| **INFO**(默认) | 开始 → 接口说明 → 参数 → 结果 → 结束 |
| **DEBUG** | 上面全部 **+** 请求 URL、请求头、**请求体 JSON 全文**、状态码、耗时、响应头、**响应体 JSON 全文** |
切换方式:改 `config.py` 一行。
```python
LOG_LEVEL = "info" # 日常运行
LOG_LEVEL = "debug" # 排查协议问题
```
DEBUG 下额外多出:
```
-> 请求 GetGameSettingApi
URL : https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/83a5d5a20b062c5c2ad817460b8f7f76
请求头 : {"Content-Type":"application/json","charset":"UTF-8","Mai-Encoding":"1.55","User-Agent":"83a5...#TESTCLIENT1","Accept-Encoding":"","Content-Encoding":"deflate"}
请求体 :
{
"placeId": 1000,
"clientId": "TESTCLIENT1"
}
<- 响应 GetGameSettingApi
状态码 : 200
耗时 : 312 ms
响应头 : {"Date":"...","Content-Type":"application/json"}
响应体 :
{
"isAouAccession": false,
"gameSetting": {
"isMaintenance": false,
"requestInterval": 1200,
...
}
}
```
> DEBUG 模式下**完整输出,不折叠、不截断** —— 调试时就是要能逐条核对
> 1000+ 条曲目、104 条地图。如需限制,改 `config.py` 的 `LOG_VALUE_MAX`。
---
## 四、主脚本怎么写
### 4.1 ★ 核心原则:**不要为每个 api 调用写日志**
日志全部写在 api 脚本内部。主脚本**只需要**:
1. 开头 `setup_logging("脚本名")`
2. 照常调 api 函数
3. 结尾 `teardown_logging()`
**不要**在每次调用前写 `log_section`、调用后写 `log_info` —— 那是多余的,
api 函数自己会打印 `=== 函数名 开始运行 ===` / `[运行结果]` / `=== 函数名 运行结束 ===`。
> 完整范例见工程根目录的 `upsert.py`:整条「扫码 → 预览 → 登录 → 结算上传 → 登出」
> 只有四行业务代码,**一行日志都没写**。
### 4.2 最小可用版本
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
from api import (setup_logging, teardown_logging,
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType)
def main() -> int:
setup_logging("我的脚本") # ← 只需要这一句
try:
user_id, token = ParseQRCode(sys.argv[1])
UserPreview(user_id, token)
login = UserLogin(user_id, token)
UserLogout(user_id, token, login.loginDateTime,
logout_type=LogoutType.TestIn)
return 0
finally:
teardown_logging()
```
**就这些。** 没有装饰器,没有埋点,没有 log_section。
### 4.3 嵌套调用也会自动记日志
`UpsertUserAll` 内部会调 `fetch_upsert_snapshot` / `UserNewItemList` / `UserData`,
这些**内部调用同样会打印自己的开始/结束行**(见 `upsert_user_all._resolve_api_func`),
所以结算链路的日志层次是完整嵌套的:
```
=== UpsertUserAll 开始运行 ===
[接口说明] 上传一局成绩(UpsertUserAllApi)—— 一个函数跑完整条结算链路。
=== fetch_upsert_snapshot 开始运行 ===
...
=== fetch_upsert_snapshot 运行结束 ===
=== UserNewItemList 开始运行 ===
...
=== UserNewItemList 运行结束 ===
...
=== UpsertUserAll 运行结束 ===
```
### 4.4 想额外记点自己的话(可选)
不写也完全不影响:
```python
from api import log_info, log_warning, log_error, log_section, log_field
log_section("阶段标题") # 带分隔线的分节
log_info("普通信息") # 就当成「会进日志文件的 print」
log_warning("需要留意的情况")
log_error("出错了")
log_field("预览数据", preview) # INFO 一行概要;DEBUG 展开完整 JSON
```
### 4.5 ★ 保证登出
账号若停留在 `isLogin=True`,下次登录会 `returnCode=100`。用这个模板:
```python
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
...
login = UserLogin(user_id, token)
session.update(login_dt=login.loginDateTime, logged_in=True)
...
UserLogout(...)
session["logged_in"] = False
finally:
if session["logged_in"]:
log_warning("[收尾] 异常中断,强制登出 ...")
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn)
except Exception as e:
log_error(f"[收尾] 强制登出失败:{e}")
```
---
## 五、api 脚本怎么写
**不用加装饰器**,直接定义函数就行:
```python
# api/userpreview.py
from .log_setup import log_info, log_debug, log_request, log_response
from .maimai_net import request_dict
def UserPreview(user_id: int, token: str, *, client_id: str = CLIENT_ID):
"""获取用户预览信息。""" # ← 首行成为 [接口说明]
log_info(f"获取用户预览:user_id={user_id}") # 可选
raw = request_dict("GetUserPreviewApi", req, user_id=user_id, client_id=client_id)
# ↑ maimai_net 内部已经调了 log_request / log_response,
# DEBUG 模式下会自动输出收发数据,不用在这里重复写
return parse(raw)
```
### 新增 api 必须登记
写完新函数后,在 `api/__init__.py` 做两步:
```python
# 1) 导入
from .xxx import MyNewApi
# 2) 加进跟踪名单
_API_FUNCS = (
"ParseQRCode",
"UserPreview",
...
"MyNewApi", # ← 加这里
)
```
`autowrap(globals(), _API_FUNCS)` 会在导入时自动给它套上日志外壳。
**漏了这步,该函数就不会输出 `===开始运行===`。**
---
## 六、完整 API 参考
### 6.1 会话(主脚本用)
```python
setup_logging(script_name, level=None) -> str
# 初始化会话:确定模式、打开两个日志文件、打印横幅
# level=None 时读 config.LOG_LEVEL
# 同一进程重复调用安全(只初始化一次)
teardown_logging()
# 收尾:打印「本次运行调用的 api」清单 + 结束横幅 + 关文件
# 不调用也会在退出时自动执行
```
### 6.2 自动跟踪
```python
autowrap(namespace, names) -> list[str] # api/__init__.py 调用,一般不用手写
get_called_apis() -> list[str] # 本次会话调用过的 api,按首次调用顺序
```
### 6.3 模式
```python
get_level() -> "info" | "debug"
set_level(v) -> "info" | "debug"
is_debug() -> bool
LEVEL_INFO = "info"
LEVEL_DEBUG = "debug"
```
### 6.4 流程日志(INFO 可见)
```python
log_info("普通信息")
log_step("扫码登录") # → [步骤] 扫码登录
log_step("扫码登录", index=1, total=5) # → [步骤 1/5] 扫码登录
log_result("user_id=123") # → [结果] user_id=123
log_warning("账号已登录") # → [警告] ...
log_error("网络不可达") # → [错误] ...
log_section("阶段标题") # 带分隔线的分节
log_field("预览数据", preview) # INFO 一行;DEBUG 多行展开
log_field("原始响应", text, debug_only=True) # 只在 DEBUG 出现
```
### 6.5 收发数据日志(DEBUG 专属)
```python
log_debug("只在 DEBUG 出现")
log_request(url="https://...", body=req, headers=hdr, api_name="XxxApi")
log_response(status=200, body=raw, headers=hdr, api_name="XxxApi", elapsed_ms=412)
log_payload("解密后明文", text)
```
**INFO 模式下这些函数第一行就 `return`**,不做序列化、不拼字符串,可以放心留在热路径里。
### 6.6 兼容写法
```python
from api import api_log, get_logger
@api_log # 仍可用(会自动跳过重复包装)
def MyFunc(): ...
logger = get_logger()
logger.info("..."); logger.warning("..."); logger.debug("...")
```
---
## 七、日志文件
| 文件 | 打开模式 | 说明 |
|---|---|---|
| `logs/latest.log` | `w`(覆盖) | 每次运行重写,**只保留最近一次** |
| `logs/YYYY-MM-DD.log` | `a`(追加) | **当日所有运行累积**,按天分文件 |
两者**同时写入**。每行都带 `[YYYY-MM-DD HH:MM:SS]` 时间戳。
DEBUG 的收发数据同样写进文件,可以跑一次 DEBUG 再翻 `latest.log`。
---
## 八、常见问题
**Q: 为什么 api 调用没有 `===开始运行===`?**
A: 该函数没登记在 `api/__init__.py` 的 `_API_FUNCS` 里。补上即可。
**Q: 为什么 `log_debug` 什么都没输出?**
A: 当前是 INFO 模式。改 `config.py` 的 `LOG_LEVEL = "debug"`。
**Q: 改了 config.py 没生效?**
A: 模式在 `setup_logging()` 时读取。之前已初始化过就需重启脚本,
或显式 `setup_logging("名字", level="debug")`。
**Q: 主脚本没调用 `setup_logging` 会怎样?**
A: 仍会打印到控制台(自动跟踪在导入时就生效),但不写文件、没有横幅与汇总。
**Q: 写测试 / 单独跑 api 脚本时不想要日志?**
A: 不调用 `setup_logging` 就不会开文件;控制台输出仍在。
**Q: 能完全关掉吗?**
A: 可以 `set_level("info")` 抑制 DEBUG 部分。要连控制台也静音需改 `_emit` 的 `console` 参数。
---
## 九、相关文件
| 文件 | 作用 |
|---|---|
| `api/log_setup.py` | 日志模块本体(含自动包装引擎) |
| `api/__init__.py` | 统一导出 + `_API_FUNCS` 登记表 |
| `config.py` | `LOG_LEVEL` / `LOG_VALUE_MAX` 开关 |
| `upsert.py` | 主脚本范例(含完整的登出兜底) |
| `api/maimai_net.py` | 网络层,已接入 DEBUG 收发数据日志 |
+215
View File
@@ -0,0 +1,215 @@
# 真机 Hook 对照报告(MaimaiNetSpy 实测)
> 本文记录用 MelonLoader Mod **MaimaiNetSpy** 在真机上抓取一整局(登录 → 游玩 → 结算上报 → 登出)
> 的完整网络行为,并与本工程的实现逐项对照后做的修正。
>
> 抓包样本:
> - `D:\SDGB2026\Package\MaimaiNetSpy\2026-10-05_222919.log`(70 个请求的人类可读时间线)
> - `D:\SDGB2026\Package\MaimaiNetSpy\raw\*`(每个接口的请求/响应原文 JSON)
> - 关键样本:`raw/068_UpsertUserAllApiMaimaiChn_request.json`(17164 字节明文)
>
> Hook 挂载点(`gamehook/Main.cs`,8 个,全部 `[ok]`):
> `NetHttpClient.Create / AddHeader / AddCookie / Request / GetResponse`、
> `NetPacketUtil.GetUserAgent`、`GameManager.CalcSpecialNum`、`NetHttpClient.SetError`
---
## 一、结论速览
| 项 | 结论 |
|---|---|
| UpsertUserAll 报文内容 | **与真机逐字段一致**(顶层 7 键、36 节点、110 字段 playlog、11 条位串全部对齐) |
| 报文长度 | 真机 17164 B / 本工程 17164 B(同一基线重建) |
| 剩余差异 | 只有「随机数」和「时刻字符串」,见第五节 |
| HTTP 头 | `number` 已修正;`Cookie` 经实测为**无需处理**;其余一致 |
| `playSpecial` | 已按 `CalcSpecialNum = 1024` 修正 |
| `characterLevel1..5` | **发现并修掉一处回归**:应为游玩前等级,不加经验 |
---
## 二、`number` 请求头:真机不是恒 0,也不是每包都变
抓到的 70 个请求里 `number` 只有两次取值变化:
```
#1 ~ #10 number = 0 ← 还没跑过心跳
#11 ~ #17 这是 Ping 心跳包(按 Packet.cs:187 不带 number 头)
#18 ~ #70 number = 63 ← 心跳往返 63 ms,此后 53 个业务请求全部恒定
```
**源码链路**(逐条可查):
```
Main/PingChecker.cs:166 PacketHelper.StartPacket(new PacketPing(PingSuccess, PingError))
Net.Packet.Mai2/PacketPing.cs:34
_onDone((int)sw.ElapsedMilliseconds) ← 值 = Ping 包往返毫秒
Main/PingChecker.cs:227 GameManager.PingMsec = pingMsec
Net.Packet/Packet.cs:187-189 if (!IsPingPacket()) Client.AddHeader("number", GameManager.PingMsec.ToString());
```
即:`number` = `GameManager.PingMsec`,**只由 Ping 心跳刷新**,普通业务请求不改动它。
### 本工程的修正
`api/maimai_net.py`:
- 字段从 `last_ping_msec`(每个请求都更新)改成 **`ping_msec`(只有 Ping 包更新)**,
并保留 `last_ping_msec` 只读属性兼容旧代码。
- `request()` 里新增:`if api_name.startswith("Ping"): self.ping_msec = elapsed_ms`。
- `_build_headers()` 的 `ping_msec` 参数默认 `None`(= 取 `self.ping_msec`)。
- `upsert.py` 在取完服务器设置后加了一步 `Ping()`(真机也是在第一屏之后跑心跳)。
> 实测:本机到该服务器 Ping 往返约 **266 ms**,所以本脚本发出去的 `number` 会是那个值,
> 而不是真机的 63 —— 这是**网络环境差异,不是实现差异**。
---
## 三、`Cookie` 头:hook 显示的串是假的,但 Cookie 本身是真的
抓包日志里从 #18 起显示 `Cookie : userId=12345678`,一度被误认为「要手工带一个 userId Cookie」。
**核实过程(两步,第二步推翻了我自己的第一步结论)**:
1. `gamehook/Main.cs:196-199` 的 `PreAddCookie(ulong userId)` 里写的是
`_cookie = "userId=" + userId;` —— 这行是 **hook 自己拼的展示串**,
**不是真实的 HTTP Cookie 头**。
它出现的位置正好对应 `Packet.cs:190-193` 的
`if (Query.UserId != 0L) Client.AddCookie(Query.UserId);`,
只是「本次请求带了 userId」的意思。
2. **但 Cookie 机制本身是真实存在的**,而且很关键。
用 `Ping` 探针看不到 Set-Cookie(Ping 响应头只有
`Date / Content-Type / Content-Length / Connection / Pragma / Cache-Control`),
于是我先得出了「服务器从不下发 Cookie」的结论 —— **这是错的**。
后来跑完整登录链路时,`UserLoginApi` 的响应头里明确有:
```
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
```
```
Packet.cs:228-234 if (Query.Api.StartsWith("UserLogin")) { cookieContainer.Add(Client.GetCookie());
SetCookie(Query.UserId, cookieContainer); }
Packet.cs:190-193 之后每个 userId != 0 的请求都 AddCookie(Query.UserId) 把容器挂回去
```
即:**登录时拿到的 JSESSIONID 会被后续所有请求带着** —— 这是真实的会话粘滞,
不是可有可无的装饰。
**结论**:`requests.Session` 会自动收下 `Set-Cookie` 并在后续请求自动带上,
行为与 C# 的 `CookieContainer` 完全一致,**因此仍然不需要写任何代码** ——
但前提是**必须复用同一个 `MaimaiNetClient`/`Session`**。
本工程的 `api/` 各函数在 `client=None` 时都走 `get_default_client()`(全局单例),
所以自动满足。如果谁手动 new 一个 `MaimaiNetClient` 去发结算包,就会丢掉 JSESSIONID。
---
## 四、`playSpecial` 与 `CalcSpecialNum`
Hook 抓到 `Manager.GameManager.CalcSpecialNum()` 的返回值:
```
[CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False)
```
**源码**(`Manager/GameManager.cs:332-378`)中 `1024 = 0x400` 是**无条件置位**的那一位,
其余各位的条件在这台机台上恰好都不成立(ACA 机型、GTX 显卡、i3-8100 CPU 三条否定条件都为假)。
**反推验证**(`Net.VO.Mai2/VOExtensions.cs:846-855`):
```
真机 playSpecial = 892014272
位反转32(892014272) = 57726124 = 27900 × 2069 + 1024
└────┬────┘ └─┬─┘
随机系数 余数 = CalcSpecialNum ✅
```
### 本工程的修正
- `config.py` 新增 `PLAY_SPECIAL_NUM = 1024`,注释里逐位列出 10 个标志位及源码行号。
- `calc_play_special(calc_special_num=None)` 用它替换原来的写死 `0`。
- `selftest.py` 新增反推断言:`位反转32(真机 playSpecial) % 2069 == 1024`。
---
## 五、逐字段比对的剩余差异(全部已解释)
用真机 `068` 报文**反向重建基线**后再跑本工程组包,逐路径 diff:
| 路径 | 真机 | 本工程 | 原因 |
|---|---|---|---|
| `upsertUserAll.userGamePlaylogList[0].playSpecial` | 892014272 | 随机 | **本来就是随机数**(`Random.Range(1,1037933)*2069+1024` 后位反转) |
| `upsertUserAll.userGamePlaylogList[0].playCount` | 10 | 8 | 机台**本地**计数器,服务器不返回,只能手填 `config.PLAY_LOCAL_PLAY_COUNT` |
| `userData[0].lastLoginDate` / `eventWatchedDate` | 22:31:23.0 | 22:31:22.0 | 差 1 秒:真机在「登录响应处理完那一刻」才写这个值,而 `loginDateTime` 是请求时刻 |
| `userPlaylogList[0].loginDate` | 1791210683 | 1791210682 | 同上,1 秒 |
| `userPlaylogList[0].userPlayDate` | 22:31:59.0 | 22:32:21.0 | 真机是「本局打完那一刻」,脚本没有真实游玩过程,用上报时刻代替 |
除上表外,**顶层 7 键、36 个节点名、各节点条数、userData 全部键名、11 条位串,两个方向都是 0 差异。**
> 1 秒级差异已由真机自身证明无害:同一账号两个会话里
> `userPlaylog.loginDate - loginDateTime` 分别是 **-28798**(19:21 会话)和 **-28799**(22:31 会话),
> 服务端两次都返回 `{"returnCode":1}` 并正常落库 —— 它不对这几个时刻做等式校验。
---
## 六、本轮修正清单
| 文件 | 改动 |
|---|---|
| `api/maimai_net.py` | `number` 头改为只由 Ping 刷新;补 `last_ping_msec` 兼容属性;docstring 写清 number/Cookie 语义 |
| `api/upsert_user_all.py` | ① `calc_play_special` 支持 `CalcSpecialNum`(默认 1024)<br>② **修掉 `characterLevelN` 加经验的回归**,改为游玩前快照<br>③ 新增 `derive_play_characters(snapshot)`:从服务器快照推游玩前角色<br>④ 新增 `play_date_time` 参数,把「登录时刻」与「游玩/上报时刻」拆开<br>⑤ `build_favorite_delta` 新增 `assume_missing`,区分「原始基线」与「归一化基线」 |
| `config.py` | 新增 `PLAY_SPECIAL_NUM`;补 `PLAY_CHARACTERS` / `PLAY_LOCAL_PLAY_COUNT` 的用法与实测值 |
| `api/ping.py` | docstring 写清「Ping 是 number 头的唯一来源」 |
| `upsert.py` | 加 `Ping()` 步骤;用 `derive_play_characters` 覆盖手填角色;传 `play_date_time=login_date_time_now()` |
| `api/__init__.py` | 导出 `derive_play_characters` |
| `selftest.py` | 148 → 155 项;新增 CalcSpecialNum 反推、角色快照、登录/游玩时刻分离等断言 |
---
## 七、关于 `characterLevelN` 的回归(重点)
`Net.VO.Mai2/VOExtensions.cs:1361`:
```csharp
result.characterLevel1 = (int)gameScore.CharaLevel[0];
```
`gameScore.CharaLevel` 是**本次开始游玩时**灌进去的快照,整局不变 ——
所以 `userPlaylog.characterLevelN` 是**游玩前**等级,游玩后的等级只出现在
`upsertUserAll.userCharacterList` 里。
**两个真机会话的三方对照**(Get 侧 / playlog / upsertUserCharacterList):
| 会话 | GetUserCharacterApi | userPlaylogList[0] | userCharacterList |
|---|---|---|---|
| `source/2026-10-05-new.log`(19:21) | 395=**3801** 101=**4456** 135 135 135 | **3801 4456 135 135 135** | 395=**3802** 101=**4457** 136 136 136 |
| MaimaiNetSpy `2026-10-05_222919`(22:31) | 395=**3802** 101=**4457** 136 136 136 | **3802 4457 136 136 136** | 395=**3803** 101=**4458** 137 137 137 |
**playlog 恒等于 Get 侧(游玩前),userCharacterList 恒为 +1(游玩后)。**
代码里曾经是 `level + character_exp_gain`,现已改回 `level`。
为了避免手填的 `config.PLAY_CHARACTERS` 随账号升级而过期,
新增 `derive_play_characters(snapshot)`:直接读快照里的
`userData.charaSlot`(5 个槽位,顺序即 characterId1..5)与
`userCharacterList`(每只角色的 level / awakening),`upsert.py` 已接上。
---
## 八、真实发包验证(✅ 已完成,2026-10-06 01:28)
`upsert.py <二维码> --send` 用上面这套实现真实上报了一局成绩,
**服务端返回 returnCode=1,且重新拉数据确认 playCount 1260→1261 —— 真正落库了**。
完整证据见 [`docs/实跑验证记录.md`](实跑验证记录.md)。
流程与真机一致:
```
GetGameSetting -> Ping -> ParseQRCode -> GetUserPreview -> UserLogin
-> fetch_upsert_snapshot(19 个 Get 节点)
-> GetUserNewItemList(游玩后档案)
-> UpsertUserAll(★ 有副作用)
-> UserLogout
```
安全闸门保持默认关闭:不传 `--send` 时只构造报文(`dry_run`),
`UpsertUserAll(confirm=None)` 会读取 `config.UPSERT_CONFIRM = False` 并抛 `ConfirmRequiredError`。
@@ -0,0 +1,586 @@
# 舞萌DX(maimai DX)扫码登录协议技术规范
> **来源文档**:
> - `docs-old/舞萌DX_aimedb调用链分析.md` —— 完整调用链、二维码格式、ChimeLib/aimedb 关系
> - `docs-old/chimelib协议逆向.md` —— 纯网络实现细节、签名算法、抓包验证、关键坑点
>
> **适用场景**:纯网络(纯 HTTP)复刻舞萌DX国服微信扫码登录,获取 userId 与 token
> **协议路径**:路径 A —— 微信扫码(HTTPS + JSON,`chimelib_dll.dll` + WinHTTP)
> **非适用**:路径 B —— 传统 Aime 物理卡(TCP 二进制,`amdaemon_api.dll` + IPC)
---
## 一、架构总览
### 1.1 两条 aimedb 通信路径
| 路径 | 名称 | 协议 | 核心组件 | 你实际用的 |
|------|------|------|----------|------------|
| **A** | 微信扫码 | HTTPS + JSON | `chimelib_dll.dll` (WinHTTP) | ✅ **是** |
| B | 传统 Aime 物理卡 | TCP 二进制 | `amdaemon_api.dll` (IPC) | ❌ 否 |
### 1.2 调用链(路径 A)
```
游戏客户端 Assembly-CSharp
│ ChimeReaderManager (驱动扫码、取结果)
▼
ChimeLib.NET.dll (托管封装)
│ Chime / ChimeUnit / ChimeResult / CCommGetUserData / CCommServAliveCheck
▼
chimelib_dll.dll (x64 原生,62KB,静态链接 WinHTTP)
│ CCommGetUserData_Create/Execute/IsEnd/GetUserID/GetToken
│ CCommServAliveCheck_Create/StartCheck/Execute/GetIndex/IsAlive
▼
aimedb 服务器集群
│ https://ai.sys-all.cn/ (主)
│ https://ai.sys-allnet.cn/ (备)
▼
POST /wc_aime/api/get_data → 返回 userID + token
POST /wc_aime/api/alive_check → 返回 "alive"
```
### 1.3 关键数据流向
```
二维码串(SGWCMAID...)
→ ChimeLib.NET.CCommQRCode.IsValid() 校验
→ EraseHashIdentifier() 取 [20:] 64位载荷
→ CCommGetUserData_Create(strGameID="MAID", strChipID, strCommonKey="", strQRData=载荷, strTitleKey="SDGB", serverURLIndex)
→ WinHTTP HTTPS POST /wc_aime/api/get_data
→ 响应 {errorID, userID, token, key, timestamp}
→ ChimeResult.GetChimeId() → userId (uint)
→ ChimeResult.GetToken() → token (string)
→ OperationManager.SetToken(userId, token) 缓存
→ 后续游戏服务器 API 携带 token
```
---
## 二、二维码格式规范
### 2.1 完整二维码字符串(84 字符)
```
结构:SGWC(4) + MAID(4) + timestamp(12) + payload(64 hex) = 84 字符
偏移 长度 字段 说明 示例
0–3 4 微信标识 常量 "SGWC" SGWC
4–7 4 GameID "MAID"=舞萌DX MAID
8–19 12 时间戳 yymmddHHMMss (UTC+9) 260613133715
20–83 64 载荷/哈希 64位十六进制(疑似SHA-256) 77503FD2...427759BCA5
```
### 2.2 校验规则(`CCommQRCode.IsValid()`)
```python
def is_valid(qr: str) -> bool:
return len(qr) == 84 and qr[0:4] == "SGWC" and qr[4:8] == "MAID"
```
### 2.3 传给原生层的数据(`EraseHashIdentifier()`)
```python
def erase_hash_identifier(qr: str) -> str:
"""去掉前 20 字符,返回 64 位载荷"""
return qr[20:] # 即 payload 部分
```
### 2.4 二维码 URL ⇄ 字符串互转
**URL 格式**:
```
https://wq.wahlap.net/qrcode/req/{GameID+timestamp+payload}.html?l=...&t=...&d=...
└──────────── 80 字符 ────────────┘
```
**提取规则**:
```python
from urllib.parse import urlparse
import re
def url_to_qr_string(url: str) -> str:
"""URL → 完整 84 字符二维码串"""
path = urlparse(url).path
match = re.search(r"/qrcode/req/([^/]+)\.html", path)
if not match:
raise ValueError("URL 路径格式不符")
return "SGWC" + match.group(1) # 前加 SGWC 前缀
def qr_string_to_url_body(qr: str) -> str:
"""完整二维码串 → URL 路径段(80字符,无 SGWC)"""
return qr[4:] # 去掉 SGWC
```
**查询参数**(`l`/`t`/`d`):**不参与**机台协议,仅为微信侧生成二维码时的元数据。
---
## 三、服务器端点与网络协议
### 3.1 服务器地址
| 环境 | 域名 | IP | 端口 | TLS |
|------|------|-----|------|-----|
| 主 | `ai.sys-all.cn` | 81.71.193.236 | 443 | TLSv1.2 |
| 备 | `ai.sys-allnet.cn` | (同解析) | 443 | TLSv1.2 |
### 3.2 API 端点
| 端点 | Method | Content-Type | User-Agent | 用途 |
|------|--------|--------------|------------|------|
| `/wc_aime/api/alive_check` | POST | `application/json` (body `{}`) | 无 | 探活,返回 `200 alive` |
| `/qrcode/api/alive_check` | POST | 无(空body) | `WC_AIME_LIB` | 探活变体,返回 `200 alive` |
| `/wc_aime/api/get_data` | POST | **无** | `WC_AIME_LIB` | **核心登录接口** |
### 3.3 请求头(关键:无 Content-Type)
```http
POST /wc_aime/api/get_data HTTP/1.1
Host: ai.sys-allnet.cn
User-Agent: WC_AIME_LIB
Connection: Keep-Alive
Content-Length: 243
{"chipID":"...","openGameID":"MAID","key":"...","qrCode":"...","timestamp":"...","titlekey":"SDGB"}
```
> ⚠️ **严禁添加 `Content-Type: application/json`**,否则服务端返回 `errorID=57`(HTTP 200 + 合法 JSON,极易误判为签名错误)。
### 3.4 请求体 JSON 字段
| 字段 | 类型 | 来源 | 说明 |
|------|------|------|------|
| `chipID` | string | Keychip ID | 机台唯一标识,如 `XXXX-XXXXXXXXXXX` |
| `openGameID` | string | 固定 | `"MAID"` |
| `key` | string | 签名计算 | SHA-256 大写 hex,**核心签名字段** |
| `qrCode` | string | 二维码[20:] | **64 位载荷,非完整 84 字符串** |
| `timestamp` | string | 本机本地时间 | yymmddHHMMss 格式 |
| `titlekey` | string | 固定 | `"SDGB"` |
### 3.5 响应体 JSON 字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `errorID` | int | 0=成功,非0=失败 |
| `userID` | int | 成功时为正整数(如 12345678),失败时 -1 |
| `token` | string | 成功时 20 位临时凭据,失败时空串 |
| `key` | string | 服务端返回的签名(可忽略) |
| `timestamp` | string | 服务端时间戳 |
---
## 四、签名算法(核心)
### 4.1 算法规范
```python
import hashlib
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW" # 32字符,含前导 X
def calculate_key(chip_id: str, timestamp: str) -> str:
"""
key = SHA-256(chipID + timestamp + SIGN_KEY).hexdigest().upper()
参数:
chip_id: 如 "XXXX-XXXXXXXXXXX"
timestamp: 如 "261001004115" (yymmddHHMMss)
返回:64字符大写十六进制
"""
sign_input = chip_id + timestamp + SIGN_KEY
return hashlib.sha256(sign_input.encode()).hexdigest().upper()
```
### 4.2 技术细节
| 项目 | 值 | 验证来源 |
|------|-----|----------|
| 哈希算法 | SHA-256 | Hook `CryptCreateHash` ALGID=0x800C (CALG_SHA_256) |
| 密钥串 | `XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW` | .rdata UTF-16 @0x00B040,**含前导 X** |
| 拼接顺序 | `chipID + timestamp + KEY` | **不含 qrCode、不含 titlekey** |
| 输出编码 | 大写十六进制 | hex 表 `0123456789ABCDEF` @0x00A440 |
### 4.3 抓包验证向量
```
输入:
chipID = "XXXX-XXXXXXXXXXX"
timestamp = "261001004115"
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
SHA256("XXXX-XXXXXXXXXXX" + "261001004115" + "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW")
= 67015F28CFC3C7FA5E629BECE54893B581A6DBD153426AE5E6BA1681C6D8E327
```
与 mitmproxy 抓包完全一致 ✓
---
## 五、完整请求流程(纯 Python 实现)
### 5.1 最小可用代码
```python
import hashlib, http.client, json, ssl
from datetime import datetime
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
CHIP_ID = "XXXX-XXXXXXXXXXX" # 可配置
HOST = "ai.sys-allnet.cn"
PATH = "/wc_aime/api/get_data"
def parse_qr_login(qr_input: str, chip_id: str = CHIP_ID) -> tuple[int, str]:
"""
输入:二维码串(84字符)或 URL
返回:(user_id: int, token: str)
"""
# 1. 统一输入 → 标准 84 字符串
qr_string = normalize_qr_input(qr_input)
# 2. 校验格式
validate_qr_string(qr_string)
# 3. 构造请求体
ts = datetime.now().strftime("%y%m%d%H%M%S")
key = calculate_key(chip_id, ts)
qr_code = qr_string[20:] # 仅 64 位载荷
body_dict = {
"chipID": chip_id,
"openGameID": "MAID",
"key": key,
"qrCode": qr_code,
"timestamp": ts,
"titlekey": "SDGB",
}
body_bytes = json.dumps(body_dict, separators=(",", ":")).encode()
# 4. 发送请求(关键:无 Content-Type)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection(HOST, 443, context=ctx, timeout=20)
try:
conn.request("POST", PATH, body=body_bytes, headers={
"Connection": "Keep-Alive",
"User-Agent": "WC_AIME_LIB",
"Content-Length": str(len(body_bytes)),
# ★ 绝不加 Content-Type
})
resp = conn.getresponse()
data = json.loads(resp.read().decode())
finally:
conn.close()
# 5. 解析响应
if data.get("errorID") != 0 or data.get("userID") == -1:
raise RuntimeError(f"登录失败: errorID={data.get('errorID')}, userID={data.get('userID')}")
return data["userID"], data["token"]
def normalize_qr_input(qr_input: str) -> str:
"""URL 或字符串 → 标准 84 字符二维码串"""
qr = qr_input.strip()
if qr.startswith("http://") or qr.startswith("https://"):
qr = url_to_qr_string(qr)
return qr
def validate_qr_string(qr: str) -> None:
"""校验二维码格式,不通过抛 ValueError"""
if len(qr) != 84:
raise ValueError(f"长度应为84,实际{len(qr)}")
if not qr.startswith("SGWC"):
raise ValueError("必须以 SGWC 开头")
if qr[4:8] != "MAID":
raise ValueError(f"GameID 必须为 MAID,实际 {qr[4:8]}")
# 可选:校验 payload 为 64 位 hex
if not all(c in "0123456789ABCDEF" for c in qr[20:]):
raise ValueError("载荷必须为大写 hex")
```
### 5.2 关键实现要点清单
| 要点 | 正确做法 | 错误做法后果 |
|------|----------|--------------|
| qrCode 字段 | `qr_string[20:]` (64位) | 传完整 84 字符 → 签名不匹配 |
| 签名拼接 | `chipID + timestamp + KEY` | 加入 qrCode/titlekey → 签名错误 |
| 密钥串 | 含前导 `X` (32字符) | 漏掉 X → 签名完全不同 |
| 请求头 | 无 `Content-Type` | 加上 → `errorID=57` |
| timestamp | 本机本地时间即可 | 纠结时区 → 其实服务端不强校验 |
| SSL 验证 | 关闭验证 | 开启 → 证书链可能不通过 |
---
## 六、错误码对照
| errorID | 含义 | 处理建议 |
|---------|------|----------|
| 0 | 成功 | 正常返回 userID + token |
| 1-8 | 原生 ChimeErrorId | 读卡器/服务器/二维码无效等通用错误 |
| **57** | **二维码已失效/已被使用/请求头不符** | 检查:二维码是否过期(~10分钟)、是否重复提交、**请求头是否含 Content-Type** |
| `userID:-1` | 失败标志 | 配合 errorID 判断具体原因 |
> ### ★ 实测补充(2026-10-06)
>
> 本工程用同一套参数实测到的失败响应是 **`errorID=2`**(不是 57):
>
> ```json
> {"errorID": 2, "key": "B744ADD5...", "timestamp": "261006125706",
> "userID": -1, "token": ""}
> ```
>
> **触发条件是「二维码过期」,不是「用过一次」。**
>
> | 二维码生成时刻(timestamp 内嵌) | 使用时刻 | 结果 |
> |---|---|---|
> | `261006012047`(01:20:47) | 01:21:55(+68s) | ✅ 成功 |
> | `261006124637`(12:46:37) | 12:52:22(+5m45s) | ✅ 成功 |
> | `261006130621`(13:06:21) | 13:10:42(+4m21s) | ✅ 成功 |
> | 上面那个 12:46:37 的二维码 | 13:0x(+约 15min) | ❌ `errorID=2` |
>
> 即:**二维码是「有时效」的,不是「按次数计量」的**。
>
> **有效期 = 10 分钟**(实测至少 5 分 45 秒内均可用,超时后返回 `errorID=2`)。
> 所以报错时**不要怀疑「用过了」**,重新生成一个即可。
---
## 七、时间戳处理细节
| 时间戳位置 | 时区 | 格式 | 说明 |
|------------|------|------|------|
| 二维码内 (偏移8-19) | **UTC+9 (日本时间)** | yymmddHHMMss | 微信侧生成,约 10 分钟有效期 |
| 请求体 `timestamp` | **本机本地时间** | yymmddHHMMss | 实测 UTC+8 与 DLL 行为一致,服务端不强校验时区 |
| 响应 `timestamp` | 服务端时间 | yymmddHHMMss | 仅作参考 |
> **注意**:本机时区非 UTC+8 时,请求体 timestamp 仍用本地时间,但需确保系统时间准确(NTP 同步)。
---
## 八、探活接口(可选,纯 Python 可独立实现)
### 8.1 探活请求
```python
def check_alive(host: str = "ai.sys-allnet.cn") -> bool:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection(host, 443, context=ctx, timeout=10)
try:
conn.request("POST", "/wc_aime/api/alive_check", body=b"{}", headers={
"Content-Type": "application/json", # 探活需加 Content-Type
"Connection": "Keep-Alive",
})
resp = conn.getresponse()
return resp.status == 200 and resp.read().decode().strip() == "alive"
finally:
conn.close()
```
### 8.2 行为差异
| 端点 | Content-Type | User-Agent | 响应 |
|------|--------------|------------|------|
| `/wc_aime/api/alive_check` | `application/json` | 无 | `200 alive` |
| `/qrcode/api/alive_check` | 无 | `WC_AIME_LIB` | `200 alive` |
| `/wc_aime/api/get_data` | **无** | `WC_AIME_LIB` | 业务响应 |
> DLL 实测:`CCommServAliveCheck` 探活阶段**不发真实网络请求**(WinHTTP hook 不触发),纯 Python 可直接调用 alive_check 验证服务器存活。
---
## 九、原生 DLL 接口签名(供 P/Invoke 参考)
### 9.1 CCommServAliveCheck 系列
```c
// 创建探活会话
HANDLE CCommServAliveCheck_Create();
// 设置连接权限
void CCommServAliveCheck_setConnectPermission(HANDLE h, BOOL permit);
// 开始探活
void CCommServAliveCheck_startCheck(HANDLE h);
// 执行轮询
void CCommServAliveCheck_execute(HANDLE h);
// 是否忙碌
BOOL CCommServAliveCheck_isBusy(HANDLE h);
// 获取存活服务器索引
int CCommServAliveCheck_getIndex(HANDLE h);
// 是否存活
BOOL CCommServAliveCheck_isAlive(HANDLE h);
// 销毁
void CCommServAliveCheck_Destroy(HANDLE h);
```
### 9.2 CCommGetUserData 系列
```c
// 创建登录会话
// strGameID="MAID", strChipID=KeychipID, strCommonKey="", strQRData=二维码[20:], strTitleKey="SDGB", serverURLIndex=探活返回索引
HANDLE CCommGetUserData_Create(
LPCSTR strGameID,
LPCSTR strChipID,
LPCSTR strCommonKey,
LPCSTR strQRData,
LPCSTR strTitleKey,
int serverURLIndex
);
// 执行请求
void CCommGetUserData_execute(HANDLE h);
// 是否结束
BOOL CCommGetUserData_isEnd(HANDLE h);
// 获取错误码 (0=成功)
int CCommGetUserData_getErrorID(HANDLE h);
// 获取 userID (uint)
unsigned int CCommGetUserData_getUserID(HANDLE h);
// 获取 token (ANSI 字符串)
LPCSTR CCommGetUserData_getToken(HANDLE h);
// 销毁
void CCommGetUserData_Destroy(HANDLE h);
```
### 9.3 托管层调用序列(ChimeUnit.Execute 状态机)
```csharp
// 1. 探活
var alive = CCommServAliveCheck_Create();
CCommServAliveCheck_setConnectPermission(alive, true);
CCommServAliveCheck_startCheck(alive);
while (CCommServAliveCheck_isBusy(alive)) CCommServAliveCheck_execute(alive);
int urlIndex = CCommServAliveCheck_getIndex(alive);
bool isAlive = CCommServAliveCheck_isAlive(alive);
CCommServAliveCheck_Destroy(alive);
// 2. 登录
var login = CCommGetUserData_Create(
"MAID", // gameID
keychipId, // chipID (如 XXXX-XXXXXXXXXXX)
"", // commonKey (托管层传空,原生层内部处理)
qrString.Substring(20), // qrData (64位载荷)
"SDGB", // titleKey
urlIndex // 探活返回的服务器索引
);
while (!CCommGetUserData_isEnd(login)) CCommGetUserData_execute(login);
if (CCommGetUserData_getErrorID(login) == 0) {
uint userId = CCommGetUserData_getUserID(login);
string token = CCommGetUserData_getToken(login); // PtrToStringAnsi
}
CCommGetUserData_Destroy(login);
```
---
## 十、常量汇总表
```python
# ===== 签名与密钥 =====
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW" # 含前导 X,32字符
# ===== 默认机台标识 =====
DEFAULT_CHIP_ID = "XXXX-XXXXXXXXXXX"
# ===== 服务器 =====
PRIMARY_HOST = "ai.sys-all.cn"
BACKUP_HOST = "ai.sys-allnet.cn"
PORT = 443
LOGIN_PATH = "/wc_aime/api/get_data"
ALIVE_PATH = "/wc_aime/api/alive_check"
# ===== 固定请求字段 =====
OPEN_GAME_ID = "MAID"
TITLE_KEY = "SDGB"
USER_AGENT = "WC_AIME_LIB"
# ===== 二维码常量 =====
QR_PREFIX = "SGWC"
QR_GAME_ID = "MAID"
QR_PREFIX_LEN = 20 # SGWC(4) + MAID(4) + timestamp(12)
QR_PAYLOAD_LEN = 64
QR_TOTAL_LEN = 84
# ===== HTTP 头(登录接口) =====
LOGIN_HEADERS = {
"Connection": "Keep-Alive",
"User-Agent": USER_AGENT,
# "Content-Type": "application/json", # ★ 绝不加这一行
}
```
---
## 十一、抓包调试方法论
### 11.1 强制走代理
```csharp
// Hook WinHttpOpen
// dwAccessType = 3 (WINHTTP_ACCESS_TYPE_NAMED_PROXY)
// pwszProxy = "127.0.0.1:8890"
```
### 11.2 mitmproxy CA 证书安装
```powershell
# 必须用 X509Store 静默导入,Import-Certificate 会因 UI 限制失败
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store("Root", "CurrentUser")
$store.Open("ReadWrite")
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("mitmproxy-ca-cert.pem")
$store.Add($cert)
$store.Close()
```
### 11.3 落盘与读取
```bash
mitmdump -w flow.mitm
# Python 读取
from mitmproxy.io import FlowReader
with open("flow.mitm", "rb") as f:
for flow in FlowReader(f).stream():
print(flow.request.pretty_url)
print(flow.request.content)
print(flow.response.content)
```
### 11.4 捕获签名输入
```csharp
// Hook CryptHashData 导出入口
// 捕获 pbData 指针指向的字节数组 → 即签名原文
// 对比:chipID + timestamp + KEY
```
### 11.5 常见 Hook 坑点
| 坑点 | 原因 | 解决 |
|------|------|------|
| IAT hook 全失效 | pefile `imp.address` 已是完整 VA,再加 ImageBase 导致地址错误 | 直接用 `imp.address` + ASLR_delta |
| IAT 写入后还原 | CrackProof 驱动写保护页面 | 改用全局 inline hook:改写 advapi32 导出函数入口 + capstone 精确 trampoline |
| x64 trampoline 崩 | 指令切碎不完整 | capstone 逐条解析,累计 >=14 字节再跳转 |
| 误判加壳 | Hook 全失效以为加壳 | 实测:.text 熵 6.2、465 个 FF 15、入口点可正常反汇编 → **未加壳** |
---
## 十二、实测验证记录
| # | 二维码时间戳 | userID | token (20位) | 备注 |
|---|--------------|--------|--------------|------|
| 1 | 260925205225 | 12345678 | TESTTOKEN0000000000 | DLL 验证成功 |
| 2 | 260930235204 | 12345678 | e1Tsql1aP1SHLUthRdYI | |
| 3 | 261001001304 | 12345678 | rI48n34Ttne1qR9FDFnY | 同一时间戳多次请求 token 各异 |
| 4 | 261001001304 | 12345678 | znoWwYW1bMpwlNUzcTpX | |
| 5 | 261001001304 | 12345678 | 8Cp6IztEGvLlYt5SRjxw | |
**结论**:
- userID 固定(同一账号)
- token 每次请求生成新的 20 位临时凭据(一次性)
- 探活+登录总耗时 ~800ms(网络延迟主导)
---
## 十三、最小复刻检查清单
实现纯网络扫码登录,必须覆盖:
- [ ] 二维码输入归一化(URL → 84字符串)
- [ ] 格式校验(长度84、前缀SGWC、GameID=MAID、载荷64位hex)
- [ ] 载荷提取(`qr_string[20:]`)
- [ ] 时间戳生成(本地时间 `%y%m%d%H%M%S`)
- [ ] 签名计算(SHA256(chipID+timestamp+KEY) 大写hex)
- [ ] 请求体构造(紧凑JSON,字段顺序固定)
- [ ] HTTPS POST(无Content-Type,UA=WC_AIME_LIB,Keep-Alive)
- [ ] SSL 不验证证书
- [ ] 响应解析(errorID=0、userID>0、token非空)
- [ ] 错误处理(errorID=57→请求头问题,errorID=2→二维码过期)
---
*本文档仅包含技术协议实现细节,不含项目结构、代码规范、文件路径等工程化内容。*
+186
View File
@@ -0,0 +1,186 @@
# 账号转生说明(`reincarnate.py`)
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里转生部分的**详细版**。
---
## 一、它干了什么
把一个账号(源 **A**)的数据克隆到另一个账号(目标 **B**):
```
源账号 A:二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData
或:直接读 data/<A>.json(离线复用)
↓ 节点数据(19 个节点)
目标账号 B:二维码 -> ParseQRCode -> UserPreview -> UserLogin
循环:
等待 -> UpsertUserAll(一包曲目)
-> UserLogout(type=1)
-> UserLogin(isContinue=true) 续关,拿新 loginId
收尾:最后一包登出后结束(不再续关)
```
---
## 二、用法
```bash
# 源数据来自二维码(会联网抓 A 的数据,并落盘到 data/<A>.json)
python reincarnate.py --dst-qr <B的二维码> --src-qr <A的二维码>
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码)
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json
# ★ 真实上传
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --send
# 只看计划,不发包(★ 建议先跑这个)
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --plan
```
| 参数 | 默认 | 说明 |
|---|---|---|
| `--dst-qr` | 必填 | 目标账号 B 的二维码 |
| `--src-qr` / `--src-json` | 二选一 | 源数据来源 |
| `--plan` | — | 只列分批计划,不发包 |
| `--send` | 关 | ★ 真实发包 |
| `--limit N` | 0(全部) | 只迁前 N 首(试跑用) |
| `--per-packet N` | 4 | 每包曲目数,**只能是 1 / 3 / 4** |
| `--wait N` | 5.0 | 每次上传前的等待秒数(0 = 不等) |
| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认**保留 B 自己的**) |
| `--no-continue` | 关 | 不续关,只发一包(调试用) |
| `--debug` | 关 | 日志里带完整收发报文 |
---
## 三、★ 每包几首:1 / 3 / 4 可以,**恰好 2 首会被服务端拒绝**
真机一局(一个 credit)最多打 4 首,所以一条 `UpsertUserAll` 报文里的
`userPlaylogList` / `userGamePlaylogList` **最多 4 条**。
但实测:**每包恰好 2 首会被服务端拒绝**(1 / 3 / 4 首都正常)。
所以本脚本的分批器会**主动避开 2 首包**:
| 总量 | `--per-packet` | 原始切分 | 修正后 |
|---|---|---|---|
| 10 首 | 4 | `[4,4,2]` | `[4,3,3]` |
| 6 首 | 4 | `[4,2]` | `[3,3]` |
| 2 首 | 4 | `[2]` | `[1,1]` |
| 5 首 | 3 | `[3,2]` | `[3,1,1]` |
| 8 首 | 3 | `[3,3,2]` | `[3,3,1,1]` |
| 5 首 | 4 | `[4,1]` | 本来就合法,不动 |
修正规则(`reincarnate.split_batches`):
1. 前一批 **>= 4** → 从它借 1 首过来(前一批变 >=3,本批变 3)
2. 借不到(前一批只有 3,或自己就是第一批)→ 把这个 2 **拆成 1 + 1**
> ★ 只借位是不够的:前一批是 3 时借完自己变 2,等于把 2 挪了个位置。
自检里有 `1..299 首 × per∈{1,3,4}` 的全量扫描,保证永不产生 2 首包。
---
## 四、★ 为什么要「续关登录」
`UpsertUserAll` 的 `playlogId` 必须等于**本次登录**的 `loginId`
(`PacketUpsertUserAll.cs:31` / `UserAllRequestVO`)。
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 —— 真机走的就是「续关」:
```csharp
// source/Assembly-CSharp/Process/ContinueProcess.cs:403
new PacketUserLogin(userID, accessCode, isContinue: true, GameManager.IsEventMode ? 1 : 0, ...)
```
对应本工程:`UserLogin(user_id, token, is_continue=True)`
—— 和普通登录的唯一区别就是 `UserLoginRequestVO.isContinue = true`,
会返回**新的 `loginId` / `loginDateTime`**。
---
## 五、★ 登出必须用 type=1(Logout),不要用 4(TestIn)
`Net.VO.Mai2/LogoutType.cs`:
```csharp
public enum LogoutType { None=0, Logout=1, Cancel=2, Error=3, TestIn=4, Quit=5 }
```
**真机抓包实证** `UserLogoutApi` 用的是 **type=1**:
```json
{"userId":12570152,"accessCode":"","regionId":8,"placeId":1007,
"clientId":"A63E01C3047","loginDateTime":1791325354,"type":1,"delayLog":{...}}
```
源码侧的正常会话结束也是 type=1:
```csharp
// source/Assembly-CSharp/Process.UserDataNet.State.UserDataULState/StateULUserLogout.cs:24
PacketHelper.StartPacket(new PacketUserLogout(_index, _userId, LogoutType.Logout, ...));
```
**续关场景下用 4(TestIn)会出问题** —— 那是「测试登入 / 顶号」的语义
(`GameMain.cs:104` 退出游戏时才用)。
`UserLogout` 对 type=1 会自动带上完整 `delayLog`
(`PacketUserLogout.cs:21`:`logoutType == LogoutType.Logout` 时才收集会话统计)。
---
## 六、playlog 从哪来
`GetNeedUserData` 抓不到 `userPlaylogList`(那是逐局记录,没有对应的 Get 接口),
所以本脚本**从曲目记录反推合成**:
| `UserMusicDetail`(9 字段) | → | `UserPlaylog`(110 字段) |
|---|---|---|
| `musicId` / `level` | | `musicId` / `level` |
| `achievement` | | `achievement` |
| `deluxscoreMax` | | `deluxscore` |
| `scoreRank` | | `scoreRank` / `isClear` |
| `comboStatus` / `syncStatus` | | 同名 |
| 其余 | | `config.PLAY_*` 默认值 |
`UserMusicDetail` 的全部字段(`Net.VO.Mai2/UserMusicDetail.cs`):
`musicId` / `level` / `playCount` / `achievement` / `comboStatus` /
`syncStatus` / `deluxscoreMax` / `scoreRank` / `extNum1`。
---
## 七、迁移范围
默认迁**全部节点**(整账号克隆):
- **第 1 包**带全部非曲目节点(`userData` / `userExtend` / `userOption` /
`userCharacterList` / `userMapList` / …)
- **之后每包**只带这一批曲目 + 合成的 `userGamePlaylogList`
- 位串 `isNew*` 按 **B 的基线**逐条算(B 没有 → `"1"`,已有 → `"0"`),
主键表见 `api/upsert_user_all.py` 的 `DIFF_PRIMARY_KEYS`
**不迁的节点**(`SKIP_NODES`):
| 节点 | 原因 |
|---|---|
| `userMusicDetailList` | 单独分批 |
| `userGamePlaylogList` | 本脚本按曲目合成 |
| `user2pPlaylog` | 2P 记录,迁移无意义 |
| `userGetPointList` | 里程/奖励,迁过去会凭空发点 |
| `userTradeItemList` | 无 Get 来源 |
| `userFriendSeasonRankingList` | 无 Get 来源 |
**默认不克隆 `userName` / `accessCode`**(避免把 B 改名);要连身份一起搬就加
`--clone-identity`。
---
## 八、⚠️ 注意事项
1. **未实跑过。** 建议先用 `--plan` 看计划,再 `--limit 8` 小批量试,确认无误再全量。
2. **会真实写入账号 B(不可撤销)。** 不传 `--send` 时只构造报文。
3. **耗时**:曲目多的话包数很多(每包最多 4 首),加上 `--wait` 的等待,
几百首会跑很久。想快可以调小 `--wait`。
4. **二维码有效期 10 分钟**,但转生过程中只用一次(登录时)——
续关用的是 token,不需要新二维码。
5. 基线快照只在**第一次登录后抓一次**,后续包沿用同一份做差分。
曲目节点由 `extra_data` 整体覆盖,位串也单独算,不受影响。
+498
View File
@@ -0,0 +1,498 @@
# 购买功能票 —— 存档与交接文档
> **状态:暂停(2026-10-06)**
> 暂停原因:多次失败购买后主账号疑似被服务端拉黑;实现仍未跑通。
> 本文把「从开始做买票 → 到暂停」之间的**全部有用信息**归档,
> 目的是**将来恢复这项工作时能 5 分钟上手**,不用重新踩一遍。
---
## 〇、30 秒速览
1. **协议已完全搞清楚** —— 源码逐行 + 真机抓包 328 字节原文都有
2. **报文已做到与真机逐字节一致**,但服务端仍回 `returnCode=0`
3. **同一账号对照实验证明:机台能买、脚本不能买** → 差异在**客户端**,不是账号/服务端
4. 差异最可能在 **HTTP 层**(请求头 / Cookie / 连接特征),**不在报文体**
5. 暂停前最后动作:给 MelonLoader Mod 加了「完整请求头 + 响应头 dump」,**等一次真机抓包**
6. ⚠️ **恢复工作前先确认账号没被拉黑**(见第八节)
---
## 一、结论速览
| 项 | 状态 |
|---|---|
| 接口名 / URL 混淆 | ✅ 已确认 |
| 请求体结构(4 顶层 + 8 + 4 字段) | ✅ 与真机逐字节一致 |
| `purchaseDate` 的 `.f` 小数位 | ✅ 已确认并修正 |
| `validDate` 算法 | ✅ 已确认并双向验证 |
| `price` 语义(= `ticket.creditNum`) | ✅ 已确认 |
| `stock` 语义(购买后的值) | ✅ 已确认 |
| 键集合 / 字段顺序 | ✅ 与真机一致 |
| 可购买清单校验 | ✅ 已实现(真机也有这一步) |
| 已持票守卫 | ✅ 已实现(**自加的安全措施**,真机没有) |
| **服务端接受** | ❌ **`returnCode=0`** |
| **根因** | ❓ **未定位**,怀疑 HTTP 层 |
---
## 二、协议事实(全部有源码依据)
### 2.1 接口
| 项目 | 值 | 依据 |
|---|---|---|
| 逻辑 API 名 | `UpsertUserChargelogApi` | `Net.Packet.Mai2/PacketUploadUserChargelog.cs:19` |
| URL 尾段 | `md5("UpsertUserChargelogApiMaimaiChn" + "8bF76dE9")` = `4411a4c8f7f5c0e95a3ac17f1746aad3` | `Packet.cs:485` + `NetQuery.cs:14` |
| 请求 VO | `Net.VO.Mai2/UserChargelogRequestVO.cs` | — |
| 响应 | `{returnCode, apiName}` | — |
| 副作用 | ★ 有,写入购买记录 | — |
### 2.2 请求体(字段顺序照 C# 声明)
```jsonc
{
"userId": <ulong>,
"userChargelog": { // UserChargelog.cs,8 字段
"chargeId": 2,
"price": 1, // ★ = ticket.creditNum,不是投币数
"purchaseDate": "2026-10-06 13:20:10.0", // ★ 带 .f 一位小数
"playCount": 1262,
"playerRating": 15000,
"placeId": 1000,
"regionId": 5,
"clientId": "TESTCLIENT1"
},
"userCharge": { // UserCharge.cs,4 字段
"chargeId": 2,
"stock": 1, // ★ 购买后的库存(= 原值 + 1)
"purchaseDate": "2026-10-06 13:20:10.0",
"validDate": "2027-01-04 04:00:00" // ★ 不带小数
},
"loginDateTime": 1791159576 // ★ long,来自 UserLoginApi
}
```
### 2.3 两个容易踩的格式
| 字段 | 格式 | 例子 |
|---|---|---|
| `purchaseDate` | `TimeManager.DateTimeFormat` = `yyyy-MM-dd HH:mm:ss.f` | `2026-10-06 13:20:10.0` |
| `validDate` | 显式串 `yyyy-MM-dd 04:00:00` | `2027-01-04 04:00:00` |
> **一个带小数一个不带** —— 少写 `.0` 服务端直接 `returnCode=0`(踩过)。
### 2.4 `validDate` 算法
`Manager/TicketManager.cs:562-565`:
```csharp
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
```
真机双向验证:
| chargeId | 真机 purchaseDate | 减 4h + 90d | 真机 validDate | 吻合 |
|---|---|---|---|---|
| 5 | 2026-10-04 18:22:10 | 2027-01-02 | `2027-01-02 04:00:00` | ✅ |
| 3 | 2026-10-02 10:49:23 | 2026-12-31 | `2026-12-31 04:00:00` | ✅ |
### 2.5 `price` = `ticket.creditNum`
`Net.VO.Mai2/VOExtensions.cs:410`:`result.price = ticket.creditNum;`
数据来自游戏本地主数据 `Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml`
(已抄进 `config.TICKET_CREDIT_NUM`):
| chargeId | 票名 | `creditNum`(上报的 price) | 机台投币数 |
|---|---|---|---|
| 2 | 2倍チケット | **1** | 1 |
| 3 | 3倍チケット | **2** | 2 |
| 4 | 4倍チケット | 3 | — |
| 5 | 5倍チケット | 4 | — |
| 6 | 6倍チケット | 5 | — |
### 2.6 真机的完整流程
```
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
├─ ticketKind.id == 1(付费票)
├─ :137-150 在 GetGameChargeDataList() 里找该 chargeId
│ ★ 找不到直接 return false,不发请求
├─ :207 SetBuyPaidTicketData -> stock++、更新 purchaseDate / validDate
└─ :234 RequestUserCharge
├─ VOExtensions.cs:436 ExportEnableUserCharge -> 购买后的 UserCharge
├─ VOExtensions.cs:403 ExportUserChargelog -> price = ticket.creditNum
└─ 发 UpsertUserChargelogApi
```
成功后 `TicketManager.cs:255 RequestSuccess` 会**本地** `stock--`(票被本局用掉),
但这个减一**不会发给服务器** —— 所以服务端会一直保留 `stock=1`,
直到下一次结算的 `UpsertUserAll` 把新的 `userChargeList` 写回去。
---
## 三、真机抓包原文(★ 成功样本)
来源:`MaimaiNetSpy/raw/061_UpsertUserChargelogApiMaimaiChn_request.json`(328 字节)
```json
{"userId":12345678,"userChargelog":{"chargeId":2,"price":1,"purchaseDate":"2026-10-06 13:20:10.0","playCount":1262,"playerRating":15000,"placeId":1000,"regionId":5,"clientId":"TESTCLIENT1"},"userCharge":{"chargeId":2,"stock":1,"purchaseDate":"2026-10-06 13:20:10.0","validDate":"2027-01-04 04:00:00"},"loginDateTime":1791159576}
```
响应:
```json
{"returnCode":1,"apiName":"com.sega.maimai.api.UpsertUserChargelogApi"}
```
请求前后的时序(同一会话):
```
[13] 13:19:45 UserLoginApi
[14]~[60] 入场全量数据下载(47 个请求)
[61] 13:20:10 UpsertUserChargelogApi ← 成功
[62] 13:20:46 GetUserNewItemListApi
[63] 13:20:53 UserLogoutApi
```
---
## 四、我的实现现状
| 文件 | 说明 |
|---|---|
| `api/upsert_user_chargelog.py` | 买票 API。报文构造 + 三道守卫 + 闸门 |
| `buy_ticket.py` | 主脚本:扫码 → 登录 → 入场下载 → 查票 → 等待 → 买票 → 查票 → 登出 |
### 4.1 `UpsertUserChargelog` 参数
| 参数 | 默认 | 说明 |
|---|---|---|
| `user_id` / `charge_id` / `login_date_time` | 必填 | 后两个必须与 `UserLoginApi` 一致 |
| `charge_list` | 自动抓 | 当前票券(`GetUserChargeApi`) |
| `game_charge_list` | 自动抓 | 可购买清单(`GetGameChargeApi`) |
| `user_data` | 自动抓 | 取 `playCount` / `playerRating` |
| `stock` | 现有 + 1 | 购买后库存 |
| `price` | `TICKET_CREDIT_NUM[charge_id]` | = `creditNum` |
| `purchase_date` / `valid_date` | 现在 / 由前者推 | |
| `guard_purchasable` | `True` | 不在可购买清单里就拒绝 |
| `guard_no_stock` | `True` | ★ **已持票就拒绝**(自加,真机没有) |
| `confirm` | `config.UPSERT_CHARGELOG_CONFIRM` | 不开不发包 |
### 4.2 已验证「正确」的东西
| 项 | 证据 |
|---|---|
| URL / 混淆名 | `4411a4c8f7f5c0e95a3ac17f1746aad3` = 真机 |
| User-Agent | 逐字符相同 |
| 键集合 / 顺序 / 类型 | 与真机逐字节同构(328 B vs 328 B) |
| `.f` 格式 | `purchaseDate` 已带一位小数 |
| Cookie | 登录响应确实下发 `Set-Cookie: JSESSIONID=...`,Session 自动带 |
| `number` 头 | 已调 `Ping()`(真机 75,我这边 47~61) |
| 入场数据下载 | 已补(18~19 节点) |
| 节奏 | 已加 30 秒等待(真机是 25 秒) |
### 4.3 我发出的实际请求头(离线检视)
```
User-Agent = '4411a4c8f7f5c0e95a3ac17f1746aad3#<userId>'
Accept-Encoding = '' ← 空串(.NET 不发这个头)
Accept = '*/*' ← requests 自带
Connection = 'keep-alive'
Content-Type = 'application/json'
charset = 'UTF-8'
Mai-Encoding = '1.55'
Content-Encoding = 'deflate'
number = '61'
Cookie = 'JSESSIONID=...'
Content-Length = '32'
```
---
## 五、完整实验时间线(全部失败)
| # | 时间 | 账号 | chargeId | 关键改动 | 结果 |
|---|---|---|---|---|---|
| 1 | 13:10 | A | 2 | `purchaseDate` **少 `.0`** | `returnCode=0` |
| 2 | 13:41 | A | 5 | 格式已修 | `returnCode=0`(★ 5 号票**未上架**) |
| 3 | 13:50 | A | 3 | 换了上架的票 | `returnCode=0` |
| 4 | 13:53 | A | 3 | + 入场全量下载 | `returnCode=0` |
| 5 | 14:03 | A | 2 | + 5 秒等待 | `returnCode=0` |
| 6 | 15:51 | A | 3 | + `Ping()`、30 秒等待 | `returnCode=0` |
| 7 | 16:33 | **B** | 2 | 换了个**没被黑的号** | 🛡️ 被守卫拦下(B 已持票) |
| 8 | **16:39** | **B** | 2 | B 的票已用掉,守卫放行 | ❌ **`returnCode=0`** |
### ★ 第 8 次是决定性实验
| | 机台 | 我的脚本 |
|---|---|---|
| 账号 A | ✅ 13:20、14:06 成功 | ❌ 6 次失败 |
| **账号 B** | ✅ **16:31:51 成功** | ❌ **16:39:37 失败** |
**同一个账号 B:机台能买、脚本不能买 → 差异在客户端,不在账号/服务端。**
> 此前一直以为是「账号被拉黑」,第 8 次推翻了它。
---
## 六、已排除的假设
| # | 假设 | 排除依据 |
|---|---|---|
| 1 | URL / 混淆名错 | 服务端回正确 `apiName` |
| 2 | User-Agent 错 | 与真机逐字符相同 |
| 3 | Cookie 缺失 | 登录响应确有 `Set-Cookie: JSESSIONID` |
| 4 | 报文字段 / 顺序 / 类型错 | 与真机逐字节同构 |
| 5 | `purchaseDate` 格式错 | 已补 `.f`(第 1 次之后) |
| 6 | 票未上架 | 第 3 次起换成清单里的票 |
| 7 | 已持有功能票 | 第 8 次确认持有 0 张 |
| 8 | `number` 头为 0 | 第 6 次起已 `Ping()` |
| 9 | 缺少入场数据下载 | 第 4 次起已补 |
| 10 | 节奏太快 | 第 6 次起 30 秒等待 |
| 11 | `chargeId` 本身有问题 | 换用真机验证过的 2 号票仍失败 |
| 12 | 账号被拉黑 | ★ **第 8 次用没被黑的号 B 仍然失败** |
### 剩余假设
| # | 假设 | 怎么验证 |
|---|---|---|
| **A** | ★ **HTTP 请求头 / 连接特征有差异** | 抓真机完整请求头逐字节对比(mod 已就绪) |
| B | 服务端对金钱接口做了 TLS 指纹校验 | 需要 MITM 代理 |
| C | 需要某个本工程不知道的前置状态 | 同上 |
---
## 七、★ 别人的可用实现(截图转录)
> 图片主人说这套代码**能成功买票**,流程为「登录 → userdata → ticket → logout」。
### 7.1 报文构造
```python
import json
import pytz
from .sdgb import sdgb_api
from datetime import datetime, timedelta
def get_ticket(userId: int, cookie, chargeId, price, regionId, placeId,
clientId, playCount: int, playerRating: int):
now = (datetime.now(pytz.timezone('Asia/Shanghai'))).strftime("%Y-%m-%d %H:%M:%S.0")
validDate = (datetime.now(pytz.timezone('Asia/Shanghai')) + timedelta(days=90)) \
.replace(hour=4, minute=0, second=0).strftime("%Y-%m-%d %H:%M:%S")
data = json.dumps({
"userId": userId,
"userCharge": {
"chargeId": chargeId,
"stock": 1,
"purchaseDate": now,
"validDate": validDate
},
"userChargelog": {
"chargeId": chargeId,
"price": price,
"purchaseDate": now,
"placeId": placeId,
"regionId": regionId,
"clientId": clientId,
"playCount": playCount,
"playerRating": playerRating,
},
"loginDate": now, # ★★ 注意:是 loginDate(字符串),不是 loginDateTime
})
ticket_result = sdgb_api(data, "UpsertUserChargelogApi", cookie)
return ticket_result
```
### 7.2 调用方流程
```python
if ticket_id in ticket_list:
login_time = login_datetime_now()
login_result = user_login_checked(user_id, token, region_id, place_id, client_id, login_time)
if login_result['Result']['returnCode'] == 1:
login_cookie = login_result['Cookie'] # ★ 显式取出 cookie
logger.info(f'{user_id} get_ticket: 获取到 Cookie = {login_cookie}')
match ticket_id: # ★ price 表
case 2: ticket_price = 1
case 3: ticket_price = 2
case 4: ticket_price = 3
case 5: ticket_price = 4
case 6: ticket_price = 5
case _: ticket_price = 0
userdata_result = user_datas(user_id, login_cookie) # ★ cookie 显式传
play_count = userdata_result["userData"]["playCount"]
player_rating = userdata_result["userData"]["playerRating"]
# ★★ 获取 userdata 后等待 60 秒再请求 ticket
logger.info(f'{user_id} get_ticket: 等待 60 秒后请求 ticket')
time.sleep(60)
ticket_result = api_get_ticket(
user_id, login_cookie, ticket_id, ticket_price,
region_id, place_id, client_id, play_count, player_rating
)
logger.debug(f'ticket_result 完整内容 = {json.dumps(ticket_result, ensure_ascii=False, indent=2)}')
if ticket_result and ticket_result.get('returnCode') == 1:
logout_result = user_logout(user_id, login_cookie, region_id, place_id, client_id, 0, 4)
...
```
### 7.3 ★ 与我实现的差异对照
| # | 他们 | 我 | 重要性 |
|---|---|---|---|
| **1** | **`"loginDate": "2026-10-06 16:39:37.0"`**(字符串) | `"loginDateTime": 1791304745`(数字) | ★★★ **最大疑点** |
| 2 | `stock` 硬编码 `1` | 现有 + 1 | 低(结果一样) |
| 3 | 等待 **60 秒** | 30 秒 | 中 |
| 4 | `validDate` = `(now+90d).replace(hour=4)` | `(now-4h+90d).date() + " 04:00:00"` | 低(源码支持我的) |
| 5 | `purchaseDate` 的 `.0` 写死 | 取真实十分位 | 低 |
| 6 | **Cookie 显式传递** | `requests.Session` 自动 | ★★ 中 |
| 7 | 无 `GetGameChargeApi` 前置 | 有 | 低 |
| 8 | 无「已持票」守卫 | 有(自加) | 低(安全相关) |
| 9 | 流程:登录→userdata→ticket→logout | 登录→入场下载→查票→等待→ticket→查票→logout | ★ 中 |
| 10 | `userCharge` 在 `userChargelog` 之前 | 反之 | 低(JSON 无序) |
| 11 | `userChargelog` 内部顺序也不同 | 照 C# 声明 | 低 |
| 12 | `sdgb_api(data, "UpsertUserChargelogApi", cookie)` | `request_dict(...)` | ★★ **黑盒,未知** |
### 7.4 ★ 我的疑惑(恢复工作时优先解答)
1. **`loginDate` vs `loginDateTime`(最重要)**
- 源码 `UserChargelogRequestVO.cs` 声明的是 `public long loginDateTime;`
- 真机抓包发的是 `"loginDateTime":1791159576`(数字)
- **他们发的是 `"loginDate"`(字符串)**,而且说能成功
- 三种可能:
a. 服务端**不校验**这个字段 → 那我的 `loginDateTime` 也不是问题
b. 服务端认的是 `loginDate`(旧字段名)
c. 他们的 `sdgb_api` 内部做了改名(把 `loginDate` → `loginDateTime`)
- **必须先搞清楚这一条**,否则会一直在错的方向排查
2. **`sdgb_api(data, apiName, cookie)` 到底做了什么?**(最大的黑盒)
- 是否补 `MaimaiChn` 后缀?是否做 md5 混淆?
- 请求头怎么设的?(`Accept-Encoding`?`number`?`Mai-Encoding`?`Content-Type`?)
- **cookie 是怎么放进请求的**(header 直写?cookie jar?)
- 加解密实现是否一致
- ★ **他们的成功很可能就藏在这里**
3. **Cookie 显式传递的含义**
- 他们从登录结果里取出 cookie,手工传给每个请求
- 这个 cookie 是 `Set-Cookie` 原样,还是提取出的 `JSESSIONID=...`?
- 和 `requests.Session` 自动管理的**有没有不同**?
(比如:他们可能把**整个 Set-Cookie 串**塞进 `Cookie:` 头)
4. **60 秒等待是必需的吗?**
- 真机是 25 秒,他们用 60 秒 —— 是协议要求还是保险起见?
5. **`stock` 硬编码 1**
- 如果账号已有该票(`stock=1`),他们还是发 1
- 那「已持票不能买」的规则到底存不存在?(我加守卫是基于用户的警告)
6. **`validDate` 在凌晨会差一天**
- 他们的算法在 00:00~04:00 之间会比我多算一天
- 谁对?(源码支持我的,但他们的能跑通)
---
## 八、⚠️ 账号状态与风险
| 账号 | 状态 | 备注 |
|---|---|---|
| **A**(主号) | ⚠️ **疑似被拉黑** | 6 次失败购买后,机台与脚本都买不了 |
| **B**(测试号) | 暂未见异常 | 机台成功过;脚本失败 1 次 |
**风险提示:**
1. **不要反复重试买票** —— 每次都是一次真实的服务端交互,**可能正是失败次数导致拉黑**
2. 恢复工作前**先用机台确认账号还能不能买**
3. 优先用**非主力账号**做实验
4. 已有的三道守卫(可购买清单 / 已持票 / 副作用闸门)**不要为了「试通」而关掉**
---
## 九、恢复工作时怎么做
### 9.1 先读这些
| 文件 | 看什么 |
|---|---|
| 本文 | 全部背景 |
| `api/upsert_user_chargelog.py` | 实现与守卫 |
| `buy_ticket.py` | 主脚本流程 |
| `source/Assembly-CSharp/Net.Packet.Mai2/PacketUploadUserChargelog.cs` | 接口定义 |
| `source/Assembly-CSharp/Manager/TicketManager.cs:130-270` | 真机购买流程 |
| `source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:403-451` | 三个 Export 函数 |
### 9.2 第一件事:抓真机完整 HTTP 头
**MelonLoader Mod 已经改好了**(`gamehook/Main.cs`),现在会 dump:
- `★ 真实 HTTP 请求头(HttpWebRequest,含框架自带)` —— Method / ContentType / UserAgent /
ContentLength / KeepAlive / Expect / AutomaticDecompression / **Headers.AllKeys** / **Cookie 头**
- `★ 真实 HTTP 响应头` —— StatusCode / 全部响应头(含 **`Set-Cookie: JSESSIONID`**)
构建与部署:
```bash
cd gamehook && dotnet build -c Release
copy bin\Release\MaimaiNetSpy.dll "D:\SDGB2026\Package\Mods\MaimaiNetSpy.dll"
```
然后**在机台上用没被黑的账号买一张票**,把日志发回来:
```
D:\SDGB2026\Package\MaimaiNetSpy\YYYY-MM-DD_HHMMSS.log
```
### 9.3 拿到日志后怎么比
1. 找 `UpsertUserChargelogApi` 那一段
2. 对比**请求头集合**:多了什么?少了什么?
- 重点:`Expect`、`Accept-Encoding`、`Cookie`、`Connection`、以及**头的顺序**
3. 对比 `Cookie` 头:真机发的是 `JSESSIONID=...` 还是别的?
4. 对比登录响应的 `Set-Cookie`:和我 Session 里存的是不是同一个
5. 如果请求头完全一致 → 转向 TLS 指纹 / MITM 抓完整 TCP 层
### 9.4 验证买票是否成功的命令
```bash
# 预检(不发包)
python buy_ticket.py "<二维码>" --charge-id 2
# 真实购买(★ 有副作用)
python buy_ticket.py "<二维码>" --charge-id 2 --send
# 可选参数
--wait-before 60 # 默认 30 秒
--skip-entry # 跳过入场全量下载
--debug # 含完整收发报文
```
### 9.5 一个可能的快速验证
如果第 7.4 节的疑惑 1(`loginDate` vs `loginDateTime`)成立,
那么最快的验证是:**在 `extra_data` 里把 `loginDateTime` 换掉**——
但目前 `extra_data` 只作用于 `upsertUserAll` 的 36 个节点,
**顶层字段(`userId` / `userChargelog` / `userCharge` / `loginDateTime`)改不了**,
需要临时改 `upsert_user_chargelog.py` 的 `UserChargelogRequestVO` 定义。
---
## 十、代码位置索引
| 位置 | 内容 |
|---|---|
| `api/upsert_user_chargelog.py` | `UpsertUserChargelog` / `ChargelogConfirmError` / `ChargelogGuardError` / `calc_valid_date` / `parse_wall_time` / `now_cst` |
| `buy_ticket.py` | 主脚本 + `show_charges()`(跑前跑后查票) |
| `config.py` | `TICKET_CREDIT_NUM` / `TICKET_NAMES` / `PAID_TICKET_IDS` / `UPSERT_CHARGELOG_CONFIRM` / `TICKET_VALID_DAYS` / `TICKET_VALID_HOUR_OFFSET` |
| `selftest.py` | 13.6 节:买票相关的 20+ 项自检(含**与真机报文逐字节回归**) |
| `gamehook/Main.cs` | `PostRequestDumpHeaders` / `PostGetResponse`(响应头) |
---
*本文写于 2026-10-06,买票功能暂停时。*
+477
View File
@@ -0,0 +1,477 @@
# 购买功能票流程(登录 → 买票 → 登出)
> 对应 `api/upsert_user_chargelog.py` 与主脚本范例 `buy_ticket.py`。
> **判据 = `source/Assembly-CSharp/` 反编译源码 + 游戏本地主数据 Ticket.xml。**
>
> ⚠ 本文的实现**全部来自源码推导**,还**没有真机 NetworkLog 对照**。
> 文末列出「需要一次抓包确认」的点。
---
## 一、一句话
在机台买一张功能票 = 往服务器发一个 `UpsertUserChargelogApi`,
把「买了哪张票、多少钱、什么时候买的、有效期到什么时候、买完还剩几张」写进去。
```
ParseQRCode -> UserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
```
---
## 二、接口
| 项目 | 值 |
|---|---|
| 逻辑 API 名 | `UpsertUserChargelogApi` |
| 源码 | `Net.Packet.Mai2/PacketUploadUserChargelog.cs:19` |
| 请求 VO | `Net.VO.Mai2/UserChargelogRequestVO.cs`(4 字段) |
| 响应 | `{returnCode, apiName}` |
| 副作用 | ★ 有,会写入一条购买记录 |
### 2.1 请求体(字段顺序照 C# 声明)
```jsonc
{
"userId": 12345678,
"userChargelog": { // ← UserChargelog.cs,8 字段
"chargeId": 2, // 买的哪张票
"price": 1, // ★ = ticket.creditNum,不是 money price
"purchaseDate": "2026-10-06 13:01:22",
"playCount": 1260, // 玩家总游玩次数
"playerRating": 15000, // 玩家 Rating
"placeId": 1000,
"regionId": 1,
"clientId": "TESTCLIENT1"
},
"userCharge": { // ← UserCharge.cs,4 字段
"chargeId": 2,
"stock": 1, // ★ 购买后的库存
"purchaseDate": "2026-10-06 13:01:22",
"validDate": "2027-01-04 04:00:00"
},
"loginDateTime": 1791291143
}
```
---
## 三、源码流程(逐行可查)
```
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
│
├─ ticket.ticketKind.id == 1 ? ← 只有 Paid 票才走购买
│ (ticketKind: 1=Paid 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara)
│
├─ 在 OperationManager.GetGameChargeDataList() 里找该 chargeId 的 price
│ (= GetGameChargeApi 的响应,含 orderId/chargeId/price/startDate/endDate)
│
├─ TicketManager.cs:207 SetBuyPaidTicketData(playerId, ticketId)
│ ├─ ChargeList 里已有该 chargeId -> stock++、更新 purchaseDate / validDate
│ └─ 没有 -> 新增一条 stock=1
│
└─ TicketManager.cs:234 RequestUserCharge(playerId)
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
│ -> 取**购买后**(stock > 0)的那条 UserCharge
│
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
│ chargeId = ticketId
│ price = DataManager.GetTicket(ticketId).creditNum ★
│ purchaseDate = TimeManager.GetNowDateString()
│ playCount = src.Detail.PlayCount
│ playerRating = (int)src.Detail.Rating
│ placeId = Auth.LocationId
│ regionId = Auth.RegionCode
│ clientId = AMDaemon.System.KeychipId.ShortValue
│
└─ 发 UpsertUserChargelogApi
returnCode == 1 -> RequestSuccess(本地 stock 再 --,因为票马上被用掉)
否则 -> RequestFail
```
### 3.1 ★ `price` 不是「花的钱」
`VOExtensions.cs:410`:
```csharp
result.price = ticket.creditNum;
```
`creditNum` 是**票的信用点倍数**,来自游戏本地主数据,**不是** `GetGameChargeApi` 的 money price。
| chargeId | 票名 | `creditNum`(= 上报的 price) | `GetGameChargeApi.price`(投币数) |
|---|---|---|---|
| 2 | 2倍チケット | **1** | 1 |
| 3 | 3倍チケット | **2** | 2 |
| 4 | 4倍チケット | **3** | 未上架 |
| 5 | 5倍チケット | **4** | 未上架 |
| 6 | 6倍チケット | **5** | 未上架 |
> 2/3 号票两者数值恰好相同,别被误导 —— 源码取的是 `creditNum`。
> 数据已抄进 `config.TICKET_CREDIT_NUM`,来源:
> `<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml`
### 3.2 ★ `validDate` 算法
`Manager/TicketManager.cs:564`:
```csharp
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
```
**真机双向验证**(`source/2026-10-05.log:123` 的 `userChargeList`):
| chargeId | purchaseDate | 减 4h | 加 90d | validDate(真机) | 吻合 |
|---|---|---|---|---|---|
| 5 | 2026-10-04 18:22:10 | 10-04 14:22 | 2027-01-02 | `2027-01-02 04:00:00` | ✅ |
| 3 | 2026-10-02 10:49:23 | 10-02 06:49 | 2026-12-31 | `2026-12-31 04:00:00` | ✅ |
---
## 四、怎么用
### 4.1 主脚本(`buy_ticket.py`)
```bash
# 预检:只看报文,零副作用
python buy_ticket.py "<二维码>" --charge-id 2
# 先看看现在有什么票、能买什么
python buy_ticket.py "<二维码>" --list
# ★ 真实购买
python buy_ticket.py "<二维码>" --charge-id 2 --send
```
### 4.2 在代码里调
```python
from api import (setup_logging, teardown_logging,
ParseQRCode, UserLogin, UserLogout, LogoutType,
UpsertUserChargelog)
setup_logging("买票")
try:
user_id, token = ParseQRCode(qr)
login = UserLogin(user_id, token)
# ★ 一个函数搞定:取当前票券 -> 取档案 -> 算 validDate -> 算 price -> 闸门 -> 发包
UpsertUserChargelog(user_id, 2, login.loginDateTime,
dry_run=False, confirm=True)
UserLogout(user_id, token, login.loginDateTime,
logout_type=LogoutType.TestIn)
finally:
teardown_logging()
```
### 4.3 参数
| 参数 | 默认 | 说明 |
|---|---|---|
| `user_id` / `charge_id` / `login_date_time` | 必填 | 后两个必须与 `UserLoginApi` 一致 |
| `charge_list` | 自动抓 | 当前票券列表(`GetUserChargeApi`),用来算购买后库存 |
| `user_data` | 自动抓 | 当前档案(`GetUserDataApi`),用来填 `playCount`/`playerRating` |
| `stock` | 现有 + 1 | 购买后该票的库存 |
| `price` | `TICKET_CREDIT_NUM[charge_id]` | = `ticket.creditNum` |
| `purchase_date` | 现在 | `YYYY-MM-DD HH:MM:SS` |
| `valid_date` | 由 `purchase_date` 推 | 现在 -4h +90d,取日期补 `04:00:00` |
| `play_count` / `player_rating` | 取自 `user_data` | |
| `confirm` | `config.UPSERT_CHARGELOG_CONFIRM`(False) | ★ 不开不发包 |
| `dry_run` | `False` | `True` = 只构造报文 |
---
## 五、买完之后票会怎样?
按源码,**买票接口自己就把 `userCharge`(含新库存)写进去了**,
不需要额外再发 `UpsertUserAll`。
真机的完整顺序是「买票 → 立刻用掉这一张」:
```
SetBuyPaidTicketData: stock++ (本地 +1)
RequestUserCharge: 发 stock(购买后的值)
RequestSuccess: stock-- (票被本局用掉了)
```
所以 `userChargeList` 里的 `stock` 平时大多是 **0** ——
真机 22:31 那次的 `userChargeList` 三条全是 `stock: 0`,就是这个原因。
> **本模块只负责「买」**,默认上报的是**购买后**的库存(现有 + 1)。
> 如果你要复刻「买完立刻用掉」的完整语义,买完再发一次
> `UpsertUserAll(..., extra_data={"userChargeList": [...]})` 把 `stock` 减回去。
---
## 六、★ 真机抓包对照(已修正,报文逐字节一致)
### 6.0 结论:唯一差异是 `purchaseDate` 少了一个 `.0`
真机抓包(`MaimaiNetSpy` `raw/061_UpsertUserChargelogApiMaimaiChn_request.json`,328 字节):
```json
{"userId":12345678,
"userChargelog":{"chargeId":2,"price":1,"purchaseDate":"2026-10-06 13:20:10.0",
"playCount":1262,"playerRating":15000,"placeId":1000,
"regionId":1,"clientId":"TESTCLIENT1"},
"userCharge":{"chargeId":2,"stock":1,"purchaseDate":"2026-10-06 13:20:10.0",
"validDate":"2027-01-04 04:00:00"},
"loginDateTime":1791292785}
```
**14 个字段逐项对照:**
| 字段 | 真机 | 修正前 | 结论 |
|---|---|---|---|
| `userChargelog.chargeId` | 2 | 2 | ✅ |
| `userChargelog.price` | **1** | 1(= `creditNum`) | ✅ **源码推导正确** |
| `userChargelog.purchaseDate` | **`2026-10-06 13:20:10.0`** | `2026-10-06 13:10:42` | ❌ **少 `.0`** |
| `userChargelog.playCount` | 1262 | 1262 | ✅ |
| `userChargelog.playerRating` | 15000 | 15000 | ✅ |
| `userChargelog.placeId` / `regionId` / `clientId` | 1000 / 5 / `TESTCLIENT1` | 同 | ✅ |
| `userCharge.chargeId` | 2 | 2 | ✅ |
| `userCharge.stock` | **1** | 1(**加 1 后**) | ✅ **源码推导正确** |
| `userCharge.purchaseDate` | `2026-10-06 13:20:10.0` | 少 `.0` | ❌ |
| `userCharge.validDate` | **`2027-01-04 04:00:00`** | 同 | ✅ **算法完全正确** |
| `loginDateTime` | 1791292785 | 同 | ✅ |
| 字段顺序 | — | 同 | ✅ |
**修正后逐字节比对:真机 328 字节 / 本工程 328 字节,完全相同。**
### 6.0.1 根因:`TimeManager.DateTimeFormat` 带一位小数
`Manager/TimeManager.cs:7,62-70`:
```csharp
public const string DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f"; // ← .f = 一位小数
public static string GetNowDateString()
=> GetDateString(GetNowUnixTime());
public static string GetDateString(long unixTime)
=> GetDateTime(unixTime).ToString("yyyy-MM-dd HH:mm:ss.f");
```
我原来用了 `%Y-%m-%d %H:%M:%S`(无小数),所以服务端 `returnCode=0`。
> ★ 两个格式别搞混:
> | 字段 | 格式 | 例子 |
> |---|---|---|
> | `purchaseDate` | `yyyy-MM-dd HH:mm:ss.f` | `2026-10-06 13:20:10.0` |
> | `validDate` | `yyyy-MM-dd 04:00:00`(显式串) | `2027-01-04 04:00:00` |
>
> **一个带小数一个不带**,这是最容易踩的地方。
> ★ 另一个细节:`GetNowTime()` 返回的是 **`DateTime.UtcNow`**,
> `GetDateString` 再 `Add(CSToffset)` —— 所以游戏里的日期串**恒是北京时间**,
> 与本机时区无关。脚本跑在非东八区机器上时 `datetime.now()` 会错,
> 所以本模块用 `now_cst()`(UTC+8)显式计算。
### 6.1 修正前的实跑记录(2026-10-06 13:10,已废弃)
```
[购买前] 当前票券 3 条:
chargeId=2 stock=0 validDate=2026-10-12 04:00:00
chargeId=3 stock=0 validDate=2026-12-31 04:00:00
chargeId=5 stock=0 validDate=2027-01-02 04:00:00
[购买前] 持有中的功能票:0 张 ← 安全检查通过,可以买
安全检查通过:账号当前没有持有任何功能票
票名 : 2倍チケット
库存 : 0 -> 1
price : 1(= ticket.creditNum)
purchaseDate: 2026-10-06 13:10:42
validDate : 2027-01-04 04:00:00
闸门已放行,开始上传购买记录 ...
★ 购买失败:returnCode=0 apiName=com.sega.maimai.api.UpsertUserChargelogApi
```
**账号无恙**(已只读复查):`userChargeList` 仍是原来 3 条、
`playCount=1262`、无封禁。
### 6.2 这次失败排除了什么
| 结论 | 依据 |
|---|---|
| **URL / 混淆名正确** | 服务端回了 `apiName=com.sega.maimai.api.UpsertUserChargelogApi` |
| **报文格式被接受** | 不是 0 字节响应、不是解密解压失败,是**业务层拒绝**(`returnCode=0`) |
| **不是闸门问题** | 闸门已放行,确实发了包 |
| **不是「已持票」问题** | 购买前确认 3 条票 stock 全是 0 |
| **就是 `purchaseDate` 格式** | 抓包对照后确认(见 6.0) |
### 6.3 剩下的未验证点
| # | 待验证 | 现状 |
|---|---|---|
| 1 | 买票后是否**还要**发 `UpsertUserAll` 才持久化库存 | 源码流程里没有;抓包只抓到这一条请求,**推测不需要** |
| 2 | `validDate` 是否会被服务端按本地时区重算 | 真机发的是 `2027-01-04 04:00:00`,与本工程算法一致 |
| 3 | 服务端是否对「有效期未过的同一 chargeId」有额外限制 | 真机在 `chargeId=2`(validDate 2026-10-12 未过)的情况下照样买了,**说明没有这个限制** |
### 6.4 抓包时确认过的时序
抓到的这一次只发了 **`UpsertUserChargelogApi` 一条写请求**,
请求体里同时带了 `userCharge`(新库存)和 `userChargelog`(购买记录)——
印证了「买票接口自己就把库存写进去了」。
---
## 六点五、★ 第二次实跑:`chargeId=5` 又被 `returnCode=0`(2026-10-06 13:41)
修完 `.f` 格式后再跑一次(这次买 **5 倍券**),**还是 `returnCode=0`**:
```
[购买前] 持有中的功能票:0 张 ← 安全检查通过
安全检查通过:账号当前没有持有任何功能票
票名 : 5倍チケット
库存 : 0 -> 1
price : 4(= ticket.creditNum)
purchaseDate: 2026-10-06 13:41:43.3 ← .f 格式已正确
validDate : 2027-01-04 04:00:00
闸门已放行,开始上传购买记录 ...
★ 购买失败:returnCode=0 apiName=com.sega.maimai.api.UpsertUserChargelogApi
```
### 6.5.1 根因:`chargeId=5` 根本没上架
只读查 `GetGameChargeApi`:
```json
{"length":2,"gameChargeList":[
{"orderId":1,"chargeId":2,"price":1,...}, ← 只有 2 和 3
{"orderId":2,"chargeId":3,"price":2,...}]}
```
**chargeId=5 不在清单里 → 服务端直接拒。**
### 6.5.2 源码里本来就有这道检查(我漏了)
`Manager/TicketManager.cs:137-150`:
```csharp
if (ticket.ticketKind.id == 1)
{
GameCharge[] gameChargeDataList = Singleton<OperationManager>.Instance.GetGameChargeDataList();
for (int i = 0; i < gameChargeDataList.Length; i++)
{
GameCharge gameCharge = gameChargeDataList[i];
if (gameCharge.chargeId == ticketId) // ★ 必须在清单里找到
{
...
RequestUserCharge(playerId); // 才发购买请求
return true;
}
}
}
return false; // ★ 找不到 -> 不买
```
**真机也是「先查清单,找到才买」** —— 我第一版实现直接跳到发请求,漏了这一步。
### 6.5.3 已补的守卫
`UpsertUserChargelog` 新增 `guard_purchasable=True`(默认开):
- 不传 `game_charge_list` 就自动调 `GetGameChargeApi`
- `charge_id` 不在清单里 → 抛 `ChargelogGuardError`,**未发包**
`buy_ticket.py` 更把这道检查放到了**扫码之前**(`GetGameChargeApi` 不需要登录),
不满足就直接退出 —— **不浪费二维码、不产生登录会话**:
```
当前可购买 chargeId:[2, 3]
chargeId=2 2倍チケット money price=1 有效期 2021-03-18 07:00:00 ~ 2029-12-31 00:00:00
chargeId=3 3倍チケット money price=2 有效期 2021-03-18 07:00:00 ~ 2029-12-31 00:00:00
[错误] chargeId=5(5倍チケット)**不在当前可购买清单里** —— 连登录都不做,直接退出。
```
### 6.5.4 本机台到底能买什么
| chargeId | 票名 | 上架 | `creditNum`(上报的 price) | money price |
|---|---|---|---|---|
| 2 | 2倍チケット | ✅ | 1 | 1 |
| 3 | 3倍チケット | ✅ | 2 | 2 |
| 4 | 4倍チケット | ❌ | 3 | — |
| 5 | 5倍チケット | ❌ | 4 | — |
| 6 | 6倍チケット | ❌ | 5 | — |
> ★ 上架清单是**机台/店铺配置**,不同店铺可能不一样。
> 每次买之前都该先看 `GetGameChargeApi`。
### 6.5.5 账号状态
两次失败都**没有写入任何东西**:`userChargeList` 三条 `stock` 全是 0。
服务端的 `returnCode=0` 是「干净拒绝」。
---
## 六点六、★★ 第三、四次实跑:`chargeId=2`(真机验证过的配置)**也失败**
### 6.6.1 做了什么
第 3 次(13:50,chargeId=3)和第 4 次(14:03,**chargeId=2**)分别补齐了:
| 补齐项 | 说明 |
|---|---|
| `number` 头 | 加了 `Ping()`,从 `"0"` 变成 `"61"`(真机 75) |
| 入场全量下载 | 登录后先拉 18~19 个节点,复刻真机 [14]~[60] |
| 买票前等待 | 加 5 秒,贴近真机 login→买票 的 25 秒节奏 |
**结果仍然是 `returnCode=0`。**
### 6.6.2 与真机报文的最终并排对比
第 4 次的报文 vs 真机抓包:
| 路径 | 真机 | 本工程 |
|---|---|---|
| 键集合 | — | **完全相同** |
| `userChargelog` 键序 | — | **完全相同** |
| `userCharge` 键序 | — | **完全相同** |
| `userChargelog.purchaseDate` | `2026-10-06 13:20:10.0` | `2026-10-06 14:03:14.6` |
| `userChargelog.playCount` | 1262 | 1263 |
| `userCharge.purchaseDate` | `2026-10-06 13:20:10.0` | `2026-10-06 14:03:14.6` |
| `loginDateTime` | 1791292785 | 1791295385 |
**唯一不同的就是这 3 个「当下时刻」类的值。** 其余 11 个字段逐字节相同。
### 6.6.3 已逐项排除的可能
| # | 假设 | 排除依据 |
|---|---|---|
| 1 | URL / 混淆名错 | `4411a4c8f7f5c0e95a3ac17f1746aad3` 与真机一致;服务端回正确 `apiName` |
| 2 | User-Agent 错 | 与真机逐字符相同 |
| 3 | Cookie 缺失 | 登录响应确实下发 `Set-Cookie: JSESSIONID=...`,Session 自动带 |
| 4 | 报文字段/顺序/类型错 | 与真机逐字节同构 |
| 5 | `purchaseDate` 格式错 | 已补 `.f`(`14:03:14.6`) |
| 6 | 票未上架 | `chargeId=2` 在 `GetGameChargeApi` 清单里 |
| 7 | 已持有功能票 | 购买前确认 3 条 `stock` 全 0 |
| 8 | `number` 头为 0 | 已加 `Ping()` → `"61"` |
| 9 | 缺少入场数据下载 | 已补(18~19 节点,0 失败) |
| 10 | 节奏太快 | 已加 5 秒等待 |
| 11 | `chargeId` 本身有问题 | **改用真机验证过的 `chargeId=2` 仍然失败** |
### 6.6.4 剩余假设
请求体已经被证明「结构上完全正确」,所以问题只可能在**请求之外**:
| # | 假设 | 怎么验证 |
|---|---|---|
| **A** | ★ **服务端有「购买频率/每日上限」规则** —— 账号在 **13:20 刚买过同一张票**,43 分钟后再买被拒 | **在真机上再买一次 2 倍券**:若真机也失败 → 规则成立,本工程实现正确;若真机成功 → 本工程有 bug |
| B | 服务端对**金钱相关接口**做了 TLS/HTTP 指纹校验(非游戏客户端特征) | 需要本地 MITM 代理对比完整 HTTP 请求头 |
| C | 服务端要求某个本工程不知道的前置状态 | 同上 |
**★ 假设 A 是最可能、也最容易验证的**:`returnCode=0` 只用了 **47 ms** 就返回,
像是命中了一条规则检查,而不是超时或异常。
---
## 七、安全闸门
| 调用方式 | 行为 |
|---|---|
| `UpsertUserChargelog(...)` | 抛 `ChargelogConfirmError`,**未发包** |
| `UpsertUserChargelog(..., dry_run=True)` | 只构造并返回报文 |
| `UpsertUserChargelog(..., confirm=True)` | 真实发送 |
全局开关:`config.UPSERT_CHARGELOG_CONFIRM = False`(改成 `True` 会永久放行,不建议)。
+255
View File
@@ -0,0 +1,255 @@
# 舞萌DX 纯网络脚本工程 · 项目总览
> 项目名 **WaHappy** —— 舞萌DX 的游戏助手。
> 面向使用者的说明见根目录 [`README.md`](../README.md);
> 本文件是**开发向的主文档**,开工前先读它;遇到「该放哪、该怎么写」的问题以本文件为准。
---
## 一、这个工程是什么
用 **Python 脚本**,通过**纯网络(纯 HTTP)** 形式直接与舞萌DX(SDGB 国服)的**游戏服务器**交互,
复刻游戏客户端的协议行为。
| 对象 | 是什么 | 对应反编译源码 |
|---|---|---|
| **游戏服务器** | 玩家数据读写:扫码登录、取数、上传成绩、登出 | `source/Assembly-CSharp/` |
| **纯网络形式** | 脚本**直接收发 HTTP 报文**(AES 加密 JSON),不依赖运行中的游戏客户端 | `source/Assembly-CSharp/Net/` |
| **扫码登录** | 微信二维码换 userId + token,走 aimedb | `source/ChimeLib.NET/` |
---
## 二、目录结构
```
wahappy-v0-goal/
├── config.py ★ 运行时可配置项(密钥/设备/区域/歌曲数据/日志级别)
├── upsert.py ★ 主脚本范例:扫码登录 → 上传成绩 → 登出
├── buy_ticket.py ★ 主脚本范例:扫码登录 → 购买功能票 → 登出
├── selftest.py 零网络自检(220 项,判据=源码+真机日志+真机抓包)
│
├── api/ ★ 所有与服务器交互的脚本(一个 api 一个脚本)
│ ├── __init__.py 统一导出 + 日志跟踪登记
│ ├── log_setup.py 统一日志(INFO / DEBUG 双模式)
│ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
│ ├── user_getter_common.py 取数脚本公共底座
│ ├── chime.py 扫码登录
│ ├── upsert_user_all.py ★ 上传成绩
│ └── ... 其余见第六节
│
├── source/ ★ 反编译源码 + 网络日志,只读查证用
├── docs/ ★ 文档
└── logs/ ★ 日志输出(latest.log + 日期.log)
```
---
## 三、api/ 脚本契约
`api/` 里的每个脚本是一个**功能模块**,对外暴露**函数**;函数**能接收参数**,
内部完成「组包 → 发包 → 解析 → 返回结果」的全过程。
```python
def 某Api函数(必填参数, *, 可选参数...) -> 结果:
"""一句话说明(会出现在日志的 [接口说明] 里)"""
...
```
参数形态规律:
- **业务数据**(user_id / token / 二维码 / playlog_id)→ **位置参数**
- **配置项**(client_id / region_id / timeout_ms)→ **关键字参数**,可省略走默认值
---
## 四、日志
### 4.1 两种模式
| 模式 | 输出内容 |
|---|---|
| **INFO**(默认) | 只展示 api 脚本的**运行情况和结果** |
| **DEBUG** | 在 INFO 基础上,额外展示**向 api 发送的数据**和**api 返回的数据** |
切换只改 `config.py` 一行:
```python
LOG_LEVEL = "info" # 只看流程
LOG_LEVEL = "debug" # 连收发数据一起看
```
### 4.2 运行提示
每个 api 函数工作时会提示开始,结束时提示结束:
```
=== ParseQRCode 开始运行 ===
[接口说明] 解析二维码并换取 userId 与 token。
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
=== ParseQRCode 运行结束 ===
```
DEBUG 模式额外输出:
```
-> 请求 GetGameSettingApi
URL : https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/83a5...
请求头 : {"Content-Type":"application/json","Mai-Encoding":"1.55"}
请求体 :
{
"placeId": 1000,
"clientId": "TESTCLIENT1"
}
<- 响应 GetGameSettingApi
状态码 : 200
耗时 : 312 ms
响应体 :
{
"isAouAccession": false,
"gameSetting": { ... }
}
```
### 4.3 日志文件(两种形式同时存在、同时写入)
| 文件 | 模式 | 说明 |
|---|---|---|
| `logs/latest.log` | **覆盖**(`w`) | 只包含**当次**主脚本运行产出的日志,下次运行被覆盖 |
| `logs/YYYY-MM-DD.log` | **追加**(`a`) | 存放**当日**产生的所有日志 |
每行都带 `[YYYY-MM-DD HH:MM:SS]` 时间戳。
### 4.4 主脚本怎么写
```python
from api import setup_logging, teardown_logging, ParseQRCode, UserLogin
def main():
setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录
try:
user_id, token = ParseQRCode(qr)
login = UserLogin(user_id, token)
finally:
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
```
**不需要**写装饰器,**不需要**自己埋点 —— `api/__init__.py` 在导入时就统一套好了日志外壳。
---
## 五、协议实现要点(全部有源码依据)
| 环节 | 依据 | 要点 |
|---|---|---|
| **URL 生成** | `Packet.cs:485` + `NetQuery.cs:14` | BASE_URL + md5(接口名 + "MaimaiChn" + "8bF76dE9") |
| **请求封装** | `NetHttpClient.cs:262-300` | JSON → 原始 deflate → 0x78 0x9C + Adler32(大端) → AES-256-CBC |
| **响应解封装** | `NetHttpClient.cs:355-380` | AES 解密 → 去 2 字节头 / 4 字节尾 → deflate → JSON |
| **AES 参数** | `CipherAES.cs:11-12` | Key 32B / IV 16B / CBC / PKCS7 |
| **请求头** | `NetHttpClient.cs:174-190` | Mai-Encoding: 1.55、Content-Encoding: deflate、UA = 混淆名#userId |
| **number 头** | `Packet.cs:187-189` + `PingChecker.cs:227` | = Ping 心跳的往返毫秒(`GameManager.PingMsec`),**只由 Ping 刷新**;除 Ping 外每个接口都带 |
| **Cookie** | `Packet.cs:190-193,228-234` | ★ **UserLoginApi 响应会下发 `Set-Cookie: JSESSIONID=...`**,之后每个带 userId 的请求都会带上;`requests.Session` 自动处理,但**必须复用同一个 client**(`get_default_client()`) |
| **playSpecial** | `VOExtensions.cs:846-855` + `GameManager.cs:332-378` | `位反转32(Random(1,1037933)*2069 + CalcSpecialNum)`;标准 ACA 机台 `CalcSpecialNum = 1024 = 0x400` |
| **扫码签名** | 技术规范 §4 | SHA256(chipID + timestamp + KEY) 大写;**不带 Content-Type** |
| **loginDateTime** | `PacketUserLogin.cs:29` | 「本地墙上时间当 UTC」= 标准 Unix 秒 + 时区偏移 |
### ★ 一个必须注意的坑:Accept-Encoding 必须为空
`requests` 默认发 `Accept-Encoding: gzip, deflate`。本协议响应体是 AES 密文(二进制),
服务端若据此做内容编码会返回**空 body**。C# 端不需要是因为 `HttpWebRequest`
默认 `AutomaticDecompression = None`。所以 `maimai_net.py` 固定置空该头。
---
## 六、API 清单
### 6.1 会话链路(6 个)
| 脚本 | 函数 | 对应接口 |
|---|---|---|
| `chime.py` | `ParseQRCode(qr, *, chip_id)` | aimedb /wc_aime/api/get_data |
| `gamesettings.py` | `GetGameSettings(*, place_id, client_id)` | GetGameSettingApi |
| `ping.py` | `Ping(*, client_id)` | Ping |
| `userpreview.py` | `UserPreview(user_id, token, *, ...)` | GetUserPreviewApi |
| `userlogin.py` | `UserLogin(user_id, token, *, region_id, place_id)` | UserLoginApi |
| `userlogout.py` | `UserLogout(user_id, token, login_dt, *, logout_type)` | UserLogoutApi |
### 6.2 上传成绩(2 个)
| 脚本 | 函数 | 说明 |
|---|---|---|
| `upsert_user_all.py` | `UpsertUserAll(user_id, playlog_id, *, login_date_time, auth_date_time, token, snapshot, extra_data, play, ...)` | ★ **一个函数跑完整条结算链路**:抓快照 → 组包 → 前置接口 → 自检 → 60 秒冷却 → 发包 → 落库回查。唯一有副作用的接口 |
| `upsert_user_all.py` | `derive_play_characters(snapshot)` | 从服务器快照推「本局 5 只角色 + 游玩前等级」,免得手填的 `config.PLAY_CHARACTERS` 过期 |
| `upsert_user_all.py` | `load_extra_data(extra)` / `merge_extra_data(user_all, extra)` | 把外部 JSON(dict / JSON 串 / 文件路径)自动匹配插入 36 节点 |
| `upsert_user_all.py` | `verify_upsert_landing(user_id, snapshot, play)` | 发包后重新拉服务器数据,确认真的落库 |
| `upsert_snapshot.py` | `fetch_upsert_snapshot(user_id, ...)` | 一键抓结算基线快照(`UpsertUserAll` 内部会按需自动调) |
| `upsert_user_chargelog.py` | `UpsertUserChargelog(user_id, charge_id, login_date_time, ...)` | ★ **购买功能票**(UpsertUserChargelogApi):自动取当前票券/档案、算 validDate 与 price、闸门、发包 |
| `upsert_user_chargelog.py` | `calc_valid_date(now)` / `ticket_price(id)` / `ticket_name(id)` | 有效期算法(-4h+90d)、票的 creditNum、票名 |
### 6.3 取数(一个接口一个脚本)
| 脚本 | 函数 | 对应接口 |
|---|---|---|
| `userdata.py` | UserData | GetUserDataApi |
| `user_extend.py` | UserExtend | GetUserExtendApi |
| `user_option.py` | UserOption | GetUserOptionApi |
| `user_character.py` | UserCharacter | GetUserCharacterApi |
| `user_ghost.py` | UserGhost | GetUserGhostApi |
| `user_map.py` | UserMap / UserMapAll | GetUserMapApi |
| `user_login_bonus.py` | UserLoginBonus / UserLoginBonusAll | GetUserLoginBonusApi |
| `user_rating.py` | UserRating | GetUserRatingApi |
| `user_item.py` | UserItem / UserItemAll | GetUserItemApi |
| `user_music.py` | UserMusicDetail / UserMusicDetailAll | GetUserMusicApi |
| `user_course.py` | UserCourse / UserCourseAll | GetUserCourseApi |
| `user_charge.py` | UserCharge | GetUserChargeApi |
| `user_favorite.py` | UserFavorite / UserFavoriteAll | GetUserFavoriteApi |
| `user_activity.py` | UserActivity | GetUserActivityApi |
| `user_mission.py` | UserMissionData | GetUserMissionDataApi |
| `user_intimate.py` | UserIntimate | GetUserIntimateApi |
| `user_shop_stock.py` | UserShopStock | GetUserShopStockApi |
| `user_favorite_item.py` | UserFavoriteItem / UserFavoriteItemAll | GetUserFavoriteItemApi |
| `user_rival.py` | UserRivalData / UserRivalMusic(All) | GetUserRivalDataApi / GetUserRivalMusicApi |
| `user_friend_bonus.py` | UserFriendBonus | GetUserFriendBonusApi |
| `user_kaleidx_scope.py` | UserKaleidxScope / GameKaleidxScope | GetUserKaleidxScopeApi / GetGameKaleidxScopeApi |
| `user_region.py` | UserRegion | GetUserRegionApi |
| `user_recommend_music.py` | UserRecommendRateMusic / UserRecommendSelectMusic | GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi |
| `user_new_item.py` | UserNewItemList | GetUserNewItemListApi |
| `game_ranking.py` | GetGameRanking | GetGameRankingApi |
| `game_event.py` | GetGameEvent | GetGameEventApi |
| `game_charge.py` | GetGameCharge | GetGameChargeApi |
| `game_weekly_data.py` | GetGameWeeklyData | GetGameWeeklyDataApi |
| `game_tournament_info.py` | GetGameTournamentInfo | GetGameTournamentInfoApi |
| `game_ng_music_id.py` | GetGameNgMusicId | GetGameNgMusicIdApi |
---
## 七、新增一个 api 要做什么
1. 在 `api/` 下新建脚本,定义一个能传参的函数
2. 函数首行写 docstring(会变成日志的 `[接口说明]`)
3. 用 `from .log_setup import log_info, log_request, log_response` 记日志
4. 用 `from .maimai_net import request_dict` 发包
5. 在 `api/__init__.py` 里**导入**并**加进 `_API_FUNCS`** ← 漏了这步就不会有运行提示
---
## 八、相关文档
| 文件 | 内容 |
|---|---|
| `docs/上传成绩开发文档.md` | UpsertUserAll 的 36 节点 / 110 字段 / 差分语义 / 真机对照 |
| `docs/主脚本编写指南.md` | ★ **写自己的主脚本**:三条铁律 / 最小骨架 / 四个流程模板 / 常见错误 |
| `docs/购买功能票流程.md` | ★ 登录 → 买票 → 登出:接口结构 / 源码流程 / validDate 算法 / 待抓包确认项 |
| `docs/UpsertUserAll踩坑清单.md` | ★ **28 条坑的逐条记录**:现象 / 根因 / 源码依据 / 正确做法 / 自检项 + 发包前 Checklist |
| `docs/实跑验证记录.md` | ★ **真实跑通记录**:returnCode=1 + 回读确认 playCount 1260→1261,含完整时间线与落库证据 |
| `docs/真机Hook对照报告.md` | ★ MelonLoader Mod 真机抓包(70 请求)与本实现的逐项对照:number / Cookie / CalcSpecialNum / 剩余差异说明 |
| `docs/UpsertUserAll实跑对比.md` | 逐字段对比明细(110 字段 × 真机 vs 本工程) |
| `docs/UpsertUserAll落库排查报告.md` | 「returnCode=1 但没落库」的根因分析 |
| `docs/日志与主脚本指南.md` | 日志模块完整参考 + 主脚本写法 |
| `docs/舞萌DX_扫码登录协议技术规范.md` | 扫码登录协议(上一代整理) |
| `source/2026-10-05.log` | 真机网络日志(本工程最重要的判据) |
---
*新增 api 后请同步更新第六节清单。*
-63
View File
@@ -1,63 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
MaimaiNetSpy —— MelonLoader Mod:hook 舞萌DX 游戏网络层做实时拦截
==========================================================================
目标框架必须与游戏/MelonLoader 一致:net472(MonoBleedingEdge + MelonLoader 0.6.4)
构建:dotnet build -c Release
产物:bin/Release/net472/MaimaiNetSpy.dll -> 复制到 D:\SDGB2026\Package\Mods\
-->
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<AssemblyName>MaimaiNetSpy</AssemblyName>
<RootNamespace>MaimaiNetSpy</RootNamespace>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<Version>1.0.0</Version>
<!-- 不生成 apphost / 不需要运行时配置 -->
<UseAppHost>false</UseAppHost>
<GenerateRuntimeConfigurationFiles>false</GenerateRuntimeConfigurationFiles>
<!-- 允许引用 net472 参考程序集 -->
<AutomaticallyUseReferenceAssemblyPackages>true</AutomaticallyUseReferenceAssemblyPackages>
</PropertyGroup>
<!-- 游戏程序集与 MelonLoader 的引用(全部 Private=false,不复制到输出) -->
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>D:\SDGB2026\Package\MelonLoader\net35\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>D:\SDGB2026\Package\MelonLoader\net35\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Assembly-CSharp">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\Assembly-CSharp.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Assembly-CSharp-firstpass">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\Assembly-CSharp-firstpass.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\UnityEngine.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="AMDaemon.NET">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\AMDaemon.NET.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ChimeLib.NET">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\ChimeLib.NET.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
-448
View File
@@ -1,448 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using HarmonyLib;
using MelonLoader;
// ★ MelonLoader 靠这个特性识别 mod 并调用 OnInitializeMelon。
// 缺了它只会 "Melon Assembly loaded" 但不会实例化(表现为 "N Mods loaded" 不计入)。
[assembly: MelonInfo(typeof(MaimaiNetSpy.Main), "MaimaiNetSpy", "1.0.0", "wahappy")]
[assembly: MelonGame(null, null)]
[assembly: MelonColor(255, 120, 200, 255)]
namespace MaimaiNetSpy
{
/// <summary>
/// MaimaiNetSpy —— hook 舞萌DX 游戏网络层,做实时拦截与落盘。
///
/// 拦截点(全部依据 Assembly-CSharp 的实际签名):
/// Net.NetHttpClient.AddHeader(name, value) 所有请求头
/// Net.NetHttpClient.AddCookie(userId) Cookie
/// Net.NetHttpClient.Request(bytes, ua, method) ★ 加密前的明文 JSON
/// Net.NetHttpClient.GetResponse() 解密后的响应
/// Net.NetPacketUtil.GetUserAgent(query) 逻辑 API 名
/// Manager.GameManager.CalcSpecialNum() playSpecial 的硬件标志位
///
/// 输出:
/// &lt;游戏根目录&gt;/MaimaiNetSpy/YYYY-MM-DD_HHmmss.log 人类可读时间线
/// &lt;游戏根目录&gt;/MaimaiNetSpy/raw/*.json 每个接口的请求/响应原文
/// </summary>
public class Main : MelonMod
{
internal static MelonLogger.Instance Log;
//: 当前请求的上下文(按线程隔离,游戏是异步回调模型)
private static readonly object _lock = new object();
private static string _outDir;
private static StreamWriter _writer;
//: 本次请求累积的头部(AddHeader 逐条调用,Request 时才真正发出)
[ThreadStatic] private static List<KeyValuePair<string, string>> _headers;
[ThreadStatic] private static string _cookie;
[ThreadStatic] private static string _currentApi;
[ThreadStatic] private static string _currentUrl;
//: 统计
private static int _reqCount;
private static int _respCount;
public override void OnInitializeMelon()
{
Log = LoggerInstance;
string root = MelonLoader.Utils.MelonEnvironment.GameRootDirectory;
_outDir = Path.Combine(root, "MaimaiNetSpy");
Directory.CreateDirectory(_outDir);
Directory.CreateDirectory(Path.Combine(_outDir, "raw"));
string file = Path.Combine(_outDir,
DateTime.Now.ToString("yyyy-MM-dd_HHmmss") + ".log");
_writer = new StreamWriter(file, false, new UTF8Encoding(false)) { AutoFlush = true };
Line("=".PadRight(78, '='));
Line(" MaimaiNetSpy 已加载 —— 舞萌DX 网络层实时拦截");
Line(" 日志: " + file);
Line("=".PadRight(78, '='));
try
{
var h = new HarmonyLib.Harmony("MaimaiNetSpy");
int n = 0;
// ★ Request 有两个重载:Request() 与 Request(byte[], string, string)
// 必须按参数类型精确匹配,否则会命中无参版本导致
// "Parameter bytes not found in method Request()"
n += PatchBySig(h, "Net.NetHttpClient", "Create",
new[] { typeof(string) },
nameof(PreCreate), null);
n += PatchBySig(h, "Net.NetHttpClient", "AddHeader",
new[] { typeof(string), typeof(string) },
nameof(PreAddHeader), null);
n += PatchBySig(h, "Net.NetHttpClient", "AddCookie",
new[] { typeof(ulong) },
nameof(PreAddCookie), null);
n += PatchBySig(h, "Net.NetHttpClient", "Request",
new[] { typeof(byte[]), typeof(string), typeof(string) },
nameof(PreRequest), nameof(PostRequestDumpHeaders));
n += PatchBySig(h, "Net.NetHttpClient", "GetResponse",
Type.EmptyTypes,
null, nameof(PostGetResponse));
n += PatchBySig(h, "Net.NetPacketUtil", "GetUserAgent",
new[] { AccessTools.TypeByName("Net.INetQuery") },
null, nameof(PostGetUserAgent));
n += PatchBySig(h, "Manager.GameManager", "CalcSpecialNum",
Type.EmptyTypes,
null, nameof(PostCalcSpecialNum));
n += PatchBySig(h, "Net.NetHttpClient", "SetError",
new[] { typeof(System.Net.WebExceptionStatus), typeof(string),
typeof(int), typeof(System.Net.HttpWebResponse) },
nameof(PreSetError), null);
Line(" 已挂载 " + n + " 个 hook");
}
catch (Exception e)
{
Line(" !! hook 挂载失败: " + e);
}
Line("");
}
public override void OnDeinitializeMelon()
{
try { _writer?.Flush(); _writer?.Dispose(); } catch { }
}
// ------------------------------------------------------------------
// hook 挂载工具
// ------------------------------------------------------------------
/// <summary>
/// 按**参数类型**精确挂载 hook。
///
/// ★ 为什么必须精确匹配:NetHttpClient.Request 有两个重载
/// Boolean Request() —— 只发请求(无体)
/// Boolean Request(Byte[] bytes, String ua, String m) —— 带请求体
/// 用 AccessTools.Method(t, "Request") 会命中前者,
/// 导致 prefix 里的 bytes 参数对不上而抛
/// "Parameter bytes not found in method Request()"。
/// </summary>
private static int PatchBySig(HarmonyLib.Harmony h, string typeName, string methodName,
Type[] paramTypes, string pre, string post)
{
var t = AccessTools.TypeByName(typeName);
if (t == null) { Line(" [skip] 类型未找到: " + typeName); return 0; }
if (paramTypes == null || paramTypes.Any(x => x == null))
{ Line(" [skip] 参数类型解析失败: " + typeName + "." + methodName); return 0; }
var m = AccessTools.Method(t, methodName, paramTypes);
if (m == null)
{
Line(" [skip] 方法未找到: " + typeName + "." + methodName
+ "(" + string.Join(", ", Array.ConvertAll(paramTypes, x => x.Name)) + ")");
return 0;
}
HarmonyMethod preM = pre == null ? null
: new HarmonyMethod(typeof(Main).GetMethod(pre, BindingFlags.Static | BindingFlags.NonPublic));
HarmonyMethod postM = post == null ? null
: new HarmonyMethod(typeof(Main).GetMethod(post, BindingFlags.Static | BindingFlags.NonPublic));
try
{
h.Patch(m, preM, postM);
Line(" [ok] " + typeName + "." + methodName
+ "(" + string.Join(", ", Array.ConvertAll(paramTypes, x => x.Name)) + ")");
return 1;
}
catch (Exception e)
{
Line(" [fail] " + typeName + "." + methodName + " -> " + e.Message);
return 0;
}
}
// ------------------------------------------------------------------
// Create —— 捕获完整 URL(NetHttpClient.Create(url))
// ------------------------------------------------------------------
private static void PreCreate(string url)
{
_currentUrl = url;
}
// ------------------------------------------------------------------
// AddHeader —— 收集请求头
// ------------------------------------------------------------------
private static void PreAddHeader(string name, string value)
{
if (_headers == null) _headers = new List<KeyValuePair<string, string>>();
_headers.Add(new KeyValuePair<string, string>(name, value));
}
private static void PostAddHeader()
{
// 不在这里输出:AddHeader 会被多次调用,等 Request 时统一打印
}
// ------------------------------------------------------------------
// AddCookie —— 记录 Cookie
// ------------------------------------------------------------------
private static void PreAddCookie(ulong userId)
{
_cookie = "userId=" + userId;
}
// ------------------------------------------------------------------
// Request —— ★ 核心:加密前的明文 JSON 在这里
// ------------------------------------------------------------------
private static void PreRequest(byte[] bytes, string userAgent, string method)
{
_reqCount++;
string plain = null;
try
{
if (bytes != null && bytes.Length > 0)
plain = Encoding.UTF8.GetString(bytes);
}
catch { }
var sb = new StringBuilder();
sb.AppendLine();
sb.AppendLine("+" + new string('-', 76));
sb.AppendLine("| [" + _reqCount + "] >>> 请求 "
+ DateTime.Now.ToString("HH:mm:ss.fff"));
sb.AppendLine("+" + new string('-', 76));
sb.AppendLine("| API : " + (_currentApi ?? "(unknown)"));
if (!string.IsNullOrEmpty(_currentUrl))
sb.AppendLine("| URL : " + _currentUrl);
sb.AppendLine("| User-Agent : " + userAgent);
sb.AppendLine("| Method : " + method);
sb.AppendLine("| Cookie : " + (_cookie ?? "(none)"));
sb.AppendLine("| 明文长度 : " + (bytes == null ? 0 : bytes.Length) + " 字节");
if (_headers != null && _headers.Count > 0)
{
sb.AppendLine("| ---- 请求头(AddHeader 收集)----");
foreach (var kv in _headers)
sb.AppendLine("| " + kv.Key + ": " + kv.Value);
}
else
{
sb.AppendLine("| ---- 请求头: (无 AddHeader 调用) ----");
}
if (plain != null)
{
sb.AppendLine("| ---- 请求体(加密前明文)----");
foreach (var ln in Wrap(plain, 150))
sb.AppendLine("| " + ln);
}
Line(sb.ToString());
// 落盘原始明文
try
{
string safe = Sanitize(_currentApi ?? ("req" + _reqCount));
File.WriteAllText(
Path.Combine(_outDir, "raw", _reqCount.ToString("D3") + "_" + safe + "_request.json"),
plain ?? "", new UTF8Encoding(false));
}
catch { }
// 清空累积状态(本次请求已消费)
_headers = null;
}
private static void PostRequest()
{
// 状态清理交给下一次 PreRequest
}
// ------------------------------------------------------------------
// ★ 真实 HTTP 请求头 dump
// ------------------------------------------------------------------
//
// 为什么必须用 postfix:
// PreRequest 跑在 Request() 方法体**之前**,那时
// Method / ContentType / UserAgent / Content-Encoding 都还没设置。
// 只有 postfix 才能看到最终真正要发出去的头(含框架自带的)。
//
// 目的:把真机的**完整请求头**抓下来,和纯 Python 脚本发的做逐字节对比。
// (排查「报文一致但服务端 returnCode=0」这类问题)
private static void PostRequestDumpHeaders(Net.NetHttpClient __instance)
{
try
{
var fld = AccessTools.Field(typeof(Net.NetHttpClient), "_request");
var wr = (fld == null ? null : fld.GetValue(__instance)) as System.Net.HttpWebRequest;
if (wr == null) { Line("| [头 dump] _request 取不到"); return; }
var sb = new StringBuilder();
sb.AppendLine("| ---- ★ 真实 HTTP 请求头(HttpWebRequest,含框架自带)----");
sb.AppendLine("| [Method] : " + wr.Method);
sb.AppendLine("| [ContentType] : " + wr.ContentType);
sb.AppendLine("| [UserAgent] : " + wr.UserAgent);
sb.AppendLine("| [ContentLength] : " + wr.ContentLength);
sb.AppendLine("| [KeepAlive] : " + wr.KeepAlive);
sb.AppendLine("| [Expect] : " + wr.Expect);
sb.AppendLine("| [AutomaticDecompression] : " + wr.AutomaticDecompression);
sb.AppendLine("| [AllowAutoRedirect] : " + wr.AllowAutoRedirect);
try
{
foreach (string k in wr.Headers.AllKeys)
sb.AppendLine("| " + k + ": " + wr.Headers[k]);
}
catch (Exception e2) { sb.AppendLine("| [Headers 枚举失败] " + e2.Message); }
try
{
sb.AppendLine("| [Cookie 头] : "
+ (wr.CookieContainer == null ? "(null container)"
: "[" + wr.CookieContainer.GetCookieHeader(wr.RequestUri) + "]"));
}
catch (Exception e3) { sb.AppendLine("| [Cookie 取失败] " + e3.Message); }
Line(sb.ToString());
}
catch (Exception e)
{
Line("| [头 dump 失败] " + e);
}
}
// ------------------------------------------------------------------
// GetResponse —— 解密后的响应
// ------------------------------------------------------------------
private static void PostGetResponse(Net.NetHttpClient __instance, ref MemoryStream __result)
{
_respCount++;
// ★ 响应头 dump(尤其看 UserLoginApi 的 Set-Cookie: JSESSIONID)
try
{
var fldR = AccessTools.Field(typeof(Net.NetHttpClient), "_response");
var resp = (fldR == null ? null : fldR.GetValue(__instance)) as System.Net.HttpWebResponse;
if (resp != null)
{
var sh = new StringBuilder();
sh.AppendLine("| ---- ★ 真实 HTTP 响应头 ----");
sh.AppendLine("| [StatusCode] : " + (int)resp.StatusCode);
try
{
foreach (string k in resp.Headers.AllKeys)
sh.AppendLine("| " + k + ": " + resp.Headers[k]);
}
catch (Exception e4) { sh.AppendLine("| [响应头枚举失败] " + e4.Message); }
Line(sh.ToString());
}
}
catch (Exception e5) { Line("| [响应头 dump 失败] " + e5.Message); }
try
{
if (__result == null) return;
long pos = __result.Position;
__result.Position = 0;
byte[] buf = new byte[__result.Length];
int read = __result.Read(buf, 0, buf.Length);
__result.Position = pos;
string txt = Encoding.UTF8.GetString(buf, 0, read);
var sb = new StringBuilder();
sb.AppendLine("| [" + _respCount + "] <<< 响应 "
+ DateTime.Now.ToString("HH:mm:ss.fff")
+ " (" + read + " 字节)");
foreach (var ln in Wrap(txt, 150))
sb.AppendLine("| " + ln);
Line(sb.ToString());
string safe = Sanitize(_currentApi ?? ("resp" + _respCount));
File.WriteAllText(
Path.Combine(_outDir, "raw", _respCount.ToString("D3") + "_" + safe + "_response.json"),
txt, new UTF8Encoding(false));
}
catch (Exception e)
{
Line("| [响应解析失败] " + e.Message);
}
}
// ------------------------------------------------------------------
// GetUserAgent —— 从 UA 反推 API 名;同时记录 URL
// ------------------------------------------------------------------
private static void PostGetUserAgent(Net.INetQuery query, ref string __result)
{
try
{
_currentApi = query?.Api;
// UA 形如 "<md5>#<userId>"
Line(" [API] " + _currentApi + " UA=" + __result);
}
catch { }
}
// ------------------------------------------------------------------
// CalcSpecialNum —— playSpecial 的硬件标志位
// ------------------------------------------------------------------
private static void PostCalcSpecialNum(ref int __result)
{
Line(" [CalcSpecialNum] = " + __result
+ " (SpecialAP=" + SafeBool(() => Manager.GameManager.SpecialAP)
+ " IsFreedomMode=" + SafeBool(() => Manager.GameManager.IsFreedomMode)
+ ")");
}
private static string SafeBool(Func<bool> f)
{
try { return f().ToString(); } catch { return "?"; }
}
// ------------------------------------------------------------------
// SetError —— 记录网络层错误(含 HTTP 状态码)
// ------------------------------------------------------------------
private static void PreSetError(System.Net.WebExceptionStatus status, string error,
int state, System.Net.HttpWebResponse response)
{
Line(" [NetError] status=" + status + " state=" + state
+ " error=" + error
+ (response != null ? (" http=" + (int)response.StatusCode) : ""));
}
// ------------------------------------------------------------------
// 工具
// ------------------------------------------------------------------
private static string Sanitize(string s)
{
var sb = new StringBuilder();
foreach (char c in s)
sb.Append(char.IsLetterOrDigit(c) || c == '_' || c == '-' ? c : '_');
return sb.ToString();
}
private static IEnumerable<string> Wrap(string s, int width)
{
if (s == null) yield break;
for (int i = 0; i < s.Length; i += width)
yield return s.Substring(i, Math.Min(width, s.Length - i));
}
internal static void Line(string s)
{
lock (_lock)
{
try { _writer?.WriteLine(s); } catch { }
}
}
}
}
-1161
View File
File diff suppressed because it is too large Load Diff
-239
View File
@@ -1,239 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
================================================================================
整条流程只有四步,而且**一行日志都不用自己写** ——
每个 api 函数被调用时会自动打印:
=== UserLogin 开始运行 ===
[接口说明] 用户正式登录(开启本次登录会话)。
[位置参数] 12345678, 'iUfgRmjr...'
[关键字参数] region_id=1, place_id=1000
=== UserLogin 运行结束 ===
日志模式改 config.py 一行:
LOG_LEVEL = "info" 只展示 api 脚本的运行情况和结果
LOG_LEVEL = "debug" 额外展示向 api 发送的数据与 api 返回的数据
日志文件(两种同时写):
logs/latest.log 只含**当次**主脚本运行(下次覆盖)
logs/YYYY-MM-DD.log 存放**当日**产生的所有日志(追加)
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python upsert.py <二维码> # 预检:登录 + 抓快照 + 组包,不发包
python upsert.py <二维码> --send # 真实上传成绩(★ 有副作用,不可撤销)
python upsert.py <二维码> --send --debug # 同上,日志里带完整收发报文
python upsert.py <二维码> --send --cooldown 0 # 跳过发包前的 60 秒冷却
python upsert.py <二维码> --send --no-verify # 跳过上传后的落库回查
# ★ 额外塞节点(--extra-data,可重复,后给的覆盖先给的)
python upsert.py <二维码> --send --extra-data extra.json
python upsert.py <二维码> --send --extra-data '{"isNewMapList":"1"}'
python upsert.py <二维码> --send --extra-data a.json --extra-data b.json
--------------------------------------------------------------------------------
★ UpsertUserAll 一个函数就把整条结算链路跑完了
--------------------------------------------------------------------------------
1. 抓基线快照(没传 snapshot 时自动抓,用 token)
2. 从快照推本局 5 只角色(游玩前等级)
3. 构造本局成绩明细 userPlaylogList
4. 调前置接口 GetUserNewItemListApi
5. 组装 upsertUserAll 的 36 个节点(快照基线 + 本局演算 + 差分)
6. 合并 extra_data 里的外部 JSON
7. 发包前自检
8. 冷却 -> 真实发包
9. 落库回查(重新拉服务器数据,确认真的写进去了)
--------------------------------------------------------------------------------
★ --extra-data:额外塞节点
--------------------------------------------------------------------------------
想手工覆盖 / 补上某几个节点(比如本局拿到的新 KaleidxScope 门)时用:
# ① 从文件读(★ 推荐,不用跟 shell 引号搏斗)
python upsert.py <二维码> --send --extra-data extra.json
# ② 直接写 JSON 字符串
python upsert.py <二维码> --send --extra-data '{"isNewMapList":"1"}'
# ③ 多个来源,后给的覆盖先给的
python upsert.py <二维码> --send --extra-data base.json --extra-data patch.json
extra.json 长这样:
{
"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"
}
★ 键必须是 upsertUserAll 的 36 个节点之一(含 11 条 isNew* 位串),
写错会明确报错并列出可用节点名。
★ 没给对应位串时会按数组长度自动补 "0" 并提示 ——
新增条目请显式传 "1","0" 表示存量更新。
★ JSON 在**扫码之前**就校验,写错了直接退出,不会浪费二维码。
── 在代码里直接调 api 的等价写法 ──
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
extra_data={"userKaleidxScopeList": [...], "isNewKaleidxScopeList": "1"},
confirm=True,
)
"""
from __future__ import annotations
import sys
from api import (
setup_logging,
teardown_logging,
log_info,
log_warning,
log_error,
ParseQRCode,
UserPreview,
UserLogin,
UserLogout,
LogoutType,
UpsertUserAll,
ConfirmRequiredError,
load_extra_data,
PatchError,
NODE_ORDER,
NODE_TYPES,
)
from config import PLACE_ID, REGION_ID
def parse_args(argv):
"""极简命令行解析:第一个非 -- 开头的参数是二维码。"""
opts = {"qr": "", "send": False, "debug": False, "no_verify": False,
"cooldown": None, "extra_data": []}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--no-verify":
opts["no_verify"] = True
elif a == "--extra-data" and i + 1 < len(argv):
# 可重复;值是 JSON 字符串或 JSON 文件路径
opts["extra_data"].append(argv[i + 1])
i += 1
elif a == "--cooldown" and i + 1 < len(argv):
opts["cooldown"] = float(argv[i + 1])
i += 1
elif not a.startswith("--") and not opts["qr"]:
opts["qr"] = a
i += 1
return opts
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["qr"]:
print(__doc__)
return 1
# --debug 只影响本次运行,不改 config.py
if opts["debug"]:
import config as _cfg
_cfg.LOG_LEVEL = "debug"
# 日志会话:开头一句。之后每次 api 调用都会自动记录,主脚本不用再写日志
setup_logging("upsert")
# ---- ★ 先解析 --extra-data(放在扫码之前:JSON 写错就别浪费二维码了)----
extra = None
if opts["extra_data"]:
merged = {}
try:
for item in opts["extra_data"]:
part = load_extra_data(item)
merged.update(part)
log_info("--extra-data 读入 " + str(len(part)) + " 个节点:" + ", ".join(part))
except PatchError as exc:
log_error("--extra-data 解析失败:" + str(exc))
return 6
# 节点名也在这里查(不然要等到登录后组包时才报错,白跑一趟)
unknown = [k for k in merged if k not in NODE_TYPES]
if unknown:
log_error("--extra-data 里有未知节点名:" + ", ".join(unknown))
log_error(" 可用节点(" + str(len(NODE_ORDER)) + " 个):" + ", ".join(NODE_ORDER))
return 6
if not merged:
log_warning("--extra-data 解析出来是空的,忽略")
else:
extra = merged
log_info("--extra-data 合计 " + str(len(extra)) + " 个节点:" + ", ".join(extra))
# 会话状态:保证异常时也能登出(否则账号会卡在 isLogin=True,下次登录 returnCode=100)
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---- 扫码:二维码 -> userId + token ----
user_id, token = ParseQRCode(opts["qr"])
session.update(user_id=user_id, token=token)
# ---- 预览:顺便挡住「账号已在游戏中」的情况,避免顶号 ----
preview = UserPreview(user_id, token)
if preview.isLogin:
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
# ---- 登录:拿到 playlogId 与 loginDateTime(结算上报要用)----
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
# ---- 结算:★ 一个函数搞定构造 + 发包 + 落库回查 ----
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
extra_data=extra,
dry_run=not opts["send"],
confirm=opts["send"],
cooldown_sec=opts["cooldown"],
verify_landing=not opts["no_verify"],
place_id=PLACE_ID, region_id=REGION_ID,
)
return 0
except ConfirmRequiredError:
return 5
except Exception as exc:
log_error(type(exc).__name__ + ": " + str(exc))
return 1
finally:
# 兜底登出:登录过且还没登出就释放会话,否则账号卡在 isLogin=True
if session["logged_in"]:
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
teardown_logging()
if __name__ == "__main__":
sys.exit(main())