feat: WaHappy v0 —— 舞萌DX 纯网络游戏助手(占位版)
当前仅 upsert.py 可用:扫码登录 -> 上传一局成绩 -> 落库回查 -> 登出。 - api/ 下 40+ 个接口模块已实现,236 项零网络自检全绿 - 上传成绩链路已真机跑通,报文与真机抓包逐字节一致 - 开发文档见 docs 分支;source/ 与 logs/ 不入库
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
# 统一换行符,避免 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
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# ---- 运行产物 ----
|
||||||
|
logs/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# ---- 本地查证用(含 SEGA 二进制 / 反编译代码 / 真机日志,不入库)----
|
||||||
|
source/
|
||||||
|
|
||||||
|
# ---- 文档单独放 docs 分支,主分支不含 ----
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# ---- .NET 构建产物 ----
|
||||||
|
gamehook/bin/
|
||||||
|
gamehook/obj/
|
||||||
|
|
||||||
|
# ---- 编辑器 / 系统 ----
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.user
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# WaHappy
|
||||||
|
|
||||||
|
> **舞萌DX 的游戏助手**
|
||||||
|
>
|
||||||
|
> 宗旨:让舞萌DX的玩家能够更好地游玩游戏,获得快乐。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 这是什么
|
||||||
|
|
||||||
|
WaHappy 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
|
||||||
|
|
||||||
|
它不修改游戏、不注入进程,只是把游戏客户端做的事用脚本重做一遍:
|
||||||
|
扫码登录 → 取数据 → 上传成绩 → 登出。
|
||||||
|
|
||||||
|
> 协议实现全部来自反编译源码 `source/Assembly-CSharp/` 与真机网络日志的双向验证,
|
||||||
|
> 每个关键常量在代码注释里都标了**源码行号**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当前状态:v0,**未完工**
|
||||||
|
|
||||||
|
**v0 目前只是一个占位版本,请以「能不能用」而不是「有多少代码」来判断它。**
|
||||||
|
|
||||||
|
| 模块 | 状态 |
|
||||||
|
|---|---|
|
||||||
|
| **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 |
|
||||||
|
| `buy_ticket.py` | ⚠️ **不可用** —— 购买功能票接口疑似已被服务端拉黑,见下方警告 |
|
||||||
|
| `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** |
|
||||||
|
| **文档** | 📄 在 **`docs` 分支**(主分支不含)—— 开发过程、踩坑记录、实跑验证,内容比代码本身还多 |
|
||||||
|
|
||||||
|
### ⚠️ 关于购买功能票
|
||||||
|
|
||||||
|
`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/` 是本地查证用的反编译源码(含 SEGA 二进制与真机日志),
|
||||||
|
> 代码注释里写的 `source/Assembly-CSharp/xxx.cs:123` 都是**引用出处**,
|
||||||
|
> 需要自己准备一份才能逐行核对。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install requests pycryptodome
|
||||||
|
python selftest.py # 零网络自检,应该 236 项全绿
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 分钟**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 主脚本怎么写
|
||||||
|
|
||||||
|
**核心原则:不要为每个 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
@@ -0,0 +1,497 @@
|
|||||||
|
# -*- 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",
|
||||||
|
]
|
||||||
+301
@@ -0,0 +1,301 @@
|
|||||||
|
# -*- 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/舞萌DX_扫码登录协议技术规范.md §3.4 的抓包验证向量也是
|
||||||
|
#: chipID = "XXXX-XXXXXXXXXXX"。
|
||||||
|
#: 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())
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# -*- 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# -*- 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# -*- 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# -*- 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# -*- 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# -*- 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,704 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
# -*- 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
@@ -0,0 +1,95 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
# -*- 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
@@ -0,0 +1,531 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# -*- 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
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# -*- 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
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# -*- 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# -*- 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# -*- 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
@@ -0,0 +1,259 @@
|
|||||||
|
#!/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": 5.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())
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
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 的硬件标志位
|
||||||
|
///
|
||||||
|
/// 输出:
|
||||||
|
/// <游戏根目录>/MaimaiNetSpy/YYYY-MM-DD_HHmmss.log 人类可读时间线
|
||||||
|
/// <游戏根目录>/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), null);
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// GetResponse —— 解密后的响应
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static void PostGetResponse(ref MemoryStream __result)
|
||||||
|
{
|
||||||
|
_respCount++;
|
||||||
|
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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1146
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
|||||||
|
#!/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 # 跳过上传后的落库回查
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
★ UpsertUserAll 一个函数就把整条结算链路跑完了
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
1. 抓基线快照(没传 snapshot 时自动抓,用 token)
|
||||||
|
2. 从快照推本局 5 只角色(游玩前等级)
|
||||||
|
3. 构造本局成绩明细 userPlaylogList
|
||||||
|
4. 调前置接口 GetUserNewItemListApi
|
||||||
|
5. 组装 upsertUserAll 的 36 个节点(快照基线 + 本局演算 + 差分)
|
||||||
|
6. 合并 extra_data 里的外部 JSON
|
||||||
|
7. 发包前自检
|
||||||
|
8. 冷却 -> 真实发包
|
||||||
|
9. 落库回查(重新拉服务器数据,确认真的写进去了)
|
||||||
|
|
||||||
|
想额外塞几个节点(比如本局拿到的新 KaleidxScope 门)就传 extra_data:
|
||||||
|
|
||||||
|
UpsertUserAll(
|
||||||
|
user_id, login.loginId,
|
||||||
|
login_date_time=login.loginDateTime,
|
||||||
|
auth_date_time=login.dateTime,
|
||||||
|
token=token,
|
||||||
|
extra_data={
|
||||||
|
"userKaleidxScopeList": [{"gateId": 1, "isGateFound": True, ...}],
|
||||||
|
"isNewKaleidxScopeList": "1",
|
||||||
|
},
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
extra_data 接受 dict / JSON 字符串 / JSON 文件路径;
|
||||||
|
键必须是 36 个节点之一,没给位串时会按数组长度自动补并给出提示。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from api import (
|
||||||
|
setup_logging,
|
||||||
|
teardown_logging,
|
||||||
|
log_error,
|
||||||
|
ParseQRCode,
|
||||||
|
UserPreview,
|
||||||
|
UserLogin,
|
||||||
|
UserLogout,
|
||||||
|
LogoutType,
|
||||||
|
UpsertUserAll,
|
||||||
|
ConfirmRequiredError,
|
||||||
|
)
|
||||||
|
from config import PLACE_ID, REGION_ID
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv):
|
||||||
|
"""极简命令行解析:第一个非 -- 开头的参数是二维码。"""
|
||||||
|
opts = {"qr": "", "send": False, "debug": False, "no_verify": False, "cooldown": None}
|
||||||
|
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 == "--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")
|
||||||
|
|
||||||
|
# 会话状态:保证异常时也能登出(否则账号会卡在 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,
|
||||||
|
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())
|
||||||
Reference in New Issue
Block a user