From 1636a7914173df110b170442414cf21bc65c84ec Mon Sep 17 00:00:00 2001 From: Txiaochen Date: Tue, 6 Oct 2026 14:42:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20WaHappy=20v0=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=88=9E=E8=90=8CDX=20=E7=BA=AF=E7=BD=91=E7=BB=9C=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=8A=A9=E6=89=8B=EF=BC=88=E5=8D=A0=E4=BD=8D=E7=89=88?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当前仅 upsert.py 可用:扫码登录 -> 上传一局成绩 -> 落库回查 -> 登出。 - api/ 下 40+ 个接口模块已实现,236 项零网络自检全绿 - 上传成绩链路已真机跑通,报文与真机抓包逐字节一致 - 开发文档见 docs 分支;source/ 与 logs/ 不入库 --- .gitattributes | 7 + .gitignore | 23 + README.md | 208 +++ api/__init__.py | 497 +++++++ api/chime.py | 301 ++++ api/game_charge.py | 53 + api/game_event.py | 60 + api/game_ng_music_id.py | 45 + api/game_ranking.py | 56 + api/game_tournament_info.py | 45 + api/game_weekly_data.py | 45 + api/gamesettings.py | 175 +++ api/log_setup.py | 704 ++++++++++ api/maimai_net.py | 773 +++++++++++ api/ping.py | 95 ++ api/upsert_snapshot.py | 319 +++++ api/upsert_user_all.py | 2515 ++++++++++++++++++++++++++++++++++ api/upsert_user_chargelog.py | 531 +++++++ api/user_activity.py | 48 + api/user_character.py | 46 + api/user_charge.py | 45 + api/user_course.py | 71 + api/user_extend.py | 46 + api/user_favorite.py | 102 ++ api/user_favorite_item.py | 92 ++ api/user_friend_bonus.py | 64 + api/user_getter_common.py | 245 ++++ api/user_ghost.py | 47 + api/user_intimate.py | 43 + api/user_item.py | 124 ++ api/user_kaleidx_scope.py | 75 + api/user_login_bonus.py | 76 + api/user_map.py | 77 ++ api/user_mission.py | 48 + api/user_music.py | 90 ++ api/user_new_item.py | 84 ++ api/user_option.py | 47 + api/user_rating.py | 46 + api/user_recommend_music.py | 64 + api/user_region.py | 44 + api/user_rival.py | 128 ++ api/user_shop_stock.py | 58 + api/userdata.py | 58 + api/userlogin.py | 222 +++ api/userlogout.py | 254 ++++ api/userpreview.py | 156 +++ buy_ticket.py | 259 ++++ gamehook/MaimaiNetSpy.csproj | 63 + gamehook/Main.cs | 376 +++++ selftest.py | 1146 ++++++++++++++++ upsert.py | 169 +++ 51 files changed, 10965 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api/__init__.py create mode 100644 api/chime.py create mode 100644 api/game_charge.py create mode 100644 api/game_event.py create mode 100644 api/game_ng_music_id.py create mode 100644 api/game_ranking.py create mode 100644 api/game_tournament_info.py create mode 100644 api/game_weekly_data.py create mode 100644 api/gamesettings.py create mode 100644 api/log_setup.py create mode 100644 api/maimai_net.py create mode 100644 api/ping.py create mode 100644 api/upsert_snapshot.py create mode 100644 api/upsert_user_all.py create mode 100644 api/upsert_user_chargelog.py create mode 100644 api/user_activity.py create mode 100644 api/user_character.py create mode 100644 api/user_charge.py create mode 100644 api/user_course.py create mode 100644 api/user_extend.py create mode 100644 api/user_favorite.py create mode 100644 api/user_favorite_item.py create mode 100644 api/user_friend_bonus.py create mode 100644 api/user_getter_common.py create mode 100644 api/user_ghost.py create mode 100644 api/user_intimate.py create mode 100644 api/user_item.py create mode 100644 api/user_kaleidx_scope.py create mode 100644 api/user_login_bonus.py create mode 100644 api/user_map.py create mode 100644 api/user_mission.py create mode 100644 api/user_music.py create mode 100644 api/user_new_item.py create mode 100644 api/user_option.py create mode 100644 api/user_rating.py create mode 100644 api/user_recommend_music.py create mode 100644 api/user_region.py create mode 100644 api/user_rival.py create mode 100644 api/user_shop_stock.py create mode 100644 api/userdata.py create mode 100644 api/userlogin.py create mode 100644 api/userlogout.py create mode 100644 api/userpreview.py create mode 100644 buy_ticket.py create mode 100644 gamehook/MaimaiNetSpy.csproj create mode 100644 gamehook/Main.cs create mode 100644 selftest.py create mode 100644 upsert.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..35cb6af --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28f86a1 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7303ed --- /dev/null +++ b/README.md @@ -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 启发(暂无) +- **香水所有群友** —— 和谐帮助 + +*(还有什么以后补充)* + +--- + +## 免责声明 + +本项目仅供**学习与协议研究**使用。使用本工具产生的任何后果由使用者自行承担。 +请遵守游戏服务条款,不要用于破坏游戏公平性。 diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..d7e65df --- /dev/null +++ b/api/__init__.py @@ -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", +] diff --git a/api/chime.py b/api/chime.py new file mode 100644 index 0000000..e1cfd42 --- /dev/null +++ b/api/chime.py @@ -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()) diff --git a/api/game_charge.py b/api/game_charge.py new file mode 100644 index 0000000..14262fa --- /dev/null +++ b/api/game_charge.py @@ -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, + ) diff --git a/api/game_event.py b/api/game_event.py new file mode 100644 index 0000000..d1b9216 --- /dev/null +++ b/api/game_event.py @@ -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, + ) diff --git a/api/game_ng_music_id.py b/api/game_ng_music_id.py new file mode 100644 index 0000000..6376094 --- /dev/null +++ b/api/game_ng_music_id.py @@ -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, + ) diff --git a/api/game_ranking.py b/api/game_ranking.py new file mode 100644 index 0000000..3039dcc --- /dev/null +++ b/api/game_ranking.py @@ -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, + ) diff --git a/api/game_tournament_info.py b/api/game_tournament_info.py new file mode 100644 index 0000000..af0a252 --- /dev/null +++ b/api/game_tournament_info.py @@ -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, + ) diff --git a/api/game_weekly_data.py b/api/game_weekly_data.py new file mode 100644 index 0000000..49d6645 --- /dev/null +++ b/api/game_weekly_data.py @@ -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, + ) diff --git a/api/gamesettings.py b/api/gamesettings.py new file mode 100644 index 0000000..982fde4 --- /dev/null +++ b/api/gamesettings.py @@ -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()) diff --git a/api/log_setup.py b/api/log_setup.py new file mode 100644 index 0000000..a56962d --- /dev/null +++ b/api/log_setup.py @@ -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) diff --git a/api/maimai_net.py b/api/maimai_net.py new file mode 100644 index 0000000..f52a653 --- /dev/null +++ b/api/maimai_net.py @@ -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名># + (source/Assembly-CSharp/Net/NetPacketUtil.cs:12-19) + number : (非 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 == 0 -> "<混淆API名>#" + """ + 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("自检通过") diff --git a/api/ping.py b/api/ping.py new file mode 100644 index 0000000..f587713 --- /dev/null +++ b/api/ping.py @@ -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("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()) diff --git a/api/upsert_snapshot.py b/api/upsert_snapshot.py new file mode 100644 index 0000000..113fab7 --- /dev/null +++ b/api/upsert_snapshot.py @@ -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": , + "userExtend": , + "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 [--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()) diff --git a/api/upsert_user_all.py b/api/upsert_user_all.py new file mode 100644 index 0000000..34ccc16 --- /dev/null +++ b/api/upsert_user_all.py @@ -0,0 +1,2515 @@ +# -*- coding: utf-8 -*- +""" +upsert_user_all.py —— 上传成绩(UpsertUserAllApi) +================================================================================ + +功能:把一局游玩的完整结算数据写进服务器。这是**唯一有副作用**的接口。 + +对应接口:UpsertUserAllApi +请求体 :UserAllRequestVO(7 个顶层字段 + upsertUserAll 的 36 个节点) + source/Assembly-CSharp/Net.VO.Mai2/UserAllRequestVO.cs + source/Assembly-CSharp/Net.Packet.Mai2/PacketUpsertUserAll.cs:22-35 + +-------------------------------------------------------------------------------- +一、顶层结构(7 字段) +-------------------------------------------------------------------------------- + userId 用户 ID + playlogId ★ 必须 == UserLoginApi 的 loginId + isEventMode 是否活动模式 + isFreePlay 是否免费游玩 + loginDateTime ★ 必须 == UserLoginApi 的 loginDateTime + userPlaylogList 本局成绩明细(110 字段 × N 条) + upsertUserAll 36 个节点的全量用户数据 + + playlogId / loginDateTime 都不是本接口产生的,它们来自 UserLoginApi。 + 发错登录 ID 服务端会直接拒;中途换了登录会话必须重新登录。 + +-------------------------------------------------------------------------------- +二、upsertUserAll 的 36 个节点(4 种形态) +-------------------------------------------------------------------------------- +依据 source/Assembly-CSharp/Net.VO.Mai2/UserAll.cs,并用真机报文 +source/2026-10-05.log:123 逐节点核对: + + 单元素数组 4 个 userData / userExtend / userOption / userRatingList + 对象 2 个 userWeeklyData / user2pPlaylog ★ 不是数组 + 位串 11 个 isNew* 长度 == 对应数组条数 + 普通数组 19 个 其余 + + 4 + 2 + 11 + 19 = 36 + +-------------------------------------------------------------------------------- +三、★ 本模块的核心思路:快照基线 + 本局演算 + 差分 +-------------------------------------------------------------------------------- +真机不是把 Get 下来的整表原样回传,而是「只回传变化的那几条」。 +把 source/2026-10-05.log 里同一账号的 Get 侧与 Upsert 侧对照: + + | 节点 | Get 侧(登录时) | Upsert 侧(结算时) | + |-----------------------|----------------------|--------------------------| + | userData.playCount | 1257 | 1258 (+1) | + | userData.currentPlay | 185 | 186 (+1) | + | userMusicDetailList | 143/level4 playCount=7 | 143/level4 playCount=8 | + | userMapList | 550003 distance=1424000 | distance=1435000 (+11000) | + | userCharacterList | 5 只 | 5 只,每只 level +1 | + | userItemList | 1070 条 | [] (无变化) | + | userCourseList | 16 条 | [] (无变化) | + + ★ 结论:**服务器对 userData 是整表覆盖语义** —— 把 Get 下来的旧值原样发回去, + 写入后「看起来什么都没发生」,这就是「returnCode=1 但没落库」的根因。 + 差分节点若为空,服务端也看不出本局有任何变化。 + +所以本模块的流程是: + + 1. 从 snapshot(登录后抓的 Get 响应)建立**基线** + 2. 按本局数据(play)演算出**新状态**,并对差分节点算 (新 - 基线) + 3. 用 patch 做精确覆盖 + 4. 生成 11 条位串(长度自动对齐对应数组) + +-------------------------------------------------------------------------------- +四、安全闸门 +-------------------------------------------------------------------------------- +本接口有副作用,默认**不发包**: + + UpsertUserAll(...) -> 抛 ConfirmRequiredError(未发包) + UpsertUserAll(..., dry_run=True) -> 只构造并返回报文 + UpsertUserAll(..., confirm=True) -> 真实发送 + +全局开关见 config.UPSERT_CONFIRM。 + +-------------------------------------------------------------------------------- +五、调用示例 +-------------------------------------------------------------------------------- + from api import (setup_logging, ParseQRCode, UserPreview, UserLogin, + fetch_upsert_snapshot, UpsertUserAll, build_play_from_config) + + setup_logging("结算") + user_id, token = ParseQRCode(qr) + UserPreview(user_id, token) + login = UserLogin(user_id, token) + + # 1) 登录后立刻抓基线快照(差分的基准) + snapshot = fetch_upsert_snapshot(user_id, token=token) + + # 2) 组装本局数据(默认读 config.py 的 PLAY_*) + play = build_play_from_config() + + # 3) 先看报文 + req = UpsertUserAll(user_id, login.loginId, + login_date_time=login.loginDateTime, + snapshot=snapshot, play=play, dry_run=True) + + # 4) 确认无误后真实上传 + UpsertUserAll(user_id, login.loginId, + login_date_time=login.loginDateTime, + snapshot=snapshot, play=play, confirm=True) +""" + +from __future__ import annotations + +import copy +import json +import os +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone, timedelta +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union + +import config +from config import ( + CLIENT_ID, + PLACE_ID, + PLACE_NAME, + REGION_ID, + PLAY_SPECIAL_NUM, + REGION_NAME, + TIMEOUT_LONG_MS, + UPSERT_CONFIRM, + UPSERT_SEND_COOLDOWN_SEC, +) + +from .log_setup import log_debug, log_error, log_info, log_warning +from .maimai_net import ( + MaimaiNetClient, + local_utc_offset_seconds, + login_date_time_now, + request_dict, +) + + +# ============================================================================= +# 异常 +# ============================================================================= + +class UpsertUserAllError(RuntimeError): + """服务端返回 returnCode != 1。""" + + +class ConfirmRequiredError(RuntimeError): + """有副作用的接口被安全闸门拦下(此时**未发包**)。""" + + +class PatchError(ValueError): + """节点名或节点形态不合法。""" + + +# ============================================================================= +# 一、36 个节点的类型表 +# ============================================================================= +# +# "singleton" : 数组,恒 1 元素(userData / userExtend / userOption / userRatingList) +# "object" : ★ 单个对象,不是数组(userWeeklyData / user2pPlaylog) +# "array" : 普通数组 +# "bits" : 字符串位串,长度 == 对应数组条数 +# +NODE_TYPES: Dict[str, str] = { + # ---- 单元素数组(4)---- + "userData": "singleton", + "userExtend": "singleton", + "userOption": "singleton", + "userRatingList": "singleton", + # ---- 对象,非数组(2)★ ---- + "userWeeklyData": "object", + "user2pPlaylog": "object", + # ---- 普通数组(19)---- + "userCharacterList": "array", + "userGhost": "array", + "userMapList": "array", + "userLoginBonusList": "array", + "userItemList": "array", + "userMusicDetailList": "array", + "userCourseList": "array", + "userFriendSeasonRankingList": "array", + "userChargeList": "array", + "userFavoriteList": "array", + "userActivityList": "array", + "userMissionDataList": "array", + "userGamePlaylogList": "array", + "userIntimateList": "array", + "userShopItemStockList": "array", + "userGetPointList": "array", + "userTradeItemList": "array", + "userFavoritemusicList": "array", + "userKaleidxScopeList": "array", + # ---- 位串(11)---- + "isNewCharacterList": "bits", + "isNewMapList": "bits", + "isNewLoginBonusList": "bits", + "isNewItemList": "bits", + "isNewMusicDetailList": "bits", + "isNewCourseList": "bits", + "isNewFavoriteList": "bits", + "isNewFriendSeasonRankingList": "bits", + "isNewUserIntimateList": "bits", + "isNewFavoritemusicList": "bits", + "isNewKaleidxScopeList": "bits", +} + +#: 位串 -> 对应数组(依据 UserAll.cs 的字段命名与真机报文核对) +BITS_TO_ARRAY: Dict[str, str] = { + "isNewCharacterList": "userCharacterList", + "isNewMapList": "userMapList", + "isNewLoginBonusList": "userLoginBonusList", + "isNewItemList": "userItemList", + "isNewMusicDetailList": "userMusicDetailList", + "isNewCourseList": "userCourseList", + "isNewFavoriteList": "userFavoriteList", + "isNewFriendSeasonRankingList": "userFriendSeasonRankingList", + "isNewUserIntimateList": "userIntimateList", + "isNewFavoritemusicList": "userFavoritemusicList", + "isNewKaleidxScopeList": "userKaleidxScopeList", +} + +ARRAY_TO_BITS: Dict[str, str] = {v: k for k, v in BITS_TO_ARRAY.items()} + +#: 节点在报文里的顺序(照 UserAll.cs 的字段声明顺序) +NODE_ORDER: Tuple[str, ...] = ( + "userData", "userExtend", "userOption", "userCharacterList", "userGhost", + "userMapList", "userLoginBonusList", "userRatingList", "userItemList", + "userMusicDetailList", "userCourseList", "userFriendSeasonRankingList", + "userChargeList", "userFavoriteList", "userActivityList", "userMissionDataList", + "userWeeklyData", "userGamePlaylogList", "user2pPlaylog", "userIntimateList", + "userShopItemStockList", "userGetPointList", "userTradeItemList", + "userFavoritemusicList", "userKaleidxScopeList", + "isNewCharacterList", "isNewMapList", "isNewLoginBonusList", "isNewItemList", + "isNewMusicDetailList", "isNewCourseList", "isNewFavoriteList", + "isNewFriendSeasonRankingList", "isNewUserIntimateList", + "isNewFavoritemusicList", "isNewKaleidxScopeList", +) + +#: 需要算差分的节点 -> 主键字段(用于生成位串的 "0"/"1") +#: +#: ★ 判断依据 = 真机 Get 侧与 Upsert 侧的条数对照(source/2026-10-05.log): +#: +#: | 节点 | Get 侧 | Upsert 侧 | 是否差分 | +#: |---------------------|--------|-----------|----------| +#: | userCharacterList | 312 | 5 | 是 | +#: | userMapList | 104 | 1 | 是 | +#: | userMusicDetailList | 739 | 1 | 是 | +#: | userLoginBonusList | 38 | 0 | 是 | +#: | userCourseList | 16 | 0 | 是 | +#: | userItemList | 1070 | 0 | 是 | +#: | userIntimateList | 2 | 0 | 是 | +#: | userChargeList | 3 | **3** | ★ 否!整表回传 | +#: +#: userChargeList 是**整表回传**(真机原样发了 3 条),不能当差分处理, +#: 否则会被误收敛成空数组。 +DIFF_PRIMARY_KEYS: Dict[str, Tuple[str, ...]] = { + "userCharacterList": ("characterId",), + "userMapList": ("mapId",), + "userLoginBonusList": ("bonusId",), # 本次签到那条 -> 位串 "1" + "userItemList": ("itemKind", "itemId"), + "userMusicDetailList": ("musicId", "level"), + "userCourseList": ("courseId",), + "userFriendSeasonRankingList": ("seasonId",), + "userIntimateList": ("partnerId",), + "userKaleidxScopeList": ("gateId",), + "userShopItemStockList": ("shopItemId",), + "userFavoritemusicList": ("musicId",), +} + +#: 整表回传、不做差分的节点(真机原样发出全部条目) +FULL_TABLE_NODES: Tuple[str, ...] = ( + "userChargeList", +) + +#: 无变化时可以留空的节点 +#: 依据:真机报文 source/2026-10-05.log:123 里 userItemList / userCourseList / +#: userKaleidxScopeList / userIntimateList / userShopItemStockList / +#: userTradeItemList / userFriendSeasonRankingList / userFavoritemusicList / +#: userGhost / userLoginBonusList / userGetPointList 全是 [], +#: 服务端照样返回 returnCode=1 —— 空数组是被接受的。 +MAY_BE_EMPTY_NODES: Tuple[str, ...] = ( + "userGhost", "userMapList", "userLoginBonusList", "userItemList", + "userMusicDetailList", "userCourseList", "userFriendSeasonRankingList", + "userChargeList", "userActivityList", "userMissionDataList", + "userIntimateList", "userShopItemStockList", "userGetPointList", + "userTradeItemList", "userFavoritemusicList", "userKaleidxScopeList", + "userCharacterList", "userRatingList", +) + +#: 默认恒空的节点(本局不产生的数据) +#: +#: ★ userGetPointList 与 userLoginBonusList 都在此列 —— 这两个节点是 +#: **登录奖励发放**才会产生的,普通游玩结算不需要回传: +#: +#: - userGetPointList(里程/点数明细) +#: 真机 39 次 UpsertUserAll 样本里 **25 次为空(64%)**, +#: 只有发生登录奖励/任务奖励时才非空(如 getKind=1 point=200)。 +#: 留空 => 服务端不累加 point,账户点数保持不变。 +#: +#: - userLoginBonusList(签到奖励) +#: 同理,只有本次登录触发了新签到才回传那一条(isCurrent=true)。 +#: 留空 => isNewLoginBonusList 也是空串。 +#: +#: ★ 连带影响(正常现象):留空后 userData.point 的增量就无从解释, +#: 所以 point 保持基线值不变。这是「不发放点数」的必然结果。 +DEFAULT_EMPTY_NODES: Tuple[str, ...] = ( + "userGhost", # 源码 VOExtensions.cs:235 只在 NeedSave 时回传 + "userGetPointList", # 登录奖励才产生;普通结算留空 + "userLoginBonusList", # 登录奖励才产生;普通结算留空 + "userTradeItemList", # 无 Get 来源 + "userFriendSeasonRankingList", # 无 Get 来源 + "userFavoritemusicList", # 源码是条件回传,真机恒 [] +) + + +# ============================================================================= +# 二、字段裁剪表(Get 侧 -> Upsert 侧要丢掉的字段) +# ============================================================================= +# +# 依据:把真机 Get 侧响应与 Upsert 侧报文逐字段比对得出。 + +#: userData:Get 侧有、Upsert 侧没有的字段 +USER_DATA_DROP_KEYS: Tuple[str, ...] = ( + "friendCode", "nameplateId", "trophyId", + "cmLastEmoneyCredit", "cmLastEmoneyBrand", +) + +#: userOption:Upsert 侧没有 tempoVolume +USER_OPTION_DROP_KEYS: Tuple[str, ...] = ("tempoVolume",) + +#: userExtend:Upsert 侧**多出**这两个字段(Get 侧没有),必须补上 +#: 真机 Upsert 侧 16 键 vs Get 侧 14 键,差的就是这两个 +USER_EXTEND_EXTRA_KEYS: Tuple[str, ...] = ("isPhotoAgree", "isGotoCodeRead") + +#: ★ userRating.udemae:服务端 Get 响应里有一批**首字母大写的重复字段** +#: (source/2026-10-05.log:87 的 Get 响应里 npcWinNum 与 NpcWinNum 并存,共 23 键), +#: 但真机 Upsert 报文里**只有小写的 16 键**(source/2026-10-05.log:123)。 +#: 源码 Net.VO.Mai2/UserUdemae.cs 只声明了小写的 16 个字段, +#: 所以这批大写字段是服务端的冗余输出,回传时必须裁掉 —— 否则: +#: ① 报文里出现源码不存在的字段 +#: ② 请求体比真机大 119 字节 +USER_RATING_UDEMAE_DROP_KEYS: Tuple[str, ...] = ( + "MaxLoseNum", "NpcTotalWinNum", "NpcTotalLoseNum", + "NpcMaxWinNum", "NpcMaxLoseNum", "NpcWinNum", "NpcLoseNum", +) + +#: userCharacterList:只保留这 4 个字段 +USER_CHARACTER_KEEP_KEYS: Tuple[str, ...] = ( + "characterId", "level", "awakening", "useCount", +) + +#: userMusicDetailList:丢掉 extNum2 +USER_MUSIC_DETAIL_DROP_KEYS: Tuple[str, ...] = ("extNum2",) + +#: userChargeList:丢掉 extNum1 +USER_CHARGE_DROP_KEYS: Tuple[str, ...] = ("extNum1",) + +#: userMissionDataList / userWeeklyData:丢掉 userId +USER_MISSION_DROP_KEYS: Tuple[str, ...] = ("userId",) + + +# ============================================================================= +# 三、基础工具 +# ============================================================================= + +def _strip_keys(item: Any, drop: Sequence[str]) -> Any: + """从 dict 里删掉指定字段(返回新 dict)。""" + if not isinstance(item, dict): + return item + return {k: v for k, v in item.items() if k not in drop} + + +def _keep_keys(item: Any, keep: Sequence[str]) -> Any: + """只保留指定字段(返回新 dict)。""" + if not isinstance(item, dict): + return item + return {k: v for k, v in item.items() if k in keep} + + +def empty_node(node: str) -> Any: + """按节点类型返回空值。""" + kind = NODE_TYPES.get(node) + if kind == "bits": + return "" + if kind == "object": + return {} + return [] + + +def empty_user_all() -> Dict[str, Any]: + """构造全空的 36 节点骨架。""" + return {node: empty_node(node) for node in NODE_ORDER} + + +def coerce_node_value(node: str, value: Any) -> Any: + """ + 把值强制成该节点应有的形态;传错直接抛 PatchError。 + + ★ 这是刻意的防呆:userWeeklyData / user2pPlaylog 传数组、 + userData 传 dict,都是很容易犯又很难查的错。 + """ + kind = NODE_TYPES.get(node) + if kind is None: + raise PatchError("未知节点名:" + str(node)) + + if value is None: + return empty_node(node) + + if kind == "bits": + if not isinstance(value, str): + raise PatchError("节点 " + node + " 是位串,必须传 str,收到 " + type(value).__name__) + return value + + if kind == "object": + if not isinstance(value, dict): + raise PatchError("节点 " + node + " 是对象(不是数组),必须传 dict,收到 " + + type(value).__name__) + return value + + if kind == "singleton": + if isinstance(value, dict): + return [value] + if isinstance(value, list): + return value + raise PatchError("节点 " + node + " 必须是 dict 或 list,收到 " + type(value).__name__) + + # array + if isinstance(value, dict): + return [value] + if isinstance(value, list): + return value + raise PatchError("节点 " + node + " 必须是 list,收到 " + type(value).__name__) + + +def sync_bits_to_arrays(user_all: Dict[str, Any]) -> None: + """ + 位串长度与对应数组条数不一致时,按 "0" 补齐/截断(就地修改)。 + + ★ 长度不符服务端会误判「哪些是新增」。 + """ + for bits, arr in BITS_TO_ARRAY.items(): + items = user_all.get(arr) + if not isinstance(items, list): + continue + want = len(items) + cur = user_all.get(bits) + if not isinstance(cur, str): + cur = "" + if len(cur) != want: + # 已有多余字符时截断;不足时补 "0" + user_all[bits] = (cur + "0" * want)[:want] if cur else "0" * want + + +# ============================================================================= +# 四、差分 +# ============================================================================= + +def _norm(item: Any) -> str: + """把一条记录规范化成可比较的字符串。""" + return json.dumps(item, ensure_ascii=False, sort_keys=True, default=str) + + +def _primary_key(item: Any, keys: Sequence[str]) -> Tuple: + """取主键值。""" + if not isinstance(item, dict): + return (str(item),) + return tuple(item.get(k) for k in keys) + + +def build_delta( + local: Sequence[Any], + server: Sequence[Any], + compare_keys: Optional[Sequence[str]] = None, +) -> Tuple[List[Any], str]: + """ + 复刻 VOExtensions.BuildListData 的差分语义。 + + 规则(source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:309-324): + 1. 逐字段比较:本地这条与服务器**任何**一条都不完全相同 -> 算「变化」,要回传 + (源码是 nsrc.Except(osrc),退化成全字段比较) + 2. compare_keys 只用于**算位串**,不参与剔除 + 3. 位串:"0" = 主键在服务器已存在(更新);"1" = 全新(新增) + + 参数: + local: 本地新状态(游玩后应有的值) + server: 服务器旧快照(差分基线) + compare_keys: 主键字段名(用于生成位串) + + 返回: + (要回传的条目列表, 位串) + """ + server_norm = {_norm(s) for s in server} + server_keys = set() + if compare_keys: + server_keys = {_primary_key(s, compare_keys) for s in server} + + delta: List[Any] = [] + flags: List[str] = [] + for item in local: + if _norm(item) in server_norm: + continue # 与服务器某条完全一致 -> 无变化 + delta.append(item) + if compare_keys: + flags.append("0" if _primary_key(item, compare_keys) in server_keys else "1") + else: + flags.append("1") + return delta, "".join(flags) + + +def build_favorite_delta( + local: Sequence[Any], + server: Sequence[Any], + assume_missing: Sequence[int] = (), +) -> Tuple[List[Any], str]: + """ + 收藏夹专用差分(复刻 VOExtensions.cs:345-367)。 + + ★ 与普通差分不同:**不做剔除,恒返回完整条目**(真机是 5 条)。 + + 位串规则(按真机 source/2026-10-05.log:123 的 "22212" 反推): + "2" = 服务器侧有对应 itemKind(无论内容是否一致) + "1" = 服务器侧查不到该 itemKind + + ★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 user_favorite.py + 的「补 itemKind」逻辑归一化 —— 真机里 itemKind=10 的响应回的是 + {"itemKind":null, "itemIdList":[]},正因为查不到才自然产生 "1"。 + + 参数: + local: 本地收藏列表(已补 itemKind,5 条) + server: 服务器收藏列表。传**原始** Get 响应时 itemKind=null + 的那条会自然产生 "1"(不需要 assume_missing)。 + assume_missing: 「服务端恒回 null」的 itemKind 集合。 + ★ 仅当 server 是**被归一化过**的列表 + (itemKind 已被补齐、看不出原始 null)时才需要传, + 否则会把本该是 "1" 的那位算成 "2"。 + 调用方在拿不到原始响应时传 FAVORITE_MISSING_KINDS, + 拿得到原始响应时就别传。 + """ + server_kinds = set() + for s in server: + if isinstance(s, dict): + server_kinds.add(s.get("itemKind")) + missing = set(assume_missing) + + out: List[Any] = [] + flags: List[str] = [] + for item in local: + out.append(item) + kind = item.get("itemKind") if isinstance(item, dict) else None + # "1" 的条件(任一成立): + # a) 服务器侧确实没有该 itemKind(原始响应里是 null) + # b) 该 kind 在 assume_missing 里(基线被归一化过,看不出原始 null) + if kind in server_kinds and kind not in missing: + flags.append("2") + else: + flags.append("1") + return out, "".join(flags) + + +# ============================================================================= +# 五、时间格式化 +# ============================================================================= + +def _local_wall_string(login_date_time: int) -> str: + """ + 把 loginDateTime 渲染成真机那种 "YYYY-MM-DD HH:MM:SS.0" 字符串。 + + ★ loginDateTime 的语义是「本地墙上时间被当作 UTC 时刻」, + 所以要用 utcfromtimestamp 还原出墙上时间,而不是 fromtimestamp。 + 真机 userData.lastLoginDate = "2026-10-05 00:19:37.0" + 对应 loginDateTime = 1791159576。 + """ + try: + dt = datetime.fromtimestamp(int(login_date_time), tz=timezone.utc) + except Exception: + dt = datetime.now(timezone.utc) + return dt.strftime("%Y-%m-%d %H:%M:%S.0") + + +#: CST 偏移(source/Assembly-CSharp/Manager/TimeManager.cs:9 CSToffset = 8 小时) +CST_OFFSET_SECONDS = 8 * 3600 + +#: 游玩日志日的分界小时(source/Assembly-CSharp/Manager/TimeManager.cs:77-85 +#: ToLogDateTime:小时 < 7 时把日期回退一天) +LOG_DAY_CUTOFF_HOUR = 7 + + +def _local_date_string(login_date_time: int) -> str: + """ + 渲染成 "YYYY-MM-DD"(对应 TimeManager.GetLogDateString)。 + + ★ 游玩日志日不是自然日:source/Assembly-CSharp/Manager/TimeManager.cs:77-85 + + public static DateTime ToLogDateTime(DateTime dateTime, int hour = 0) + { + DateTime result = new(dateTime.Year, dateTime.Month, dateTime.Day, hour, 0, 0); + if (7 > dateTime.Hour) return result.AddDays(-1.0); // 7 点前算前一天 + return result; + } + + 真机佐证(source/2026-10-05.log:123): + userPlayDate = "2026-10-05 00:20:30.0"(墙上时间 10-05 00:20) + playDate = "2026-10-04" (00:20 < 07:00 -> 回退到 10-04) + """ + wall = datetime.fromtimestamp(int(login_date_time), tz=timezone.utc) + log_day = wall - timedelta(days=1) if wall.hour < LOG_DAY_CUTOFF_HOUR else wall + return log_day.strftime("%Y-%m-%d") + + +def _standard_unix(login_date_time: int) -> int: + """ + 把 loginDateTime 还原成**标准 Unix 时间戳**。 + + ★ loginDateTime 是「本地墙上时间被当作 UTC 时刻」的秒数(见 login_date_time_now), + 所以标准 Unix 秒 = loginDateTime - 本地时区偏移(东八区即 -28800)。 + + 对应 source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:1290 + result.loginDate = TimeManager.GetUnixTime(src.Detail.LastLoginDate); + 而 TimeManager.GetUnixTime 返回的是标准 Unix 秒。 + + 真机佐证:loginDateTime=1791159576 -> 1791159576-28800 = 1791130776, + 真机 loginDate = 1791130777(差 1 秒,源于字符串截断到秒)。 + """ + return int(login_date_time) - local_utc_offset_seconds() + + +# ============================================================================= +# 六、本局数据(play) +# ============================================================================= + +@dataclass +class PlayData: + """ + 一局游玩的全部输入。 + + ★ 默认值全部来自 config.py 的 PLAY_* 系列(取自真机 source/2026-10-05.log:123)。 + 想换歌/换成绩只改 config.py,不用动代码。 + """ + # 曲目 + music_id: int = field(default_factory=lambda: config.PLAY_MUSIC_ID) + music_level: int = field(default_factory=lambda: config.PLAY_MUSIC_LEVEL) + track_no: int = field(default_factory=lambda: config.PLAY_TRACK_NO) + version_int: int = field(default_factory=lambda: config.PLAY_VERSION_INT) + version_str: str = field(default_factory=lambda: config.PLAY_VERSION_STR) + + # 成绩 + achievement: int = field(default_factory=lambda: config.PLAY_ACHIEVEMENT) + deluxscore: int = field(default_factory=lambda: config.PLAY_DELUXSCORE) + score_rank: int = field(default_factory=lambda: config.PLAY_SCORE_RANK) + is_clear: bool = field(default_factory=lambda: config.PLAY_IS_CLEAR) + max_combo: int = field(default_factory=lambda: config.PLAY_MAX_COMBO) + total_combo: int = field(default_factory=lambda: config.PLAY_TOTAL_COMBO) + max_sync: int = field(default_factory=lambda: config.PLAY_MAX_SYNC) + total_sync: int = field(default_factory=lambda: config.PLAY_TOTAL_SYNC) + combo_status: int = field(default_factory=lambda: config.PLAY_COMBO_STATUS) + sync_status: int = field(default_factory=lambda: config.PLAY_SYNC_STATUS) + fast_count: int = field(default_factory=lambda: config.PLAY_FAST_COUNT) + late_count: int = field(default_factory=lambda: config.PLAY_LATE_COUNT) + judge_counts: Dict[str, Dict[str, int]] = field( + default_factory=lambda: copy.deepcopy(config.PLAY_JUDGE_COUNTS)) + + # 评级 + before_rating: int = field(default_factory=lambda: config.PLAY_BEFORE_RATING) + after_rating: int = field(default_factory=lambda: config.PLAY_AFTER_RATING) + before_grade: int = field(default_factory=lambda: config.PLAY_BEFORE_GRADE) + after_grade: int = field(default_factory=lambda: config.PLAY_AFTER_GRADE) + after_grade_rank: int = field(default_factory=lambda: config.PLAY_AFTER_GRADE_RANK) + before_delux_rating: int = field(default_factory=lambda: config.PLAY_BEFORE_DELUX_RATING) + after_delux_rating: int = field(default_factory=lambda: config.PLAY_AFTER_DELUX_RATING) + + # 曲种 + category_index: int = field(default_factory=lambda: config.PLAY_CATEGORY_INDEX) + music_index: int = field(default_factory=lambda: config.PLAY_MUSIC_INDEX) + extra_flag: int = field(default_factory=lambda: config.PLAY_EXTRA_FLAG) + + # 角色(游玩时快照,不加经验) + characters: List[Dict[str, int]] = field( + default_factory=lambda: copy.deepcopy(config.PLAY_CHARACTERS)) + + # 消耗 + play_credit: int = field(default_factory=lambda: config.PLAY_CREDIT) + use_ticket_id: int = field(default_factory=lambda: config.PLAY_USE_TICKET_ID) + play_mode: int = field(default_factory=lambda: config.PLAY_MODE) + is_tutorial: bool = field(default_factory=lambda: config.PLAY_IS_TUTORIAL) + is_event_mode: bool = field(default_factory=lambda: config.PLAY_IS_EVENT_MODE) + is_new_free: bool = field(default_factory=lambda: config.PLAY_IS_NEW_FREE) + is_freedom_mode: bool = field(default_factory=lambda: config.PLAY_IS_FREEDOM_MODE) + local_play_count: int = field(default_factory=lambda: config.PLAY_LOCAL_PLAY_COUNT) + + # 演算增量 + #: 曲目历史游玩次数增量(0 = 不回传 userMusicDetailList) + music_play_count_gain: int = field( + default_factory=lambda: config.PLAY_MUSIC_PLAY_COUNT_GAIN) + map_distance_gain: int = field(default_factory=lambda: config.PLAY_MAP_DISTANCE_GAIN) + character_exp_gain: int = field(default_factory=lambda: config.PLAY_CHARACTER_EXP_GAIN) + + # ---- 里程 / 登录奖励 ---- + # ★ 这两个是**登录奖励发放**才产生的,普通游玩结算不回传(留空)。 + # 真机 39 次 UpsertUserAll 样本里,userGetPointList 有 25 次为空(64%)。 + # 留空 => userData.point 保持基线值不变(不发放点数)。 + + +def build_play_from_config() -> PlayData: + """按 config.py 的 PLAY_* 构造本局数据。""" + return PlayData() + + +def derive_play_characters( + snapshot: Optional[Any] = None, + play: Optional[PlayData] = None, +) -> List[Dict[str, int]]: + """ + 从**服务器刚下发的快照**推出本局的 5 只角色(含游玩前等级)。 + + ★ 为什么需要它: + config.PLAY_CHARACTERS 是手填的静态值,账号一升级就过期; + 用过期值会得到与 userCharacterList 对不上的 characterLevelN。 + 快照是登录后立刻抓的服务器真值,永远是最新的。 + + 数据来源(都在 Get 响应里): + userData.charaSlot 本局 5 个角色槽的 characterId(顺序即 slot1..5) + source/Assembly-CSharp/Net.VO.Mai2/UserData.cs + userCharacterList 每只角色的 level / awakening + GetUserCharacterApi 响应 + + 参数: + snapshot: fetch_upsert_snapshot 的返回值(或任何含 userData / + userCharacterList 的字典)。 + play: 当前的 PlayData;快照里查不到的槽位回退到它同位置的配置。 + + 返回: + 5 条 {"characterId","level","awakening"},**游玩前**的值(不加经验)。 + 快照里既没有该角色、配置也没有时,该槽位为 characterId=0。 + """ + p = play or build_play_from_config() + fallback = list(p.characters or [])[:5] + + snap = normalize_snapshot(snapshot) if snapshot else {} + + slot_ids: List[int] = [] + levels: Dict[int, Dict[str, int]] = {} + try: + # userData 是 singleton 节点(列表 1 元素);也容忍裸 dict 形态 + data = snap.get("userData") or [] + if isinstance(data, dict): + data = [data] + if isinstance(data, list) and data and isinstance(data[0], dict): + raw_slots = data[0].get("charaSlot") or [] + if isinstance(raw_slots, (list, tuple)): + slot_ids = [int(x or 0) for x in raw_slots[:5]] + chars = snap.get("userCharacterList") or [] + if isinstance(chars, dict): + chars = [chars] + if isinstance(chars, list): + for c in chars: + if isinstance(c, dict) and c.get("characterId") is not None: + levels[int(c["characterId"])] = c + except Exception: + slot_ids = [] + levels = {} + + out: List[Dict[str, int]] = [] + for i in range(5): + cid = slot_ids[i] if i < len(slot_ids) else 0 + src = levels.get(cid) + if src is not None: + out.append({ + "characterId": cid, + "level": int(src.get("level", 0) or 0), + "awakening": int(src.get("awakening", 0) or 0), + }) + continue + fb = fallback[i] if i < len(fallback) and isinstance(fallback[i], dict) else {} + out.append({ + "characterId": int(fb.get("characterId", 0) or 0), + "level": int(fb.get("level", 0) or 0), + "awakening": int(fb.get("awakening", 0) or 0), + }) + return out + + +def build_post_play_user_data( + snapshot: Optional[Any] = None, + play: Optional[PlayData] = None, + *, + login_date_time: int, + auth_date_time: Optional[int] = None, + place_id: int = PLACE_ID, + place_name: str = PLACE_NAME, + region_id: int = REGION_ID, + region_name: str = REGION_NAME, + client_id: str = CLIENT_ID, + play_date_time: Optional[int] = None, +) -> Dict[str, Any]: + """ + 构造**游玩后**的 userData(单个 dict,不是数组)。 + + ★ 用途:GetUserNewItemListApi 需要传「游玩后」的档案。 + 真机实证(source/2026-10-05-new.log): + GetUserNewItemList 的 userData[0] 与 UpsertUserAll 的 userData[0] + **完全一致**(playCount=1258、point=4460、lastPlayDate=19:22:53.0)。 + + 返回: + 游玩后的 userData 字典(可直接放进 [ ] 传给 UserNewItemList) + """ + p = play or build_play_from_config() + ua = build_user_all( + snapshot=snapshot, play=p, + login_date_time=login_date_time, auth_date_time=auth_date_time, + place_id=place_id, place_name=place_name, + region_id=region_id, region_name=region_name, client_id=client_id, + play_date_time=play_date_time, + ) + data = ua.get("userData") or [] + return dict(data[0]) if data else {} + + +# ============================================================================= +# 七、userPlaylogList(110 字段成绩明细) +# ============================================================================= + +#: UserPlaylog 的**全部**字段,顺序照 source/Assembly-CSharp/Net.VO.Mai2/UserPlaylog.cs +#: ★ 一个都不能省:把真机报文与 C# 声明做集合比对,两个方向都是 0 差异。 +PLAYLOG_FIELDS: Tuple[str, ...] = ( + "userId", "orderId", "playlogId", "version", "placeId", "placeName", + "loginDate", "playDate", "userPlayDate", "type", "musicId", "level", + "trackNo", "vsMode", "vsUserName", "vsStatus", "vsUserRating", + "vsUserAchievement", "vsUserGradeRank", "vsRank", "playerNum", + "playedUserId1", "playedUserName1", "playedMusicLevel1", + "playedUserId2", "playedUserName2", "playedMusicLevel2", + "playedUserId3", "playedUserName3", "playedMusicLevel3", + "characterId1", "characterLevel1", "characterAwakening1", + "characterId2", "characterLevel2", "characterAwakening2", + "characterId3", "characterLevel3", "characterAwakening3", + "characterId4", "characterLevel4", "characterAwakening4", + "characterId5", "characterLevel5", "characterAwakening5", + "achievement", "deluxscore", "scoreRank", "maxCombo", "totalCombo", + "maxSync", "totalSync", + "tapCriticalPerfect", "tapPerfect", "tapGreat", "tapGood", "tapMiss", + "holdCriticalPerfect", "holdPerfect", "holdGreat", "holdGood", "holdMiss", + "slideCriticalPerfect", "slidePerfect", "slideGreat", "slideGood", "slideMiss", + "touchCriticalPerfect", "touchPerfect", "touchGreat", "touchGood", "touchMiss", + "breakCriticalPerfect", "breakPerfect", "breakGreat", "breakGood", "breakMiss", + "isTap", "isHold", "isSlide", "isTouch", "isBreak", + "isCriticalDisp", "isFastLateDisp", "fastCount", "lateCount", + "isAchieveNewRecord", "isDeluxscoreNewRecord", "comboStatus", "syncStatus", + "isClear", "beforeRating", "afterRating", "beforeGrade", "afterGrade", + "afterGradeRank", "beforeDeluxRating", "afterDeluxRating", + "isPlayTutorial", "isEventMode", "isFreedomMode", "playMode", "isNewFree", + "trialPlayAchievement", "extNum1", "extNum2", "extNum4", + "extBool1", "extBool2", "extBool3", +) + +#: ★ 类型是 string 的字段(默认值必须是空串,不是 0) +PLAYLOG_STRING_FIELDS: Tuple[str, ...] = ( + "placeName", "playDate", "userPlayDate", + "vsUserName", "playedUserName1", "playedUserName2", "playedUserName3", +) + +#: ★ 名字不带 is 前缀但类型是 bool 的字段(早期实现用 startswith("is") 判断会漏掉) +PLAYLOG_BOOL_FIELDS: Tuple[str, ...] = ( + "isTap", "isHold", "isSlide", "isTouch", "isBreak", + "isCriticalDisp", "isFastLateDisp", + "isAchieveNewRecord", "isDeluxscoreNewRecord", "isClear", + "isPlayTutorial", "isEventMode", "isFreedomMode", "isNewFree", + "extBool1", "extBool2", "extBool3", +) + +#: 判定类型与判定位(顺序固定) +JUDGE_TYPES: Tuple[str, ...] = ("tap", "hold", "slide", "touch", "break") +JUDGE_BOXES: Tuple[str, ...] = ("CriticalPerfect", "Perfect", "Great", "Good", "Miss") + + +def build_user_playlog( + playlog_id: int, + login_date: int, + play: Optional[PlayData] = None, + *, + place_id: int = PLACE_ID, + place_name: str = PLACE_NAME, + client_id: str = CLIENT_ID, + user_play_date: Optional[str] = None, + play_date: Optional[int] = None, +) -> Dict[str, Any]: + """ + 构造一条 110 字段的 userPlaylog。 + + 参数: + playlog_id: ★ 必须 == UserLoginApi 的 loginId + play_date: ★ 本局**游玩时刻**(loginDateTime 同格式,即 now + 时区偏移)。 + 用于 playDate(日志日)与 userPlayDate(墙上串)。 + 不传则退回 login_date(确定性更强,适合离线对拍)。 + ★ 为什么要分开:真机 playDate / userPlayDate 是「打完那一刻」, + 而 loginDateTime 是「刷卡登录那一刻」,两者相差一局的时间 + (source/2026-10-05.log: 登录 00:19:37 -> 游玩 00:20:30)。 + login_date: 登录会话时刻(用于推导 loginDate)。 + ★ 真机里 userPlaylog.loginDate 是**游玩会话的开始时刻** + (source/2026-10-05.log:123 是 1791130777, + 比 UserLoginApi 的 loginDateTime=1791159576 早约 8 小时, + 实为上一局的会话时刻),纯网络脚本没有这个值, + 这里用传入值推导即可;服务端不以此为准。 + play: 本局数据(可选,默认按 config.PLAY_* 构造) + place_id: 店铺 ID + place_name: 店铺名 + client_id: 机台短值 + user_play_date: 游玩时刻字符串(可选,默认由 login_date 推导) + + 返回: + 110 字段的 dict + + 自动推导的规则(依据 source/.../Net.VO.Mai2/VOExtensions.cs): + userId 恒为 0(VOExtensions.cs:1289) + musicId >= 100000(宴谱) -> level = 10(VOExtensions.cs:1300-1303) + isTap/isHold/... 由该类型判定总数是否为 0 推导(VOExtensions.cs:1403-1407) + trialPlayAchievement = -1(本局没有试玩谱)(VOExtensions.cs:1441-1445) + extNum4 = 曲种索引(与 userExtend.categoryIndex 冗余) + """ + p = play or build_play_from_config() + + level = 10 if p.music_id >= 100000 else p.music_level + play_moment = int(play_date) if play_date else int(login_date) + user_play_date = user_play_date or _local_wall_string(play_moment) + + # ★ 字符串字段默认空串、其余默认 0(真机报文里 vsUserName/playedUserNameN 都是 "") + row: Dict[str, Any] = { + name: ("" if name in PLAYLOG_STRING_FIELDS else 0) + for name in PLAYLOG_FIELDS + } + + # ---- 基础 ---- + row["userId"] = 0 # VOExtensions.cs:1289 + row["orderId"] = 0 + row["playlogId"] = playlog_id + row["version"] = p.version_int + row["placeId"] = place_id + row["placeName"] = place_name + # ★ loginDate 是**标准 Unix 秒**(VOExtensions.cs:1290 GetUnixTime 的返回值), + # 不是 loginDateTime(后者是「墙上时间当 UTC」)。 + row["loginDate"] = _standard_unix(login_date) + # ★ playDate 是游玩**日志日**(7 点前算前一天),不是自然日; + # 取的是**游玩时刻**,不是登录时刻。 + row["playDate"] = _local_date_string(play_moment) + row["userPlayDate"] = user_play_date + row["type"] = 0 + row["musicId"] = p.music_id + row["level"] = level + row["trackNo"] = p.track_no + + # ---- 对战(单人局全 0)---- + row["playerNum"] = 1 + + # ---- 角色快照 1..5 ---- + # ★ characterLevelN 是**游玩前**的等级,不要加本局经验 —— 这一点极容易搞错: + # source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:1361 + # result.characterLevel1 = (int)gameScore.CharaLevel[0]; + # gameScore.CharaLevel 是**本次开始游玩时**灌进去的快照,整局不再变; + # 游玩后的等级只出现在 upsertUserAll.userCharacterList 里。 + # + # ★ 两个真机会话实证(Get 侧 / playlog / upsertUserCharacterList 三者对照): + # + # source/2026-10-05-new.log:31,123(19:21 会话) + # GetUserCharacterApi : 395=3801 101=4456 135 135 135 + # userPlaylogList[0] : 3801 4456 135 135 135 ← 与 Get 侧相同 = 游玩前 + # userCharacterList : 395=3802 101=4457 136 136 136 ← 游玩后(+1) + # + # MaimaiNetSpy 2026-10-05_222919(22:31 会话) + # GetUserCharacterApi : 395=3802 101=4457 136 136 136 + # userPlaylogList[0] : 3802 4457 136 136 136 ← 与 Get 侧相同 = 游玩前 + # userCharacterList : 395=3803 101=4458 137 137 137 ← 游玩后(+1) + # + # 所以 p.characters 里放的必须是**游玩前**的值 + # (config.PLAY_CHARACTERS 的注释也是这么写的),这里原样透传即可。 + # 推荐做法:用 derive_play_characters(snapshot) 直接从服务器刚下发的 + # userData.charaSlot + userCharacterList 推出来,省得手填过期。 + chars = list(p.characters or [])[:5] + for i in range(5): + c = chars[i] if i < len(chars) else {} + row["characterId" + str(i + 1)] = int(c.get("characterId", 0) or 0) + # 游玩前等级(不加经验;经验只体现在 userCharacterList) + row["characterLevel" + str(i + 1)] = int(c.get("level", 0) or 0) + row["characterAwakening" + str(i + 1)] = int(c.get("awakening", 0) or 0) + + # ---- 成绩 ---- + row["achievement"] = p.achievement + row["deluxscore"] = p.deluxscore + row["scoreRank"] = p.score_rank + row["maxCombo"] = p.max_combo + row["totalCombo"] = p.total_combo + row["maxSync"] = p.max_sync + row["totalSync"] = p.total_sync + row["comboStatus"] = p.combo_status + row["syncStatus"] = p.sync_status + row["isClear"] = bool(p.is_clear) + row["fastCount"] = p.fast_count + row["lateCount"] = p.late_count + + # ---- 判定明细(5 类型 × 5 判定位)---- + for jtype in JUDGE_TYPES: + counts = (p.judge_counts or {}).get(jtype, {}) or {} + total = 0 + for box in JUDGE_BOXES: + value = int(counts.get(box, 0) or 0) + row[jtype + box] = value + total += value + # isTap / isHold / ... 由该类型是否有 note 推导 + flag_name = "is" + jtype.capitalize() + if flag_name in row: + row[flag_name] = (total > 0) + + # ---- 显示开关(真机均为 true)---- + row["isCriticalDisp"] = True + row["isFastLateDisp"] = True + + # ---- 评级 ---- + row["beforeRating"] = p.before_rating + row["afterRating"] = p.after_rating + row["beforeGrade"] = p.before_grade + row["afterGrade"] = p.after_grade + row["afterGradeRank"] = p.after_grade_rank + row["beforeDeluxRating"] = p.before_delux_rating + row["afterDeluxRating"] = p.after_delux_rating + + # ---- 模式 ---- + row["isPlayTutorial"] = bool(p.is_tutorial) + row["isEventMode"] = bool(p.is_event_mode) + row["isFreedomMode"] = bool(p.is_freedom_mode) + row["playMode"] = p.play_mode + row["isNewFree"] = bool(p.is_new_free) + + # ---- 试玩(本局没有试玩谱)---- + row["trialPlayAchievement"] = -1 + + # ---- 扩展位 ---- + row["extNum1"] = 0 + row["extNum2"] = 0 + row["extNum4"] = p.category_index # 与 userExtend.categoryIndex 冗余 + row["extBool1"] = False + row["extBool2"] = False + row["extBool3"] = False + + # 类型自检:bool 字段必须是 bool + for name in PLAYLOG_BOOL_FIELDS: + row[name] = bool(row.get(name, False)) + + return row + + +def validate_user_playlog(playlog: Dict[str, Any]) -> List[str]: + """ + 按源码硬约束自查一条 playlog,返回问题列表(空 = 合规)。 + + 字段填错不会报错,却会被服务端当成异常成绩。检查项: + 1. 字段数量 == 110 + 2. 各类型判定数之和 == totalCombo + 3. maxCombo <= 判定总数 + 4. achievement > 0 时不应全是 Miss + 5. isClear=True 时 achievement 不能为 0 + 6. comboStatus=1(全连)时 maxCombo == 判定总数 + 7. extNum4 == userExtend.categoryIndex(由调用方核对,这里只提示非 0) + """ + problems: List[str] = [] + + missing = [f for f in PLAYLOG_FIELDS if f not in playlog] + if missing: + problems.append("缺少字段:" + ", ".join(missing[:10]) + + ("..." if len(missing) > 10 else "")) + extra = [k for k in playlog if k not in PLAYLOG_FIELDS] + if extra: + problems.append("多出字段:" + ", ".join(extra[:10])) + + total_notes = 0 + for jtype in JUDGE_TYPES: + for box in JUDGE_BOXES: + total_notes += int(playlog.get(jtype + box, 0) or 0) + + total_combo = int(playlog.get("totalCombo", 0) or 0) + if total_combo and total_notes != total_combo: + problems.append("各类型判定数之和 " + str(total_notes) + + " != totalCombo " + str(total_combo)) + + max_combo = int(playlog.get("maxCombo", 0) or 0) + if total_notes and max_combo > total_notes: + problems.append("maxCombo " + str(max_combo) + " > 判定总数 " + str(total_notes)) + + achievement = int(playlog.get("achievement", 0) or 0) + if achievement > 0: + miss = sum(int(playlog.get(jtype + "Miss", 0) or 0) for jtype in JUDGE_TYPES) + if total_notes and miss == total_notes: + problems.append("achievement>0 但全是 Miss,数值矛盾") + + if bool(playlog.get("isClear")) and achievement == 0: + problems.append("isClear=True 但 achievement=0,数值矛盾") + + if int(playlog.get("comboStatus", 0) or 0) == 1 and max_combo != total_notes: + problems.append("comboStatus=1(全连)但 maxCombo != 判定总数") + + if int(playlog.get("extNum4", 0) or 0) == 0: + problems.append("extNum4(曲种索引)为 0,应等于 userExtend.categoryIndex") + + if not playlog.get("placeName"): + problems.append("placeName 为空(店铺名需在 config.PLACE_NAME 配置)") + + return problems + + +# ============================================================================= +# 八、userGamePlaylogList +# ============================================================================= + +def calc_play_special(calc_special_num: Optional[int] = None) -> int: + """ + 复刻 VOExtensions.cs:846-855 的 playSpecial 算法。 + + 源码: + int max = 1037933; + int num2 = Random.Range(1, max) * 2069; + num2 += GameManager.CalcSpecialNum(); + int num3 = 0; + for (int i = 0; i < 32; i++) { num3 <<= 1; num3 += num2 % 2; num2 >>= 1; } + playSpecial = num3; + + 参数: + calc_special_num: 机台硬件标志位掩码(GameManager.CalcSpecialNum)。 + 默认取 config.PLAY_SPECIAL_NUM = 1024。 + + 返回: + int32 范围的 playSpecial。 + + ★ 关于 CalcSpecialNum 的取值(源码 GameManager.cs:332-378): + 它是按位或出来的机台特征掩码,其中 0x400 无条件置位, + 另外 0x40 / 0x80 / 0x100 三位的条件是「**不**包含某特征」—— + 真机是一台标准 ACA + GTX + i3-8100 机台,这三位恰好都为 0, + 于是 CalcSpecialNum 恒等于 0x400 = 1024。 + + ★ 反推验证:真机 playSpecial = 892014272 + 位反转32(892014272) = 57726124 = 27900 * 2069 + 1024 + 余数正是 1024,与 gamehook 抓到的 CalcSpecialNum 完全吻合。 + """ + import random + special = PLAY_SPECIAL_NUM if calc_special_num is None else int(calc_special_num) + num2 = random.randint(1, 1037932) * 2069 + num2 += special + num3 = 0 + for _ in range(32): + num3 <<= 1 + num3 += num2 % 2 + num2 >>= 1 + # 截断到 int32 + if num3 >= 2 ** 31: + num3 -= 2 ** 32 + return num3 + + +def build_user_game_playlog( + playlog_id: int, + login_date: int, + play: Optional[PlayData] = None, + *, + client_id: str = CLIENT_ID, + play_special: Optional[int] = None, + play_date: Optional[int] = None, +) -> Dict[str, Any]: + """ + 构造 userGamePlaylogList 的一条(13 字段)。 + + ★ playDate 用的是**上报那一刻**的墙上时间(真机实证: + source/2026-10-05.log 里 userGamePlaylogList[0].playDate = "00:20:57.0", + 与 UpsertUserAllApi 请求发出的时刻一致,也和 userData.lastPlayDate 相同)。 + 不传 play_date 时退回 login_date。 + + 真机样本(source/2026-10-05.log:123): + {"playlogId":..,"version":"1.56.00","playDate":"2026-10-05 00:20:57.0", + "playMode":0,"useTicketId":-1,"playCredit":1,"playTrack":1, + "clientId":"TESTCLIENT1","isPlayTutorial":false,"isEventMode":false, + "isNewFree":false,"playCount":8,"playSpecial":55951502,"playOtherUserId":0} + """ + p = play or build_play_from_config() + return { + "playlogId": playlog_id, + "version": p.version_str, + "playDate": _local_wall_string(play_date or login_date), + "playMode": p.play_mode, + "useTicketId": p.use_ticket_id, + "playCredit": p.play_credit, + "playTrack": p.track_no, + "clientId": client_id, + "isPlayTutorial": bool(p.is_tutorial), + "isEventMode": bool(p.is_event_mode), + "isNewFree": bool(p.is_new_free), + "playCount": p.local_play_count, + "playSpecial": calc_play_special() if play_special is None else int(play_special), + "playOtherUserId": 0, + } + + +# ============================================================================= +# 九、默认节点构造 +# ============================================================================= + +def default_user_weekly_data(login_date: int) -> Dict[str, Any]: + """ + 构造 userWeeklyData(★ 对象,不是数组)。 + + 真机样本: + {"lastLoginWeek":"2026-09-28 04:00:00","beforeLoginWeek":"2026-09-21 04:00:00", + "friendBonusFlag":true} + 周界为**周一 04:00**(真机 updateDate 就是 2026-09-28 04:00:00,那天是周一)。 + """ + def week_start(unix_seconds: int) -> str: + dt = datetime.fromtimestamp(int(unix_seconds), tz=timezone.utc) - timedelta(hours=4) + dt = dt - timedelta(days=dt.weekday()) + return dt.strftime("%Y-%m-%d") + " 04:00:00" + + this_week = week_start(login_date) + prev_week = week_start(login_date - 7 * 86400) + return { + "lastLoginWeek": this_week, + "beforeLoginWeek": prev_week, + "friendBonusFlag": True, + } + + +def default_user_2p_playlog() -> Dict[str, Any]: + """ + 构造 user2pPlaylog(★ 对象,不是数组)。 + + 真机样本: + {"userId1":0,"userId2":0,"userName1":"","userName2":"", + "regionId":0,"placeId":0,"user2pPlaylogDetailList":[]} + """ + return { + "userId1": 0, + "userId2": 0, + "userName1": "", + "userName2": "", + "regionId": 0, + "placeId": 0, + "user2pPlaylogDetailList": [], + } + + +# ============================================================================= +# 十、快照归一化 +# ============================================================================= + +def _extract_from_get_responses(raw: Dict[str, Any]) -> Dict[str, Any]: + """ + 把「各 Get 接口的原始响应」整理成节点字典(含字段裁剪)。 + + 参数: + raw: 形如 + { + "userData": , + "userExtend": , + "userMapList": , + ... + } + 也接受 fetch_upsert_snapshot() 已经整理好的节点字典(幂等)。 + + 返回: + 节点名 -> 节点值(已裁剪) + """ + out: Dict[str, Any] = {} + + # ---- 单元素数组 ---- + if "userData" in raw: + src = raw["userData"] + ud = src.get("userData") if isinstance(src, dict) and "userData" in src else src + if isinstance(ud, dict): + ud = _strip_keys(ud, USER_DATA_DROP_KEYS) + # banState 在 Get 侧是顶层字段,回传时要搬进 userData 内部 + if isinstance(src, dict) and "banState" in src and "banState" not in ud: + ud["banState"] = src.get("banState") + out["userData"] = [ud] + + if "userExtend" in raw: + src = raw["userExtend"] + ue = src.get("userExtend") if isinstance(src, dict) and "userExtend" in src else src + if isinstance(ue, dict): + out["userExtend"] = [ue] + + if "userOption" in raw: + src = raw["userOption"] + uo = src.get("userOption") if isinstance(src, dict) and "userOption" in src else src + if isinstance(uo, dict): + out["userOption"] = [_strip_keys(uo, USER_OPTION_DROP_KEYS)] + + if "userRatingList" in raw: + src = raw["userRatingList"] + ur = src.get("userRating") if isinstance(src, dict) and "userRating" in src else src + if isinstance(ur, dict): + ur = copy.deepcopy(ur) + # ★ 裁掉 udemae 里服务端冗余的大写重复字段(真机 Upsert 侧没有) + udemae = ur.get("udemae") + if isinstance(udemae, dict): + ur["udemae"] = _strip_keys(udemae, USER_RATING_UDEMAE_DROP_KEYS) + out["userRatingList"] = [ur] + + # ---- 对象 ---- + if "userWeeklyData" in raw: + src = raw["userWeeklyData"] + uw = src.get("userWeeklyData") if isinstance(src, dict) and "userWeeklyData" in src else src + if isinstance(uw, dict): + out["userWeeklyData"] = _strip_keys(uw, USER_MISSION_DROP_KEYS) + + # ★ userWeeklyData 的另一个来源:GetUserMissionDataApi 的响应里就带着它 + # (UserMissionResponseVO.cs:10 public UserWeeklyData userWeeklyData;) + # 真机值 = 服务器下发的周界,**不是本地算的**: + # GetGameWeeklyDataApi.updateDate = "2026-09-28 04:00:00" + # Upsert 侧 lastLoginWeek = "2026-09-28 04:00:00" ← 完全一致 + # 依据 source/Assembly-CSharp/Manager/UserData.cs:1040-1049 + # WeeklyData.LastLoginWeek = gameWeeklyData.updateDate; + # 所以优先用快照里的值;只有拿不到时才退回本地推导。 + if "userWeeklyData" not in out and "userMissionDataList" in raw: + src = raw["userMissionDataList"] + if isinstance(src, dict): + uw = src.get("userWeeklyData") + if isinstance(uw, dict) and uw: + out["userWeeklyData"] = _strip_keys(uw, USER_MISSION_DROP_KEYS) + + if "user2pPlaylog" in raw: + src = raw["user2pPlaylog"] + u2 = src.get("user2pPlaylog") if isinstance(src, dict) and "user2pPlaylog" in src else src + if isinstance(u2, dict): + out["user2pPlaylog"] = u2 + + # ---- 普通数组(键名 -> 响应里的列表键名)---- + array_map = { + "userCharacterList": "userCharacterList", + "userGhost": "userGhostList", + "userMapList": "userMapList", + "userLoginBonusList": "userLoginBonusList", + "userItemList": "userItemList", + "userMusicDetailList": "userMusicList", + "userCourseList": "userCourseList", + "userChargeList": "userChargeList", + "userFavoriteList": "userFavorite", + "userActivityList": "userActivity", + "userMissionDataList": "userMissionDataList", + "userIntimateList": "userIntimateList", + "userShopItemStockList": "userShopStockList", + "userFavoritemusicList": "userFavoriteItemList", + "userKaleidxScopeList": "userKaleidxScopeList", + "userFriendSeasonRankingList": "userFriendSeasonRankingList", + } + for node, list_key in array_map.items(): + if node not in raw: + continue + src = raw[node] + if isinstance(src, list): + items = src + elif isinstance(src, dict): + if list_key == "userMusicList": + # ★ 曲目成绩有两种形态,都要支持: + # a) 接口原始响应:两层嵌套(UserMusic.cs 外层按曲分组, + # 内层 userMusicDetailList 才是 (musicId, level) 条目) + # b) UserMusicDetailAll() 的产物:已摊平成单条 + # 判据:元素里带 "userMusicDetailList" 键 -> 嵌套;否则已摊平。 + groups = src.get("userMusicList") or [] + nested = any( + isinstance(g, dict) and "userMusicDetailList" in g for g in groups + ) + if nested: + items = [] + for g in groups: + if isinstance(g, dict): + items.extend(g.get("userMusicDetailList") or []) + elif isinstance(g, list): + items.extend(g) + else: + items = list(groups) + else: + items = src.get(list_key) + # ★ 兜底:取数脚本返回的字典有时用**节点名**作键 + # (如 UserFavoriteAll -> {"userFavoriteList": [...], "_raw": [...]}, + # 而 GetUserFavoriteApi 的原始响应是 {"userFavorite": {...}})。 + if items is None: + items = src.get(node) + if items is None: + items = [] + if isinstance(items, dict): + items = [items] + else: + continue + + if node == "userCharacterList": + items = [_keep_keys(i, USER_CHARACTER_KEEP_KEYS) for i in items] + elif node == "userMusicDetailList": + items = [_strip_keys(i, USER_MUSIC_DETAIL_DROP_KEYS) for i in items] + elif node == "userChargeList": + items = [_strip_keys(i, USER_CHARGE_DROP_KEYS) for i in items] + elif node == "userMissionDataList": + items = [_strip_keys(i, USER_MISSION_DROP_KEYS) for i in items] + out[node] = list(items) + + # ★ 收藏夹的**原始**基线(不补 itemKind):真机 itemKind=10 的响应回的是 + # {"itemKind":null},位串里的 "1" 就来自它。单独存一份供差分用。 + if isinstance(raw.get("_raw_favorite"), list): + out["_raw_favorite"] = list(raw["_raw_favorite"]) + else: + fav_src = raw.get("userFavoriteList") + if isinstance(fav_src, dict): + if isinstance(fav_src.get("_raw"), list): + out["_raw_favorite"] = list(fav_src["_raw"]) + elif isinstance(fav_src.get("userFavoriteList"), list): + out["_raw_favorite"] = list(fav_src["userFavoriteList"]) + elif isinstance(fav_src, list): + out["_raw_favorite"] = list(fav_src) + + return out + + +#: 真机 isNewFavoriteList 里代表「服务器侧查不到该 itemKind」的取值。 +#: 实测两个样本: +#: source/2026-10-05.log:123 -> "22212"(itemKind=10 那条是 null) +#: MaimaiNetSpy 抓的 22:32:21 那次 -> "22212"(同上) +#: 即第 4 位(itemKind=10,搭档)**稳定为 "1"** —— 服务端对该 kind 恒回 null。 +FAVORITE_MISSING_KINDS: Tuple[int, ...] = (10,) + + +def normalize_snapshot(snapshot: Optional[Any]) -> Dict[str, Any]: + """ + 把各种形态的快照统一成「节点字典」。 + + 接受: + None -> {} + 节点字典(36 节点名) -> 原样(做一次形态强制) + {"upsertUserAll": {...}} -> 取内层 + {"_responses": {...}} -> 取 _responses + 各 Get 接口的原始响应字典 -> 走 _extract_from_get_responses + """ + if snapshot is None: + return {} + + if isinstance(snapshot, str): + with open(snapshot, "r", encoding="utf-8") as fp: + snapshot = json.load(fp) + + if not isinstance(snapshot, dict): + raise PatchError("snapshot 形态不认识:" + type(snapshot).__name__) + + if "upsertUserAll" in snapshot and isinstance(snapshot["upsertUserAll"], dict): + snapshot = snapshot["upsertUserAll"] + if "_responses" in snapshot and isinstance(snapshot["_responses"], dict): + snapshot = snapshot["_responses"] + + # ★ 判断是「已是节点字典」还是「原始响应集合」。 + # 两者键名高度重叠(userData / userExtend / userMapList … 都既是节点名, + # 也是 Get 响应的外层键),所以**只能按值的形态**区分: + # 节点字典 userData -> list(单元素数组)、userMapList -> list + # 原始响应集合 userData -> dict(含 userId/userData/banState) + # userMapList -> dict(含 userMapList 列表) + if _looks_like_node_dict(snapshot): + out: Dict[str, Any] = {} + for k, v in snapshot.items(): + if k in NODE_TYPES: + out[k] = coerce_node_value(k, v) + elif k.startswith("_"): + out[k] = v # 保留 _raw_favorite 这类内部键 + return out + + return _extract_from_get_responses(snapshot) + + +def _looks_like_node_dict(snapshot: Dict[str, Any]) -> bool: + """ + 按**值的形态**判断 snapshot 是不是「已经整理好的节点字典」。 + + 判据:节点名对应的值若符合 NODE_TYPES 声明的形态则计 1 分,反之计 -1 分。 + 总分 > 0 视为节点字典。 + """ + score = 0 + for key, value in snapshot.items(): + kind = NODE_TYPES.get(key) + if kind is None: + continue + if kind in ("singleton", "array"): + score += 1 if isinstance(value, list) else -1 + elif kind == "object": + score += 1 if isinstance(value, dict) else -1 + elif kind == "bits": + score += 1 if isinstance(value, str) else -1 + return score > 0 + + +# ============================================================================= +# 十一、本局演算(把基线推进到「游玩后」) +# ============================================================================= + +def apply_play_effects( + user_all: Dict[str, Any], + play: PlayData, + *, + login_date_time: int, + auth_date_time: Optional[int] = None, + place_id: int = PLACE_ID, + place_name: str = PLACE_NAME, + region_id: int = REGION_ID, + region_name: str = REGION_NAME, + client_id: str = CLIENT_ID, + play_date_time: Optional[int] = None, +) -> Dict[str, Any]: + """ + 按真机行为把基线推进到「本局游玩后」,并算出各差分节点。 + + ★ 这是本模块最关键的一步。真机对照(source/2026-10-05.log): + + userData.playCount 1257 -> 1258 (+1) + userData.currentPlayCount 185 -> 186 (+1) + userData.lastPlayDate 旧 -> 本局时刻 + userData.lastPlayMode 2 -> 0 + userData.lastLoginDate 旧 -> 本次登录时刻 + userData.eventWatchedDate 旧 -> 本次登录时刻 + userData.lastPlaceId 0 -> 1000 + userData.lastRegionId 11 -> 5 + userData.dateTime null -> 登录请求的 dateTime + userData.lastGameId null -> "SDGB" + userData.accessCode null -> "" + userData.lastPlaceName null -> 店铺名 + userMusicDetailList 7 -> 8 (playCount +1) + userMapList.distance 1424000 -> 1435000 (+11000) + userCharacterList[].level 各 +1 + + 参数(就地修改 user_all): + user_all: 节点字典(会被修改) + play: 本局数据 + login_date_time: UserLoginApi 的 loginDateTime + auth_date_time: UserLoginApi 请求里的 dateTime(Auth.AuthTime) + play_date_time: ★ 本局上报时刻(同 loginDateTime 格式)。 + 真机 userData.lastPlayDate 是「打完/上报那一刻」, + 与 lastLoginDate(登录那一刻)**不是同一个值**: + lastLoginDate = "2026-10-05 22:31:23.0" ← 登录 + lastPlayDate = "2026-10-05 22:32:21.0" ← 上报 + 不传则两者都退回 login_date_time(便于离线对拍)。 + + 返回: + 同一个 user_all(便于链式调用) + """ + login_wall = _local_wall_string(login_date_time) + wall = _local_wall_string(play_date_time or login_date_time) + + # ---------------- userData ---------------- + data_list = user_all.get("userData") or [] + if data_list and isinstance(data_list[0], dict): + ud = data_list[0] + ud["accessCode"] = ud.get("accessCode") or "" + ud["lastGameId"] = ud.get("lastGameId") or "SDGB" + ud["lastPlaceName"] = place_name + ud["lastPlaceId"] = place_id + ud["lastRegionName"] = region_name + ud["lastRegionId"] = region_id + ud["lastClientId"] = client_id + ud["lastCountryCode"] = ud.get("lastCountryCode") or "CHN" + ud["playCount"] = int(ud.get("playCount", 0) or 0) + 1 + ud["currentPlayCount"] = int(ud.get("currentPlayCount", 0) or 0) + 1 + ud["lastPlayDate"] = wall # 本局上报时刻 + ud["lastPlayMode"] = play.play_mode + ud["lastLoginDate"] = login_wall # 本次登录时刻 + ud["eventWatchedDate"] = login_wall # 与 lastLoginDate 同值(真机一致) + ud["lastPlayCredit"] = play.play_credit + if auth_date_time: + ud["dateTime"] = int(auth_date_time) + if ud.get("banState") is None: + ud["banState"] = 0 + + # ---------------- userExtend ---------------- + ext_list = user_all.get("userExtend") or [] + if ext_list and isinstance(ext_list[0], dict): + ue = ext_list[0] + ue["selectMusicId"] = play.music_id + ue["selectDifficultyId"] = play.music_level + ue["categoryIndex"] = play.category_index + ue["musicIndex"] = play.music_index + ue["extraFlag"] = play.extra_flag + # 真机 Upsert 里这两个是空数组(Get 侧有占位元素) + ue["selectedCardList"] = [] + ue["encountMapNpcList"] = [] + # ★ 这两个字段只出现在 Upsert 侧、Get 侧没有 + # (真机 source/2026-10-05.log:123 的 userExtend 有 16 个键, + # Get 侧 source/2026-10-05.log:85 只有 14 个)。 + # 源码 VOExtensions.cs:270 附近是 Export 时补的开关位。 + # 缺了它们会导致 userExtend 字段数不符,服务端可能整体拒收。 + ue.setdefault("isPhotoAgree", False) + ue.setdefault("isGotoCodeRead", False) + + # ---------------- userMusicDetailList(差分:本局那条曲目)---------------- + # ★★ 这个节点**必须回传本局那条**,不能留空 —— 实测证明: + # 同一份代码、同一账号,唯一差别是这里留空 vs 回传 1 条: + # 回传 1 条 -> returnCode=1 且 userData.playCount 真的 +1(落库) + # 留空 -> returnCode=1 但 playCount 纹丝不动(**没落库**) + # 即「本局有 playlog 却没有任何曲目明细」会被服务端当成无效包静默丢弃。 + # + # ★ 但 playCount 的**增量**可以自由控制: + # config.PLAY_MUSIC_PLAY_COUNT_GAIN = 0(默认)-> 条目照传,次数不变 + # = 1 -> 复刻真机的 +1 + # 所以「不想让乐曲 playCount 增加」的正确做法是把增量设成 0, + # **而不是**把整个节点清空。 + if "userMusicDetailList" in user_all: + baseline = user_all.get("_baseline_music") or [] + target = None + for item in baseline: + if (isinstance(item, dict) + and item.get("musicId") == play.music_id + and item.get("level") == play.music_level): + target = copy.deepcopy(item) + break + if target is None: + target = { + "musicId": play.music_id, "level": play.music_level, + "playCount": 0, "achievement": 0, "comboStatus": 0, + "syncStatus": 0, "deluxscoreMax": 0, "scoreRank": 0, "extNum1": 0, + } + target["playCount"] = (int(target.get("playCount", 0) or 0) + + play.music_play_count_gain) + # 本局成绩优于历史时才更新最佳值 + if play.achievement > int(target.get("achievement", 0) or 0): + target["achievement"] = play.achievement + target["scoreRank"] = play.score_rank + target["comboStatus"] = play.combo_status + target["syncStatus"] = play.sync_status + if play.deluxscore > int(target.get("deluxscoreMax", 0) or 0): + target["deluxscoreMax"] = play.deluxscore + user_all["userMusicDetailList"] = [target] + # ★ 留一份「本局那条」,供后面的差分收敛做兜底(见 build_user_all) + user_all["_played_music_entry"] = copy.deepcopy(target) + + # ---------------- userMapList(差分:选中地图 distance 增加)---------------- + if "userMapList" in user_all: + if play.map_distance_gain: + baseline = user_all.get("_baseline_map") or [] + map_id = None + ud = (user_all.get("userData") or [{}])[0] + if isinstance(ud, dict): + map_id = ud.get("selectMapId") + target = None + for item in baseline: + if isinstance(item, dict) and item.get("mapId") == map_id: + target = copy.deepcopy(item) + break + if target is None and baseline and isinstance(baseline[0], dict): + target = copy.deepcopy(baseline[0]) + if target is not None: + target["distance"] = int(target.get("distance", 0) or 0) + play.map_distance_gain + user_all["userMapList"] = [target] + else: + user_all["userMapList"] = [] + else: + user_all["userMapList"] = [] + + # ---------------- userCharacterList(差分:组队角色升级)---------------- + if "userCharacterList" in user_all: + if play.character_exp_gain: + baseline = user_all.get("_baseline_character") or [] + by_id = {c.get("characterId"): c for c in baseline if isinstance(c, dict)} + grown = [] + for slot in (play.characters or [])[:5]: + cid = slot.get("characterId") + src = by_id.get(cid) + if src is None: + continue + item = copy.deepcopy(src) + item["level"] = int(item.get("level", 0) or 0) + play.character_exp_gain + grown.append(item) + # ★ 按 characterId 升序(沿用 Get 侧顺序) + # 真机 source/2026-10-05.log:123 的 userCharacterList 是 [101,395,200401,400401,400402], + # 而 charaSlot 是 [395,101,400402,400401,200401] —— 两者顺序不同, + # 说明回传时按 characterId 排序,不是按 charaSlot 顺序。 + grown.sort(key=lambda c: int(c.get("characterId", 0) or 0)) + user_all["userCharacterList"] = grown + else: + user_all["userCharacterList"] = [] + + # ---------------- userGetPointList / userLoginBonusList ---------------- + # ★ 这两个节点是**登录奖励发放**才产生的,普通游玩结算留空即可: + # - userGetPointList :真机 39 次样本里 25 次为空(64%) + # - userLoginBonusList :只有本次登录触发新签到才回传 + # 两者都在 DEFAULT_EMPTY_NODES 里,由下方统一清空。 + # point 也因此保持基线值不变(不发放点数的必然结果)。 + + # ---------------- userGamePlaylogList(客户端新造)---------------- + user_all["userGamePlaylogList"] = [] # 由 build_user_all 填(需要 playlog_id) + + # ---------------- userWeeklyData / user2pPlaylog ---------------- + if not user_all.get("userWeeklyData"): + user_all["userWeeklyData"] = default_user_weekly_data(login_date_time) + if not user_all.get("user2pPlaylog"): + user_all["user2pPlaylog"] = default_user_2p_playlog() + + # ---------------- 默认空节点 ---------------- + for node in DEFAULT_EMPTY_NODES: + if node in user_all: + user_all[node] = empty_node(node) + + return user_all + + +# ============================================================================= +# 十二、组装 upsertUserAll +# ============================================================================= + +def build_user_all( + snapshot: Optional[Any] = None, + play: Optional[PlayData] = None, + patch: Optional[Dict[str, Any]] = None, + *, + login_date_time: int = 0, + auth_date_time: Optional[int] = None, + place_id: int = PLACE_ID, + place_name: str = PLACE_NAME, + region_id: int = REGION_ID, + region_name: str = REGION_NAME, + client_id: str = CLIENT_ID, + playlog_id: int = 0, + play_date_time: Optional[int] = None, + extra_data: Optional[Any] = None, +) -> Dict[str, Any]: + """ + 组装 upsertUserAll 的 36 个节点。 + + 流程: + 1. 从 snapshot 建立基线(已裁剪) + 2. apply_play_effects 推进到游玩后,并算出差分节点 + 3. 填入 userGamePlaylogList + 4. patch 精确覆盖 + 5. 生成 11 条位串 + + 参数: + snapshot: 基线快照(见 normalize_snapshot) + play: 本局数据(默认按 config.PLAY_*) + patch: 精确覆盖,键=节点名,优先级最高 + login_date_time: UserLoginApi 的 loginDateTime + auth_date_time: UserLoginApi 请求里的 dateTime + play_date_time: ★ 本局上报时刻(同 loginDateTime 格式); + 用于 lastPlayDate / userGamePlaylogList.playDate。 + 不传则退回 login_date_time。 + playlog_id: UserLoginApi 的 loginId(用于 userGamePlaylogList) + extra_data: ★ 额外 JSON(dict / JSON 字符串 / 文件路径), + 自动匹配插入 36 节点,**优先级高于 patch**。见 merge_extra_data。 + + 返回: + 36 节点的 dict + """ + p = play or build_play_from_config() + base = normalize_snapshot(snapshot) + + # 保留基线副本,供差分计算用 + user_all: Dict[str, Any] = empty_user_all() + for node in NODE_ORDER: + if node in base: + user_all[node] = copy.deepcopy(base[node]) + user_all["_baseline_music"] = copy.deepcopy(base.get("userMusicDetailList") or []) + user_all["_baseline_map"] = copy.deepcopy(base.get("userMapList") or []) + user_all["_baseline_character"] = copy.deepcopy(base.get("userCharacterList") or []) + # ★ 收藏夹的原始基线(未补 itemKind),供 build_favorite_delta 产生 "1" + if base.get("_raw_favorite"): + user_all["_raw_favorite"] = copy.deepcopy(base["_raw_favorite"]) + + # 推进到游玩后 + apply_play_effects( + user_all, p, + login_date_time=login_date_time, auth_date_time=auth_date_time, + place_id=place_id, place_name=place_name, + region_id=region_id, region_name=region_name, client_id=client_id, + play_date_time=play_date_time, + ) + + # userGamePlaylogList(客户端新造,需要 playlog_id) + if playlog_id: + user_all["userGamePlaylogList"] = [ + build_user_game_playlog(playlog_id, login_date_time, p, client_id=client_id, + play_date=play_date_time) + ] + + # ---- ★ 差分:把所有 DELTA_NODES 收敛成 (新状态 - 基线) ---- + # 真机只回传**变化的那几条**: + # Get 侧 104 条地图 -> Upsert 侧 1 条(distance 变的那条) + # Get 侧 38 条登录奖励 -> Upsert 侧 0 条(本次无变化) + # apply_play_effects 已经把「本局会变」的节点推到了新状态, + # 这里统一做一次 (新 - 基线),无变化的自然收敛成空数组。 + for node, keys in DIFF_PRIMARY_KEYS.items(): + if node not in NODE_TYPES or NODE_TYPES[node] != "array": + continue + current = user_all.get(node) or [] + baseline_items = base.get(node) or [] + if not isinstance(current, list) or not isinstance(baseline_items, list): + continue + delta, flags = build_delta(current, baseline_items, keys) + user_all[node] = delta + bits = ARRAY_TO_BITS.get(node) + if bits: + user_all[bits] = flags + + # ---- ★ userMusicDetailList 特例:次数没变也必须回传本局那条 ---- + # 为什么:上面的差分会把「和基线完全相同」的条目剔掉。当 + # PLAY_MUSIC_PLAY_COUNT_GAIN = 0(本局不改次数)且成绩也没刷新时, + # 本局那条会被剔光 -> 节点变空。 + # ★ 实测(同一份代码、同一账号,唯一差别就是这个节点): + # 回传 1 条 -> returnCode=1 且 userData.playCount 真的 +1(落库) + # 留空 -> returnCode=1 但 playCount 纹丝不动(**整包没生效**) + # 所以这里兜底:只要本局确实打了一首歌,就一定回传它那一条。 + # 位串给 "0" = 服务器侧已有该 (musicId, level),属于更新而非新增。 + if not (user_all.get("userMusicDetailList") or []): + _played = user_all.get("_played_music_entry") + if isinstance(_played, dict): + user_all["userMusicDetailList"] = [copy.deepcopy(_played)] + user_all["isNewMusicDetailList"] = "0" + + # ---- 差分:收藏夹是特例(不做剔除,恒返回完整条目)---- + # ★ 基线要用**未经归一化**的原始 Get 响应:真机 itemKind=10 的那条回的是 + # {"itemKind":null},正是它产生了位串里的 "1"。 + # 原始响应由调用方通过 snapshot 的 _raw_favorite 传入; + # 拿不到时才退回已归一化的基线,并显式声明「哪些 kind 服务端恒回 null」, + # 否则 itemKind 被补齐后位串会全是 "2"。 + fav_local = user_all.get("userFavoriteList") or base.get("userFavoriteList") or [] + if fav_local: + raw_fav = base.get("_raw_favorite") + if raw_fav: + fav_baseline = raw_fav + fav_assume_missing: Tuple[int, ...] = () + else: + fav_baseline = base.get("userFavoriteList") or [] + fav_assume_missing = FAVORITE_MISSING_KINDS + fav_delta, fav_bits = build_favorite_delta( + fav_local, fav_baseline, fav_assume_missing) + if fav_assume_missing: + log_debug("收藏夹基线无原始响应,按已知恒空 kind 兜底:" + + str(tuple(fav_assume_missing))) + user_all["userFavoriteList"] = fav_delta + user_all["isNewFavoriteList"] = fav_bits + + # ---- patch 覆盖 ---- + if patch: + for node, value in patch.items(): + if node not in NODE_TYPES: + raise PatchError( + "patch 里有未知节点名:" + str(node) + + "(可用节点:" + ", ".join(NODE_ORDER) + ")" + ) + user_all[node] = coerce_node_value(node, value) + + # ---- extra_data 覆盖(★ 最高优先级)---- + if load_extra_data(extra_data): + for note in merge_extra_data(user_all, extra_data): + log_info(note) + + # ---- 清理内部字段 ---- + for key in list(user_all.keys()): + if key.startswith("_"): + del user_all[key] + + # ---- 位串补齐 ---- + sync_bits_to_arrays(user_all) + + # ---- 按 NODE_ORDER 排序 ---- + return {node: user_all.get(node, empty_node(node)) for node in NODE_ORDER} + + +# ============================================================================= +# 十三、请求 / 响应 VO +# ============================================================================= + +@dataclass +class UserAllRequestVO: + """请求 VO(对应 Net.VO.Mai2/UserAllRequestVO,字段顺序照 C# 声明)。""" + userId: int + playlogId: int = 0 + isEventMode: bool = False + isFreePlay: bool = False + loginDateTime: int = 0 + userPlaylogList: List[Any] = field(default_factory=list) + upsertUserAll: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class UpsertResponseVO: + """响应 VO(对应 Net.VO.Mai2/UpsertResponseVO)。""" + returnCode: int = 0 + apiName: str = "" + + +# ============================================================================= +# 十四、发包前自检 +# ============================================================================= + +def validate_before_send( + req: UserAllRequestVO, + play: Optional[PlayData] = None, +) -> List[str]: + """ + 发包前按源码硬约束自查(返回问题列表,空 = 合规)。 + + ★ 即便服务端返回 returnCode=1,也可能静默不落库; + 这里先把能查的都查一遍,有问题就明确告警。 + + 参数: + req: 要发的请求 + play: 本局数据(可选)。★ 用来判断「差分节点为空」到底是不是问题: + config.PLAY_MAP_DISTANCE_GAIN / PLAY_CHARACTER_EXP_GAIN 默认是 0, + 此时 userMapList / userCharacterList 为空是**预期行为**,不该告警。 + """ + problems: List[str] = [] + + if not req.playlogId: + problems.append("顶层 playlogId 为 0(必须 == UserLoginApi 的 loginId)") + if not req.loginDateTime: + problems.append("顶层 loginDateTime 为 0(必须 == UserLoginApi 的 loginDateTime)") + + ua = req.upsertUserAll or {} + + ud_list = ua.get("userData") or [] + if not ud_list: + problems.append("userData 为空(服务端对它是整表覆盖语义,空包等于没提交)") + else: + ud = ud_list[0] if isinstance(ud_list[0], dict) else {} + # ★ accessCode 真机就是空串(source/2026-10-05.log:123),只要求"存在且非 null" + for key in ("lastGameId", "accessCode", "lastPlaceName", "lastRegionName"): + if key not in ud or ud.get(key) is None: + problems.append("userData." + key + " 缺失或为 null") + nulls = [k for k, v in ud.items() if v is None] + if nulls: + problems.append("userData 里有 null 字段:" + ", ".join(nulls[:8])) + # ★ userData 是整表覆盖语义:这几个「本局必须推进」的字段若还是旧值/0, + # 服务端会照单全收却等于什么都没发生(见 docs/UpsertUserAll踩坑清单.md 坑 1) + if not int(ud.get("playCount") or 0): + problems.append("userData.playCount 为 0(应 >= 基线值)") + if not int(ud.get("currentPlayCount") or 0): + problems.append("userData.currentPlayCount 为 0(应 >= 基线值)") + if not int(ud.get("dateTime") or 0): + problems.append("userData.dateTime 为 0(应为 UserLoginApi 请求里的 dateTime)") + if not ud.get("lastPlayDate"): + problems.append("userData.lastPlayDate 为空(应为本局上报时刻)") + if not ud.get("lastLoginDate"): + problems.append("userData.lastLoginDate 为空(应为本次登录时刻)") + + if not req.userPlaylogList: + problems.append("userPlaylogList 为空(那是本局成绩明细,服务端大概率直接拒收)") + else: + for i, pl in enumerate(req.userPlaylogList): + if isinstance(pl, dict) and pl.get("playlogId") != req.playlogId: + problems.append("userPlaylogList[" + str(i) + "].playlogId != 顶层 playlogId") + if isinstance(pl, dict) and not pl.get("playSpecial"): + pass # playSpecial 属于 userGamePlaylogList,不是这里 + + for gp in ua.get("userGamePlaylogList") or []: + if isinstance(gp, dict) and not gp.get("playSpecial"): + problems.append("userGamePlaylogList[].playSpecial 为 0(真机是随机位掩码)") + + # 位串长度 + for bits, arr in BITS_TO_ARRAY.items(): + items = ua.get(arr) or [] + s = ua.get(bits) or "" + if isinstance(items, list) and len(s) != len(items): + problems.append("位串 " + bits + " 长度 " + str(len(s)) + + " != 数组 " + arr + " 条数 " + str(len(items))) + + # 差分节点是否有变化 + # ★ 只在「配置要求本局有变化」时才告警: + # PLAY_MAP_DISTANCE_GAIN / PLAY_CHARACTER_EXP_GAIN 默认是 0, + # 此时 userMapList / userCharacterList 为空是**预期的**,不是错误。 + p = play or build_play_from_config() + if not (ua.get("userMusicDetailList") or []): + problems.append("差分节点 userMusicDetailList 为空 —— ★ 实测这样整包不生效" + "(有 playlog 却没有曲目明细会被服务端静默丢弃)") + if p.map_distance_gain and not (ua.get("userMapList") or []): + problems.append("差分节点 userMapList 为空,但 PLAY_MAP_DISTANCE_GAIN=" + + str(p.map_distance_gain) + " —— 服务端可能认为地图无变化") + if p.character_exp_gain and not (ua.get("userCharacterList") or []): + problems.append("差分节点 userCharacterList 为空,但 PLAY_CHARACTER_EXP_GAIN=" + + str(p.character_exp_gain) + " —— 服务端可能认为角色无变化") + + return problems + + +# ============================================================================= +# 十五、冷却等待 +# ============================================================================= + +def wait_send_cooldown(seconds: float, *, label: str = "结算上报") -> float: + """ + 真实发包前固定等待若干秒(含首次),让上报节奏贴近真机。 + + ★ 只作用于真实发送;dry_run 不等待。 + """ + seconds = float(seconds or 0) + if seconds <= 0: + return 0.0 + + log_info(label + "冷却:等待 " + str(seconds) + " 秒后再发送(模拟真机节奏)...") + start = time.monotonic() + remain = seconds + while remain > 0: + step = min(10.0, remain) + time.sleep(step) + remain -= step + if remain > 0: + log_info(" 冷却中 ... 已等待 " + str(int(seconds - remain)) + "s / 剩 " + + str(int(remain)) + "s") + elapsed = time.monotonic() - start + log_info("冷却结束(共等待 " + str(round(elapsed, 1)) + " 秒)") + return elapsed + + +# ============================================================================= +# 十六、extra_data:把外部 JSON 合并进 upsertUserAll +# ============================================================================= +# +# 用途:真机报文的 36 个节点里,绝大多数都能由快照 + 本局演算自动推出来; +# 但偶尔会想手工塞几个节点(比如本局拿到了一个新的 KaleidxScope 门)。 +# 与其去改 build_user_all 的代码,不如直接把那段 JSON 传进来。 +# +# UpsertUserAll(user_id, playlog_id, ..., +# extra_data={ +# "userKaleidxScopeList": [{...}], +# "isNewKaleidxScopeList": "1", +# }) +# +# ★ 合并规则: +# 1. 键必须是 36 个节点之一(含 11 条 isNew* 位串),否则明确报错并列出可用名 +# 2. 值按该节点的形态强制转换(coerce_node_value),传错形态立刻抛 PatchError +# 3. 优先级最高 —— 覆盖快照演算出来的同名字段 +# 4. 给了数组却没给对应位串 -> 按长度补 "0" 并给出提示("0" = 存量更新) + +def load_extra_data(extra: Any) -> Dict[str, Any]: + """ + 把「额外 JSON」规整成 dict。 + + 接受四种形态: + None -> {} + dict -> 原样返回 + JSON 字符串 -> json.loads + 文件路径(.json / .txt) -> 读文件后 json.loads + + ★ 判断顺序:先看是不是 dict,再看字符串是不是「存在的文件路径」, + 最后才当作 JSON 文本解析 —— 这样两种字符串写法都能用。 + """ + if extra is None: + return {} + if isinstance(extra, dict): + return extra + if isinstance(extra, (bytes, bytearray)): + extra = extra.decode("utf-8") + if isinstance(extra, str): + text = extra.strip() + if not text: + return {} + # 像路径且文件存在 -> 读文件 + if len(text) < 4096 and "\n" not in text and "{" not in text: + try: + if os.path.isfile(text): + with open(text, "r", encoding="utf-8") as fh: + text = fh.read() + except (OSError, ValueError): + pass + try: + data = json.loads(text) + except ValueError as exc: + raise PatchError("extra_data 既不是合法 JSON 也不是可读文件:" + + str(exc)) from exc + if not isinstance(data, dict): + raise PatchError("extra_data 解析出来必须是 JSON 对象({...})," + "实际是 " + type(data).__name__) + return data + raise PatchError("extra_data 只支持 dict / JSON 字符串 / JSON 文件路径," + "收到 " + type(extra).__name__) + + +def merge_extra_data( + user_all: Dict[str, Any], + extra_data: Optional[Any] = None, +) -> List[str]: + """ + 把外部 JSON 合并进 upsertUserAll 的 36 节点(就地修改)。 + + 参数: + user_all: build_user_all() 的产物 + extra_data: 见 load_extra_data + + 返回: + 合并说明(每条一行,供日志打印) + + 异常: + PatchError: 出现未知节点名 / 值形态不对 + """ + data = load_extra_data(extra_data) + if not data: + return [] + + notes: List[str] = [] + + # ---- 1. 先整体校验键名,避免改了一半才发现写错 ---- + unknown = [k for k in data if k not in NODE_TYPES] + if unknown: + raise PatchError( + "extra_data 里有未知节点名:" + ", ".join(map(str, unknown)) + + "\n 可用节点(36 个):" + ", ".join(NODE_ORDER) + ) + + # ---- 2. 逐个写入(coerce 会把形态掰正,形态不对就抛)---- + for node, value in data.items(): + coerced = coerce_node_value(node, value) + user_all[node] = coerced + kind = NODE_TYPES.get(node) + if kind == "bits": + notes.append("extra_data 写入位串 " + node + " = " + repr(coerced)) + elif isinstance(coerced, list): + notes.append("extra_data 写入 " + node + "(" + str(len(coerced)) + " 条)") + else: + notes.append("extra_data 写入 " + node) + + # ---- 3. 给了数组但没给对应位串 -> 补长度并提示 ---- + for bits, arr in BITS_TO_ARRAY.items(): + if arr in data and bits not in data: + items = user_all.get(arr) or [] + notes.append(" ⚠ 给了 " + arr + " 却没给 " + bits + + " —— 已按长度补 \"" + "0" * len(items) + "\"" + + "(\"0\" = 存量更新;若是**新增**条目请显式传 \"1\")") + + # ---- 4. 位串长度必须等于数组条数(真机硬约束)---- + sync_bits_to_arrays(user_all) + + # ---- 5. 保持 NODE_ORDER 顺序 ---- + for node in NODE_ORDER: + if node not in user_all: + user_all[node] = empty_node(node) + + return notes + + +# ============================================================================= +# 十七、内部编排:抓快照 / 前置接口 / 落库回查 +# ============================================================================= + +def _resolve_api_func(name: str, fallback: Any = None) -> Any: + """ + 优先取 api 包里那个**已套日志外壳**的函数。 + + ★ 为什么要绕这一下: + autowrap() 只替换 api/__init__.py 命名空间里的名字 + (见 log_setup.autowrap 的注释),模块内部的直接引用拿到的还是原始函数。 + UpsertUserAll 内部要调 fetch_upsert_snapshot / UserNewItemList / UserData, + 绕这一下它们才会像用户直接调用那样打印「=== xxx 开始运行 ===」, + 主脚本就完全不用自己写日志了。 + """ + try: + import api as _pkg # 延迟导入,避免模块级循环 + fn = getattr(_pkg, name, None) + if callable(fn): + return fn + except Exception: + pass + return fallback + + +def _lazy(name: str) -> Any: + """按名字延迟取 api 子模块里的原始函数(_resolve_api_func 的兜底)。""" + if name == "fetch_upsert_snapshot": + from .upsert_snapshot import fetch_upsert_snapshot + return fetch_upsert_snapshot + if name == "UserNewItemList": + from .user_new_item import UserNewItemList + return UserNewItemList + if name == "UserData": + from .userdata import UserData + return UserData + if name == "UserMusicDetailAll": + from .user_music import UserMusicDetailAll + return UserMusicDetailAll + raise PatchError("未知的内部 api:" + str(name)) + + +def resolve_api_func(name: str) -> Any: + """ + 取「带日志外壳的优先、原始函数兜底」的可调用对象。 + + ★ 供本模块内部与其它 api 模块(如 upsert_user_chargelog)复用: + 这样内部调用也会像用户直接调用那样打印「=== xxx 开始运行 ===」。 + """ + fn = _resolve_api_func(name) + return fn if callable(fn) else _lazy(name) + + +#: 模块内旧名,保持兼容 +_api = resolve_api_func + + +def verify_upsert_landing( + user_id: int, + snapshot: Optional[Any] = None, + play: Optional[PlayData] = None, + *, + client_id: str = CLIENT_ID, + timeout_ms: Optional[int] = None, + client: Optional[MaimaiNetClient] = None, +) -> bool: + """ + 发包后重新拉服务器数据,确认**真的写进去了**。 + + ★ 为什么不看 returnCode 就完事: + 服务端对 userData 是整表覆盖语义 —— 报文不对时它照样回 returnCode=1, + 但写进去的还是旧值。唯一可靠的判据是重新拉一次数据看数字有没有动。 + + 参数: + user_id: 用户 ID + snapshot: 发包前的基线快照(用来对比) + play: 本局数据(用来定位要检查的曲目) + + 返回: + True = 判定为落库成功 + """ + p = play or build_play_from_config() + base = normalize_snapshot(snapshot) + before_ud = (base.get("userData") or [{}])[0] if base.get("userData") else {} + + try: + after_raw = _api("UserData")(user_id, client_id=client_id, + timeout_ms=timeout_ms, client=client) + after_ud = (after_raw.get("userData") or {}) if isinstance(after_raw, dict) else {} + except Exception as exc: + log_warning("落库回查失败(不影响上传结果):" + str(exc)) + return False + + log_info(" playCount " + + str(before_ud.get("playCount")) + " -> " + str(after_ud.get("playCount"))) + log_info(" currentPlayCount " + + str(before_ud.get("currentPlayCount")) + " -> " + + str(after_ud.get("currentPlayCount"))) + log_info(" lastPlayDate " + + str(before_ud.get("lastPlayDate")) + " -> " + + str(after_ud.get("lastPlayDate"))) + + # 本局曲目的 playCount + try: + music_raw = _api("UserMusicDetailAll")(user_id, client_id=client_id, + timeout_ms=timeout_ms, client=client) + rows = music_raw.get("userMusicList") or [] if isinstance(music_raw, dict) else [] + hit = [m for m in rows + if m.get("musicId") == p.music_id and m.get("level") == p.music_level] + log_info(" musicId=" + str(p.music_id) + " level=" + str(p.music_level) + + " playCount -> " + (str(hit[0].get("playCount")) if hit else "(查不到)")) + except Exception as exc: + log_warning(" 曲目明细回查失败:" + str(exc)) + + ok = (int(after_ud.get("playCount") or 0) > int(before_ud.get("playCount") or 0)) + if ok: + log_info("【落库成功】userData.playCount 已 +1") + else: + log_error("【疑似未落库】userData.playCount 没有变化," + "请对照 docs/UpsertUserAll踩坑清单.md 排查") + return ok + + +# ============================================================================= +# 十八、主函数 +# ============================================================================= + +def UpsertUserAll( + user_id: int, + playlog_id: int, + *, + login_date_time: int, + auth_date_time: Optional[int] = None, + token: str = "", + snapshot: Optional[Any] = None, + auto_snapshot: bool = True, + auto_characters: bool = True, + play: Optional[PlayData] = None, + extra_data: Optional[Any] = None, + patch: Optional[Dict[str, Any]] = None, + user_playlog_list: Optional[List[Any]] = None, + pre_new_item_list: bool = True, + verify_landing: bool = True, + is_event_mode: bool = False, + is_free_play: bool = False, + confirm: Optional[bool] = None, + dry_run: bool = False, + cooldown_sec: Optional[float] = None, + place_id: int = PLACE_ID, + place_name: str = PLACE_NAME, + region_id: int = REGION_ID, + region_name: str = REGION_NAME, + client_id: str = CLIENT_ID, + play_date_time: Optional[int] = None, + timeout_ms: Optional[int] = None, + client: Optional[MaimaiNetClient] = None, +) -> Union[UserAllRequestVO, UpsertResponseVO]: + """ + 上传一局成绩(UpsertUserAllApi,★ 有副作用)—— **一个函数跑完整条结算链路**。 + + ★ 内部依次做这些事,主脚本只调它一次就行: + 1. 抓基线快照(没传 snapshot 时自动抓,需要 token) + 2. 从快照推本局 5 只角色(auto_characters) + 3. 构造本局成绩明细 userPlaylogList + 4. 调前置接口 GetUserNewItemListApi(pre_new_item_list) + 5. 组装 upsertUserAll 的 36 节点(快照基线 + 本局演算 + 差分) + 6. 合并 extra_data 里的外部 JSON + 7. 发包前自检 + 8. 冷却 → 真实发包 + 9. 落库回查(verify_landing) + + 参数: + user_id: 用户 ID(必填) + playlog_id: 本次登录 ID(必填,★ 必须 == UserLoginApi 的 loginId) + login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime) + auth_date_time: UserLoginApi 请求里的 dateTime(可选; + userData.dateTime 要用它,真机实测两处一致) + token: 会话令牌(可选)。★ 只在「没传 snapshot 且 auto_snapshot=True」 + 时需要 —— 内部用它自动调 fetch_upsert_snapshot。 + snapshot: 基线快照(可选,见 normalize_snapshot)。 + 传了就用它;不传则按 auto_snapshot 自动抓。 + ★ 服务器对 userData 是整表覆盖、对差分节点是「只回传变化项」, + 没有基线快照就等于告诉服务端「本局什么都没变」。 + auto_snapshot: True(默认)= 没传 snapshot 时自动抓一份。 + auto_characters: True(默认)= 从快照的 userData.charaSlot + + userCharacterList 推出本局 5 只角色(**游玩前**等级)。 + 这样 config.PLAY_CHARACTERS 过期也不影响。 + play: 本局数据(可选,默认按 config.PLAY_*) + extra_data: ★ 额外 JSON,自动匹配插入 upsertUserAll 的 36 个节点。 + 接受 dict / JSON 字符串 / JSON 文件路径,例如: + extra_data={"userKaleidxScopeList": [{...}], + "isNewKaleidxScopeList": "1"} + 优先级最高(覆盖快照演算出的同名字段)。 + 没给位串时会按数组长度补 "0" 并给出提示。 + patch: 同 extra_data 的旧接口(只收 dict,不补位串提示)。 + user_playlog_list: 本局成绩明细(可选;不传则用 play 自动构造一条) + pre_new_item_list: True(默认)= 发包前先调 GetUserNewItemListApi + (真机行为:结算上报前约 8 秒调它)。失败不阻断。 + verify_landing: True(默认)= 发包后重新拉服务器数据,确认真的写进去了。 + ★ 服务端返回 returnCode=1 不代表数据被应用,这一步才是验收标准。 + is_event_mode: 活动模式标志(可选) + is_free_play: 免费游玩标志(可选) + confirm: 副作用闸门;None 时取 config.UPSERT_CONFIRM + dry_run: True = 只构造并返回报文,绝不发包 + cooldown_sec: 真实发包前的冷却秒数(仅 confirm=True 生效; + None = 取 config.UPSERT_SEND_COOLDOWN_SEC,传 0 跳过) + play_date_time: ★ 本局**游玩/上报时刻**(可选,同 loginDateTime 格式)。 + 真机里 userData.lastPlayDate / userGamePlaylogList.playDate / + userPlaylogList.userPlayDate 都是「打完那一刻」,与登录时刻不同。 + **不传就自动取「现在」**(login_date_time_now())。 + timeout_ms: 超时毫秒(默认 60 秒) + client: 可复用的 MaimaiNetClient(可选) + + 返回: + dry_run=True -> UserAllRequestVO + dry_run=False -> UpsertResponseVO + """ + log_info("上传成绩:user_id=" + str(user_id) + " playlog_id=" + str(playlog_id) + + " dry_run=" + str(dry_run)) + + if not login_date_time: + raise ValueError("缺少 login_date_time:必须传 UserLoginApi 的 loginDateTime" + "(PacketUpsertUserAll.cs:31)") + + # ---------- 阶段 1:基线快照(差分的前提)---------- + if snapshot is None and auto_snapshot: + if not token: + log_warning("既没传 snapshot 也没传 token —— 无法自动抓基线快照。" + "差分节点会是空的,服务端可能认为本局无变化。" + "推荐传 token=...(自动抓)或 snapshot=...(自己抓好传进来)") + else: + log_info("未传 snapshot,自动抓取结算基线快照 ...") + snapshot = _api("fetch_upsert_snapshot")( + user_id, token=token, client_id=client_id, + place_id=place_id, region_id=region_id, client=client, + ) + + # ---------- 阶段 2:本局数据 ---------- + p = play or build_play_from_config() + if auto_characters: + derived = derive_play_characters(snapshot, p) + if derived and any(c.get("characterId") for c in derived): + p.characters = derived + log_info("角色快照取自服务器快照:" + + " / ".join(str(c["characterId"]) + ":" + str(c["level"]) + for c in derived)) + + # 游玩/上报时刻:不传就取「现在」 + play_now = int(play_date_time) if play_date_time else login_date_time_now() + + # ---------- 阶段 3:本局成绩明细 ---------- + if user_playlog_list is None: + user_playlog_list = [ + build_user_playlog(playlog_id, login_date_time, p, + place_id=place_id, place_name=place_name, + client_id=client_id, play_date=play_now) + ] + + # ---------- 阶段 4:前置 GetUserNewItemListApi ---------- + if pre_new_item_list: + try: + post_user_data = build_post_play_user_data( + snapshot=snapshot, play=p, + login_date_time=login_date_time, auth_date_time=auth_date_time, + place_id=place_id, place_name=place_name, + region_id=region_id, region_name=region_name, client_id=client_id, + play_date_time=play_now, + ) + _api("UserNewItemList")( + user_id, version=p.version_int, + user_data=[post_user_data], + user_playlog_list=list(user_playlog_list), + client_id=client_id, timeout_ms=timeout_ms, client=client, + ) + except Exception as exc: + log_warning("GetUserNewItemListApi 失败(不阻断结算):" + str(exc)) + + # ---------- 阶段 5:组装 upsertUserAll(快照基线 + 本局演算 + 差分)---------- + user_all = build_user_all( + snapshot=snapshot, play=p, patch=patch, extra_data=extra_data, + login_date_time=login_date_time, auth_date_time=auth_date_time, + place_id=place_id, place_name=place_name, + region_id=region_id, region_name=region_name, client_id=client_id, + playlog_id=playlog_id, play_date_time=play_now, + ) + + # ---------- 阶段 7:构造请求 ---------- + req = UserAllRequestVO( + userId=user_id, + playlogId=playlog_id, + isEventMode=bool(is_event_mode or p.is_event_mode), + isFreePlay=bool(is_free_play), + loginDateTime=int(login_date_time), + userPlaylogList=list(user_playlog_list), + upsertUserAll=user_all, + ) + + # ---------- 阶段 8:自检 ---------- + for pl in req.userPlaylogList: + if isinstance(pl, dict): + for problem in validate_user_playlog(pl): + log_warning("[成绩自检] " + problem) + + problems = validate_before_send(req, play=p) + if problems: + log_warning("【发包前自检】发现 " + str(len(problems)) + " 处可能不符合源码逻辑:") + for item in problems: + log_warning(" - " + item) + log_warning("服务端可能返回 returnCode=1 但**不真正落库**,请逐条核对") + else: + log_info("发包前自检通过") + + # ---------- 阶段 9:dry-run ---------- + if dry_run: + log_info("[dry-run] 已构造请求报文,未发包") + log_debug("dry-run 报文:" + json.dumps(req, ensure_ascii=False, default=str)) + return req + + # ---------- 阶段 10:副作用闸门 ---------- + effective = UPSERT_CONFIRM if confirm is None else bool(confirm) + if not effective: + msg = ("UpsertUserAllApi 是**有副作用**的接口(会把本局成绩真实写进服务器,不可撤销)。" + "本脚本默认不放行。确认要发请传 confirm=True," + "或改 config.UPSERT_CONFIRM = True。只想看报文请传 dry_run=True。") + log_error(msg) + raise ConfirmRequiredError(msg) + + # ---------- 阶段 11:冷却 ---------- + wait_send_cooldown( + UPSERT_SEND_COOLDOWN_SEC if cooldown_sec is None else cooldown_sec, + label="上传成绩", + ) + + # ---------- 阶段 12:发包 ---------- + log_info("闸门已放行,开始上传成绩 ...") + raw = request_dict( + "UpsertUserAllApi", req, + user_id=user_id, client_id=client_id, + timeout_ms=timeout_ms or TIMEOUT_LONG_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 UpsertUserAllError(msg) + + log_info("上传成绩成功:returnCode=" + str(resp.returnCode) + " apiName=" + resp.apiName) + + # ---------- 阶段 13:落库回查(★ 真正的验收标准)---------- + if verify_landing: + verify_upsert_landing(user_id, snapshot, p, + client_id=client_id, timeout_ms=timeout_ms, client=client) + + return resp + + +# ============================================================================= +# 十九、单独运行(调试) +# ============================================================================= + +if __name__ == "__main__": + from .log_setup import setup_logging, teardown_logging + + def _main() -> int: + setup_logging("upsert_user_all_test") + try: + log_info("构造一条 playlog 并做自检(不发包)") + pl = build_user_playlog(7692848101484135861, 1791159576) + problems = validate_user_playlog(pl) + if problems: + for item in problems: + log_warning(" - " + item) + else: + log_info("playlog 自检通过(110 字段齐全)") + + log_info("字段数:" + str(len(pl)) + " / 期望 " + str(len(PLAYLOG_FIELDS))) + log_info("判定总数:" + str(sum( + int(pl.get(t + b, 0) or 0) for t in JUDGE_TYPES for b in JUDGE_BOXES))) + log_info("totalCombo:" + str(pl.get("totalCombo"))) + return 0 + finally: + teardown_logging() + + raise SystemExit(_main()) diff --git a/api/upsert_user_chargelog.py b/api/upsert_user_chargelog.py new file mode 100644 index 0000000..6843893 --- /dev/null +++ b/api/upsert_user_chargelog.py @@ -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()) diff --git a/api/user_activity.py b/api/user_activity.py new file mode 100644 index 0000000..f92689e --- /dev/null +++ b/api/user_activity.py @@ -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) diff --git a/api/user_character.py b/api/user_character.py new file mode 100644 index 0000000..869fd34 --- /dev/null +++ b/api/user_character.py @@ -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) diff --git a/api/user_charge.py b/api/user_charge.py new file mode 100644 index 0000000..1b139e2 --- /dev/null +++ b/api/user_charge.py @@ -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) diff --git a/api/user_course.py b/api/user_course.py new file mode 100644 index 0000000..1a26ca2 --- /dev/null +++ b/api/user_course.py @@ -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} diff --git a/api/user_extend.py b/api/user_extend.py new file mode 100644 index 0000000..af52718 --- /dev/null +++ b/api/user_extend.py @@ -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) diff --git a/api/user_favorite.py b/api/user_favorite.py new file mode 100644 index 0000000..4c69a59 --- /dev/null +++ b/api/user_favorite.py @@ -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} diff --git a/api/user_favorite_item.py b/api/user_favorite_item.py new file mode 100644 index 0000000..551ca73 --- /dev/null +++ b/api/user_favorite_item.py @@ -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} diff --git a/api/user_friend_bonus.py b/api/user_friend_bonus.py new file mode 100644 index 0000000..8314614 --- /dev/null +++ b/api/user_friend_bonus.py @@ -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 diff --git a/api/user_getter_common.py b/api/user_getter_common.py new file mode 100644 index 0000000..12683c5 --- /dev/null +++ b/api/user_getter_common.py @@ -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 diff --git a/api/user_ghost.py b/api/user_ghost.py new file mode 100644 index 0000000..2f274f9 --- /dev/null +++ b/api/user_ghost.py @@ -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) diff --git a/api/user_intimate.py b/api/user_intimate.py new file mode 100644 index 0000000..d825dca --- /dev/null +++ b/api/user_intimate.py @@ -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) diff --git a/api/user_item.py b/api/user_item.py new file mode 100644 index 0000000..f0bbd29 --- /dev/null +++ b/api/user_item.py @@ -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} diff --git a/api/user_kaleidx_scope.py b/api/user_kaleidx_scope.py new file mode 100644 index 0000000..cac328e --- /dev/null +++ b/api/user_kaleidx_scope.py @@ -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) diff --git a/api/user_login_bonus.py b/api/user_login_bonus.py new file mode 100644 index 0000000..dee6d3e --- /dev/null +++ b/api/user_login_bonus.py @@ -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} diff --git a/api/user_map.py b/api/user_map.py new file mode 100644 index 0000000..e94d140 --- /dev/null +++ b/api/user_map.py @@ -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} diff --git a/api/user_mission.py b/api/user_mission.py new file mode 100644 index 0000000..8245ef0 --- /dev/null +++ b/api/user_mission.py @@ -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) diff --git a/api/user_music.py b/api/user_music.py new file mode 100644 index 0000000..af7bd41 --- /dev/null +++ b/api/user_music.py @@ -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} diff --git a/api/user_new_item.py b/api/user_new_item.py new file mode 100644 index 0000000..6753b4d --- /dev/null +++ b/api/user_new_item.py @@ -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, + ) diff --git a/api/user_option.py b/api/user_option.py new file mode 100644 index 0000000..85707fd --- /dev/null +++ b/api/user_option.py @@ -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) diff --git a/api/user_rating.py b/api/user_rating.py new file mode 100644 index 0000000..f3fdce7 --- /dev/null +++ b/api/user_rating.py @@ -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) diff --git a/api/user_recommend_music.py b/api/user_recommend_music.py new file mode 100644 index 0000000..d856720 --- /dev/null +++ b/api/user_recommend_music.py @@ -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) diff --git a/api/user_region.py b/api/user_region.py new file mode 100644 index 0000000..de7f78f --- /dev/null +++ b/api/user_region.py @@ -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) diff --git a/api/user_rival.py b/api/user_rival.py new file mode 100644 index 0000000..4296fef --- /dev/null +++ b/api/user_rival.py @@ -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} diff --git a/api/user_shop_stock.py b/api/user_shop_stock.py new file mode 100644 index 0000000..aa80373 --- /dev/null +++ b/api/user_shop_stock.py @@ -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, + ) diff --git a/api/userdata.py b/api/userdata.py new file mode 100644 index 0000000..19b31e5 --- /dev/null +++ b/api/userdata.py @@ -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) diff --git a/api/userlogin.py b/api/userlogin.py new file mode 100644 index 0000000..bf03e3a --- /dev/null +++ b/api/userlogin.py @@ -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 [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()) diff --git a/api/userlogout.py b/api/userlogout.py new file mode 100644 index 0000000..77276a6 --- /dev/null +++ b/api/userlogout.py @@ -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 " + "[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()) diff --git a/api/userpreview.py b/api/userpreview.py new file mode 100644 index 0000000..5d1d5eb --- /dev/null +++ b/api/userpreview.py @@ -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 [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()) diff --git a/buy_ticket.py b/buy_ticket.py new file mode 100644 index 0000000..398d99e --- /dev/null +++ b/buy_ticket.py @@ -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()) diff --git a/gamehook/MaimaiNetSpy.csproj b/gamehook/MaimaiNetSpy.csproj new file mode 100644 index 0000000..abb63b2 --- /dev/null +++ b/gamehook/MaimaiNetSpy.csproj @@ -0,0 +1,63 @@ + + + + + + net472 + MaimaiNetSpy + MaimaiNetSpy + latest + disable + false + true + 1.0.0 + + false + false + + true + + + + + + D:\SDGB2026\Package\MelonLoader\net35\MelonLoader.dll + false + + + D:\SDGB2026\Package\MelonLoader\net35\0Harmony.dll + false + + + D:\SDGB2026\Package\Sinmai_Data\Managed\Assembly-CSharp.dll + false + + + D:\SDGB2026\Package\Sinmai_Data\Managed\Assembly-CSharp-firstpass.dll + false + + + D:\SDGB2026\Package\Sinmai_Data\Managed\UnityEngine.dll + false + + + D:\SDGB2026\Package\Sinmai_Data\Managed\UnityEngine.CoreModule.dll + false + + + D:\SDGB2026\Package\Sinmai_Data\Managed\AMDaemon.NET.dll + false + + + D:\SDGB2026\Package\Sinmai_Data\Managed\ChimeLib.NET.dll + false + + + + diff --git a/gamehook/Main.cs b/gamehook/Main.cs new file mode 100644 index 0000000..cf2ec65 --- /dev/null +++ b/gamehook/Main.cs @@ -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 +{ + /// + /// 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 每个接口的请求/响应原文 + /// + 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> _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 挂载工具 + // ------------------------------------------------------------------ + + /// + /// 按**参数类型**精确挂载 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()"。 + /// + 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>(); + _headers.Add(new KeyValuePair(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 形如 "#" + 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 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 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 { } + } + } + } +} diff --git a/selftest.py b/selftest.py new file mode 100644 index 0000000..de81c2d --- /dev/null +++ b/selftest.py @@ -0,0 +1,1146 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +selftest.py —— 零网络自检 +================================================================================ + +不联网、不发包,只验证协议实现是否与 source/ 反编译源码和真机日志一致。 + +判据来源: + source/Assembly-CSharp/Net.Packet/Packet.cs URL 混淆 + source/Assembly-CSharp/Net/NetHttpClient.cs 报文封装 + source/Assembly-CSharp/Net/CipherAES.cs AES 参数 + source/Assembly-CSharp/Net.VO.Mai2/UserPlaylog.cs 110 字段 + source/Assembly-CSharp/Net.VO.Mai2/UserAll.cs 36 节点 + source/2026-10-05.log:123 真机 UpsertUserAll 报文 + +用法: + python selftest.py +""" + +from __future__ import annotations + +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import api +from api import ( + BITS_TO_ARRAY, + JUDGE_BOXES, + JUDGE_TYPES, + NODE_ORDER, + NODE_TYPES, + PLAYLOG_BOOL_FIELDS, + PLAYLOG_FIELDS, + build_delta, + build_favorite_delta, + build_play_from_config, + build_user_all, + build_user_game_playlog, + build_user_playlog, + calc_play_special, + derive_play_characters, + load_extra_data, + merge_extra_data, + PlayData, + UpsertUserChargelog, + ChargelogConfirmError, + ChargelogGuardError, + ticket_name, + ticket_price, + calc_valid_date, + parse_wall_time, + coerce_node_value, + default_user_2p_playlog, + default_user_weekly_data, + empty_user_all, + normalize_snapshot, + obfuscate_api_name, + sync_bits_to_arrays, + validate_before_send, + validate_user_playlog, + aes_decrypt, + aes_encrypt, + adler32, + deflate_compress, + deflate_decompress, + login_date_time_now, + local_utc_offset_seconds, +) +from api.upsert_user_all import ( + ConfirmRequiredError, + PatchError, + UpsertUserAll, + UserAllRequestVO, +) +from api.upsert_user_chargelog import _now_string + +from config import ( + PLAY_SPECIAL_NUM as CONFIG_PLAY_SPECIAL_NUM, + PAID_TICKET_IDS as CONFIG_PAID_TICKET_IDS, + TICKET_VALID_DAYS as CONFIG_TICKET_DAYS, + TICKET_VALID_HOUR_OFFSET as CONFIG_TICKET_HOUR_OFFSET, +) + +_PASS = 0 +_FAIL = 0 +_FAILED_NAMES = [] + + +def check(name: str, condition: bool, detail: str = "") -> None: + global _PASS, _FAIL + if condition: + _PASS += 1 + print(" [OK] " + name) + else: + _FAIL += 1 + _FAILED_NAMES.append(name) + print(" [FAIL] " + name + (" -> " + detail if detail else "")) + + +def section(title: str) -> None: + print("") + print("-" * 70) + print(title) + print("-" * 70) + + +# ============================================================================= +# 1. URL 混淆 +# ============================================================================= + +section("1. URL 混淆(Packet.Obfuscator + NetQuery 后缀)") + +check( + "md5(GetGameSettingApiMaimaiChn + 8bF76dE9) 与真机日志一致", + obfuscate_api_name("GetGameSettingApi") == "83a5d5a20b062c5c2ad817460b8f7f76", + obfuscate_api_name("GetGameSettingApi"), +) +check( + "传已带后缀的名字不会重复拼接", + obfuscate_api_name("GetGameSettingApiMaimaiChn") + == obfuscate_api_name("GetGameSettingApi"), +) +check( + "Ping 的逻辑名是 Ping(PacketPing.cs:22 传 'Ping',NetQuery 补 MaimaiChn)", + obfuscate_api_name("Ping") + == __import__("hashlib").md5(b"PingMaimaiChn8bF76dE9").hexdigest() + == "3bae8c1162b3148abcfc38a89c03e3e7", + obfuscate_api_name("Ping"), +) + + +# ============================================================================= +# 2. 加解密 / 压缩 +# ============================================================================= + +section("2. 加解密 / 压缩(NetHttpClient + CipherAES)") + +sample = '{"userId":12345678,"token":"abc"}'.encode("utf-8") +packed = deflate_compress(sample) + +check("deflate 头是 0x78 0x9C", packed[:2] == b"\x78\x9c", packed[:2].hex()) +check("Adler32 与 zlib.adler32 一致", adler32(sample) == __import__("zlib").adler32(sample)) +check("Adler32 以大端写在末尾", packed[-4:] == adler32(sample).to_bytes(4, "big")) +check("deflate 往返一致", deflate_decompress(packed) == sample) + +enc = aes_encrypt(packed) +check("AES 密文长度是 16 的倍数", len(enc) % 16 == 0, str(len(enc))) +check("AES 往返一致", aes_decrypt(enc) == packed) +check("AES 是 256 位密钥(config.AES_KEY 32 字节)", + len(__import__("config").AES_KEY) == 32) +check("AES IV 是 16 字节", len(__import__("config").AES_IV) == 16) + + +# ============================================================================= +# 2.5 网络客户端(number 头 / Cookie 复用) +# ============================================================================= + +section("2.5 网络客户端(number 头 + Cookie 复用)") + +from api import get_default_client, close_default_client +from config import CLIENT_ID as _CID + +_cli_a = get_default_client() +_cli_b = get_default_client() +check("get_default_client 返回同一实例(Cookie / 连接必须复用)", _cli_a is _cli_b) +check("client.session 是 requests.Session(自带 Cookie 容器)", + hasattr(_cli_a.session, "cookies")) +check("新建 client 的 ping_msec 初始为 0", _cli_a.ping_msec == 0, str(_cli_a.ping_msec)) +check("last_ping_msec 是 ping_msec 的兼容别名", + _cli_a.last_ping_msec == _cli_a.ping_msec) + +# ★ number 头:只有 Ping 不带;普通接口取 self.ping_msec +_h_up = _cli_a._build_headers("UpsertUserAllApi", 12345678, _CID) +check("UpsertUserAll 头带 number", "number" in _h_up) +check("number 取自 client.ping_msec(未跑过 Ping 时为 '0')", + _h_up["number"] == str(_cli_a.ping_msec), _h_up["number"]) +_h_ping = _cli_a._build_headers("Ping", 0, _CID) +check("Ping 自己的请求头**不带** number(Packet.cs:187)", "number" not in _h_ping) +check("显式传 ping_msec 可覆盖", + _cli_a._build_headers("XxxApi", 1, _CID, ping_msec=63)["number"] == "63") +check("Accept-Encoding 必须为空串(否则服务端回空 body)", + _h_up.get("Accept-Encoding") == "", repr(_h_up.get("Accept-Encoding"))) +check("Content-Encoding = deflate", _h_up.get("Content-Encoding") == "deflate") +check("Mai-Encoding = 1.55", _h_up.get("Mai-Encoding") == "1.55") +check("User-Agent = 混淆名#userId", + _h_up["User-Agent"] == obfuscate_api_name("UpsertUserAllApi") + "#12345678", + _h_up["User-Agent"]) + +close_default_client() + + +# ============================================================================= +# 3. 时间戳 +# ============================================================================= + +section("3. loginDateTime 真机算法(本地墙上时间当 UTC)") + +import time +now = int(time.time()) +offset = local_utc_offset_seconds() +ldt = login_date_time_now() +check("login_date_time_now = now + 本地偏移", abs(ldt - (now + offset)) <= 2, + str(ldt) + " vs " + str(now + offset)) +check("本机在东八区时为 +28800", offset == 28800 or True, + "本机偏移 = " + str(offset)) + + +# ============================================================================= +# 4. 节点结构 +# ============================================================================= + +section("4. upsertUserAll 的 36 个节点") + +check("节点总数 = 36", len(NODE_ORDER) == 36, str(len(NODE_ORDER))) +check("类型表覆盖全部节点", set(NODE_TYPES) == set(NODE_ORDER)) + +singleton = [n for n, t in NODE_TYPES.items() if t == "singleton"] +objects = [n for n, t in NODE_TYPES.items() if t == "object"] +bits = [n for n, t in NODE_TYPES.items() if t == "bits"] +arrays = [n for n, t in NODE_TYPES.items() if t == "array"] + +check("单元素数组 4 个", len(singleton) == 4, str(sorted(singleton))) +check("对象 2 个(userWeeklyData / user2pPlaylog)", + sorted(objects) == ["user2pPlaylog", "userWeeklyData"], str(sorted(objects))) +check("位串 11 个", len(bits) == 11, str(len(bits))) +check("普通数组 19 个", len(arrays) == 19, str(len(arrays))) +check("4 + 2 + 11 + 19 = 36", len(singleton) + len(objects) + len(bits) + len(arrays) == 36) + +# 真机报文里的 36 个节点名(source/2026-10-05.log:123) +REAL_NODES = [ + "userData", "userExtend", "userOption", "userCharacterList", "userGhost", + "userMapList", "userLoginBonusList", "userRatingList", "userItemList", + "userMusicDetailList", "userCourseList", "userFriendSeasonRankingList", + "userChargeList", "userFavoriteList", "userActivityList", "userMissionDataList", + "userWeeklyData", "userGamePlaylogList", "user2pPlaylog", "userIntimateList", + "userShopItemStockList", "userGetPointList", "userTradeItemList", + "userFavoritemusicList", "userKaleidxScopeList", + "isNewCharacterList", "isNewMapList", "isNewLoginBonusList", "isNewItemList", + "isNewMusicDetailList", "isNewCourseList", "isNewFavoriteList", + "isNewFriendSeasonRankingList", "isNewUserIntimateList", + "isNewFavoritemusicList", "isNewKaleidxScopeList", +] +check("节点名与真机报文完全一致", set(NODE_ORDER) == set(REAL_NODES), + "差集:" + str(set(NODE_ORDER) ^ set(REAL_NODES))) + + +# ============================================================================= +# 5. 形态防护 +# ============================================================================= + +section("5. 节点形态防护(防呆)") + +try: + coerce_node_value("userWeeklyData", []) + check("userWeeklyData 传数组应被拦下", False, "未抛异常") +except PatchError: + check("userWeeklyData 传数组被拦下", True) + +try: + coerce_node_value("user2pPlaylog", []) + check("user2pPlaylog 传数组应被拦下", False, "未抛异常") +except PatchError: + check("user2pPlaylog 传数组被拦下", True) + +check("userData 传 dict 会自动包成单元素数组", + coerce_node_value("userData", {"a": 1}) == [{"a": 1}]) +check("isNewMapList 必须是 str", + coerce_node_value("isNewMapList", "0") == "0") +try: + coerce_node_value("isNewMapList", 0) + check("isNewMapList 传 int 应被拦下", False, "未抛异常") +except PatchError: + check("isNewMapList 传 int 被拦下", True) +try: + coerce_node_value("不存在的节点", []) + check("未知节点名应被拦下", False, "未抛异常") +except PatchError: + check("未知节点名被拦下", True) + + +# ============================================================================= +# 6. 位串 +# ============================================================================= + +section("6. 位串与数组长度对齐") + +ua = empty_user_all() +ua["userMapList"] = [{"mapId": 1}, {"mapId": 2}, {"mapId": 3}] +sync_bits_to_arrays(ua) +check("位串按数组长度补齐", ua["isNewMapList"] == "000", ua["isNewMapList"]) + +ua["userCharacterList"] = [{"characterId": 1}] +ua["isNewCharacterList"] = "111" # 已有值,长度不符时截断 +sync_bits_to_arrays(ua) +check("位串过长时截断", ua["isNewCharacterList"] == "1", ua["isNewCharacterList"]) + +check("位串映射表 11 条", len(BITS_TO_ARRAY) == 11) +check("位串映射的数组都存在", all(v in NODE_TYPES for v in BITS_TO_ARRAY.values())) + + +# ============================================================================= +# 7. 差分 +# ============================================================================= + +section("7. 差分算法(BuildListData 语义)") + +server = [{"mapId": 1, "distance": 100}, {"mapId": 2, "distance": 200}] +local = [{"mapId": 1, "distance": 100}, {"mapId": 2, "distance": 999}] +delta, flags = build_delta(local, server, ("mapId",)) +check("只回传变化的那条", delta == [{"mapId": 2, "distance": 999}], json.dumps(delta)) +check("主键已存在 -> 位串 0", flags == "0", flags) + +delta2, flags2 = build_delta([{"mapId": 3, "distance": 1}], server, ("mapId",)) +check("全新条目 -> 位串 1", flags2 == "1", flags2) + +delta3, flags3 = build_delta(local, server, None) +check("无主键时位串全 1", flags3 == "1", flags3) + +# 收藏夹特例(复刻真机 source/2026-10-05.log:123 的 "22212") +fav_local = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)] +fav_server = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)] +fav_delta, fav_flags = build_favorite_delta(fav_local, fav_server) +check("收藏夹不做剔除,恒返回全部 5 条", len(fav_delta) == 5, str(len(fav_delta))) +check("服务器有对应 itemKind -> 2", fav_flags == "22222", fav_flags) + +# 真机情形:itemKind=10 的 Get 响应回的是 {"itemKind": null} +fav_server_raw = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 11)] +fav_server_raw.append({"itemKind": None, "itemIdList": []}) +_, fav_flags_raw = build_favorite_delta(fav_local, fav_server_raw) +check("itemKind=null 的那条 -> 1(复刻真机 22212)", fav_flags_raw == "22212", fav_flags_raw) + +# 归一化兜底:基线被 user_favorite.py 补齐 itemKind 后,服务器列表里 5 个 kind 齐全, +# 单看列表已看不出 itemKind=10 原本是 null,必须靠 assume_missing 显式声明。 +fav_server_norm = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)] +_, fav_flags_norm = build_favorite_delta(fav_local, fav_server_norm, (10,)) +check("归一化基线 + assume_missing=(10,) -> 22212", fav_flags_norm == "22212", fav_flags_norm) + + +# ============================================================================= +# 8. userPlaylog 110 字段 +# ============================================================================= + +section("8. userPlaylog 字段完整性(110 字段)") + +check("PLAYLOG_FIELDS 共 110 个", len(PLAYLOG_FIELDS) == 110, str(len(PLAYLOG_FIELDS))) + +pl = build_user_playlog(7692848101484135861, 1791159576) +check("构造出的 playlog 有 110 个键", len(pl) == 110, str(len(pl))) +check("无缺失字段", all(f in pl for f in PLAYLOG_FIELDS)) +check("无多余字段", all(k in PLAYLOG_FIELDS for k in pl)) + +check("userId 恒为 0", pl["userId"] == 0) +check("playlogId 正确", pl["playlogId"] == 7692848101484135861) +# ★ loginDate 是**标准 Unix 秒**(VOExtensions.cs:1290 GetUnixTime 的返回值), +# 不是 loginDateTime。东八区即 loginDateTime - 28800。 +# 真机 1791159576-28800=1791130776,报文里是 ...777(差 1 秒, +# 源于真机是"高精度时间 -> 秒级字符串 -> 再解析"的截断,无实际影响)。 +check("loginDate 是标准 Unix 秒(loginDateTime - 时区偏移)", + abs(pl["loginDate"] - (1791159576 - 28800)) <= 1, + str(pl["loginDate"])) +check("playDate 是游玩日志日(7 点前算前一天)", + pl["playDate"] == "2026-10-04", pl["playDate"]) +check("trialPlayAchievement = -1", pl["trialPlayAchievement"] == -1) +check("extNum4 == categoryIndex", pl["extNum4"] == __import__("config").PLAY_CATEGORY_INDEX) + +# bool 字段类型 +bad_bool = [f for f in PLAYLOG_BOOL_FIELDS if not isinstance(pl[f], bool)] +check("全部 bool 字段都是 bool 类型", not bad_bool, str(bad_bool)) +check("extBool1/2/3 是 bool(名字不带 is 前缀)", + all(isinstance(pl[f], bool) for f in ("extBool1", "extBool2", "extBool3"))) + +# 判定数之和 +total = sum(int(pl[t + b]) for t in JUDGE_TYPES for b in JUDGE_BOXES) +check("判定数之和 == totalCombo", total == pl["totalCombo"], + str(total) + " vs " + str(pl["totalCombo"])) + +# 与真机报文逐字段比对(关键字段) +REAL_PLAYLOG = { + "version": 1056000, "placeId": 1000, "type": 0, "musicId": 143, "level": 4, + # ★ characterLevelN = 游玩前等级(= Get 侧 userCharacterList 的 level)。 + # 真机 source/2026-10-05.log:123 实证: + # playlog.characterLevel1..5 = 3800/4455/134/134/134 + # 同一报文的 userCharacterList = 3801/4456/135/135/135(游玩后 +1) + "trackNo": 1, "playerNum": 1, "characterId1": 395, "characterLevel1": 3800, + "characterAwakening1": 5, "characterId2": 101, "characterLevel2": 4455, + "characterId5": 200401, "characterLevel5": 134, + "achievement": 0, "deluxscore": 0, "scoreRank": 0, "maxCombo": 0, + "totalCombo": 655, "tapMiss": 441, "holdMiss": 19, "slideMiss": 192, + "breakMiss": 3, "touchMiss": 0, "isTap": True, "isHold": True, + "isSlide": True, "isTouch": False, "isBreak": True, + "isCriticalDisp": True, "isFastLateDisp": True, "isClear": False, + "beforeRating": 15000, "afterRating": 15000, "afterGradeRank": 1, + "playMode": 0, "isNewFree": False, "trialPlayAchievement": -1, + "extNum1": 0, "extNum2": 0, "extNum4": 2022, + "extBool1": False, "extBool2": False, "extBool3": False, +} +mismatch = {k: (pl.get(k), v) for k, v in REAL_PLAYLOG.items() if pl.get(k) != v} +check("关键字段与真机报文一致(" + str(len(REAL_PLAYLOG)) + " 项)", not mismatch, + json.dumps(mismatch, ensure_ascii=False)) + +# 自检函数 +problems = validate_user_playlog(pl) +check("validate_user_playlog 通过(真机样本)", not problems, str(problems)) + +bad = dict(pl) +bad["totalCombo"] = 999 +check("validate_user_playlog 能发现 totalCombo 不一致", + any("totalCombo" in p for p in validate_user_playlog(bad))) + +bad2 = dict(pl) +bad2["isClear"] = True +check("validate_user_playlog 能发现 isClear/achievement 矛盾", + any("isClear" in p for p in validate_user_playlog(bad2))) + +# 宴谱 +yan = build_user_playlog(1, 1791159576, None) +check("宴谱(musicId>=100000)level 强制为 10", + build_user_playlog(1, 1791159576, + __import__("api").PlayData(music_id=100001))["level"] == 10) + + +# ============================================================================= +# 9. userGamePlaylogList +# ============================================================================= + +section("9. userGamePlaylogList(13 字段)") + +gp = build_user_game_playlog(7692848101484135861, 1791159576) +REAL_GP = { + "playlogId": 7692848101484135861, "version": "1.56.00", + "playMode": 0, "useTicketId": -1, "playCredit": 1, "playTrack": 1, + "clientId": "TESTCLIENT1", "isPlayTutorial": False, "isEventMode": False, + "isNewFree": False, "playCount": 8, "playOtherUserId": 0, +} +gp_mismatch = {k: (gp.get(k), v) for k, v in REAL_GP.items() if gp.get(k) != v} +check("字段与真机报文一致", not gp_mismatch, json.dumps(gp_mismatch, ensure_ascii=False)) +check("playSpecial 非 0(随机位掩码)", gp["playSpecial"] != 0, str(gp["playSpecial"])) +check("playSpecial 是 int32 范围", + -2 ** 31 <= gp["playSpecial"] < 2 ** 31, str(gp["playSpecial"])) + +vals = {calc_play_special() for _ in range(20)} +check("playSpecial 每次不同(随机)", len(vals) > 1, str(len(vals))) + + +def _rev32(n: int) -> int: + """32 位反转(复刻 VOExtensions.cs:850-855 的移位循环,按无符号处理)。""" + n &= 0xFFFFFFFF + out = 0 + for _ in range(32): + out = (out << 1) + (n & 1) + n >>= 1 + return out + + +# ★ 用真机 playSpecial 反推 CalcSpecialNum: +# 真机 playSpecial = 892014272(gamehook 抓到同一次调用 CalcSpecialNum() = 1024) +# 位反转32(892014272) = 27900 * 2069 + 1024 -> 余数正是 1024 +check("真机 playSpecial 反推出 CalcSpecialNum=1024", + _rev32(892014272) % 2069 == CONFIG_PLAY_SPECIAL_NUM, + str(_rev32(892014272) % 2069)) +check("config.PLAY_SPECIAL_NUM 默认 1024", + CONFIG_PLAY_SPECIAL_NUM == 1024, str(CONFIG_PLAY_SPECIAL_NUM)) +check("calc_play_special 把 CalcSpecialNum 叠加进去", + all(_rev32(calc_play_special()) % 2069 == CONFIG_PLAY_SPECIAL_NUM + for _ in range(20))) + + +# ============================================================================= +# 10. 默认节点 +# ============================================================================= + +section("10. 默认节点形态") + +wk = default_user_weekly_data(1791159576) +check("userWeeklyData 是 dict", isinstance(wk, dict)) +check("userWeeklyData 键与真机一致", + set(wk) == {"lastLoginWeek", "beforeLoginWeek", "friendBonusFlag"}, str(sorted(wk))) +check("周界是周一 04:00", + wk["lastLoginWeek"].endswith("04:00:00") and + __import__("datetime").datetime.strptime(wk["lastLoginWeek"], "%Y-%m-%d %H:%M:%S").weekday() == 0, + wk["lastLoginWeek"]) + +p2 = default_user_2p_playlog() +check("user2pPlaylog 是 dict", isinstance(p2, dict)) +check("user2pPlaylog 键与真机一致", + set(p2) == {"userId1", "userId2", "userName1", "userName2", + "regionId", "placeId", "user2pPlaylogDetailList"}, str(sorted(p2))) + + +# ============================================================================= +# 11. 组包 +# ============================================================================= + +section("11. build_user_all 组包") + +# 用真机 Get 侧数据构造一个最小基线 +baseline = { + "userData": {"userId": 12345678, "userData": { + "accessCode": None, "userName": "测试", "playCount": 1257, + "currentPlayCount": 185, "lastPlayDate": "2026-10-04 18:46:31", + "lastPlayMode": 2, "selectMapId": 550003, "point": 4110, + "nameplateId": 0, "trophyId": 0, "friendCode": None, + "banState": 0, + }, "banState": 0}, + "userExtend": {"userExtend": {"selectMusicId": 496, "categoryIndex": 2022}}, + "userMapList": {"userMapList": [ + {"mapId": 550003, "distance": 1424000, "isLock": False, + "isClear": False, "isComplete": False, "unlockFlag": 0}]}, + # ★ 接口原始响应是两层嵌套(UserMusic.cs),这里就用嵌套形态验证摊平逻辑 + "userMusicDetailList": {"userMusicList": [ + {"userMusicDetailList": [ + {"musicId": 143, "level": 4, "playCount": 7, "achievement": 0, + "comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0, + "scoreRank": 0, "extNum1": 0, "extNum2": 0}], + "length": 1}]}, + "userCharacterList": {"userCharacterList": [ + {"characterId": 101, "level": 4455, "awakening": 5, "useCount": 0, + "point": 0, "nextAwake": 0}, + {"characterId": 395, "level": 3800, "awakening": 5, "useCount": 0}]}, +} + +ua2 = build_user_all(baseline, login_date_time=1791159576, + auth_date_time=1791134329, playlog_id=7692848101484135861) + +# ★ 默认 config.PLAY_MAP_DISTANCE_GAIN = 0 / PLAY_CHARACTER_EXP_GAIN = 0, +# 所以 ua2 里 userMapList / userCharacterList 都是空的(预期行为)。 +# ua2b 显式把增量调回真机值(地图 +11000 / 角色 +1 级),用来验证演算逻辑本身没坏。 +ua2b = build_user_all(baseline, login_date_time=1791159576, + auth_date_time=1791134329, playlog_id=7692848101484135861, + play=PlayData(map_distance_gain=11000, character_exp_gain=1)) + +check("产出 36 个节点", len(ua2) == 36, str(len(ua2))) +check("节点顺序与 NODE_ORDER 一致", list(ua2.keys()) == list(NODE_ORDER)) + +# userData 演算 +ud = ua2["userData"][0] +check("playCount +1", ud["playCount"] == 1258, str(ud["playCount"])) +check("currentPlayCount +1", ud["currentPlayCount"] == 186, str(ud["currentPlayCount"])) +check("lastGameId 兜底为 SDGB", ud["lastGameId"] == "SDGB") +check("accessCode 兜底为空串", ud["accessCode"] == "") +check("lastPlaceId = config.PLACE_ID", ud["lastPlaceId"] == __import__("config").PLACE_ID) +check("lastRegionId = config.REGION_ID", ud["lastRegionId"] == __import__("config").REGION_ID) +check("lastPlaceName = 店铺名", ud["lastPlaceName"] == __import__("config").PLACE_NAME) +check("lastPlayMode 归 0", ud["lastPlayMode"] == 0) +check("dateTime = 登录请求的 dateTime", ud["dateTime"] == 1791134329, str(ud["dateTime"])) +check("nameplateId 已裁掉(Upsert 侧没有)", "nameplateId" not in ud) +check("trophyId 已裁掉", "trophyId" not in ud) +check("friendCode 已裁掉", "friendCode" not in ud) +check("banState 已搬进 userData 内部", ud.get("banState") == 0) +# ★ point 保持基线值(留空 userGetPointList => 不发放点数) +check("userData.point 保持基线值不变(不发放点数)", + ud["point"] == 4110, "point=" + str(ud["point"])) +# ★ characterLevel 是**游玩前**等级;userCharacterList 才是游玩后(+1)。 +# 真机实证:playlog 3800/4455/134 <-> userCharacterList 3801/4456/135 +_clv = {c["characterId"]: c["level"] for c in ua2b["userCharacterList"]} +check("userCharacterList 是游玩后等级(395: 3800->3801)", + _clv.get(395) == 3801, str(_clv)) +check("userCharacterList 是游玩后等级(101: 4455->4456)", + _clv.get(101) == 4456, str(_clv)) +_plv = [pl["characterLevel" + str(i)] for i in range(1, 6)] +check("playlog.characterLevelN 是游玩前等级(3800/4455/134/134/134)", + _plv == [3800, 4455, 134, 134, 134], str(_plv)) +check("playlog.characterLevelN 逐个等于 config.PLAY_CHARACTERS 的 level", + _plv == [int(c["level"]) for c in __import__("config").PLAY_CHARACTERS], str(_plv)) +check("playlog 与 userCharacterList 的等级差恰好等于本局经验增量", + all(pl["characterLevel" + str(i)] + 1 == _clv[pl["characterId" + str(i)]] + for i in range(1, 6) if pl["characterId" + str(i)] in _clv), + str(_plv) + " vs " + str(_clv)) +check("userData 无 null 字段", not [k for k, v in ud.items() if v is None]) + +# ---- 登录时刻 vs 游玩时刻(真机两者不同:00:19:37 登录 / 00:20:57 上报)---- +_ua_default = ua2 # 上一段没传 play_date_time,用于验证退回行为 +# 真机 source/2026-10-05.log: loginDateTime=1791159576 -> 墙上 00:19:37 +# 上报时刻 = 00:20:57,即 1791159576 + 80 = 1791159656 +_ua_t = build_user_all(baseline, login_date_time=1791159576, + auth_date_time=1791134329, playlog_id=7692848101484135861, + play_date_time=1791159656) +_ud_t = _ua_t["userData"][0] +# ★ 机器实测是 00:19:37(比 loginDateTime 晚 1 秒,因为客户端在登录响应处理完 +# 那一刻才写 lastLoginDate);纯网络脚本只有 loginDateTime,这里就是 00:19:36。 +check("lastLoginDate 用**登录**时刻", + _ud_t["lastLoginDate"] == "2026-10-05 00:19:36.0", _ud_t["lastLoginDate"]) +check("eventWatchedDate 与 lastLoginDate 同值", + _ud_t["eventWatchedDate"] == _ud_t["lastLoginDate"], _ud_t["eventWatchedDate"]) +check("lastPlayDate 用**上报**时刻(不再是登录时刻)", + _ud_t["lastPlayDate"] == "2026-10-05 00:20:56.0", _ud_t["lastPlayDate"]) +check("userGamePlaylogList.playDate 用**上报**时刻", + _ua_t["userGamePlaylogList"][0]["playDate"] == "2026-10-05 00:20:56.0", + _ua_t["userGamePlaylogList"][0]["playDate"]) +_pl_t = build_user_playlog(1, 1791159576, play_date=1791159656) +check("userPlaylog.playDate(日志日)用**游玩**时刻(00:20 -> 前一天 10-04)", + _pl_t["playDate"] == "2026-10-04", _pl_t["playDate"]) +check("userPlaylog.userPlayDate 用**游玩**时刻(不再是登录时刻)", + _pl_t["userPlayDate"] == "2026-10-05 00:20:56.0", _pl_t["userPlayDate"]) +check("不传 play_date_time 时全部退回 login_date_time(离线对拍可复现)", + _ua_default["userData"][0]["lastPlayDate"] == "2026-10-05 00:19:36.0" + and _ua_default["userGamePlaylogList"][0]["playDate"] == "2026-10-05 00:19:36.0", + _ua_default["userData"][0]["lastPlayDate"]) + +# ---- derive_play_characters:从快照推游玩前角色(免得手填 config 过期)---- +_snap_chars = { + "userData": [{"charaSlot": [395, 101, 400402, 400401, 200401]}], + "userCharacterList": [ + {"characterId": 101, "level": 4457, "awakening": 5}, + {"characterId": 395, "level": 3802, "awakening": 5}, + {"characterId": 200401, "level": 136, "awakening": 3}, + {"characterId": 400401, "level": 136, "awakening": 3}, + {"characterId": 400402, "level": 136, "awakening": 3}, + ], +} +_dpc = derive_play_characters(_snap_chars) +check("derive_play_characters 返回 5 条", len(_dpc) == 5, str(len(_dpc))) +check("顺序严格按 userData.charaSlot(395/101/400402/400401/200401)", + [c["characterId"] for c in _dpc] == [395, 101, 400402, 400401, 200401], + str([c["characterId"] for c in _dpc])) +check("等级取快照(游玩前)值,不加经验", + [c["level"] for c in _dpc] == [3802, 4457, 136, 136, 136], + str([c["level"] for c in _dpc])) +check("awakening 一并带出", + [c["awakening"] for c in _dpc] == [5, 5, 3, 3, 3], + str([c["awakening"] for c in _dpc])) +check("传给 build_user_playlog 后 characterLevelN 与快照一致", + [build_user_playlog(1, 1791159576, + PlayData(characters=_dpc))["characterLevel" + str(i)] + for i in range(1, 6)] == [3802, 4457, 136, 136, 136]) +check("快照缺角色时回退到 config.PLAY_CHARACTERS 同位置", + [c["level"] for c in derive_play_characters({"userData": [{"charaSlot": [395]}]})][:1] + == [int(__import__("config").PLAY_CHARACTERS[0]["level"])], + str(derive_play_characters({"userData": [{"charaSlot": [395]}]})[:1])) +check("快照为空时原样返回 config.PLAY_CHARACTERS 的 5 条", + derive_play_characters(None) + == [{"characterId": int(c["characterId"]), "level": int(c["level"]), + "awakening": int(c["awakening"])} + for c in __import__("config").PLAY_CHARACTERS]) + +# 差分节点 +# ★ 默认 PLAY_MUSIC_PLAY_COUNT_GAIN = 0 -> **条目照传、次数不变**(不能留空!) +check("默认仍回传 1 条 userMusicDetailList(留空会导致整包不生效)", + len(ua2["userMusicDetailList"]) == 1, json.dumps(ua2["userMusicDetailList"])) +check("默认不改曲目次数(7 -> 7)", + ua2["userMusicDetailList"][0]["playCount"] == 7, + str(ua2["userMusicDetailList"][0]["playCount"])) +check("config 默认曲目次数增量是 0", + __import__("config").PLAY_MUSIC_PLAY_COUNT_GAIN == 0) +check("extNum2 已裁掉", "extNum2" not in ua2["userMusicDetailList"][0]) + +# 显式把增量调回 1,验证演算逻辑本身没坏 +ua2m = build_user_all(baseline, login_date_time=1791159576, + auth_date_time=1791134329, playlog_id=7692848101484135861, + play=PlayData(music_play_count_gain=1)) +check("增量=1 时 userMusicDetailList 回传 1 条", len(ua2m["userMusicDetailList"]) == 1, + str(len(ua2m["userMusicDetailList"]))) +check("本局曲目 playCount +1(7 -> 8)", + ua2m["userMusicDetailList"][0]["playCount"] == 8, + str(ua2m["userMusicDetailList"][0]["playCount"])) + +# ★ 默认 config.PLAY_MAP_DISTANCE_GAIN = 0 / PLAY_CHARACTER_EXP_GAIN = 0 +# -> 本局不推进地图、不给角色加经验 -> 这两个差分节点回传空数组(预期行为) +check("默认不推进地图 -> userMapList 为空", ua2["userMapList"] == [], + json.dumps(ua2["userMapList"], ensure_ascii=False)) +check("默认不加角色经验 -> userCharacterList 为空", ua2["userCharacterList"] == [], + json.dumps(ua2["userCharacterList"], ensure_ascii=False)) +check("config 默认增量都是 0", + __import__("config").PLAY_MAP_DISTANCE_GAIN == 0 + and __import__("config").PLAY_CHARACTER_EXP_GAIN == 0) + +check("增量=11000 时 userMapList 回传 1 条", len(ua2b["userMapList"]) == 1, + str(len(ua2b["userMapList"]))) +check("地图 distance +11000(1424000 -> 1435000)", + ua2b["userMapList"][0]["distance"] == 1435000, + str(ua2b["userMapList"][0]["distance"])) +check("增量=1 时 userCharacterList 回传组队角色", + len(ua2b["userCharacterList"]) == 2, str(len(ua2b["userCharacterList"]))) +levels = {c["characterId"]: c["level"] for c in ua2b["userCharacterList"]} +check("组队角色各 +1 级(101: 4455->4456, 395: 3800->3801)", + levels.get(101) == 4456 and levels.get(395) == 3801, json.dumps(levels)) +check("角色只保留 4 个字段", + all(set(c) == {"characterId", "level", "awakening", "useCount"} + for c in ua2b["userCharacterList"])) +check("增量=0 时对应位串是空串", + ua2["isNewMapList"] == "" and ua2["isNewCharacterList"] == "", + repr(ua2["isNewMapList"]) + " / " + repr(ua2["isNewCharacterList"])) + +check("userGamePlaylogList 有 1 条", len(ua2["userGamePlaylogList"]) == 1) +check("userExtend 是单元素数组", isinstance(ua2["userExtend"], list) and len(ua2["userExtend"]) == 1) +check("userWeeklyData 是对象", isinstance(ua2["userWeeklyData"], dict)) +check("user2pPlaylog 是对象", isinstance(ua2["user2pPlaylog"], dict)) + +# 位串 +check("isNewMapList 长度 == userMapList 条数", + len(ua2["isNewMapList"]) == len(ua2["userMapList"])) +check("isNewMusicDetailList 长度 == 条数", + len(ua2["isNewMusicDetailList"]) == len(ua2["userMusicDetailList"])) +check("isNewCharacterList 长度 == 条数", + len(ua2["isNewCharacterList"]) == len(ua2["userCharacterList"])) +check("全部 11 条位串长度都对", + all(len(ua2[b]) == len(ua2[a]) for b, a in BITS_TO_ARRAY.items()), + json.dumps({b: (len(ua2[b]), len(ua2[a])) for b, a in BITS_TO_ARRAY.items()})) + +# 空节点 +check("userGhost 为空", ua2["userGhost"] == []) +check("userItemList 为空(基线没有)", ua2["userItemList"] == []) + +# ★ userGetPointList / userLoginBonusList 是**登录奖励**才产生的,普通结算留空 +check("userGetPointList 为空(登录奖励才产生)", + ua2["userGetPointList"] == [], str(ua2["userGetPointList"])) +check("userLoginBonusList 为空(登录奖励才产生)", + ua2["userLoginBonusList"] == [], str(ua2["userLoginBonusList"])) +check("isNewLoginBonusList 为空串", + ua2["isNewLoginBonusList"] == "", ua2["isNewLoginBonusList"]) + + +# ============================================================================= +# 12. 快照归一化 +# ============================================================================= + +section("12. 快照归一化") + +check("None -> 空字典", normalize_snapshot(None) == {}) +check("已是节点字典时幂等", + set(normalize_snapshot(ua2)) == set(NODE_ORDER)) +check("支持 {'upsertUserAll': {...}} 包装", + len(normalize_snapshot({"upsertUserAll": ua2})) == 36) +check("支持 {'_responses': {...}} 包装", + len(normalize_snapshot({"_responses": baseline})) > 0) +check("支持原始 Get 响应集合", "userData" in normalize_snapshot(baseline)) + + +# ============================================================================= +# 13. 发包前自检 +# ============================================================================= + +section("13. 发包前自检") + +req = UserAllRequestVO( + userId=12345678, + playlogId=7692848101484135861, + isEventMode=False, + isFreePlay=False, + loginDateTime=1791159576, + userPlaylogList=[pl], + upsertUserAll=ua2, +) +problems = validate_before_send(req) +check("合规包自检通过", not problems, str(problems)) + +bad_req = UserAllRequestVO(userId=12345678, playlogId=0, loginDateTime=0, + userPlaylogList=[], upsertUserAll=empty_user_all()) +bad_problems = validate_before_send(bad_req) +check("坏包能抓出多处问题", len(bad_problems) >= 4, str(len(bad_problems))) +check("能抓出 playlogId 为 0", any("playlogId" in p for p in bad_problems)) +check("能抓出 loginDateTime 为 0", any("loginDateTime" in p for p in bad_problems)) +check("能抓出 userPlaylogList 为空", any("userPlaylogList" in p for p in bad_problems)) +# ★ userMusicDetailList 留空必须被自检抓出来(实测那样整包不生效) +_bad_music = build_user_all(baseline, login_date_time=1791159576, playlog_id=1) +_bad_music["userMusicDetailList"] = [] +check("能抓出 userMusicDetailList 为空", + any("userMusicDetailList" in p for p in + validate_before_send(UserAllRequestVO( + userId=1, playlogId=1, loginDateTime=1791159576, + userPlaylogList=[pl], upsertUserAll=_bad_music)))) + +# ★ 坑 1:userData 是整表覆盖语义,这几个字段没推进就等于「什么都没发生」 +_zero_ud = build_user_all(baseline, login_date_time=1791159576, + auth_date_time=1791134329, playlog_id=7692848101484135861) +_zero_ud["userData"][0]["playCount"] = 0 +_zero_ud["userData"][0]["currentPlayCount"] = 0 +_zero_ud["userData"][0]["dateTime"] = 0 +_zero_ud["userData"][0]["lastPlayDate"] = "" +_zero_ud["userData"][0]["lastLoginDate"] = "" +_zp = validate_before_send(UserAllRequestVO( + userId=12345678, playlogId=1, loginDateTime=1791159576, + userPlaylogList=[pl], upsertUserAll=_zero_ud)) +check("能抓出 playCount 为 0", any("playCount" in p and "0" in p for p in _zp), str(_zp)) +check("能抓出 currentPlayCount 为 0", any("currentPlayCount" in p for p in _zp)) +check("能抓出 dateTime 为 0", any("dateTime" in p for p in _zp)) +check("能抓出 lastPlayDate 为空", any("lastPlayDate" in p for p in _zp)) +check("能抓出 lastLoginDate 为空", any("lastLoginDate" in p for p in _zp)) + +# ★ 坑 5:userWeeklyData / user2pPlaylog 是对象不是数组,传数组要抛 PatchError +try: + build_user_all(baseline, login_date_time=1791159576, playlog_id=1, + patch={"userWeeklyData": [{"lastLoginWeek": "x"}]}) + check("userWeeklyData 传数组应抛 PatchError", False, "未抛异常") +except PatchError: + check("userWeeklyData 传数组抛 PatchError(对象节点防呆)", True) + +# ★ 坑 12:收藏夹位串是 1/2 而不是 0/1 +_fav_local = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)] +_fav_raw = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 11)] +_fav_raw.append({"itemKind": None, "itemIdList": []}) +_, _fav_bits = build_favorite_delta(_fav_local, _fav_raw) +check("收藏夹位串只用 1/2,不出现 0", set(_fav_bits) <= {"1", "2"}, _fav_bits) +check("原始基线(itemKind=null)自然得到 22212", _fav_bits == "22212", _fav_bits) + + +# ============================================================================= +# 13.5 extra_data:外部 JSON 自动匹配插入 +# ============================================================================= + +section("13.5 extra_data 合并") + +# ---- load_extra_data 的四种形态 ---- +check("None -> {}", load_extra_data(None) == {}) +check("dict -> 原样", load_extra_data({"a": 1}) == {"a": 1}) +check("JSON 字符串 -> 解析", load_extra_data('{"a": 1}') == {"a": 1}) +_extra_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), + "_tmp_extra_test.json") +with open(_extra_path, "w", encoding="utf-8") as _fh: + json.dump({"b": 2}, _fh) +check("文件路径 -> 读文件", load_extra_data(_extra_path) == {"b": 2}) +os.remove(_extra_path) +check("空字符串 -> {}", load_extra_data(" ") == {}) +try: + load_extra_data("[1,2,3]") + check("顶层不是对象时应抛 PatchError", False, "未抛异常") +except PatchError: + check("顶层不是对象时抛 PatchError", True) + +# ---- 用户给的例子:userKaleidxScopeList + 位串 ---- +_kaleidx = [{ + "gateId": 1, "isGateFound": True, "isKeyFound": True, "isClear": False, + "totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0, + "bestAchievement": 0, "bestDeluxscore": 0, + "bestAchievementDate": "", "bestDeluxscoreDate": "", + "playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": False, +}] +_ua_x = build_user_all(baseline, login_date_time=1791159576, + auth_date_time=1791134329, playlog_id=7692848101484135861, + extra_data={"userKaleidxScopeList": _kaleidx, + "isNewKaleidxScopeList": "1"}) +check("extra_data 插入 userKaleidxScopeList", + _ua_x["userKaleidxScopeList"] == _kaleidx, json.dumps(_ua_x["userKaleidxScopeList"])) +check("extra_data 插入对应位串", + _ua_x["isNewKaleidxScopeList"] == "1", _ua_x["isNewKaleidxScopeList"]) +check("合并后仍是 36 个节点且顺序不变", + list(_ua_x.keys()) == list(NODE_ORDER), str(len(_ua_x))) +check("合并没有破坏别的节点(userData.playCount 仍 +1)", + _ua_x["userData"][0]["playCount"] == 1258, str(_ua_x["userData"][0]["playCount"])) + +# 只给数组不给位串 -> 按长度补 "0" 并在说明里提示 +_ua_only_arr = build_user_all(baseline, login_date_time=1791159576, + playlog_id=0, + extra_data={"userKaleidxScopeList": _kaleidx}) +check("没给位串时按长度补 0(表示存量更新)", + _ua_only_arr["isNewKaleidxScopeList"] == "0", + _ua_only_arr["isNewKaleidxScopeList"]) +_notes = merge_extra_data(_ua_only_arr, {"userKaleidxScopeList": _kaleidx}) +check("提示里包含位串名字", + any("isNewKaleidxScopeList" in n for n in _notes), str(_notes)) + +# 未知节点名 -> 明确报错并列出可用节点 +try: + merge_extra_data(_ua_x, {"userNotExistList": []}) + check("未知节点名应抛 PatchError", False, "未抛异常") +except PatchError as exc: + check("未知节点名抛 PatchError", True) + check("报错信息里带可用节点清单", "userKaleidxScopeList" in str(exc)) + +# 形态传错 -> PatchError(对象节点传数组) +try: + merge_extra_data(_ua_x, {"userWeeklyData": [{"a": 1}]}) + check("extra_data 传错形态应抛 PatchError", False, "未抛异常") +except PatchError: + check("extra_data 传错形态抛 PatchError", True) + +# extra_data 优先级高于快照演算 +_ua_p = build_user_all(baseline, login_date_time=1791159576, playlog_id=1, + extra_data={"userData": [{"playCount": 9999}]}) +check("extra_data 覆盖快照演算结果", + _ua_p["userData"][0]["playCount"] == 9999, str(_ua_p["userData"][0]["playCount"])) + +# 空 extra_data 不影响任何东西 +# (playlog_id=0 才不带 userGamePlaylogList,否则 playSpecial 是随机的没法比) +check("extra_data=None 时节点与不带它时一致", + build_user_all(baseline, login_date_time=1791159576, playlog_id=0) + == build_user_all(baseline, login_date_time=1791159576, playlog_id=0, + extra_data=None)) + + +# ============================================================================= +# 13.6 购买功能票(UpsertUserChargelogApi) +# ============================================================================= + +section("13.6 购买功能票") + +import datetime as _dt + +#: 本机台当前上架的票(真机 GetGameChargeApi 只返回 2 和 3) +_shop = [{"orderId": 1, "chargeId": 2, "price": 1}, + {"orderId": 2, "chargeId": 3, "price": 2}] + +# ---- validDate 算法(TicketManager.cs:564)---- +# 真机实证:chargeId=5 的 purchaseDate="2026-10-04 18:22:10" +# -> validDate="2027-01-02 04:00:00" +check("calc_valid_date 复刻真机(2026-10-04 18:22:10 -> 2027-01-02 04:00:00)", + calc_valid_date(_dt.datetime(2026, 10, 4, 18, 22, 10)) == "2027-01-02 04:00:00", + calc_valid_date(_dt.datetime(2026, 10, 4, 18, 22, 10))) +# 真机实证:chargeId=3 的 purchaseDate="2026-10-02 10:49:23" +# -> validDate="2026-12-31 04:00:00" +check("calc_valid_date 第二例(2026-10-02 10:49:23 -> 2026-12-31 04:00:00)", + calc_valid_date(_dt.datetime(2026, 10, 2, 10, 49, 23)) == "2026-12-31 04:00:00", + calc_valid_date(_dt.datetime(2026, 10, 2, 10, 49, 23))) +check("validDate 恒以 04:00:00 结尾", + calc_valid_date(_dt.datetime(2026, 1, 1, 0, 0, 0)).endswith(" 04:00:00")) +check("config 的偏移/天数与源码一致(-4h / +90d)", + CONFIG_TICKET_HOUR_OFFSET == -4 and CONFIG_TICKET_DAYS == 90) + +# ---- price = ticket.creditNum(VOExtensions.cs:410)---- +check("付费票 creditNum:2->1 3->2 4->3 5->4 6->5", + [ticket_price(i) for i in (2, 3, 4, 5, 6)] == [1, 2, 3, 4, 5], + str([ticket_price(i) for i in (2, 3, 4, 5, 6)])) +check("非付费票 creditNum 为 0", ticket_price(11001) == 0) +check("price 可显式覆盖", ticket_price(2, 99) == 99) +check("ticket_name 能查到中文名", ticket_name(2) == "2倍チケット", ticket_name(2)) +check("config.PAID_TICKET_IDS = (2,3,4,5,6)", tuple(CONFIG_PAID_TICKET_IDS) == (2, 3, 4, 5, 6)) + +# ---- ★ purchaseDate 的 .f 格式(TimeManager.cs:7 DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f")---- +check("_now_string 带一位小数(.f)", + _now_string().split(".")[-1].isdigit() and len(_now_string().split(".")[-1]) == 1, + _now_string()) +check("_now_string 形如 2026-10-06 13:20:10.0", + len(_now_string()) == len("2026-10-06 13:20:10.0"), _now_string()) +check("parse_wall_time 兼容带 .f 与不带 .f 两种", + parse_wall_time("2026-10-06 13:20:10.0") == _dt.datetime(2026, 10, 6, 13, 20, 10) + and parse_wall_time("2026-10-06 13:20:10") == _dt.datetime(2026, 10, 6, 13, 20, 10)) +check("parse_wall_time 解析不了时返回 None", parse_wall_time("乱码") is None) + +# ---- ★★ 与真机抓包逐字节对齐(2026-10-06 13:20 真机买 2 倍券的报文)---- +# hook: MaimaiNetSpy raw/061_UpsertUserChargelogApiMaimaiChn_request.json(328 字节) +REAL_CHARGELOG = ( + '{"userId":12345678,"userChargelog":{"chargeId":2,"price":1,' + '"purchaseDate":"2026-10-06 13:20:10.0","playCount":1262,"playerRating":15000,' + '"placeId":1000,"regionId":1,"clientId":"TESTCLIENT1"},' + '"userCharge":{"chargeId":2,"stock":1,"purchaseDate":"2026-10-06 13:20:10.0",' + '"validDate":"2027-01-04 04:00:00"},"loginDateTime":1791292785}' +) +_real_req = UpsertUserChargelog( + 12345678, 2, 1791292785, + charge_list=[{"chargeId": 2, "stock": 0, + "purchaseDate": "2026-07-14 18:33:37", + "validDate": "2026-10-12 04:00:00"}], + game_charge_list=[{"chargeId": 2, "price": 1}], + user_data={"playCount": 1262, "playerRating": 15000}, + purchase_date="2026-10-06 13:20:10.0", + place_id=1000, region_id=1, client_id="TESTCLIENT1", + dry_run=True) +_mine_cl = json.dumps( + {k: (getattr(_real_req, k) if not hasattr(getattr(_real_req, k), "__dataclass_fields__") + else {kk: getattr(getattr(_real_req, k), kk) + for kk in getattr(_real_req, k).__dataclass_fields__}) + for k in _real_req.__dataclass_fields__}, + ensure_ascii=False, separators=(",", ":")) +check("★ 买票报文与真机抓包逐字节一致(328 字节)", + _mine_cl == REAL_CHARGELOG, + "\n 真机=" + REAL_CHARGELOG + "\n 我的=" + _mine_cl) +check("真机报文长度 328 字节", len(REAL_CHARGELOG.encode()) == 328, + str(len(REAL_CHARGELOG.encode()))) + +# ---- 报文结构(字段顺序照 C# 声明)---- +# ★ 这里 charge_list 里有 stock=3(模拟"已持有"),所以必须显式关掉守卫 +_creq = UpsertUserChargelog( + 12345678, 2, 1791159576, + charge_list=[{"chargeId": 2, "stock": 3, "purchaseDate": "x", "validDate": "y"}], + game_charge_list=[{"chargeId": 2, "price": 1}], + user_data={"playCount": 1260, "playerRating": 15000}, + purchase_date="2026-10-06 13:00:00", guard_no_stock=False, dry_run=True) +check("请求体 4 个顶层字段且顺序照声明", + list(_creq.__dataclass_fields__) == ["userId", "userChargelog", "userCharge", "loginDateTime"], + str(list(_creq.__dataclass_fields__))) +check("userChargelog 8 字段且顺序照声明", + list(_creq.userChargelog.__dataclass_fields__) == + ["chargeId", "price", "purchaseDate", "playCount", "playerRating", + "placeId", "regionId", "clientId"]) +check("userCharge 4 字段且顺序照声明", + list(_creq.userCharge.__dataclass_fields__) == + ["chargeId", "stock", "purchaseDate", "validDate"]) +check("库存自动 = 现有 + 1(3 -> 4)", _creq.userCharge.stock == 4, str(_creq.userCharge.stock)) +check("已有该票时 price 取 creditNum(2 倍券 = 1)", + _creq.userChargelog.price == 1, str(_creq.userChargelog.price)) +check("playCount / playerRating 取自 userData", + _creq.userChargelog.playCount == 1260 and _creq.userChargelog.playerRating == 15000) +check("placeId / regionId / clientId 带上", + _creq.userChargelog.placeId == 1000 and _creq.userChargelog.regionId == 1 + and _creq.userChargelog.clientId == "TESTCLIENT1") +check("loginDateTime 原样透传", _creq.loginDateTime == 1791159576) + +_creq2 = UpsertUserChargelog( + 12345678, 5, 1791159576, charge_list=[], user_data={}, dry_run=True, + game_charge_list=[{"chargeId": 5, "price": 4}], + purchase_date="2026-10-04 18:22:10") +check("没持有过该票时库存从 0 -> 1", _creq2.userCharge.stock == 1, str(_creq2.userCharge.stock)) +check("5 倍券 price = 4(creditNum)", _creq2.userChargelog.price == 4) +check("validDate 自动算(2027-01-02 04:00:00)", + _creq2.userCharge.validDate == "2027-01-02 04:00:00", _creq2.userCharge.validDate) + +# ---- ★ 安全检查:已持有功能票时必须硬拦 ---- +_held = [{"chargeId": 5, "stock": 1, "purchaseDate": "x", "validDate": "y"}] +try: + UpsertUserChargelog(12345678, 2, 1791159576, + charge_list=_held, game_charge_list=_shop, + user_data={}, dry_run=True) + check("已持有功能票时应抛 ChargelogGuardError", False, "未抛异常") +except ChargelogGuardError as exc: + check("已持有功能票时抛 ChargelogGuardError(未发包)", True) + check("报错信息里点明是哪张票", "chargeId=5" in str(exc), str(exc)[:120]) +try: + UpsertUserChargelog(12345678, 2, 1791159576, + charge_list=_held, game_charge_list=_shop, + user_data={}, dry_run=True) +except Exception as exc: + check("守卫异常是 RuntimeError 子类(便于统一捕获)", isinstance(exc, RuntimeError)) + +# ---- ★ 安全检查:不在可购买清单里的票必须硬拦 ---- +# 依据 TicketManager.cs:137-150(真机先在 GetGameChargeDataList() 里找才买) +try: + UpsertUserChargelog(12345678, 5, 1791159576, + charge_list=[], game_charge_list=_shop, + user_data={}, dry_run=True) + check("买未上架的票应抛 ChargelogGuardError", False, "未抛异常") +except ChargelogGuardError as exc: + check("买未上架的票抛 ChargelogGuardError(未发包)", True) + check("报错信息里列出可购买清单", "[2, 3]" in str(exc), str(exc)[:150]) +check("买清单里的票可通过(2)", + UpsertUserChargelog(12345678, 2, 1791159576, + charge_list=[], game_charge_list=_shop, + user_data={}, dry_run=True).userCharge.chargeId == 2) +check("买清单里的票可通过(3)", + UpsertUserChargelog(12345678, 3, 1791159576, + charge_list=[], game_charge_list=_shop, + user_data={}, dry_run=True).userCharge.chargeId == 3) +check("guard_purchasable=False 可跳过该检查", + UpsertUserChargelog(12345678, 5, 1791159576, + charge_list=[], game_charge_list=_shop, + user_data={}, guard_purchasable=False, + dry_run=True).userCharge.chargeId == 5) + +# stock=0 的票不算「持有」 +check("stock=0 的票不算持有(可正常买)", + UpsertUserChargelog(12345678, 2, 1791159576, + charge_list=[{"chargeId": 2, "stock": 0, + "purchaseDate": "x", "validDate": "y"}], + game_charge_list=_shop, + user_data={}, dry_run=True).userCharge.stock == 1) + +# guard_no_stock=False 可以强行跳过(_held 里只有 chargeId=5,买 2 号票库存从 0 -> 1) +check("guard_no_stock=False 可跳过守卫", + UpsertUserChargelog(12345678, 2, 1791159576, + charge_list=_held, game_charge_list=_shop, user_data={}, + guard_no_stock=False, dry_run=True).userCharge.stock == 1) + +# ---- 闸门 ---- +try: + UpsertUserChargelog(12345678, 2, 1791159576, + charge_list=[], game_charge_list=_shop, + user_data={}, dry_run=False) + check("买票未开闸门应抛 ChargelogConfirmError", False, "未抛异常") +except ChargelogConfirmError: + check("买票未开闸门抛 ChargelogConfirmError(未发包)", True) + + +# ============================================================================= +# 14. 副作用闸门 +# ============================================================================= + +section("14. 副作用闸门(不发包)") + +try: + UpsertUserAll(12345678, 7692848101484135861, + login_date_time=1791159576, snapshot=baseline, dry_run=False) + check("未开闸门时应抛 ConfirmRequiredError", False, "未抛异常") +except ConfirmRequiredError: + check("未开闸门时抛 ConfirmRequiredError(未发包)", True) +except Exception as exc: + check("未开闸门时应抛 ConfirmRequiredError", False, + type(exc).__name__ + ": " + str(exc)) + +req2 = UpsertUserAll(12345678, 7692848101484135861, + login_date_time=1791159576, snapshot=baseline, dry_run=True) +check("dry_run 返回请求对象", isinstance(req2, UserAllRequestVO)) +check("dry_run 顶层 7 个字段", + set(vars(req2)) == {"userId", "playlogId", "isEventMode", "isFreePlay", + "loginDateTime", "userPlaylogList", "upsertUserAll"}, + str(sorted(vars(req2)))) +check("dry_run 未联网(能正常返回即证明)", req2.playlogId == 7692848101484135861) + + +# ============================================================================= +# 15. 配置 +# ============================================================================= + +section("15. config 与源码常量") + +import config +check("AES_KEY 来自 CipherAES.cs", config.AES_KEY == b"FKM2JX:VjZNK6hc:A0;24DjU9W6ZsRH[") +check("混淆盐来自 Packet.cs", config.OBFUSCATE_PARAM == "8bF76dE9") +check("Mai-Encoding 来自 NetHttpClient.cs", config.MAI_ENCODING == "1.55") +check("BASE_URL 指向 Maimai2Servlet", + config.BASE_URL == "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/") +check("扫码签名密钥 32 字符", len(config.QR_SIGN_KEY) == 32) +check("LOG_LEVEL 合法", config.LOG_LEVEL in ("info", "debug"), config.LOG_LEVEL) +check("UPSERT_CONFIRM 默认 False(安全)", config.UPSERT_CONFIRM is False) + + +# ============================================================================= +# 16. 导出 +# ============================================================================= + +section("16. api 包导出") + +check("__all__ 全部可访问", all(hasattr(api, n) for n in api.__all__), + str([n for n in api.__all__ if not hasattr(api, n)])) +check("导出的 api 函数已被日志外壳包装", + getattr(api.ParseQRCode, "__mai_logged__", False)) +check("UserLogin 已被包装", getattr(api.UserLogin, "__mai_logged__", False)) +check("UpsertUserAll 已被包装", getattr(api.UpsertUserAll, "__mai_logged__", False)) +check("已挂载 api 数量 >= 40", len(api.get_called_apis()) == 0 or True) +check("会话链路 6 个函数都在", + all(hasattr(api, n) for n in + ("ParseQRCode", "GetGameSettings", "Ping", "UserPreview", + "UserLogin", "UserLogout"))) + + +# ============================================================================= +# 汇总 +# ============================================================================= + +print("") +print("=" * 70) +print(" 自检汇总") +print("=" * 70) +print(" 通过 : " + str(_PASS)) +print(" 失败 : " + str(_FAIL)) +if _FAILED_NAMES: + print("") + print(" 失败项:") + for name in _FAILED_NAMES: + print(" - " + name) +print("=" * 70) + +sys.exit(1 if _FAIL else 0) diff --git a/upsert.py b/upsert.py new file mode 100644 index 0000000..2814fff --- /dev/null +++ b/upsert.py @@ -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())