feat: WaHappy v0 —— 舞萌DX 纯网络游戏助手(占位版)

当前仅 upsert.py 可用:扫码登录 -> 上传一局成绩 -> 落库回查 -> 登出。

- api/ 下 40+ 个接口模块已实现,236 项零网络自检全绿
- 上传成绩链路已真机跑通,报文与真机抓包逐字节一致
- 开发文档见 docs 分支;source/ 与 logs/ 不入库
This commit is contained in:
Txiaochen
2026-10-06 14:42:06 +08:00
commit 1636a79141
51 changed files with 10965 additions and 0 deletions
+497
View File
@@ -0,0 +1,497 @@
# -*- coding: utf-8 -*-
"""
api 包 —— 统一导出
================================================================================
主脚本只需要 from api import ... 就能拿到全部功能。
★ 本文件在导入时就给下面 _API_FUNCS 里列出的每个函数套上「日志外壳」,
所以主脚本拿到的已经是带日志的版本 —— 调用时会自动产出:
=== ParseQRCode 开始运行 ===
[接口说明] 解析二维码并换取 userId 与 token。
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
=== ParseQRCode 运行结束 ===
主脚本**不需要**写装饰器,也不需要自己埋点。
--------------------------------------------------------------------------------
目录结构(一个 api 一个脚本)
--------------------------------------------------------------------------------
基础设施
log_setup.py 统一日志(INFO / DEBUG 双模式)
maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
user_getter_common.py 取数脚本公共底座(请求 VO / _get / 翻页)
会话链路
chime.py 扫码登录 -> userId + token
gamesettings.py 取游戏设置
ping.py 心跳探活
userpreview.py 用户预览
userlogin.py 正式登录 -> loginId + loginDateTime
userlogout.py 登出
上传成绩
upsert_user_all.py ★ 上传成绩(唯一有副作用的接口)
upsert_snapshot.py 结算快照编排(统一调用下面的取数脚本)
取数(一个接口一个脚本)
userdata.py GetUserDataApi
user_extend.py GetUserExtendApi
user_option.py GetUserOptionApi
user_character.py GetUserCharacterApi
user_ghost.py GetUserGhostApi
user_map.py GetUserMapApi
user_login_bonus.py GetUserLoginBonusApi
user_rating.py GetUserRatingApi
user_item.py GetUserItemApi
user_music.py GetUserMusicApi
user_course.py GetUserCourseApi
user_charge.py GetUserChargeApi
user_favorite.py GetUserFavoriteApi
user_activity.py GetUserActivityApi
user_mission.py GetUserMissionDataApi
user_intimate.py GetUserIntimateApi
user_shop_stock.py GetUserShopStockApi
user_favorite_item.py GetUserFavoriteItemApi
user_rival.py GetUserRivalDataApi / GetUserRivalMusicApi
user_friend_bonus.py GetUserFriendBonusApi
user_kaleidx_scope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
user_region.py GetUserRegionApi
user_recommend_music.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
user_new_item.py GetUserNewItemListApi
game_ranking.py GetGameRankingApi
game_event.py GetGameEventApi
game_charge.py GetGameChargeApi
game_weekly_data.py GetGameWeeklyDataApi
game_tournament_info.py GetGameTournamentInfoApi
game_ng_music_id.py GetGameNgMusicIdApi
"""
from .log_setup import (
# 会话
setup_logging,
teardown_logging,
# 自动跟踪
autowrap,
get_called_apis,
# 模式
get_level,
set_level,
is_debug,
LEVEL_INFO,
LEVEL_DEBUG,
# 装饰器 / 兼容
api_log,
get_logger,
# 流程日志(INFO 可见)
log_info,
log_step,
log_result,
log_warning,
log_error,
log_section,
log_field,
log_debug,
# 收发数据日志(DEBUG 专属)
log_request,
log_response,
log_payload,
)
# ---------------------------------------------------------------------------
# 1) 网络核心
# ---------------------------------------------------------------------------
from .maimai_net import (
MaimaiNetClient,
MaimaiNetError,
get_default_client,
close_default_client,
request,
request_dict,
request_vo,
obfuscate_api_name,
build_api_url,
build_user_agent,
adler32,
aes_encrypt,
aes_decrypt,
deflate_compress,
deflate_decompress,
vo_to_json,
vo_from_json,
local_utc_offset_seconds,
login_date_time_now,
EmptyRequestVO,
UserIdRequestVO,
ReturnCodeVO,
)
# ---------------------------------------------------------------------------
# 2) 会话链路
# ---------------------------------------------------------------------------
from .chime import ParseQRCode
from .gamesettings import (
GetGameSettings,
GameSetting,
GameSettingRequestVO,
GameSettingResponseVO,
is_maintenance,
is_ping_disabled,
)
from .ping import Ping
from .userpreview import (
UserPreview,
UserPreviewRequestVO,
UserPreviewResponseVO,
)
from .userlogin import (
UserLogin,
UserLoginRequestVO,
UserLoginResponseVO,
)
from .userlogout import (
UserLogout,
LogoutType,
DelayLog,
RequestAPI,
UserLogoutRequestVO,
UserLogoutRequest2VO,
UserLogoutResponseVO,
create_empty_delay_log,
create_delay_log_from_stats,
DELAY_LOG_SLOTS,
)
# ---------------------------------------------------------------------------
# 3) 上传成绩
# ---------------------------------------------------------------------------
from .upsert_user_all import (
UpsertUserAll,
PlayData,
build_play_from_config,
derive_play_characters,
UserAllRequestVO,
UpsertResponseVO,
UpsertUserAllError,
ConfirmRequiredError,
PatchError,
# 节点结构
NODE_TYPES,
NODE_ORDER,
BITS_TO_ARRAY,
ARRAY_TO_BITS,
DIFF_PRIMARY_KEYS,
MAY_BE_EMPTY_NODES,
DEFAULT_EMPTY_NODES,
# 组包辅助
empty_node,
empty_user_all,
coerce_node_value,
sync_bits_to_arrays,
build_delta,
build_favorite_delta,
build_user_all,
build_user_playlog,
build_user_game_playlog,
build_post_play_user_data,
validate_user_playlog,
validate_before_send,
apply_play_effects,
# ★ 额外 JSON 合并 / 落库回查
load_extra_data,
merge_extra_data,
verify_upsert_landing,
default_user_weekly_data,
default_user_2p_playlog,
calc_play_special,
normalize_snapshot,
wait_send_cooldown,
# 字段表
PLAYLOG_FIELDS,
PLAYLOG_BOOL_FIELDS,
PLAYLOG_STRING_FIELDS,
JUDGE_TYPES,
JUDGE_BOXES,
USER_DATA_DROP_KEYS,
USER_OPTION_DROP_KEYS,
USER_CHARACTER_KEEP_KEYS,
USER_MUSIC_DETAIL_DROP_KEYS,
USER_CHARGE_DROP_KEYS,
USER_MISSION_DROP_KEYS,
)
from .upsert_snapshot import (
fetch_upsert_snapshot,
save_snapshot,
load_snapshot,
summarize_snapshot,
DEFAULT_SNAPSHOT_NODES,
)
from .upsert_user_chargelog import (
UpsertUserChargelog,
ChargelogConfirmError,
ChargelogGuardError,
UserChargeVO,
UserChargelogVO,
UserChargelogRequestVO,
ticket_name,
ticket_price,
calc_valid_date,
parse_wall_time,
now_cst,
)
# ---------------------------------------------------------------------------
# 4) 取数公共底座
# ---------------------------------------------------------------------------
from .user_getter_common import (
EmptyRequestVO as GetterEmptyRequestVO,
UserIdRequestVO as GetterUserIdRequestVO,
UserIdPageRequestVO,
UserLoginBonusRequestVO,
UserItemRequestVO,
UserMusicRequestVO,
UserCourseRequestVO,
UserFavoriteRequestVO,
UserFavoriteItemRequestVO,
UserShopStockRequestVO,
UserRivalRequestVO,
UserRivalMusicRequestVO,
fetch_all_pages,
ITEM_KINDS,
ITEM_NEXT_INDEX_FACTOR,
FAVORITE_ITEM_KINDS,
RIVAL_MUSIC_LEVELS,
)
# ---------------------------------------------------------------------------
# 5) 取数脚本(一个接口一个脚本)
# ---------------------------------------------------------------------------
from .userdata import UserData
from .user_extend import UserExtend
from .user_option import UserOption
from .user_character import UserCharacter
from .user_ghost import UserGhost
from .user_map import UserMap, UserMapAll
from .user_login_bonus import UserLoginBonus, UserLoginBonusAll
from .user_rating import UserRating
from .user_item import UserItem, UserItemAll
from .user_music import UserMusicDetail, UserMusicDetailAll
from .user_course import UserCourse, UserCourseAll
from .user_charge import UserCharge
from .user_favorite import UserFavorite, UserFavoriteAll
from .user_activity import UserActivity
from .user_mission import UserMissionData
from .user_intimate import UserIntimate
from .user_shop_stock import UserShopStock
from .user_favorite_item import (
UserFavoriteItem,
UserFavoriteItemAll,
FAVORITE_ITEM_KIND_MUSIC,
FAVORITE_ITEM_KIND_RIVAL,
)
from .user_rival import UserRivalData, UserRivalMusic, UserRivalMusicAll
from .user_friend_bonus import UserFriendBonus, extract_gift_points
from .user_kaleidx_scope import UserKaleidxScope, GameKaleidxScope
from .user_region import UserRegion
from .user_recommend_music import UserRecommendRateMusic, UserRecommendSelectMusic
from .user_new_item import UserNewItemList, UserNewItemListRequestVO
from .game_ranking import GetGameRanking, GameRankingRequestVO
from .game_event import GetGameEvent, GameEventRequestVO
from .game_charge import GetGameCharge, GameChargeRequestVO
from .game_weekly_data import GetGameWeeklyData
from .game_tournament_info import GetGameTournamentInfo
from .game_ng_music_id import GetGameNgMusicId
# ---------------------------------------------------------------------------
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
# ---------------------------------------------------------------------------
_API_FUNCS = (
# --- 会话链路 ---
"ParseQRCode",
"GetGameSettings",
"Ping",
"UserPreview",
"UserLogin",
"UserLogout",
# --- 上传成绩 ---
"UpsertUserAll",
"build_play_from_config",
"derive_play_characters",
"merge_extra_data",
"verify_upsert_landing",
"build_user_all",
# --- 购买功能票 ---
"UpsertUserChargelog",
"ticket_name",
"ticket_price",
"calc_valid_date",
"build_user_playlog",
"build_user_game_playlog",
"validate_user_playlog",
"validate_before_send",
"apply_play_effects",
"build_post_play_user_data",
"build_delta",
"build_favorite_delta",
"normalize_snapshot",
"fetch_upsert_snapshot",
"summarize_snapshot",
"UserNewItemList",
# --- 取数:单接口 ---
"UserData", # GetUserDataApi
"UserExtend", # GetUserExtendApi
"UserOption", # GetUserOptionApi
"UserCharacter", # GetUserCharacterApi
"UserGhost", # GetUserGhostApi
"UserMap", # GetUserMapApi(单页)
"UserMapAll", # GetUserMapApi(全量)
"UserLoginBonus", # GetUserLoginBonusApi(单页)
"UserLoginBonusAll", # GetUserLoginBonusApi(全量)
"UserRating", # GetUserRatingApi
"UserItem", # GetUserItemApi(单类)
"UserItemAll", # GetUserItemApi(全量)
"UserMusicDetail", # GetUserMusicApi(单页)
"UserMusicDetailAll", # GetUserMusicApi(全量)
"UserCourse", # GetUserCourseApi(单页)
"UserCourseAll", # GetUserCourseApi(全量)
"UserCharge", # GetUserChargeApi
"UserFavorite", # GetUserFavoriteApi(单类)
"UserFavoriteAll", # GetUserFavoriteApi(全量)
"UserActivity", # GetUserActivityApi
"UserMissionData", # GetUserMissionDataApi
"UserIntimate", # GetUserIntimateApi
"UserShopStock", # GetUserShopStockApi
"UserFavoriteItem", # GetUserFavoriteItemApi(单页)
"UserFavoriteItemAll", # GetUserFavoriteItemApi(全量)
"UserRivalData", # GetUserRivalDataApi
"UserRivalMusic", # GetUserRivalMusicApi(单页)
"UserRivalMusicAll", # GetUserRivalMusicApi(全量)
"UserFriendBonus", # GetUserFriendBonusApi
"UserKaleidxScope", # GetUserKaleidxScopeApi
"GameKaleidxScope", # GetGameKaleidxScopeApi
"UserRegion", # GetUserRegionApi
"UserRecommendRateMusic", # GetUserRecommendRateMusicApi
"UserRecommendSelectMusic", # GetUserRecommendSelectMusicApi
"UserNewItemList", # GetUserNewItemListApi
# --- 取数:游戏侧 ---
"GetGameRanking", # GetGameRankingApi
"GetGameEvent", # GetGameEventApi
"GetGameCharge", # GetGameChargeApi
"GetGameWeeklyData", # GetGameWeeklyDataApi
"GetGameTournamentInfo", # GetGameTournamentInfoApi
"GetGameNgMusicId", # GetGameNgMusicIdApi
)
# ---------------------------------------------------------------------------
# 7) 导入即包装:保证 from api import X 拿到的是带日志的版本
# ---------------------------------------------------------------------------
autowrap(globals(), _API_FUNCS)
# ---------------------------------------------------------------------------
# 8) 统一导出
# ---------------------------------------------------------------------------
__all__ = [
# ---- 日志:会话 ----
"setup_logging", "teardown_logging",
# ---- 日志:自动跟踪 ----
"autowrap", "get_called_apis",
# ---- 日志:模式 ----
"get_level", "set_level", "is_debug", "LEVEL_INFO", "LEVEL_DEBUG",
# ---- 日志:装饰器 / 兼容 ----
"api_log", "get_logger",
# ---- 日志:流程 ----
"log_info", "log_step", "log_result", "log_warning", "log_error",
"log_section", "log_field",
# ---- 日志:收发数据 ----
"log_debug", "log_request", "log_response", "log_payload",
# ---- 网络核心 ----
"MaimaiNetClient", "MaimaiNetError", "get_default_client", "close_default_client",
"request", "request_dict", "request_vo",
"obfuscate_api_name", "build_api_url", "build_user_agent", "adler32",
"aes_encrypt", "aes_decrypt", "deflate_compress", "deflate_decompress",
"vo_to_json", "vo_from_json",
"local_utc_offset_seconds", "login_date_time_now",
"EmptyRequestVO", "UserIdRequestVO", "ReturnCodeVO",
# ---- 会话链路 ----
"ParseQRCode",
"GetGameSettings", "GameSetting", "GameSettingRequestVO", "GameSettingResponseVO",
"is_maintenance", "is_ping_disabled",
"Ping",
"UserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO",
"UserLogin", "UserLoginRequestVO", "UserLoginResponseVO",
"UserLogout", "LogoutType", "DelayLog", "RequestAPI",
"UserLogoutRequestVO", "UserLogoutRequest2VO", "UserLogoutResponseVO",
"create_empty_delay_log", "create_delay_log_from_stats", "DELAY_LOG_SLOTS",
# ---- 上传成绩 ----
"UpsertUserAll", "PlayData", "build_play_from_config", "derive_play_characters",
"load_extra_data", "merge_extra_data", "verify_upsert_landing",
# ---- 购买功能票 ----
"UpsertUserChargelog", "ChargelogConfirmError", "ChargelogGuardError",
"UserChargeVO", "UserChargelogVO", "UserChargelogRequestVO",
"ticket_name", "ticket_price", "calc_valid_date", "parse_wall_time", "now_cst",
"UserAllRequestVO", "UpsertResponseVO",
"UpsertUserAllError", "ConfirmRequiredError", "PatchError",
"NODE_TYPES", "NODE_ORDER", "BITS_TO_ARRAY", "ARRAY_TO_BITS",
"DIFF_PRIMARY_KEYS", "MAY_BE_EMPTY_NODES", "DEFAULT_EMPTY_NODES",
"empty_node", "empty_user_all", "coerce_node_value", "sync_bits_to_arrays",
"build_delta", "build_favorite_delta", "build_user_all",
"build_user_playlog", "build_user_game_playlog", "build_post_play_user_data",
"validate_user_playlog", "validate_before_send", "apply_play_effects",
"default_user_weekly_data", "default_user_2p_playlog", "calc_play_special",
"normalize_snapshot", "wait_send_cooldown",
"PLAYLOG_FIELDS", "PLAYLOG_BOOL_FIELDS", "PLAYLOG_STRING_FIELDS",
"JUDGE_TYPES", "JUDGE_BOXES",
"USER_DATA_DROP_KEYS", "USER_OPTION_DROP_KEYS", "USER_CHARACTER_KEEP_KEYS",
"USER_MUSIC_DETAIL_DROP_KEYS", "USER_CHARGE_DROP_KEYS", "USER_MISSION_DROP_KEYS",
# ---- 快照编排 ----
"fetch_upsert_snapshot", "save_snapshot", "load_snapshot",
"summarize_snapshot", "DEFAULT_SNAPSHOT_NODES",
# ---- 取数公共底座 ----
"GetterEmptyRequestVO", "GetterUserIdRequestVO", "UserIdPageRequestVO",
"UserLoginBonusRequestVO", "UserItemRequestVO", "UserMusicRequestVO",
"UserCourseRequestVO", "UserFavoriteRequestVO", "UserFavoriteItemRequestVO",
"UserShopStockRequestVO", "UserRivalRequestVO", "UserRivalMusicRequestVO",
"fetch_all_pages",
"ITEM_KINDS", "ITEM_NEXT_INDEX_FACTOR", "FAVORITE_ITEM_KINDS", "RIVAL_MUSIC_LEVELS",
# ---- 取数脚本 ----
"UserData", "UserExtend", "UserOption", "UserCharacter", "UserGhost",
"UserMap", "UserMapAll",
"UserLoginBonus", "UserLoginBonusAll",
"UserRating",
"UserItem", "UserItemAll",
"UserMusicDetail", "UserMusicDetailAll",
"UserCourse", "UserCourseAll",
"UserCharge",
"UserFavorite", "UserFavoriteAll",
"UserActivity", "UserMissionData", "UserIntimate", "UserShopStock",
"UserFavoriteItem", "UserFavoriteItemAll",
"FAVORITE_ITEM_KIND_MUSIC", "FAVORITE_ITEM_KIND_RIVAL",
"UserRivalData", "UserRivalMusic", "UserRivalMusicAll",
"UserFriendBonus", "extract_gift_points",
"UserKaleidxScope", "GameKaleidxScope",
"UserRegion",
"UserRecommendRateMusic", "UserRecommendSelectMusic",
"UserNewItemList", "UserNewItemListRequestVO",
"GetGameRanking", "GameRankingRequestVO",
"GetGameEvent", "GameEventRequestVO",
"GetGameCharge", "GameChargeRequestVO",
"GetGameWeeklyData", "GetGameTournamentInfo", "GetGameNgMusicId",
]
+301
View File
@@ -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())
+53
View File
@@ -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,
)
+60
View File
@@ -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,
)
+45
View File
@@ -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,
)
+56
View File
@@ -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,
)
+45
View File
@@ -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,
)
+45
View File
@@ -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,
)
+175
View File
@@ -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())
+704
View File
@@ -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)
+773
View File
@@ -0,0 +1,773 @@
# -*- coding: utf-8 -*-
"""
maimai_net.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
================================================================================
本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。
--------------------------------------------------------------------------------
一、协议依据(全部来自 source/ 反编译源码)
--------------------------------------------------------------------------------
1) 服务器基址与 API 路径
source/Assembly-CSharp/Manager/OperationManager.cs:225,338
ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
source/Assembly-CSharp/Net.Packet/Packet.cs:116
Client = NetHttpClient.Create(BaseUrl + Obfuscator(query.Api))
source/Assembly-CSharp/Net.Packet/Packet.cs:485
Obfuscator(s) = md5_hex_lower(s + "8bF76dE9")
source/Assembly-CSharp/Net.VO/NetQuery.cs:14
Api = api + "MaimaiChn"
=> 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "8bF76dE9")
已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "8bF76dE9")
= 83a5d5a20b062c5c2ad817460b8f7f76
(source/2026-10-05.log:1 的请求就是打到这个尾段)
2) 请求体封装(三段:JSON -> deflate -> AES)
source/Assembly-CSharp/Net/NetHttpClient.cs:262-300 RequestCallback
a. DeflateStream(CompressionMode.Compress) 压缩 JSON 字节
b. 手工拼 zlib 头 0x78 0x9C + 压缩数据 + Adler32(4 字节大端)
c. CipherAES.Encrypt(...) 整体 AES 加密
source/Assembly-CSharp/Net/CipherAES.cs:11-30
AES-256-CBC / PKCS7 / Key 32B / IV 16B
3) 响应体解封装(逆过程)
source/Assembly-CSharp/Net/NetHttpClient.cs:355-380 Decrypt + Decompress
a. AES 解密
b. 去掉前 2 字节 zlib 头与后 4 字节 Adler32
c. Deflate 解压 -> UTF-8 JSON
4) 请求头
source/Assembly-CSharp/Net/NetHttpClient.cs:174-190 Request()
Content-Type : application/json
charset : UTF-8
Mai-Encoding : 1.55 (DefaultHttpHeaderEncryption)
Content-Encoding: deflate (有请求体时)
User-Agent : <混淆后的API名>#<userId 或 clientId>
(source/Assembly-CSharp/Net/NetPacketUtil.cs:12-19)
number : <ping 毫秒>(非 Ping 接口;Packet.cs:189)
★ number 的语义:GameManager.PingMsec,只由 Ping 心跳包刷新
(PingChecker.cs:227 <- PacketPing.cs:34 的 sw.ElapsedMilliseconds)。
真机整局 53 个业务请求的 number 恒为同一个值(实测 63)。
所以调用方**应当先跑一次 Ping()**,之后的请求才会带上真实 number;
没跑过时 number = 0,与真机开机后第一屏的行为一致。
★ Cookie:★ UserLoginApi 的响应会下发
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
(Packet.cs:228-234 只在 UserLogin 响应上把它收进 CookieContainer,
之后 Packet.cs:190-193 每个带 userId 的请求都挂回去)。
Ping 等接口的响应头里没有 Set-Cookie,所以必须跑过登录才会拿到。
这是**真实的会话粘滞**,必须带着。
requests.Session 会自动收下并自动带上,无需额外代码 ——
但前提是**全程复用同一个 MaimaiNetClient**(用 get_default_client())。
--------------------------------------------------------------------------------
二、★ 一个必须注意的坑:Accept-Encoding 必须为空
--------------------------------------------------------------------------------
requests 默认会发 Accept-Encoding: gzip, deflate。
本协议响应体是 AES 密文(二进制),服务端若据此做内容编码,会返回空 body。
实测:不发该头 -> HTTP 200 / 0 字节;显式置空 -> 正常返回。
C# 端不需要是因为 HttpWebRequest 默认 AutomaticDecompression = None。
所以这里固定 Accept-Encoding: ""。
--------------------------------------------------------------------------------
三、对外接口
--------------------------------------------------------------------------------
MaimaiNetClient 客户端类(连接复用 + Cookie 管理)
get_default_client() 取全局默认客户端
request(...) 便捷函数:发一次请求并返回原始 JSON 字符串
request_vo(...) 便捷函数:发一次请求并返回映射后的 dataclass
加解密工具:aes_encrypt / aes_decrypt / deflate_compress / deflate_decompress
协议工具 :obfuscate_api_name / build_api_url / adler32
"""
from __future__ import annotations
import hashlib
import json
import time
import zlib
from dataclasses import dataclass, field, is_dataclass
from typing import Any, Dict, List, Optional, Type, TypeVar
from urllib.parse import urljoin
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from config import (
AES_KEY,
AES_IV,
BASE_URL,
CLIENT_ID,
MAI_ENCODING,
OBFUSCATE_PARAM,
TIMEOUT_LONG_MS,
TIMEOUT_MS,
)
from .log_setup import log_debug, log_error, log_request, log_response
# =============================================================================
# 协议常量
# =============================================================================
#: API 名后缀(NetQuery.cs:14 Api = api + "MaimaiChn")
API_SUFFIX = "MaimaiChn"
#: HTTP 头(NetHttpClient.cs:174-190)
HEADER_CONTENT_TYPE = "application/json"
HEADER_CHARSET = "UTF-8"
HEADER_CONTENT_ENCODING = "deflate"
#: AES 参数
AES_BLOCK_SIZE = 16
T = TypeVar("T")
# =============================================================================
# 一、协议工具
# =============================================================================
def obfuscate_api_name(api_name: str) -> str:
"""
对逻辑接口名做 MD5 混淆,得到 URL 尾段(对应 Packet.Obfuscator)。
逻辑名 "GetUserPreviewApi" -> "GetUserPreviewApiMaimaiChn"
-> md5(上面 + "8bF76dE9")
参数:
api_name: 逻辑接口名,**不要**自己带 MaimaiChn 后缀
返回:
32 位小写十六进制字符串
"""
full_name = api_name if api_name.endswith(API_SUFFIX) else api_name + API_SUFFIX
return hashlib.md5((full_name + OBFUSCATE_PARAM).encode("utf-8")).hexdigest()
def build_api_url(api_name: str) -> str:
"""构建完整的请求 URL。"""
return urljoin(BASE_URL, obfuscate_api_name(api_name))
def build_user_agent(api_name: str, user_id: int = 0, client_id: str = CLIENT_ID) -> str:
"""
构建 User-Agent(对应 NetPacketUtil.GetUserAgent)。
userId != 0 -> "<混淆API名>#<userId>"
userId == 0 -> "<混淆API名>#<clientId>"
"""
tail = user_id if user_id else client_id
return obfuscate_api_name(api_name) + "#" + str(tail)
def adler32(data: bytes) -> int:
"""
计算 Adler-32 校验和(对应 NetHttpClient.Adler32:437-452)。
★ 注意与 zlib.adler32 的区别:C# 端是自己实现的,
返回值是 (b << 16) + a,与 zlib.adler32 一致,这里保留原实现以便对照。
"""
a = 1
b = 0
for byte in data:
a = (a + byte) % 65521
b = (b + a) % 65521
return (b << 16) | a
# =============================================================================
# 二、加解密 / 压缩
# =============================================================================
def aes_encrypt(data: bytes) -> bytes:
"""AES-256-CBC 加密(PKCS7 填充)。对应 CipherAES.Encrypt。"""
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
return cipher.encrypt(pad(data, AES_BLOCK_SIZE))
def aes_decrypt(data: bytes) -> bytes:
"""AES-256-CBC 解密(PKCS7 去填充)。对应 CipherAES.Decrypt。"""
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
return unpad(cipher.decrypt(data), AES_BLOCK_SIZE)
def deflate_compress(data: bytes) -> bytes:
"""
原始 deflate 压缩 + 手工拼 zlib 头与 Adler32。
对应 NetHttpClient.cs:262-300 RequestCallback:
memoryStream.WriteByte(120); // 0x78
memoryStream.WriteByte(156); // 0x9C
... 原始 deflate 数据 ...
... Adler32 大端 4 字节 ...
★ C# 的 DeflateStream 产生的是**原始 deflate**(无 zlib 头),
所以 Python 侧要用 compressobj(wbits=-zlib.MAX_WBITS)。
"""
compressor = zlib.compressobj(wbits=-zlib.MAX_WBITS)
compressed = compressor.compress(data) + compressor.flush()
checksum = adler32(data)
return bytes([0x78, 0x9C]) + compressed + checksum.to_bytes(4, "big")
def deflate_decompress(data: bytes) -> bytes:
"""
去掉 zlib 头与 Adler32 后做原始 deflate 解压。
对应 NetHttpClient.cs:355-380 Decompress:
_temporaryStream.Position = 2; // 跳过 2 字节头
_temporaryStream.SetLength(_temporaryStream.Length - 4); // 去掉 4 字节校验
"""
if len(data) < 6:
return b""
return zlib.decompress(data[2:-4], -zlib.MAX_WBITS)
# =============================================================================
# 三、真机时间戳
# =============================================================================
def local_utc_offset_seconds() -> int:
"""取本机时区相对 UTC 的偏移秒数(东八区 = +28800)。"""
offset = -time.timezone
if time.daylight and time.localtime().tm_isdst > 0:
offset = -time.altzone
return int(offset)
def login_date_time_now() -> int:
"""
生成真机 UserLoginApi 的 loginDateTime。
★ 真机算法(source/Assembly-CSharp/MAI2/DateTime.cs + PacketUserLogin.cs:29):
MAI2.DateTime.Now 返回 DateTime.UtcNow + LocalOffset,Kind = Unspecified;
new DateTimeOffset(该 Unspecified 值) 会再按本地时区加一次偏移,
最终 ToUnixTimeSeconds() = 标准 Unix 秒 + 本地时区偏移。
东八区即 now + 28800。
★ 为什么必须一致:
PacketUserLogin.cs:43 会把请求值回填进响应,
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
与登录时不一致会被服务端拒绝。
"""
return int(time.time()) + local_utc_offset_seconds()
# =============================================================================
# 四、VO 序列化 / 反序列化
# =============================================================================
def vo_to_json(obj: Any) -> str:
"""
把请求对象序列化成紧凑 JSON(对应 VOSerializer.Serialize)。
规则:
- dataclass -> 按字段声明顺序,跳过值为 None 的字段
- 带 __dict__ 的对象 -> 同上
- dict / 其他 -> 直接 json.dumps
"""
if obj is None:
return "{}"
if is_dataclass(obj) and not isinstance(obj, type):
data = {k: getattr(obj, k) for k in obj.__dataclass_fields__}
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
if isinstance(obj, dict):
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
if hasattr(obj, "__dict__"):
data = {k: v for k, v in vars(obj).items() if v is not None and not k.startswith("_")}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
def _json_default(value: Any) -> Any:
"""json.dumps 的兜底转换:dataclass / 普通对象 -> dict。"""
if is_dataclass(value) and not isinstance(value, type):
return {k: getattr(value, k) for k in value.__dataclass_fields__}
if hasattr(value, "__dict__"):
return {k: v for k, v in vars(value).items() if not k.startswith("_")}
return str(value)
def _convert_value(value: Any, target_type: Any) -> Any:
"""按目标类型递归转换嵌套 dataclass。"""
if value is None:
return None
if isinstance(target_type, type) and is_dataclass(target_type) and isinstance(value, dict):
kwargs = {}
for fname, fdef in target_type.__dataclass_fields__.items():
if fname in value:
kwargs[fname] = _convert_value(value[fname], fdef.type)
return target_type(**kwargs)
origin = getattr(target_type, "__origin__", None)
if origin is list and isinstance(value, list):
args = getattr(target_type, "__args__", None)
if args and isinstance(args[0], type) and is_dataclass(args[0]):
return [_convert_value(v, args[0]) for v in value]
return value
return value
def vo_from_json(cls: Type[T], json_str: str) -> T:
"""
把响应 JSON 反序列化成 dataclass(对应 VOSerializer.Deserialize)。
★ 只填充类里声明过的字段 —— 服务端多给的字段会被忽略,不会报错。
"""
data = json.loads(json_str)
if not isinstance(data, dict):
return data # type: ignore[return-value]
if is_dataclass(cls):
kwargs = {}
for fname, fdef in cls.__dataclass_fields__.items():
if fname in data:
kwargs[fname] = _convert_value(data[fname], fdef.type)
return cls(**kwargs)
obj = cls() # type: ignore[call-arg]
for k, v in data.items():
if hasattr(obj, k):
setattr(obj, k, v)
return obj
# =============================================================================
# 五、网络客户端
# =============================================================================
class MaimaiNetError(RuntimeError):
"""网络层 / 协议层错误。"""
class MaimaiNetClient:
"""
舞萌DX 游戏服务器客户端。
一次完整请求的流程:
请求方向:VO -> 紧凑 JSON -> 原始 deflate -> 0x78 0x9C + Adler32 -> AES-256-CBC -> HTTP POST
响应方向:HTTP Body -> AES 解密 -> 去 2 字节头 / 4 字节尾 -> deflate 解压 -> JSON -> VO
Cookie 由 requests.Session 自动管理;登录接口返回的 Set-Cookie 会被保存,
后续请求自动带上(对应 C# 的 CookieContainer,Packet.cs:183-190)。
"""
def __init__(
self,
base_url: str = BASE_URL,
timeout_ms: int = TIMEOUT_MS,
verify_ssl: bool = False,
) -> None:
self.base_url = base_url.rstrip("/") + "/"
self.timeout = timeout_ms / 1000.0
self.verify_ssl = verify_ssl
self.session = requests.Session()
#: ★ number 头的值(Packet.cs:189 GameManager.PingMsec)。
#: 只由 **Ping 心跳包**更新(对应 PingChecker.cs:227 PingSuccess),
#: 普通业务请求不会改动它 —— 真机整局 53 个请求的 number 恒为 63。
self.ping_msec: int = 0
if not verify_ssl:
try:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except Exception:
pass
# -------------------------------------------------------------------------
# 内部
# -------------------------------------------------------------------------
def _build_headers(
self,
api_name: str,
user_id: int = 0,
client_id: str = CLIENT_ID,
has_body: bool = True,
ping_msec: Optional[int] = None,
) -> Dict[str, str]:
"""
构建请求头(对应 NetHttpClient.Request:174-190 + Packet.ProcImpl:166-176)。
★ 关于 number 头:
source/Assembly-CSharp/Net.Packet/Packet.cs:187-194
if (!IsPingPacket())
{
Client.AddHeader("number", GameManager.PingMsec.ToString());
if (Query.UserId != 0L) { Client.AddCookie(Query.UserId); }
}
即**除 Ping 外的每个接口**都会带一个 number 头,值是本机到服务器的
ping 毫秒数(GameManager.PingMsec)。
★ number 的来源(源码链路,逐条可查):
Main/PingChecker.cs:166 StartPacket(new PacketPing(PingSuccess, PingError))
Net.Packet.Mai2/PacketPing.cs:34
_onDone((int)sw.ElapsedMilliseconds)
→ 值 = **Ping 包的往返毫秒**
Main/PingChecker.cs:227 GameManager.PingMsec = pingMsec
所以 number **只由 Ping 心跳刷新**,普通业务请求不会改它。
实测(gamehook 抓真机一局 70 个请求):
#1 ~ #10 number = 0 ← 还没跑过 Ping
#11 ~ #17 Ping 心跳(不带 number 头)
#18 ~ #70 number = 63 ← Ping 往返 63ms,此后恒定
本类在 request() 里对 Ping 包做同样的事:把往返毫秒写进 self.ping_msec。
★ 关于 Cookie(Packet.cs:190-193 + 228-234):
Client.AddCookie(Query.UserId) 把「登录时服务端 Set-Cookie 存下来的
CookieContainer」挂到本次请求上(NetHttpClient.cs:170-177)。
★ 实测 **UserLoginApi 的响应头里确实有**:
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
而 Ping 等接口的响应头里没有 Set-Cookie(只有 Date / Content-Type /
Content-Length / Connection / Pragma / Cache-Control)。
所以「登录前没有 Cookie、登录后一直带着 JSESSIONID」正是真机行为。
Cookie 由 requests.Session 自动管理:收到就存、后续自动带 —— 无需额外代码,
但**必须全程复用同一个 MaimaiNetClient**,否则会丢掉 JSESSIONID。
"""
headers = {
"Content-Type": HEADER_CONTENT_TYPE,
"charset": HEADER_CHARSET,
"Mai-Encoding": MAI_ENCODING,
"User-Agent": build_user_agent(api_name, user_id, client_id),
# ★ 必须显式置空,见模块 docstring 第二节
"Accept-Encoding": "",
}
if has_body:
headers["Content-Encoding"] = HEADER_CONTENT_ENCODING
# ★ number 头(Packet.cs:189):除 Ping 外的接口都要带
if not api_name.startswith("Ping"):
value = self.ping_msec if ping_msec is None else ping_msec
headers["number"] = str(int(value))
return headers
@property
def last_ping_msec(self) -> int:
"""number 头的当前值(= 最近一次 Ping 的往返毫秒)。兼容旧字段名。"""
return self.ping_msec
def _encrypt_request(self, json_str: str) -> bytes:
"""JSON -> deflate(zlib头+Adler32) -> AES。"""
return aes_encrypt(deflate_compress(json_str.encode("utf-8")))
def _decrypt_response(self, encrypted: bytes) -> str:
"""AES -> 去头尾 -> deflate -> UTF-8 字符串。"""
return deflate_decompress(aes_decrypt(encrypted)).decode("utf-8")
# -------------------------------------------------------------------------
# 公开
# -------------------------------------------------------------------------
def request(
self,
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> str:
"""
发送一次请求,返回**原始 JSON 响应字符串**。
参数:
api_name: 逻辑接口名(如 "GetUserPreviewApi",不要带 MaimaiChn)
request_vo: 请求体对象(dataclass / dict / None 表示空对象 {})
user_id: 用户 ID(0 表示未登录请求;影响 User-Agent 与 Cookie)
client_id: 客户端 ID
timeout_ms: 超时覆盖(毫秒)
extra_headers: 额外请求头(如 number)
返回:
响应 JSON 字符串
异常:
MaimaiNetError: 网络异常 / 空响应 / 解密或解压失败
"""
url = urljoin(self.base_url, obfuscate_api_name(api_name))
timeout = (timeout_ms or self.timeout * 1000) / 1000.0
request_json = vo_to_json(request_vo)
body = self._encrypt_request(request_json)
headers = self._build_headers(api_name, user_id, client_id, has_body=True)
if extra_headers:
headers.update(extra_headers)
# ★ DEBUG 模式:记录「向 api 发送的数据」
log_request(url=url, body=request_json, headers=headers, api_name=api_name)
t0 = time.monotonic()
try:
response = self.session.post(
url,
data=body,
headers=headers,
timeout=timeout,
verify=self.verify_ssl,
)
except requests.exceptions.RequestException as exc:
log_error("接口 " + api_name + " 请求失败:" + str(exc))
raise MaimaiNetError("接口 " + api_name + " 请求失败:" + str(exc)) from exc
elapsed_ms = int((time.monotonic() - t0) * 1000)
# ★ 只有 Ping 包会刷新 number(对应 PingChecker.cs:224-229 PingSuccess)。
# 普通业务包不动它,否则 number 会随每个包的耗时抖动,与真机不符。
if api_name.startswith("Ping"):
self.ping_msec = elapsed_ms
content = response.content
# 0 字节响应:请求未进入业务层
if len(content) == 0:
detail = (
"接口 " + api_name + " 返回 HTTP " + str(response.status_code)
+ ",响应体为 0 字节(空响应)。\n"
" 含义:服务端没有返回任何内容,请求很可能未进入业务层。\n"
" 排查:userId/token 是否有效、接口名与请求体字段是否与源码一致、"
"Accept-Encoding 是否被置空。\n"
" 响应头:" + repr(dict(response.headers))
)
log_error(detail)
raise MaimaiNetError(detail)
try:
response_json = self._decrypt_response(content)
except ValueError as exc:
detail = (
"接口 " + api_name + " 响应解密失败(HTTP " + str(response.status_code)
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
" 含义:服务器返回了内容,但不是合法的 AES 密文,或长度不是 16 的倍数。"
)
log_error(detail)
raise MaimaiNetError(detail) from exc
except zlib.error as exc:
detail = (
"接口 " + api_name + " 响应解压失败(HTTP " + str(response.status_code)
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
" 含义:AES 解密成功但 deflate 解压失败,响应不是预期的压缩格式"
"(可能是网关/防火墙返回的页面)。"
)
log_error(detail)
raise MaimaiNetError(detail) from exc
if not response_json:
detail = (
"接口 " + api_name + " 返回空明文(HTTP " + str(response.status_code)
+ ",密文 " + str(len(content)) + " 字节,解密后为空串)。\n"
" 含义:链路与加密都正常,但服务端没有返回内容。\n"
" 排查:userId / token 是否失效、keychip 与该卡是否匹配、该卡是否正在游戏中。"
)
log_error(detail)
raise MaimaiNetError(detail)
# ★ DEBUG 模式:记录「api 返回的数据」
try:
shown_body: Any = json.loads(response_json)
except Exception:
shown_body = response_json
log_response(
status=response.status_code,
body=shown_body,
headers={k: v for k, v in response.headers.items()},
api_name=api_name,
elapsed_ms=elapsed_ms,
)
return response_json
def request_vo(
self,
api_name: str,
request_vo: Any,
response_cls: Type[T],
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> T:
"""发送请求并把响应映射成 dataclass。"""
raw = self.request(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
return vo_from_json(response_cls, raw)
def request_dict(
self,
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""
发送请求并返回**原始响应 dict**。
★ 取数脚本一律用这个:upsertUserAll 需要响应原文,
映射成 dataclass 会丢掉服务端多给的字段。
"""
raw = self.request(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
data = json.loads(raw)
return data if isinstance(data, dict) else {"_data": data}
def close(self) -> None:
"""关闭底层会话。"""
try:
self.session.close()
except Exception:
pass
def __enter__(self) -> "MaimaiNetClient":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
# =============================================================================
# 六、全局默认客户端
# =============================================================================
_default_client: Optional[MaimaiNetClient] = None
def get_default_client() -> MaimaiNetClient:
"""获取(必要时创建)全局默认客户端 —— 连接与 Cookie 会自动复用。"""
global _default_client
if _default_client is None:
_default_client = MaimaiNetClient()
return _default_client
def close_default_client() -> None:
"""关闭全局默认客户端(一般不需要手动调用)。"""
global _default_client
if _default_client is not None:
_default_client.close()
_default_client = None
def request(
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> str:
"""便捷函数:用默认客户端发一次请求,返回原始 JSON 字符串。"""
net = client or get_default_client()
return net.request(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
def request_dict(
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""便捷函数:用默认客户端发一次请求,返回原始响应 dict。"""
net = client or get_default_client()
return net.request_dict(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
def request_vo(
api_name: str,
request_vo: Any,
response_cls: Type[T],
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> T:
"""便捷函数:用默认客户端发一次请求,返回映射后的 dataclass。"""
net = client or get_default_client()
return net.request_vo(
api_name, request_vo, response_cls,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
# =============================================================================
# 七、通用请求 / 响应 VO
# =============================================================================
@dataclass
class EmptyRequestVO:
"""空请求体 —— 对应 C# 里没有任何字段的 RequestVO(如 PingRequestVO)。"""
@dataclass
class UserIdRequestVO:
"""只有 userId 的请求体(对应 UserDetailRequestVO / UserCharacterRequestVO 等)。"""
userId: int
@dataclass
class ReturnCodeVO:
"""通用「只有 returnCode」的响应体。"""
returnCode: int = 0
# =============================================================================
# 单独运行:协议自检(不发业务请求)
# =============================================================================
if __name__ == "__main__":
print("=== maimai_net 协议自检 ===")
print("BASE_URL :", BASE_URL)
print("GetGameSettingApi :", build_api_url("GetGameSettingApi"))
print(" 期望尾段 : 83a5d5a20b062c5c2ad817460b8f7f76")
print("User-Agent 示例 :", build_user_agent("UserLoginApi", 12345678))
sample = '{"userId":12345678,"token":"abc"}'
packed = deflate_compress(sample.encode("utf-8"))
print("deflate 头/尾 :", packed[:2].hex(), packed[-4:].hex(),
" Adler32 =", hex(adler32(sample.encode("utf-8"))))
assert deflate_decompress(packed).decode("utf-8") == sample
enc = aes_encrypt(packed)
assert aes_decrypt(enc) == packed
print("AES 往返 : OK(密文", len(enc), "字节,16 的倍数:", len(enc) % 16 == 0, ")")
print("自检通过")
+95
View File
@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""
ping.py —— 心跳探活(PingMaimaiChn)
================================================================================
功能:向游戏服务器发一次心跳,确认链路可达。
对应接口:PingMaimaiChn
请求体 :{}(source/Assembly-CSharp/Net.VO.Mai2/PingRequestVO.cs 是空类)
响应 :{"result": "Pong"}
★ 接口名(容易写错,注意):
source/Assembly-CSharp/Net.Packet.Mai2/PacketPing.cs:22
new NetQuery<PingRequestVO, PingResponseVO>("Ping", 0uL);
客户端传的逻辑名是 **"Ping"**,NetQuery.cs:14 再补上 "MaimaiChn" 后缀,
所以混淆用的全名是 "PingMaimaiChn":
md5("PingMaimaiChn" + "8bF76dE9") = 3bae8c1162b3148abcfc38a89c03e3e7
本模块传 "Ping" 即可,obfuscate_api_name 会自动补后缀。
(Packet.cs:96 用 Query.Api.StartsWith("Ping") 判断是否心跳包,
也是基于补完后缀的 "PingMaimaiChn"。)
★ 为什么要跑它(不只是探活):
Packet.cs:189 非 Ping 接口都会带 HTTP 头 number = GameManager.PingMsec
PingChecker.cs:227 GameManager.PingMsec = Ping 包的往返毫秒
所以 **Ping 是 number 头的唯一来源**。先 Ping 一次,后面的业务请求才会
带上真实 number(真机实测 63ms);不 Ping 则 number = 0,
与真机开机后第一屏(还没跑过心跳)的行为一致。
MaimaiNetClient 在收到 Ping 响应后会自动把往返毫秒记进 client.ping_msec。
调用示例:
from api import Ping
if Ping():
print("服务器可达")
"""
from __future__ import annotations
from typing import Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict
def Ping(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> bool:
"""
心跳探活。
参数:
client_id: 机台短值(可选)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
True = 服务器返回 "Pong"
"""
log_info("发送心跳探活")
raw = request_dict(
"Ping", {},
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
result = raw.get("result", "")
ok = (result == "Pong")
if ok:
log_info("心跳正常:result=Pong")
else:
log_warning("心跳返回异常:result=" + str(result))
return ok
if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("ping_test")
try:
ok = Ping()
print("")
print("服务器可达:" + str(ok))
return 0 if ok else 1
finally:
teardown_logging()
raise SystemExit(_main())
+319
View File
@@ -0,0 +1,319 @@
# -*- coding: utf-8 -*-
"""
upsert_snapshot.py —— 结算快照编排(统一调用各取数脚本)
================================================================================
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(userdata.py / user_map.py …)。
--------------------------------------------------------------------------------
为什么需要快照
--------------------------------------------------------------------------------
服务器对 userData 是**整表覆盖**语义,对差分节点是**只回传变化项**语义。
要把本局的变化正确表达出来,必须先有一份「登录时的服务器状态」作基线,
再把本局的影响叠加到基线上 —— 这就是快照的作用。
真机对照(source/2026-10-05.log):
| 节点 | Get 侧(基线) | Upsert 侧(本局后) |
|---------------------|-------------------|----------------------|
| userData.playCount | 1257 | 1258 (+1) |
| userMapList | 104 条 | 仅 1 条(distance 变)|
| userMusicDetailList | 739 组 | 仅 1 条(playCount+1)|
| userItemList | 1070 条 | [](无变化) |
--------------------------------------------------------------------------------
调用示例
--------------------------------------------------------------------------------
from api import fetch_upsert_snapshot
snapshot = fetch_upsert_snapshot(user_id, token=token)
# 之后直接传给 UpsertUserAll(snapshot=snapshot, ...)
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Sequence
from config import CLIENT_ID, PLACE_ID, PLAY_SHOP_ITEM_IDS, REGION_ID, TIMEOUT_MS
from .log_setup import log_debug, log_info, log_warning
from .maimai_net import MaimaiNetClient
# 各取数脚本(一个接口一个脚本)
from .userdata import UserData
from .user_extend import UserExtend
from .user_option import UserOption
from .user_character import UserCharacter
from .user_ghost import UserGhost
from .user_map import UserMapAll
from .user_login_bonus import UserLoginBonusAll
from .user_rating import UserRating
from .user_item import UserItemAll
from .user_music import UserMusicDetailAll
from .user_course import UserCourseAll
from .user_charge import UserCharge
from .user_favorite import UserFavoriteAll
from .user_activity import UserActivity
from .user_mission import UserMissionData
from .user_intimate import UserIntimate
from .user_shop_stock import UserShopStock
from .user_favorite_item import UserFavoriteItemAll
from .user_kaleidx_scope import UserKaleidxScope
#: 默认要抓的节点(顺序即抓取顺序)
#: ★ 只列 UpsertUserAll 真正需要的;纯展示类接口(排行/活动/大会…)不在此列。
DEFAULT_SNAPSHOT_NODES: tuple = (
"userData",
"userExtend",
"userOption",
"userCharacterList",
"userMapList",
"userLoginBonusList",
"userRatingList",
"userItemList",
"userMusicDetailList",
"userCourseList",
"userChargeList",
"userFavoriteList",
"userActivityList",
"userMissionDataList",
"userIntimateList",
"userShopItemStockList",
"userFavoritemusicList",
"userKaleidxScopeList",
"userGhost",
)
def fetch_upsert_snapshot(
user_id: int,
*,
token: str = "",
client_id: str = CLIENT_ID,
place_id: int = PLACE_ID,
region_id: int = REGION_ID,
shop_item_ids: Optional[Sequence[int]] = None,
nodes: Optional[Sequence[str]] = None,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
strict: bool = False,
) -> Dict[str, Any]:
"""
抓取结算所需的全部基线数据。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(可选,多数取数接口不需要)
client_id: 机台短值
place_id: 店铺 ID
region_id: 区域 ID
shop_item_ids: 商店商品清单(可选,默认 config.PLAY_SHOP_ITEM_IDS)
nodes: 要抓的节点(可选,默认 DEFAULT_SNAPSHOT_NODES)
timeout_ms: 超时毫秒
client: 可复用的 MaimaiNetClient
strict: True = 任一接口失败就抛异常;
False(默认)= 记录失败并跳过,保证链路不崩
返回:
{
"userData": <GetUserDataApi 原始响应>,
"userExtend": <GetUserExtendApi 原始响应>,
"userMapList": {"userMapList": [...]}, # 已摊平/翻页
"userMusicDetailList": {"userMusicList": [...]},# 已摊平成单条
...
"_failed": [ {"node":.., "error":..}, ... ]
}
这个结构可以直接传给 UpsertUserAll(snapshot=...),也可 json 落盘。
"""
wanted = list(nodes) if nodes else list(DEFAULT_SNAPSHOT_NODES)
log_info("抓取结算快照:user_id=" + str(user_id) + " 节点数=" + str(len(wanted)))
raw: Dict[str, Any] = {}
failed: List[Dict[str, str]] = []
def _call(node: str, fn, *args, **kwargs):
"""逐节点容错:单个接口失败不影响整体。"""
try:
raw[node] = fn(*args, **kwargs)
return True
except Exception as exc:
failed.append({"node": node, "error": type(exc).__name__ + ": " + str(exc)})
log_warning("节点 " + node + " 抓取失败:" + type(exc).__name__ + ": " + str(exc))
if strict:
raise
return False
common = dict(client_id=client_id, timeout_ms=timeout_ms, client=client)
# ---- 逐个抓取(顺序与 DEFAULT_SNAPSHOT_NODES 一致)----
if "userData" in wanted:
_call("userData", UserData, user_id, **common)
if "userExtend" in wanted:
_call("userExtend", UserExtend, user_id, **common)
if "userOption" in wanted:
_call("userOption", UserOption, user_id, **common)
if "userCharacterList" in wanted:
_call("userCharacterList", UserCharacter, user_id, **common)
if "userMapList" in wanted:
_call("userMapList", UserMapAll, user_id, **common)
if "userLoginBonusList" in wanted:
_call("userLoginBonusList", UserLoginBonusAll, user_id, **common)
if "userRatingList" in wanted:
_call("userRatingList", UserRating, user_id, **common)
if "userItemList" in wanted:
_call("userItemList", UserItemAll, user_id, **common)
if "userMusicDetailList" in wanted:
_call("userMusicDetailList", UserMusicDetailAll, user_id, **common)
if "userCourseList" in wanted:
_call("userCourseList", UserCourseAll, user_id, **common)
if "userChargeList" in wanted:
_call("userChargeList", UserCharge, user_id, **common)
if "userFavoriteList" in wanted:
# ★ 该接口额外带一份**原始** itemKind 副本(_raw),
# 供 UpsertUserAll 生成 isNewFavoriteList 位串用
# —— 真机 itemKind=10 回的是 null,正是它产生了位串里的 "1"。
if _call("userFavoriteList", UserFavoriteAll, user_id, **common):
fav_raw = (raw.get("userFavoriteList") or {}).get("_raw")
if fav_raw:
raw["_raw_favorite"] = fav_raw
if "userActivityList" in wanted:
_call("userActivityList", UserActivity, user_id, **common)
if "userMissionDataList" in wanted:
# ★ 该接口的响应里同时带着 userWeeklyData(周界),
# 它是 Upsert 侧 userWeeklyData 节点的来源,会被自动提取。
_call("userMissionDataList", UserMissionData, user_id, **common)
if "userIntimateList" in wanted:
_call("userIntimateList", UserIntimate, user_id, **common)
if "userShopItemStockList" in wanted:
ids = list(shop_item_ids) if shop_item_ids is not None else list(PLAY_SHOP_ITEM_IDS)
if ids:
_call("userShopItemStockList", UserShopStock, user_id,
shop_item_id_list=ids, **common)
else:
log_debug("未配置 PLAY_SHOP_ITEM_IDS,跳过商店库存节点")
if "userFavoritemusicList" in wanted:
_call("userFavoritemusicList", UserFavoriteItemAll, user_id, 1, **common)
if "userKaleidxScopeList" in wanted:
_call("userKaleidxScopeList", UserKaleidxScope, user_id, **common)
if "userGhost" in wanted:
_call("userGhost", UserGhost, user_id, **common)
raw["_failed"] = failed
log_info("结算快照抓取完成:成功 " + str(len(raw) - 1) + " 个节点,失败 "
+ str(len(failed)) + " 个")
return raw
def save_snapshot(snapshot: Dict[str, Any], path: str) -> str:
"""把快照写到 JSON 文件(便于离线复用 / 人工核对)。"""
with open(path, "w", encoding="utf-8") as fp:
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
log_info("快照已保存:" + path)
return path
def load_snapshot(path: str) -> Dict[str, Any]:
"""从 JSON 文件读回快照。"""
with open(path, "r", encoding="utf-8") as fp:
data = json.load(fp)
log_info("快照已加载:" + path)
return data
def summarize_snapshot(snapshot: Dict[str, Any]) -> Dict[str, int]:
"""
统计快照各节点的条数,便于快速核对(返回 {节点名: 条数})。
条数含义:
userData 这类单元素数组 -> 1
userMusicDetailList 这类列表 -> 摊平后的条数
"""
out: Dict[str, int] = {}
for node, value in (snapshot or {}).items():
if node.startswith("_"):
continue
if isinstance(value, list):
out[node] = len(value)
elif isinstance(value, dict):
# 从响应里挑出列表
found = False
for key, sub in value.items():
if isinstance(sub, list):
out[node] = len(sub)
found = True
break
if not found:
out[node] = 1
else:
out[node] = 0
return out
# =============================================================================
# 单独运行(调试)
# =============================================================================
if __name__ == "__main__":
import sys
from .log_setup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("upsert_snapshot_test")
try:
if len(sys.argv) < 2:
print("用法: python -m api.upsert_snapshot <user_id> [--token TOKEN] [--save 路径]")
return 1
user_id = int(sys.argv[1])
token = ""
save_path = ""
args = sys.argv[2:]
i = 0
while i < len(args):
if args[i] == "--token" and i + 1 < len(args):
token = args[i + 1]
i += 2
elif args[i] == "--save" and i + 1 < len(args):
save_path = args[i + 1]
i += 2
else:
i += 1
snap = fetch_upsert_snapshot(user_id, token=token)
summary = summarize_snapshot(snap)
print("")
for node, count in summary.items():
print(" " + node.ljust(28) + "= " + str(count) + " 条")
if save_path:
save_snapshot(snap, save_path)
return 0
except Exception as exc:
log_error("抓取快照失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
File diff suppressed because it is too large Load Diff
+531
View File
@@ -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())
+48
View File
@@ -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)
+46
View File
@@ -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)
+45
View File
@@ -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)
+71
View File
@@ -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}
+46
View File
@@ -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)
+102
View File
@@ -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}
+92
View File
@@ -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}
+64
View File
@@ -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
+245
View File
@@ -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
+47
View File
@@ -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)
+43
View File
@@ -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)
+124
View File
@@ -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}
+75
View File
@@ -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)
+76
View File
@@ -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}
+77
View File
@@ -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}
+48
View File
@@ -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)
+90
View File
@@ -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}
+84
View File
@@ -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,
)
+47
View File
@@ -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)
+46
View File
@@ -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)
+64
View File
@@ -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)
+44
View File
@@ -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)
+128
View File
@@ -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}
+58
View File
@@ -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,
)
+58
View File
@@ -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)
+222
View File
@@ -0,0 +1,222 @@
# -*- coding: utf-8 -*-
"""
userlogin.py —— 用户正式登录(UserLoginApi)
================================================================================
功能:创建登录会话,返回 loginId / loginDateTime / 登录天数统计。
loginId 与 loginDateTime 是后续结算上报与登出的**必需输入**。
对应接口:UserLoginApi
请求体 :10 个字段,见 source/Assembly-CSharp/Net.VO.Mai2/UserLoginRequestVO.cs
source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:21-31
响应 :UserLoginResponseVO(returnCode=1 表示成功)
--------------------------------------------------------------------------------
★ 关于 loginDateTime(与后续接口强相关,务必理解)
--------------------------------------------------------------------------------
真机取的是「本地墙上时间被当作 UTC 时刻」的秒数,即
loginDateTime = 标准 Unix 时间戳 + 本地时区偏移
东八区即 now + 28800。
成因:MAI2.DateTime.cs 里 DateTime.UtcNow + LocalOffset 得到 Kind=Unspecified
的值,PacketUserLogin.cs:29 再用 new DateTimeOffset(该值) 又加一次本地偏移。
影响:PacketUserLogin.cs:43 会把请求值回填响应;
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
与登录时不一致会被服务端拒绝。
本模块默认按真机算法生成;需要手动指定时传 login_date_time。
★ 关于 dateTime 与 loginDateTime 的区别
dateTime = ALL.Net 认证时刻(Auth.AuthTime,标准 Unix 秒)
loginDateTime= 本次登录时刻(上面的特殊算法)
两者都出现在 UserLoginRequestVO 里,但只有 loginDateTime 会被后续接口复用。
调用示例:
from api import UserLogin
login = UserLogin(12345678, "TESTTOKEN0000000000", region_id=1, place_id=1000)
print(login.loginId, login.loginDateTime)
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_LONG_MS
from .log_setup import log_debug, log_error, log_info
from .maimai_net import (
MaimaiNetClient,
local_utc_offset_seconds,
login_date_time_now,
request_dict,
)
@dataclass
class UserLoginRequestVO:
"""请求 VO(对应 UserLoginRequestVO,字段顺序照 C# 声明)。"""
userId: int
accessCode: str = ""
regionId: int = 0
placeId: int = 0
clientId: str = ""
dateTime: int = 0
loginDateTime: int = 0
isContinue: bool = False
genericFlag: int = 0
token: str = ""
@dataclass
class UserLoginResponseVO:
"""
响应 VO(对应 UserLoginResponseVO)。
★ 后两个字段是**客户端回填**的,服务端不返回:
loginDateTime —— PacketUserLogin.cs:43 用请求值回填,
UpsertUserAll / UserLogout 都复用它
dateTime —— 本次请求发出的 ALL.Net 认证时刻,
UpsertUserAll 的 userData.dateTime 要用它
(真机 source/2026-10-05.log:123 两处都是 1791134329)
"""
returnCode: int = 0
lastLoginDate: str = ""
loginCount: int = 0
consecutiveLoginCount: int = 0
loginId: int = 0
loginDateTime: int = 0
token: str = ""
dateTime: int = 0
def UserLogin(
user_id: int,
token: str,
*,
access_code: str = "",
region_id: int = REGION_ID,
place_id: int = PLACE_ID,
client_id: str = CLIENT_ID,
is_continue: bool = False,
generic_flag: int = 0,
date_time: Optional[int] = None,
login_date_time: Optional[int] = None,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> UserLoginResponseVO:
"""
用户正式登录(创建登录会话)。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(必填,来自 ParseQRCode)
access_code: 访问码(可选,通常为空)
region_id: 区域 ID(可选,默认 config.REGION_ID)
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
client_id: 机台短值(可选)
is_continue: 是否续玩(可选)
generic_flag: 通用标志位(可选)
date_time: ALL.Net 认证时刻(可选,默认当前 Unix 秒)
login_date_time: 本次登录时刻(可选,默认按真机算法生成)
timeout_ms: 超时毫秒(可选,默认 60 秒)
client: 可复用的 MaimaiNetClient(可选)
返回:
UserLoginResponseVO(loginDateTime 已按真机行为回填)
异常:
RuntimeError: returnCode != 1
"""
log_info(
"用户正式登录:user_id=" + str(user_id)
+ " region_id=" + str(region_id) + " place_id=" + str(place_id)
)
now = int(time.time())
if date_time is None:
date_time = now
if login_date_time is None:
login_date_time = login_date_time_now()
log_debug(
"时间戳:dateTime=" + str(date_time) + " loginDateTime=" + str(login_date_time)
+ "(本地时区偏移 " + str(local_utc_offset_seconds()) + " 秒)"
)
raw = request_dict(
"UserLoginApi",
UserLoginRequestVO(
userId=user_id,
accessCode=access_code,
regionId=region_id,
placeId=place_id,
clientId=client_id,
dateTime=date_time,
loginDateTime=login_date_time,
isContinue=is_continue,
genericFlag=generic_flag,
token=token,
),
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_LONG_MS, client=client,
)
known = {f for f in UserLoginResponseVO.__dataclass_fields__}
resp = UserLoginResponseVO(**{k: v for k, v in raw.items() if k in known})
if resp.returnCode != 1:
msg = "登录失败:returnCode=" + str(resp.returnCode)
log_error(msg)
raise RuntimeError(msg)
# ★ 回填 loginDateTime(对应 PacketUserLogin.cs:43)
# 服务端不返回该字段,客户端用请求值回填;后续 UpsertUserAll / UserLogout 复用。
if not resp.loginDateTime:
resp.loginDateTime = login_date_time
log_debug("服务端未返回 loginDateTime,按真机行为回填为 " + str(login_date_time))
# ★ 回填 dateTime:本次请求实际发出的 ALL.Net 认证时刻。
# UpsertUserAll 的 userData.dateTime 要用它(真机两处一致)。
resp.dateTime = date_time
log_info(
"用户登录成功:loginId=" + str(resp.loginId)
+ " loginDateTime=" + str(resp.loginDateTime)
+ " dateTime=" + str(resp.dateTime)
+ " lastLoginDate=" + resp.lastLoginDate
+ " loginCount=" + str(resp.loginCount)
+ " consecutiveLoginCount=" + str(resp.consecutiveLoginCount)
)
return resp
if __name__ == "__main__":
import sys
from .log_setup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("userlogin_test")
try:
if len(sys.argv) < 3:
print("用法: python -m api.userlogin <user_id> <token> [region_id] [place_id]")
return 1
rid = int(sys.argv[3]) if len(sys.argv) > 3 else REGION_ID
pid = int(sys.argv[4]) if len(sys.argv) > 4 else PLACE_ID
lg = UserLogin(int(sys.argv[1]), sys.argv[2], region_id=rid, place_id=pid)
print("")
for name in UserLoginResponseVO.__dataclass_fields__:
print(" " + name.ljust(24) + "= " + str(getattr(lg, name)))
return 0
except Exception as exc:
log_error("用户登录失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
+254
View File
@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""
userlogout.py —— 用户登出(UserLogoutApi)
================================================================================
功能:结束登录会话,可选上报本次会话的网络统计(delayLog)。
对应接口:UserLogoutApi
请求体 :两种形态,见 source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogout.cs:22-63
type == 1 (Logout) -> UserLogoutRequest2VO(含 delayLog)
其他 type -> UserLogoutRequestVO (不含 delayLog)
--------------------------------------------------------------------------------
登出类型(source/Assembly-CSharp/Net.VO.Mai2/LogoutType.cs)
--------------------------------------------------------------------------------
None = 0
Logout = 1 正常登出(带 delayLog 统计)
Cancel = 2 取消 / 强制登出指定座席(NetPacketUtil.cs:47)
Error = 3 错误强制登出(GameMain.cs:91)
TestIn = 4 测试登入 / 顶号(GameMain.cs:104)
Quit = 5 退出时强制登出,可释放残留会话(GameMainObject.cs:200)
★ 真机会话结束时用的是 type=4(TestIn),见 source/2026-10-05.log:125。
★ delayLog 结构(source/Assembly-CSharp/Net.VO.Mai2/DelayLog.cs)
request 是定长 26 的数组(DelayLog.cs:17 new RequestAPI[26]),
每个元素 {count, size, msec, retry}。
调用示例:
from api import UserLogout, LogoutType
UserLogout(12345678, token, login.loginDateTime,
logout_type=LogoutType.TestIn, region_id=1, place_id=1000)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_MS
from .log_setup import log_debug, log_error, log_info
from .maimai_net import MaimaiNetClient, request_dict
#: DelayLog.request 的定长(DelayLog.cs:17)
DELAY_LOG_SLOTS = 26
class LogoutType:
"""登出类型(对应 Net.VO.Mai2.LogoutType)。"""
None_ = 0
Logout = 1 # 正常登出(带 delayLog)
Cancel = 2 # 取消 / 强制登出
Error = 3 # 错误强制登出
TestIn = 4 # 测试登入 / 顶号(真机会话结束用这个)
Quit = 5 # 退出强制登出
@dataclass
class RequestAPI:
"""单个接口的统计(对应 Net.VO.Mai2/RequestAPI.cs)。"""
count: int = 0
size: int = 0
msec: int = 0
retry: int = 0
@dataclass
class DelayLog:
"""会话网络统计(对应 Net.VO.Mai2/DelayLog.cs)。"""
dlRequests: int = 0
dlSize: int = 0
dlRetry: int = 0
loginMsec: int = 0
saveMsec: int = 0
reductionMusic: int = 0
reductionItem: int = 0
request: List[RequestAPI] = field(
default_factory=lambda: [RequestAPI() for _ in range(DELAY_LOG_SLOTS)]
)
@dataclass
class UserLogoutRequestVO:
"""简单登出请求 VO(对应 UserLogoutRequestVO)。"""
userId: int
accessCode: str = ""
regionId: int = 0
placeId: int = 0
clientId: str = ""
loginDateTime: int = 0
type: int = 0
@dataclass
class UserLogoutRequest2VO:
"""完整登出请求 VO(对应 UserLogoutRequest2VO,含 delayLog)。"""
userId: int
accessCode: str = ""
regionId: int = 0
placeId: int = 0
clientId: str = ""
loginDateTime: int = 0
type: int = 0
delayLog: Optional[DelayLog] = None
@dataclass
class UserLogoutResponseVO:
"""响应 VO(对应 UserLogoutResponseVO)。"""
returnCode: int = 0
def create_empty_delay_log() -> DelayLog:
"""创建全零的 DelayLog(26 个槽位)。"""
return DelayLog()
def create_delay_log_from_stats(
requests_stats: Optional[List[Dict[str, Any]]] = None,
*,
dl_requests: int = 0,
dl_size: int = 0,
dl_retry: int = 0,
login_msec: int = 0,
save_msec: int = 0,
reduction_music: int = 0,
reduction_item: int = 0,
) -> DelayLog:
"""
用统计数据构造 DelayLog。
参数:
requests_stats: 每个接口的统计列表,元素形如
{"count":1,"size":2090,"msec":73,"retry":0}
按 DelayLog.request 的槽位顺序填入(最多 26 个)
"""
dl = DelayLog(
dlRequests=dl_requests, dlSize=dl_size, dlRetry=dl_retry,
loginMsec=login_msec, saveMsec=save_msec,
reductionMusic=reduction_music, reductionItem=reduction_item,
)
for i, stat in enumerate(requests_stats or []):
if i >= DELAY_LOG_SLOTS:
break
dl.request[i] = RequestAPI(
count=int(stat.get("count", 0)),
size=int(stat.get("size", 0)),
msec=int(stat.get("msec", 0)),
retry=int(stat.get("retry", 0)),
)
return dl
def UserLogout(
user_id: int,
token: str,
login_date_time: int,
*,
logout_type: int = LogoutType.TestIn,
access_code: str = "",
region_id: int = REGION_ID,
place_id: int = PLACE_ID,
client_id: str = CLIENT_ID,
delay_log: Optional[DelayLog] = None,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> UserLogoutResponseVO:
"""
用户登出。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(必填)
login_date_time: 登录时刻(必填,取自 UserLogin 的 loginDateTime)
logout_type: 登出类型(可选,默认 4=TestIn,与真机一致)
access_code: 访问码(可选)
region_id: 区域 ID(可选)
place_id: 店铺 ID(可选)
client_id: 机台短值(可选)
delay_log: 会话统计(可选;仅 logout_type=1 时随报文发出)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
UserLogoutResponseVO
异常:
RuntimeError: returnCode != 1
"""
log_info(
"用户登出:user_id=" + str(user_id) + " logout_type=" + str(logout_type)
+ " loginDateTime=" + str(login_date_time)
)
if logout_type == LogoutType.Logout:
body: Any = UserLogoutRequest2VO(
userId=user_id, accessCode=access_code,
regionId=region_id, placeId=place_id, clientId=client_id,
loginDateTime=login_date_time, type=logout_type,
delayLog=delay_log if delay_log is not None else create_empty_delay_log(),
)
log_debug("使用完整登出报文(含 delayLog)")
else:
body = UserLogoutRequestVO(
userId=user_id, accessCode=access_code,
regionId=region_id, placeId=place_id, clientId=client_id,
loginDateTime=login_date_time, type=logout_type,
)
log_debug("使用简单登出报文(不含 delayLog)")
raw = request_dict(
"UserLogoutApi", body,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
resp = UserLogoutResponseVO(returnCode=int(raw.get("returnCode", 0) or 0))
if resp.returnCode != 1:
msg = "登出失败:returnCode=" + str(resp.returnCode)
log_error(msg)
raise RuntimeError(msg)
log_info("用户登出成功:returnCode=" + str(resp.returnCode))
return resp
if __name__ == "__main__":
import sys
from .log_setup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("userlogout_test")
try:
if len(sys.argv) < 4:
print("用法: python -m api.userlogout <user_id> <token> <loginDateTime> "
"[logout_type] [region_id] [place_id]")
return 1
lt = int(sys.argv[4]) if len(sys.argv) > 4 else LogoutType.TestIn
rid = int(sys.argv[5]) if len(sys.argv) > 5 else REGION_ID
pid = int(sys.argv[6]) if len(sys.argv) > 6 else PLACE_ID
resp = UserLogout(int(sys.argv[1]), sys.argv[2], int(sys.argv[3]),
logout_type=lt, region_id=rid, place_id=pid)
print("")
print("returnCode =", resp.returnCode)
return 0
except Exception as exc:
log_error("用户登出失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
+156
View File
@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""
userpreview.py —— 用户预览(GetUserPreviewApi)
================================================================================
功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。
对应接口:GetUserPreviewApi
请求体 :{"userId":.., "segaIdAuthKey":"", "token":"..", "clientId":".."}
source/Assembly-CSharp/Net.VO.Mai2/UserPreviewRequestVO.cs
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserPreview.cs:20-25
响应 :见 UserPreviewResponseVO(23 字段,含 errorId)
★ 真机调用时机:扫码拿到 userId/token 之后、UserLoginApi 之前
(source/2026-10-05.log:23)。
★ 客户端行为:isLogin=True 时拒绝继续登录,避免顶号
(source/Assembly-CSharp/Process.Entry.State/ConfirmPlay.cs:151)。
调用示例:
from api import UserPreview
pv = UserPreview(12345678, "TESTTOKEN0000000000")
print(pv.userName, pv.playerRating)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict
@dataclass
class UserPreviewRequestVO:
"""请求 VO(对应 UserPreviewRequestVO)。"""
userId: int
segaIdAuthKey: str = ""
token: str = ""
clientId: str = ""
@dataclass
class UserPreviewResponseVO:
"""
响应 VO(对应 UserPreviewResponseVO + 真机响应字段)。
★ 字段顺序按 source/Assembly-CSharp/Net.VO.Mai2/UserPreviewResponseVO.cs
与 source/2026-10-05.log:24 的真机响应合并。
"""
userId: int = 0
userName: str = ""
isLogin: bool = False
lastGameId: Optional[str] = None
lastRomVersion: str = ""
lastDataVersion: str = ""
lastLoginDate: str = ""
lastPlayDate: str = ""
playerRating: int = 0
nameplateId: int = 0
iconId: int = 0
trophyId: int = 0
partnerId: int = 0
frameId: int = 0
dispRate: int = 0
totalAwake: int = 0
isNetMember: int = 0
dailyBonusDate: str = ""
headPhoneVolume: Optional[int] = None
isInherit: bool = False
banState: int = 0
errorId: int = 0
def UserPreview(
user_id: int,
token: str,
*,
client_id: str = CLIENT_ID,
sega_id_auth_key: str = "",
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> UserPreviewResponseVO:
"""
获取用户预览信息。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(必填,来自 ParseQRCode)
client_id: 机台短值(可选)
sega_id_auth_key: SEGA ID 鉴权 key(可选,通常为空)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
UserPreviewResponseVO
"""
log_info("获取用户预览:user_id=" + str(user_id) + " client_id=" + client_id)
raw = request_dict(
"GetUserPreviewApi",
UserPreviewRequestVO(
userId=user_id,
segaIdAuthKey=sega_id_auth_key,
token=token,
clientId=client_id,
),
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
known = {f for f in UserPreviewResponseVO.__dataclass_fields__}
resp = UserPreviewResponseVO(**{k: v for k, v in raw.items() if k in known})
if resp.errorId != 0:
log_warning("服务器返回 errorId=" + str(resp.errorId))
if resp.banState != 0:
log_warning("账号可能被封禁:banState=" + str(resp.banState))
if resp.isLogin:
log_warning("账号处于已登录状态(isLogin=True),继续登录会顶号")
log_info(
"用户预览获取完成:userName=" + resp.userName
+ " playerRating=" + str(resp.playerRating)
+ " isLogin=" + str(resp.isLogin)
+ " isNetMember=" + str(resp.isNetMember)
)
return resp
if __name__ == "__main__":
import sys
from .log_setup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("userpreview_test")
try:
if len(sys.argv) < 3:
print("用法: python -m api.userpreview <user_id> <token> [client_id]")
return 1
cid = sys.argv[3] if len(sys.argv) > 3 else CLIENT_ID
pv = UserPreview(int(sys.argv[1]), sys.argv[2], client_id=cid)
print("")
for name in UserPreviewResponseVO.__dataclass_fields__:
print(" " + name.ljust(18) + "= " + str(getattr(pv, name)))
return 0
except Exception as exc:
log_error("获取用户预览失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())