maipulse: 脱敏存档

- Python 后端 + 单页前端 + Android(Chaquopy) 壳
- 曲库 1343 首 / 称号 2800 个
- 机台身份与店铺信息已抹除,字段留空
- 打包脚本含 16KB 页对齐与自动脱敏
This commit is contained in:
shouko committed 2026-10-28 22:27:46 +08:00
commit 8421898626
161 files changed
+50297

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
"""
ChargeGuard.py —— 买票前置闸门(GetUserCharge 必须已经跑过)
================================================================================
★ 这个模块解决什么问题:
````
错误写法(危险):
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
# ↑ 没先查 GetUserCharge —— 万一账号已经持有一张功能票,
# 服务端看到的就是「重复购买」,有封号风险。
```
正确写法:
```
held = GetUserCharge(user_id) # ① 先查:有没有持票
...
UpsertUserChargelog(user_id, 2, ...) # ② 再买
```
本模块在 ① 里记一笔「这个 user_id 刚查过票券」,在 ② 里检查这笔记录,
没查过就抛 `ChargelogGuardError`(**未发包**)。
★ 还顺手挡第二件事:**同一个账号在同一个进程里只允许买一次票**。
真机买票是「买完立刻被这一局用掉」(stock--),正常状态 stock 恒为 0;
脚本连着买两张是明显的异常行为,这里直接拦掉。
★ 记忆只存在于**当前进程**(内存字典),脚本退出即清空 —— 不做持久化,
免得下次运行时被上一次的记录误拦。
依据:
source/Assembly-CSharp/Manager/TicketManager.cs:130-174 UseTicketPrepare
source/Assembly-CSharp/Manager/TicketManager.cs:234-253 RequestUserCharge
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserCharge.cs:20
source/Assembly-CSharp/Net.Packet.Mai2/PacketUploadUserChargelog.cs:19
"""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
#: GetUserCharge 结果的「新鲜度」窗口(秒)。
#: 超过这个时间才买票,认为那次查询已经过期,需要重查。
CHARGE_READ_FRESH_SEC = 600.0
#: user_id -> 最近一次 GetUserCharge 的 monotonic 时刻
_LAST_READ: Dict[int, float] = {}
#: user_id -> 最近一次 GetUserCharge 返回的 userChargeList(原样)
_LAST_LIST: Dict[int, List[Dict[str, Any]]] = {}
#: user_id -> 本进程内已经买过的 chargeId 列表
_PURCHASES: Dict[int, List[int]] = {}
# =============================================================================
# 一、GetUserCharge 侧:记一笔
# =============================================================================
def note_user_charge_read(
user_id: int,
charge_list: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""
记录「刚刚为这个 user_id 查过票券」。
★ 由 GetUserCharge() 在拿到响应后自动调用,调用方不用管。
"""
try:
uid = int(user_id)
except (TypeError, ValueError):
return
_LAST_READ[uid] = time.monotonic()
if isinstance(charge_list, list):
_LAST_LIST[uid] = [c for c in charge_list if isinstance(c, dict)]
def last_read_age(user_id: int) -> Optional[float]:
"""距上次 GetUserCharge 过了多少秒;从没查过返回 None。"""
try:
uid = int(user_id)
except (TypeError, ValueError):
return None
ts = _LAST_READ.get(uid)
if ts is None:
return None
return max(0.0, time.monotonic() - ts)
def has_fresh_read(
user_id: int,
max_age: float = CHARGE_READ_FRESH_SEC,
) -> bool:
"""这个 user_id 最近(max_age 秒内)查过 GetUserCharge 吗?"""
age = last_read_age(user_id)
if age is None:
return False
return age <= float(max_age)
def last_charge_list(user_id: int) -> List[Dict[str, Any]]:
"""最近一次 GetUserCharge 返回的 userChargeList(没有则空表)。"""
try:
return list(_LAST_LIST.get(int(user_id)) or [])
except (TypeError, ValueError):
return []
def held_charges(user_id: int) -> List[Dict[str, Any]]:
"""最近一次查到的「持有中」(stock > 0)的票券。"""
return [c for c in last_charge_list(user_id) if int(c.get("stock", 0) or 0) > 0]
# =============================================================================
# 二、UpsertUserChargelog 侧:买之前查
# =============================================================================
def note_charge_purchase(user_id: int, charge_id: int) -> None:
"""记录「这个 user_id 刚刚买了一张 chargeId 的票」。"""
try:
uid = int(user_id)
except (TypeError, ValueError):
return
_PURCHASES.setdefault(uid, []).append(int(charge_id))
def purchased_charge_ids(user_id: int) -> List[int]:
"""本进程内这个 user_id 已经买过的 chargeId。"""
try:
return list(_PURCHASES.get(int(user_id)) or [])
except (TypeError, ValueError):
return []
def reset(user_id: Optional[int] = None) -> None:
"""清空记忆(测试用;不传 user_id 就全清)。"""
if user_id is None:
_LAST_READ.clear()
_LAST_LIST.clear()
_PURCHASES.clear()
return
try:
uid = int(user_id)
except (TypeError, ValueError):
return
_LAST_READ.pop(uid, None)
_LAST_LIST.pop(uid, None)
_PURCHASES.pop(uid, None)
+311
View File
@@ -0,0 +1,311 @@
# -*- 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 KEYCHIP_ID
# =============================================================================
# aimedb 扫码登录常量(原在 config.py,2026-10-06 下沉到本模块)
# =============================================================================
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
# + docs 分支《舞萌DX_扫码登录协议技术规范》
#: aimedb 服务器
AIMEDB_HOST = "ai.sys-allnet.cn"
AIMEDB_PORT = 443
AIMEDB_PATH = "/wc_aime/api/get_data"
#: 签名密钥(含前导 X,32 字符)
#: 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致
QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
#: 固定字段
QR_OPEN_GAME_ID = "MAID"
QR_TITLE_KEY = "SDGB"
QR_USER_AGENT = "WC_AIME_LIB"
from .LogSetup import log_debug, log_error, log_info, log_request, log_response
# =============================================================================
# 二维码格式常量(CCommQRCode.cs + 技术规范)
# =============================================================================
QR_PREFIX = "SGWC" # Chime.WechatID
QR_GAME_ID = "MAID"
QR_PREFIX_LEN = 20 # SGWC(4) + MAID(4) + timestamp(12)
QR_PAYLOAD_LEN = 64
QR_TOTAL_LEN = QR_PREFIX_LEN + QR_PAYLOAD_LEN # 84
#: 默认机台 chipID
#:
#: ★★ 必须用**完整形式**(带连字符),不是短值 —— 这是个容易搞错的点:
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
#: KeychipID = AMDaemon.System.KeychipId.Value; // 完整形式
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:255
#: Chime.Instance.Init(GameID, KeychipID, _chimeDevice, TitleKey);
#: 而游戏服务器那边用的是短值:
#: source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
#: clientId = AMDaemon.System.KeychipId.ShortValue; // 短值
#:
#: 即:aimedb 扫码用完整 KeychipId,游戏服务器用 ShortValue。
#: docs 分支的《扫码登录协议技术规范》§3.4 里也有对应的抓包验证向量。
#: token 与 chipID 绑定,用错会导致后续接口「returnCode=1 但不落库」。
DEFAULT_CHIP_ID = KEYCHIP_ID
# =============================================================================
# 内部工具
# =============================================================================
def _extract_qr_from_url(url: str) -> str:
"""
从二维码 URL 提取完整 84 字符串。
URL 形如:
https://wq.wahlap.net/qrcode/req/MAID2610022219463A....html?l=...&t=...&d=...
取 /qrcode/req/ 与 .html 之间的 80 字符,前面补 "SGWC"。
"""
path = urlparse(url).path
match = re.search(r"/qrcode/req/([^/]+)\.html", path)
if not match:
raise ValueError("URL 路径格式不符合预期,无法提取二维码数据:" + url)
return QR_PREFIX + match.group(1)
def _normalize_qr(qr_input: str) -> str:
"""URL 或字符串 -> 标准 84 字符二维码串(含格式校验)。"""
qr = (qr_input or "").strip()
if qr.startswith("http://") or qr.startswith("https://"):
log_debug("输入是 URL,先提取二维码字符串")
qr = _extract_qr_from_url(qr)
if len(qr) != QR_TOTAL_LEN:
raise ValueError(
"二维码长度应为 " + str(QR_TOTAL_LEN) + " 字符,实际 " + str(len(qr)) + " 字符:" + qr
)
if not qr.startswith(QR_PREFIX):
raise ValueError("二维码必须以 '" + QR_PREFIX + "' 开头:" + qr)
if qr[4:8] != QR_GAME_ID:
raise ValueError(
"二维码 GameID 应为 '" + QR_GAME_ID + "',实际 '" + qr[4:8] + "':" + qr
)
if not all(c in "0123456789ABCDEF" for c in qr[QR_PREFIX_LEN:]):
raise ValueError("二维码载荷必须为大写十六进制:" + qr)
log_debug(
"二维码校验通过:timestamp=" + qr[8:20]
+ " payload_len=" + str(len(qr[QR_PREFIX_LEN:]))
)
return qr
def _build_body(qr_string: str, chip_id: str) -> Tuple[bytes, str, str]:
"""
构造请求体。
签名(技术规范 §4.1):
key = SHA-256(chipID + timestamp + SIGN_KEY).hexdigest().upper()
★ 拼接里不含 qrCode、不含 titlekey
qrCode 字段(CCommQRCode.EraseHashIdentifier):
qr_string[20:] 即 64 位载荷
返回:
(body_bytes, timestamp, key)
"""
timestamp = datetime.now().strftime("%y%m%d%H%M%S")
key = hashlib.sha256((chip_id + timestamp + QR_SIGN_KEY).encode()).hexdigest().upper()
body_dict = {
"chipID": chip_id,
"openGameID": QR_OPEN_GAME_ID,
"key": key,
"qrCode": qr_string[QR_PREFIX_LEN:],
"timestamp": timestamp,
"titlekey": QR_TITLE_KEY,
}
body_bytes = json.dumps(body_dict, separators=(",", ":")).encode("utf-8")
return body_bytes, timestamp, key
def _send(body_bytes: bytes) -> dict:
"""
发送 HTTPS POST 并解析 JSON 响应。
★ 请求头只放 Connection / User-Agent / Content-Length。
绝不能加 Content-Type —— 加了会得到 errorID=57(技术规范 §3.3)。
"""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
headers = {
"Connection": "Keep-Alive",
"User-Agent": QR_USER_AGENT,
"Content-Length": str(len(body_bytes)),
# ★ 此处刻意不加 Content-Type
}
conn = http.client.HTTPSConnection(AIMEDB_HOST, AIMEDB_PORT, context=ctx, timeout=20)
try:
conn.request("POST", AIMEDB_PATH, body=body_bytes, headers=headers)
resp = conn.getresponse()
text = resp.read().decode("utf-8")
status = resp.status
finally:
conn.close()
log_response(status=status, body=text, api_name="aimedb/get_data")
try:
return json.loads(text)
except Exception as exc:
raise RuntimeError("aimedb 响应不是合法 JSON:" + text[:300]) from exc
def _parse(resp: dict) -> Tuple[int, str]:
"""解析响应,返回 (user_id, token)。"""
error_id = resp.get("errorID", -1)
user_id = resp.get("userID", -1)
token = resp.get("token", "") or ""
if error_id != 0:
raise RuntimeError(
"扫码登录失败:errorID=" + str(error_id) + ",响应=" + json.dumps(resp, ensure_ascii=False)
+ "\n 提示:errorID=57 通常是二维码已失效/已被使用,或请求头带了 Content-Type"
)
if user_id == -1 or not token:
raise RuntimeError(
"扫码登录失败:userID=" + str(user_id) + ", token='" + token + "'"
)
return int(user_id), str(token)
# =============================================================================
# 公开 API
# =============================================================================
def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int, str]:
"""
解析二维码并换取 userId 与 token。
参数:
qr_input: 84 字符二维码串(SGWCMAID...),或二维码 URL
chip_id: 机台 Keychip 短值(可选,默认取 config.CLIENT_ID)
返回:
(user_id, token) 元组,可直接传给 GetUserPreview / 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 .LogSetup 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 -*-
"""
GetGameCharge.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 .LogSetup import log_info
from .MaimaiNet 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,
)
+175
View File
@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
"""
GetGameSettings.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 .LogSetup import log_info, log_warning
from .MaimaiNet 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 .LogSetup 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 .LogSetup import log_error
log_error("获取游戏设置失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
+413
View File
@@ -0,0 +1,413 @@
# -*- coding: utf-8 -*-
"""
GetNeedUserData.py —— 结算快照编排(统一调用各取数脚本)
================================================================================
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(GetUserData.py / GetUserMap.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 GetNeedUserData
snapshot = GetNeedUserData(user_id, token=token)
# 之后直接传给 UpsertUserAll(snapshot=snapshot, ...)
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from config import CLIENT_ID, PLACE_ID, PLAY_SHOP_ITEM_IDS, REGION_ID, TIMEOUT_MS
from .LogSetup import log_debug, log_info, log_warning
from .MaimaiNet import MaimaiNetClient
# 各取数脚本(一个接口一个脚本)
from .GetUserData import GetUserData
from .GetUserExtend import GetUserExtend
from .GetUserOption import GetUserOption
from .GetUserCharacter import GetUserCharacter
from .GetUserGhost import GetUserGhost
from .GetUserMap import GetUserMapAll
from .GetUserLoginBonus import GetUserLoginBonusAll
from .GetUserRating import GetUserRating
from .GetUserItem import GetUserItemAll
from .GetUserMusic import GetUserMusicDetailAll
from .GetUserCourse import GetUserCourseAll
from .GetUserCharge import GetUserCharge
from .GetUserFavorite import GetUserFavoriteAll
from .GetUserActivity import GetUserActivity
from .GetUserMission import GetUserMissionData
from .GetUserIntimate import GetUserIntimate
from .GetUserShopStock import GetUserShopStock
from .GetUserFavoriteItem import GetUserFavoriteItemAll
from .GetUserKaleidxScope import GetUserKaleidxScope
# =============================================================================
# 落盘目录:<工程根>/data/<userId>.json
# =============================================================================
#: 工程根目录(本文件在 <root>/api/ 下)
PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
#: 用户数据目录:<工程根>/data
DATA_DIR: Path = PROJECT_ROOT / "data"
def user_data_path(user_id: int) -> Path:
"""用户数据的落盘路径:<工程根>/data/<userId>.json"""
return DATA_DIR / (str(int(user_id)) + ".json")
def save_user_data(snapshot: Dict[str, Any], user_id: int,
path: Optional[str] = None) -> str:
"""
把 GetNeedUserData 抓到的数据写到 <工程根>/data/<userId>.json。
★ **新数据总是覆盖老数据**(不是追加、也不是合并)。
★ 目录不存在会自动创建。
参数:
snapshot: GetNeedUserData 的返回值
user_id: 用户 ID(决定文件名)
path: 自定义路径(可选,默认走 data/<userId>.json)
返回:实际写入的文件路径
"""
target = Path(path) if path else user_data_path(user_id)
try:
os.makedirs(str(target.parent), exist_ok=True)
with open(str(target), "w", encoding="utf-8") as fp:
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
except Exception as exc:
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
return str(target)
size = 0
try:
size = os.path.getsize(str(target))
except Exception:
pass
log_info("用户数据已保存(覆盖):" + str(target) + " " + str(size) + " 字节")
return str(target)
def load_user_data(user_id: int, path: Optional[str] = None) -> Dict[str, Any]:
"""
读回 <工程根>/data/<userId>.json。
找不到文件时抛 FileNotFoundError(调用方自行决定要不要回退到联网抓取)。
"""
target = Path(path) if path else user_data_path(user_id)
if not target.exists():
raise FileNotFoundError("用户数据文件不存在:" + str(target))
with open(str(target), "r", encoding="utf-8") as fp:
data = json.load(fp)
log_info("用户数据已加载:" + str(target))
return data
def list_saved_user_ids() -> List[int]:
"""列出 data/ 下已保存的全部 userId(按文件名解析,跳过解析失败的)。"""
if not DATA_DIR.exists():
return []
out: List[int] = []
for name in os.listdir(str(DATA_DIR)):
if not name.endswith(".json"):
continue
try:
out.append(int(name[:-5]))
except ValueError:
continue
return sorted(out)
#: 默认要抓的节点(顺序即抓取顺序)
#: ★ 只列 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 GetNeedUserData(
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,
save: bool = True,
save_path: Optional[str] = None,
) -> 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(默认)= 记录失败并跳过,保证链路不崩
save: True(默认)= 抓完自动落盘到 <工程根>/data/<userId>.json
(★ 新数据总是覆盖老数据)
save_path: 自定义落盘路径(可选,给了就忽略 save 的默认路径规则)
返回:
{
"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", GetUserData, user_id, **common)
if "userExtend" in wanted:
_call("userExtend", GetUserExtend, user_id, **common)
if "userOption" in wanted:
_call("userOption", GetUserOption, user_id, **common)
if "userCharacterList" in wanted:
_call("userCharacterList", GetUserCharacter, user_id, **common)
if "userMapList" in wanted:
_call("userMapList", GetUserMapAll, user_id, **common)
if "userLoginBonusList" in wanted:
_call("userLoginBonusList", GetUserLoginBonusAll, user_id, **common)
if "userRatingList" in wanted:
_call("userRatingList", GetUserRating, user_id, **common)
if "userItemList" in wanted:
_call("userItemList", GetUserItemAll, user_id, **common)
if "userMusicDetailList" in wanted:
_call("userMusicDetailList", GetUserMusicDetailAll, user_id, **common)
if "userCourseList" in wanted:
_call("userCourseList", GetUserCourseAll, user_id, **common)
if "userChargeList" in wanted:
_call("userChargeList", GetUserCharge, user_id, **common)
if "userFavoriteList" in wanted:
# ★ 该接口额外带一份**原始** itemKind 副本(_raw),
# 供 UpsertUserAll 生成 isNewFavoriteList 位串用
# —— 真机 itemKind=10 回的是 null,正是它产生了位串里的 "1"。
if _call("userFavoriteList", GetUserFavoriteAll, 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", GetUserActivity, user_id, **common)
if "userMissionDataList" in wanted:
# ★ 该接口的响应里同时带着 userWeeklyData(周界),
# 它是 Upsert 侧 userWeeklyData 节点的来源,会被自动提取。
_call("userMissionDataList", GetUserMissionData, user_id, **common)
if "userIntimateList" in wanted:
_call("userIntimateList", GetUserIntimate, 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", GetUserShopStock, user_id,
shop_item_id_list=ids, **common)
else:
log_debug("未配置 PLAY_SHOP_ITEM_IDS,跳过商店库存节点")
if "userFavoritemusicList" in wanted:
_call("userFavoritemusicList", GetUserFavoriteItemAll, user_id, 1, **common)
if "userKaleidxScopeList" in wanted:
_call("userKaleidxScopeList", GetUserKaleidxScope, user_id, **common)
if "userGhost" in wanted:
_call("userGhost", GetUserGhost, user_id, **common)
raw["_failed"] = failed
raw["_userId"] = int(user_id)
log_info("结算快照抓取完成:成功 " + str(len(raw) - 2) + " 个节点,失败 "
+ str(len(failed)) + " 个")
# ★ 落盘:<工程根>/data/<userId>.json(覆盖式)
if save or save_path:
try:
save_user_data(raw, user_id, path=save_path)
except Exception as exc:
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
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 .LogSetup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("get_need_user_data_test")
try:
if len(sys.argv) < 2:
print("用法: python -m api.get_need_user_data <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 = GetNeedUserData(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())
+48
View File
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
"""
GetUserActivity.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 GetUserActivity
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserActivity(
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 -*-
"""
GetUserCharacter.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 GetUserCharacter
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserCharacter(
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)
+63
View File
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""
GetUserCharge.py —— 取用户票券(GetUserChargeApi)
================================================================================
取玩家的票券持有情况(真机 3 条)。
对应接口:GetUserChargeApi
请求体 :UserIdRequestVO
响应 :{userId, length, userChargeList:[{chargeId,stock,purchaseDate,validDate,extNum1}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserChargeRequestVO.cs
★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。
★ 调用本接口会**登记一次「票券已查询」**(见 api/ChargeGuard.py)。
UpsertUserChargelog 发包前会检查这笔登记 —— 没查过票就买票会被直接拒绝。
调用示例:
from api import GetUserCharge
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .ChargeGuard import note_user_charge_read
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserCharge(
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))
raw = _get("GetUserChargeApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
# ★ 记一笔「这个账号刚查过票券」——
# UpsertUserChargelog 发包前会检查这笔记录,没查过就拒绝购买。
# 见 api/ChargeGuard.py 的说明。
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
note_user_charge_read(user_id, lst)
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
if held:
log_info("★ 该账号当前持有 " + str(len(held)) + " 张功能票:"
+ "、".join("chargeId=" + str(c.get("chargeId"))
+ " stock=" + str(c.get("stock")) for c in held))
else:
log_info("该账号当前没有持有功能票")
return raw
+71
View File
@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""
GetUserCourse.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 GetUserCourse
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserCourseRequestVO, get_all_pages
def GetUserCourse(
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 GetUserCourseAll(
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 = get_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}
+58
View File
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""
GetUserData.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 GetUserData
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserData(
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)
+46
View File
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""
GetUserExtend.py —— 取用户扩展设置(GetUserExtendApi)
================================================================================
取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。
对应接口:GetUserExtendApi
请求体 :UserIdRequestVO
响应 :{userId, userExtend:{...}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserExtendRequestVO.cs
★ 其中 categoryIndex(曲种索引)与 userPlaylog 的 extNum4 必须一致
(真机两处都是 2022),组包时要注意。
调用示例:
from api import GetUserExtend
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserExtend(
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 -*-
"""
GetUserFavorite.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 GetUserFavorite, GetUserFavoriteAll
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
def GetUserFavorite(
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 GetUserFavoriteAll(
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 -*-
"""
GetUserFavoriteItem.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 GetUserFavoriteItem, GetUserFavoriteItemAll
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserFavoriteItemRequestVO, get_all_pages
#: kind 取值
FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲
FAVORITE_ITEM_KIND_RIVAL = 2 # 收藏对手
def GetUserFavoriteItem(
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 GetUserFavoriteItemAll(
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 = get_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 -*-
"""
GetUserFriendBonus.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 GetUserFriendBonus, extract_gift_points
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserFriendBonus(
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: GetUserFriendBonus 的返回值
返回:
getMiles 整数(取不到则为 0)
"""
try:
return int((friend_bonus or {}).get("getMiles", 0) or 0)
except Exception:
return 0
+47
View File
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""
GetUserGhost.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 GetUserGhost[1] {...};
只有「本局产生了幽灵」才回传,纯网络脚本复刻不出幽灵数据。
调用示例:
from api import GetUserGhost
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserGhost(
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 -*-
"""
GetUserIntimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
================================================================================
取玩家与各搭档的亲密度(真机 2 条)。
对应接口:GetUserIntimateApi
请求体 :UserIdRequestVO
响应 :{userId, length, userIntimateList:[{partnerId,intimateLevel,intimateCountRewarded}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserIntimateRequestVO.cs
调用示例:
from api import GetUserIntimate
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserIntimate(
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 -*-
"""
GetUserItem.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 GetUserItem, GetUserItemAll
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_debug, log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR
def GetUserItem(
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 GetUserItemAll(
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 -*-
"""
GetUserKaleidxScope.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(见 GetUserData.py)。
真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的
两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId,
多发字段会让服务端返回空内容。
调用示例:
from api import GetUserKaleidxScope, GetGameKaleidxScope
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, EmptyRequestVO, UserIdRequestVO
def GetUserKaleidxScope(
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 GetGameKaleidxScope(
*,
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 -*-
"""
GetUserLoginBonus.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 GetUserLoginBonus, GetUserLoginBonusAll
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserLoginBonusRequestVO, get_all_pages
def GetUserLoginBonus(
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 GetUserLoginBonusAll(
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 = get_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 -*-
"""
GetUserMap.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 GetUserMap / GetUserMapAll
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdPageRequestVO, get_all_pages
def GetUserMap(
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 GetUserMapAll(
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 = get_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 -*-
"""
GetUserMission.py —— 取用户任务与周数据(GetUserMissionDataApi)
================================================================================
取玩家的任务进度与周常数据(真机 6 条任务)。
对应接口:GetUserMissionDataApi
请求体 :UserIdRequestVO
响应 :{userId, userWeeklyData:{...}, userMissionDataList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMissionRequestVO.cs
★ 一个接口同时下发两个 Upsert 节点:
userMissionDataList 任务列表
userWeeklyData 周常数据(★ 是**对象**不是数组)
★ 两者回传 Upsert 时都要丢掉各自的 userId 字段。
调用示例:
from api import GetUserMissionData
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserMissionData(
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 -*-
"""
GetUserMusic.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/GetUserMusicDetail.cs(内层按难度)
★ 响应是**两层嵌套**:外层每元素是一首歌,内层的 userMusicDetailList 才是
按 (musicId, level) 的成绩条目。取数时要摊平。
★ 回传 Upsert 时只回传本局玩的那一条(playCount +1),其余不动。
调用示例:
from api import GetUserMusicDetail, UserMusicDetailFlat
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserMusicRequestVO, get_all_pages
def GetUserMusicDetail(
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 GetUserMusicDetailAll(
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 = get_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 -*-
"""
GetUserNewItem.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 GetUserNewItemList
"""
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 .LogSetup import log_info
from .MaimaiNet 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 GetUserNewItemList(
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 -*-
"""
GetUserOption.py —— 取用户游戏设置(GetUserOptionApi)
================================================================================
取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。
对应接口:GetUserOptionApi
请求体 :UserIdRequestVO
响应 :userOption 的字段直接平铺在响应顶层
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserOptionRequestVO.cs
★ 形态提醒:Get 侧响应是**平铺**的(顶层直接就是 optionKind/noteSpeed/...),
而回传 Upsert 时要包成单元素数组 userOption:[{...}]。
裁剪时要丢掉 tempoVolume(真机 Upsert 报文里没有这个字段)。
调用示例:
from api import GetUserOption
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserOption(
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)
+156
View File
@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""
GetUserPreview.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 GetUserPreview
pv = GetUserPreview(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 .LogSetup import log_info, log_warning
from .MaimaiNet 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 GetUserPreview(
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 .LogSetup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("userpreview_test")
try:
if len(sys.argv) < 3:
print("用法: python -m api.get_user_preview <user_id> <token> [client_id]")
return 1
cid = sys.argv[3] if len(sys.argv) > 3 else CLIENT_ID
pv = GetUserPreview(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())
+46
View File
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
"""
GetUserRating.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 GetUserRating
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRating(
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 -*-
"""
GetUserRecommendMusic.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 GetUserRecommendRateMusic, GetUserRecommendSelectMusic
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRecommendRateMusic(
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 GetUserRecommendSelectMusic(
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 -*-
"""
GetUserRegion.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 GetUserRegion
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRegion(
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 -*-
"""
GetUserRival.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 GetUserRivalData, GetUserRivalMusic
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import (
_get,
UserRivalRequestVO,
UserRivalMusicRequestVO,
RIVAL_MUSIC_LEVELS,
get_all_pages,
)
def GetUserRivalData(
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 GetUserRivalMusic(
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 GetUserRivalMusicAll(
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 = get_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 -*-
"""
GetUserShopStock.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 GetUserShopStock
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLAY_SHOP_ITEM_IDS, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserShopStockRequestVO
def GetUserShopStock(
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,
)
+704
View File
@@ -0,0 +1,704 @@
# -*- coding: utf-8 -*-
"""
LogSetup.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 GetUserPreview(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)
+820
View File
@@ -0,0 +1,820 @@
# -*- coding: utf-8 -*-
"""
MaimaiNet.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
================================================================================
本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。
--------------------------------------------------------------------------------
一、协议依据(全部来自 source/ 反编译源码)
--------------------------------------------------------------------------------
1) 服务器基址与 API 路径
source/Assembly-CSharp/Manager/OperationManager.cs:225,338
ServerUri = Auth.GameServerUri + MovieServerUri("<REDACTED>/")
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 + "<REDACTED>")
source/Assembly-CSharp/Net.VO/NetQuery.cs:14
Api = api + "MaimaiChn"
=> 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "<REDACTED>")
已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "<REDACTED>")
= 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())。
--------------------------------------------------------------------------------
二、★ 请求头集合:必须与真机一致(2026-10-06 真机抓包实证)
--------------------------------------------------------------------------------
真机(Unity Mono 的 HttpWebRequest)实际发出的头**只有这几个**:
number / Content-Type / User-Agent / charset / Mai-Encoding /
Content-Encoding / (+ 登录后的 Cookie)
**没有 Accept,也没有 Connection。**
requests 默认会补 Accept: * / Connection: keep-alive,
已在 MaimaiNetClient.__init__ 里从 session.headers 删掉。
★ Accept-Encoding 是唯一的例外,必须**存在且为空**:
实测(GetGameSettingApi,2026-10-06):
Accept-Encoding: "" -> 200 + application/json
不发 / "gzip, deflate" / "identity" -> 200 + application/octet-stream(0 字节)
反推:真机能拿到 JSON 响应体,说明 Mono 发的也是**空值**的 Accept-Encoding
(Headers.AllKeys 里看不到它,是框架层加的)。
所以这里固定 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 CLIENT_ID, MAI_ENCODING, TIMEOUT_LONG_MS, TIMEOUT_MS
# =============================================================================
# 协议常量(原在 config.py,2026-10-06 下沉到本模块 —— 它们只服务于网络层)
# =============================================================================
#: 游戏服务器(Maimai2Servlet)
#: 依据 source/Assembly-CSharp/Manager/OperationManager.cs:225,338
#: _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("<REDACTED>/")
BASE_URL = ""
#: AES 参数,依据 source/Assembly-CSharp/Net/CipherAES.cs:11-12
#: AesKey 32 字节 -> AES-256;AesIV 16 字节
AES_KEY = b""
AES_IV = b""
#: API 名混淆盐,依据 source/Assembly-CSharp/Net.Packet/Packet.cs:44
#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
OBFUSCATE_PARAM = ""
from .LogSetup 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(上面 + "<REDACTED>")
参数:
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()
# ---------------------------------------------------------------------
# ★ 对齐真机的请求头集合(2026-10-06 真机抓包实证)
# ---------------------------------------------------------------------
# 真机(Unity Mono 的 HttpWebRequest)在 Headers.AllKeys 里**只有**:
# number / Content-Type / User-Agent / charset / Mai-Encoding /
# Content-Encoding / (+ Cookie)
# 没有 Accept,也没有 Connection。
#
# 而 requests 默认会补上:
# Accept: */* <- 删掉(真机不发)
# Connection: keep-alive <- 删掉(真机不发;HTTP/1.1 本来就是长连接)
# Accept-Encoding <- 删掉(真机不发),改由 _build_headers 显式置空
#
# ★ Accept-Encoding 为什么必须**存在且为空**:
# 实测(GetGameSettingApi,2026-10-06):
# Accept-Encoding: "" -> 200 + application/json(384 B)
# 不发该头 / "gzip, deflate" / "identity"
# -> 200 + application/octet-stream(0 B)
# 所以这里从 Session 默认里删掉,再由 _build_headers 显式发一个空值。
self.session.headers.pop("Accept", None)
self.session.headers.pop("Connection", None)
self.session.headers.pop("Accept-Encoding", None)
#: ★ 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" + "<REDACTED>") = 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 .LogSetup import log_info, log_warning
from .MaimaiNet 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 .LogSetup 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())
+54
View File
@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""
UploadUserPlaylog —— 上传单局成绩原始记录(UploadUserPlaylogApi)
★ 为什么需要它:
真机结算序列是**先 UploadUserPlaylog、后 UpsertUserAll**(2026-10-21 抓包 055/056 号):
055_UploadUserPlaylogApi request 2083 字节 -> returnCode=1
056_UpsertUserAllApi request 21833 字节 -> returnCode=1
Astral 原来只发 UpsertUserAll,服务端就只回 returnCode=1 而不落库。
这条前置是「本次会话确实打过这一局」的凭证。
请求体(源码 Net.VO.Mai2/UserPlaylogRequestVO.cs):
{ "userId": <int>, "userPlaylog": { ...110 字段... } }
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .MaimaiNet import MaimaiNetClient, get_default_client, request_dict
from .LogSetup import log_info
def UploadUserPlaylog(
user_id: int,
user_playlog: Dict[str, Any],
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""上传一局成绩明细(无落库副作用,但服务端会记账,供 UpsertUserAll 消费)。
参数:
user_id: 用户 ID
user_playlog: 110 字段成绩明细,用
api.UpsertUserAll.build_user_playlog(playlog_id, login_date, play) 构造
返回:原始响应 dict({"returnCode": 1, "apiName": "UploadUserPlaylogApi"})
"""
body = {"userId": int(user_id), "userPlaylog": user_playlog}
log_info("上传成绩明细:user_id=" + str(user_id)
+ " musicId=" + str(user_playlog.get("musicId"))
+ " level=" + str(user_playlog.get("level"))
+ " achievement=" + str(user_playlog.get("achievement")))
return request_dict(
"UploadUserPlaylogApi", body,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS,
client=client,
)
File diff suppressed because it is too large. Load diff
+620
View File
@@ -0,0 +1,620 @@
# -*- coding: utf-8 -*-
r"""
UpsertUserChargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
================================================================================
功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。
对应接口:UpsertUserChargelogApi
请求体 :UserChargelogRequestVO(4 字段,顺序照 C# 声明)
source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs
public ulong userId;
public UserChargelog userChargelog;
public GetUserCharge 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)的那条 GetUserCharge
├─ 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
已抄进 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,
TIMEOUT_MS,
UPSERT_CHARGELOG_CONFIRM,
)
# =============================================================================
# 功能票(Ticket)主数据(原在 config.py,2026-10-06 下沉到本模块)
# =============================================================================
#
# 依据:游戏本地主数据
# Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml
# 源码侧 Manager.MaiStudio.TicketData.creditNum
#
# ★ 为什么需要它:
# UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是
# **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price:
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410
# result.price = ticket.creditNum;
#
# ticketKind 含义:1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
#: chargeId -> creditNum
TICKET_CREDIT_NUM = {
0: 0,
2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1
10005: 0, 10105: 0, 10205: 0, # 活动票
11001: 0, # 免费 1.5 倍券
30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票
}
#: 票名(只用于日志好看,不影响协议)
TICKET_NAMES = {
0: "使用しない",
2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット",
10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2",
10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット",
30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票",
}
#: 付费票(ticketKind=1)—— 只有这些能在机台购买
PAID_TICKET_IDS = (2, 3, 4, 5, 6)
#: 票有效期算法参数
#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564
#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10
#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00"
TICKET_VALID_HOUR_OFFSET = -4
TICKET_VALID_DAYS = 90
from .ChargeGuard import (
has_fresh_read,
last_read_age,
note_charge_purchase,
purchased_charge_ids,
)
from .LogSetup import log_debug, log_error, log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
from .UpsertUserAll 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:
"""GetUserCharge —— 一张票的持有状态(GetUserCharge.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;
数据来源 TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
"""
if override is not None:
return int(override)
if int(charge_id) not in TICKET_CREDIT_NUM:
log_warning("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,
guard_require_charge_read: bool = True,
guard_single_purchase: 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(PAID_TICKET_IDS)
login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime)
charge_list: 当前票券列表(可选)。不传则自动调 GetUserChargeApi。
用来算「购买后库存」和是否已有该票。
user_data: 当前 userData(可选)。不传则自动调 GetUserDataApi。
用来填 playCount / playerRating。
stock: ★ 购买后该票的库存;不传 = 现有 + 1
price: ★ userChargelog.price;不传 = 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。
guard_require_charge_read:
★ 默认 True = **运行本函数之前必须先调用过 GetUserCharge**
(本进程内、10 分钟内),否则直接拒绝购买。
目的:防止「没查票就买票」造成同一账号重复购买。
显式传了 charge_list 也算「查过了」。
实现见 api/ChargeGuard.py。
guard_single_purchase:
★ 默认 True = **同一账号在同一进程里只允许买一次票**。
真机买完票会立刻被本局用掉(stock 恒为 0),
连着买两张属于异常行为。实现见 api/ChargeGuard.py。
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(PAID_TICKET_IDS):
log_warning("chargeId=" + str(charge_id) + " 不在 PAID_TICKET_IDS="
+ str(tuple(PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
# ---------- 0. ★ 前置闸门:必须先跑过 GetUserCharge ----------
# ★ 为什么放在「取当前状态」之前:下面那一步在 charge_list 为空时会
# 自己调一次 GetUserCharge —— 闸门若放在后面,它自己就把自己放行了。
if guard_require_charge_read and charge_list is None:
if not has_fresh_read(user_id):
msg = ("安全检查未通过:本次购买之前**没有调用过 GetUserCharge**。\n"
" 买票前必须先查一次账号当前票券(有没有持票),否则无法排除"
"「重复购买」——服务端看到的是状态矛盾,有**封号风险**。\n"
" 正确顺序:GetUserCharge(user_id) -> UpsertUserChargelog(...)\n"
" 或者直接把查到的票券列表传进来:charge_list=[...]\n"
" 见 api/ChargeGuard.py。")
log_error(msg)
raise ChargelogGuardError(msg)
log_info("前置检查通过:本次购买前已调用 GetUserCharge("
+ str(round(float(last_read_age(user_id) or 0.0), 1)) + " 秒前)")
# ---------- 0.5 ★ 前置闸门:同一账号本进程内只允许买一次 ----------
if guard_single_purchase:
done = purchased_charge_ids(user_id)
if done:
msg = ("安全检查未通过:本进程内该账号(user_id=" + str(user_id)
+ ")**已经买过功能票**(chargeId=" + str(done) + ")。\n"
" 真机买完票会立刻被这一局用掉,正常状态 stock 恒为 0;"
"连着买两张属于异常行为,有**封号风险**。\n"
" 确需强行购买请传 guard_single_purchase=False。")
log_error(msg)
raise ChargelogGuardError(msg)
# ---------- 1. 取当前状态 ----------
if charge_list is None:
log_info("未传 charge_list,自动抓取当前票券 ...")
raw = resolve_api_func("GetUserCharge")(
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("GetUserData")(
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))
# ★ 记一笔「这个账号本进程已经买过票」,同进程再买会被前置闸门拦下
note_charge_purchase(user_id, charge_id)
return resp
# =============================================================================
# 四、单独运行(调试)
# =============================================================================
if __name__ == "__main__":
from .LogSetup 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())
+245
View File
@@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
"""
UserGetterCommon.py —— 各取数脚本的公共底座
================================================================================
只提供三样东西,取数逻辑都在各自的脚本里:
1. 各接口的请求 VO(严格对应 source/.../Net.VO.Mai2/*RequestVO.cs)
2. _get() 发一次请求并返回**原始响应 dict**
3. get_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 .LogSetup import log_debug, log_warning
from .MaimaiNet 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 get_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
+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 .LogSetup import log_debug, log_error, log_info
from .MaimaiNet 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 .LogSetup 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 .LogSetup import log_debug, log_error, log_info
from .MaimaiNet 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 .LogSetup 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())
+563
View File
@@ -0,0 +1,563 @@
# -*- coding: utf-8 -*-
"""
api 包 —— 统一导出
================================================================================
主脚本只需要 from api import ... 就能拿到全部功能。
★ 本文件在导入时就给下面 _API_FUNCS 里列出的每个函数套上「日志外壳」,
所以主脚本拿到的已经是带日志的版本 —— 调用时会自动产出:
=== ParseQRCode 开始运行 ===
[接口说明] 解析二维码并换取 userId 与 token。
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
=== ParseQRCode 运行结束 ===
主脚本**不需要**写装饰器,也不需要自己埋点。
--------------------------------------------------------------------------------
目录结构(一个 api 一个脚本)
--------------------------------------------------------------------------------
基础设施
LogSetup.py 统一日志(INFO / DEBUG 双模式)
MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
UserGetterCommon.py 取数脚本公共底座(请求 VO / _get / 翻页)
ChargeGuard.py ★ 买票前置闸门:买票前必须先跑过 GetUserCharge
会话链路
Chime.py 扫码登录 -> userId + token
GetGameSettings.py GetGameSettingApi(游戏设置)
Ping.py 心跳探活
GetUserPreview.py GetUserPreviewApi(用户预览)
UserLogin.py UserLoginApi(正式登录 -> loginId + loginDateTime)
UserLogout.py UserLogoutApi(登出)
上传 / 结算(有副作用的接口)
UpsertUserAll.py ★ UpsertUserAllApi(上传成绩 + 解锁歌曲/解禁难度)
UpsertUserChargelog.py UpsertUserChargelogApi(购买功能票)
GetNeedUserData.py ★ 结算快照编排(统一调用下面的取数脚本),
并把结果落盘到 <工程根>/data/<userId>.json
★ 命名约定:服务端 API 名里带 Get 的,**脚本名与函数名一律带 get / Get**
(不要模棱两可)。所以下面这些文件全是 get_ 开头。
取数 —— GetUser*(一个接口一个脚本)
GetUserData.py GetUserDataApi
GetUserExtend.py GetUserExtendApi
GetUserOption.py GetUserOptionApi
GetUserCharacter.py GetUserCharacterApi
GetUserGhost.py GetUserGhostApi
GetUserMap.py GetUserMapApi
GetUserLoginBonus.py GetUserLoginBonusApi
GetUserRating.py GetUserRatingApi
GetUserItem.py GetUserItemApi
GetUserMusic.py GetUserMusicApi
GetUserCourse.py GetUserCourseApi
GetUserCharge.py GetUserChargeApi
GetUserFavorite.py GetUserFavoriteApi
GetUserActivity.py GetUserActivityApi
GetUserMission.py GetUserMissionDataApi
GetUserIntimate.py GetUserIntimateApi
GetUserShopStock.py GetUserShopStockApi
GetUserFavoriteItem.py GetUserFavoriteItemApi
GetUserRival.py GetUserRivalDataApi / GetUserRivalMusicApi
GetUserFriendBonus.py GetUserFriendBonusApi
GetUserKaleidxScope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
GetUserRegion.py GetUserRegionApi
GetUserRecommendMusic.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
GetUserNewItem.py GetUserNewItemListApi
取数 —— GetGame*
GetGameSettings.py GetGameSettingApi
GetGameCharge.py GetGameChargeApi
"""
from .LogSetup 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 .MaimaiNet import (
# 协议常量(原在 config.py,已下沉到 MaimaiNet.py)
BASE_URL,
AES_KEY,
AES_IV,
OBFUSCATE_PARAM,
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,
# aimedb / QR 常量(原在 config.py,已下沉到 Chime.py)
AIMEDB_HOST,
AIMEDB_PORT,
AIMEDB_PATH,
QR_SIGN_KEY,
QR_OPEN_GAME_ID,
QR_TITLE_KEY,
QR_USER_AGENT,
)
from .GetGameSettings import (
GetGameSettings,
GameSetting,
GameSettingRequestVO,
GameSettingResponseVO,
is_maintenance,
is_ping_disabled,
)
from .Ping import Ping
from .GetUserPreview import (
GetUserPreview,
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 .UpsertUserAll import (
UpsertUserAll,
PlayData,
build_play_from_config,
derive_play_characters,
derive_play_rating,
derive_play_grade,
find_music_detail,
PLAY_SPECIAL_NUM,
PLAY_LOCAL_PLAY_COUNT,
PLAY_AFTER_GRADE_RANK,
DERIVE_GRADE_FROM_MUSIC_DETAIL,
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,
# ★ 歌曲解锁(解禁):写进 userItemList 的 itemKind 5/6/7
build_unlock_items,
ITEM_KIND_MUSIC_UNLOCK,
ITEM_KIND_MUSIC_UNLOCK_MASTER,
ITEM_KIND_MUSIC_UNLOCK_REMASTER,
ITEM_KIND_MUSIC_UNLOCK_STRONG,
UNLOCK_MASTER_MIN_MUSIC_ID,
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 .GetNeedUserData import (
GetNeedUserData,
save_snapshot,
load_snapshot,
summarize_snapshot,
DEFAULT_SNAPSHOT_NODES,
# ★ 用户数据落盘(<工程根>/data/<userId>.json)
PROJECT_ROOT,
DATA_DIR,
user_data_path,
save_user_data,
load_user_data,
list_saved_user_ids,
)
# ---------------------------------------------------------------------------
# ★ 买票前置闸门:GetUserCharge 必须先跑过(见 api/ChargeGuard.py)
# ---------------------------------------------------------------------------
from .ChargeGuard import (
CHARGE_READ_FRESH_SEC,
has_fresh_read,
held_charges,
last_charge_list,
last_read_age,
note_charge_purchase,
note_user_charge_read,
purchased_charge_ids,
reset as reset_charge_guard,
)
from .UpsertUserChargelog import (
# 票券主数据(原在 config.py,已下沉到 UpsertUserChargelog.py)
TICKET_CREDIT_NUM,
TICKET_NAMES,
PAID_TICKET_IDS,
TICKET_VALID_HOUR_OFFSET,
TICKET_VALID_DAYS,
UpsertUserChargelog,
ChargelogConfirmError,
ChargelogGuardError,
UserChargeVO,
UserChargelogVO,
UserChargelogRequestVO,
ticket_name,
ticket_price,
calc_valid_date,
parse_wall_time,
now_cst,
)
# ---------------------------------------------------------------------------
# 4) 取数公共底座
# ---------------------------------------------------------------------------
from .UserGetterCommon import (
EmptyRequestVO as GetterEmptyRequestVO,
UserIdRequestVO as GetterUserIdRequestVO,
UserIdPageRequestVO,
UserLoginBonusRequestVO,
UserItemRequestVO,
UserMusicRequestVO,
UserCourseRequestVO,
UserFavoriteRequestVO,
UserFavoriteItemRequestVO,
UserShopStockRequestVO,
UserRivalRequestVO,
UserRivalMusicRequestVO,
get_all_pages,
ITEM_KINDS,
ITEM_NEXT_INDEX_FACTOR,
FAVORITE_ITEM_KINDS,
RIVAL_MUSIC_LEVELS,
)
# ---------------------------------------------------------------------------
# 5) 取数脚本(一个接口一个脚本)
# ---------------------------------------------------------------------------
from .GetUserData import GetUserData
from .GetUserExtend import GetUserExtend
from .GetUserOption import GetUserOption
from .GetUserCharacter import GetUserCharacter
from .GetUserGhost import GetUserGhost
from .GetUserMap import GetUserMap, GetUserMapAll
from .GetUserLoginBonus import GetUserLoginBonus, GetUserLoginBonusAll
from .GetUserRating import GetUserRating
from .GetUserItem import GetUserItem, GetUserItemAll
from .GetUserMusic import GetUserMusicDetail, GetUserMusicDetailAll
from .GetUserCourse import GetUserCourse, GetUserCourseAll
from .GetUserCharge import GetUserCharge
from .GetUserFavorite import GetUserFavorite, GetUserFavoriteAll
from .GetUserActivity import GetUserActivity
from .GetUserMission import GetUserMissionData
from .GetUserIntimate import GetUserIntimate
from .GetUserShopStock import GetUserShopStock
from .GetUserFavoriteItem import (
GetUserFavoriteItem,
GetUserFavoriteItemAll,
FAVORITE_ITEM_KIND_MUSIC,
FAVORITE_ITEM_KIND_RIVAL,
)
from .GetUserRival import GetUserRivalData, GetUserRivalMusic, GetUserRivalMusicAll
from .GetUserFriendBonus import GetUserFriendBonus, extract_gift_points
from .GetUserKaleidxScope import GetUserKaleidxScope, GetGameKaleidxScope
from .GetUserRegion import GetUserRegion
from .GetUserRecommendMusic import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
from .GetUserNewItem import GetUserNewItemList, UserNewItemListRequestVO
from .GetGameCharge import GetGameCharge, GameChargeRequestVO
# ---------------------------------------------------------------------------
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
# ---------------------------------------------------------------------------
_API_FUNCS = (
# --- 会话链路 ---
"ParseQRCode",
"GetGameSettings",
"Ping",
"GetUserPreview",
"UserLogin",
"UserLogout",
# --- 上传成绩 ---
"UpsertUserAll",
"build_play_from_config",
"derive_play_characters",
"merge_extra_data",
"verify_upsert_landing",
"build_user_all",
"build_unlock_items",
# --- 购买功能票 ---
"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",
"GetNeedUserData",
"summarize_snapshot",
"GetUserNewItemList",
# --- 取数:单接口 ---
"GetUserData", # GetUserDataApi
"GetUserExtend", # GetUserExtendApi
"GetUserOption", # GetUserOptionApi
"GetUserCharacter", # GetUserCharacterApi
"GetUserGhost", # GetUserGhostApi
"GetUserMap", # GetUserMapApi(单页)
"GetUserMapAll", # GetUserMapApi(全量)
"GetUserLoginBonus", # GetUserLoginBonusApi(单页)
"GetUserLoginBonusAll", # GetUserLoginBonusApi(全量)
"GetUserRating", # GetUserRatingApi
"GetUserItem", # GetUserItemApi(单类)
"GetUserItemAll", # GetUserItemApi(全量)
"GetUserMusicDetail", # GetUserMusicApi(单页)
"GetUserMusicDetailAll", # GetUserMusicApi(全量)
"GetUserCourse", # GetUserCourseApi(单页)
"GetUserCourseAll", # GetUserCourseApi(全量)
"GetUserCharge", # GetUserChargeApi
"GetUserFavorite", # GetUserFavoriteApi(单类)
"GetUserFavoriteAll", # GetUserFavoriteApi(全量)
"GetUserActivity", # GetUserActivityApi
"GetUserMissionData", # GetUserMissionDataApi
"GetUserIntimate", # GetUserIntimateApi
"GetUserShopStock", # GetUserShopStockApi
"GetUserFavoriteItem", # GetUserFavoriteItemApi(单页)
"GetUserFavoriteItemAll", # GetUserFavoriteItemApi(全量)
"GetUserRivalData", # GetUserRivalDataApi
"GetUserRivalMusic", # GetUserRivalMusicApi(单页)
"GetUserRivalMusicAll", # GetUserRivalMusicApi(全量)
"GetUserFriendBonus", # GetUserFriendBonusApi
"GetUserKaleidxScope", # GetUserKaleidxScopeApi
"GetGameKaleidxScope", # GetGameKaleidxScopeApi
"GetUserRegion", # GetUserRegionApi
"GetUserRecommendRateMusic", # GetUserRecommendRateMusicApi
"GetUserRecommendSelectMusic", # GetUserRecommendSelectMusicApi
"GetUserNewItemList", # GetUserNewItemListApi
# --- 取数:游戏侧 ---
"GetGameCharge", # GetGameChargeApi
)
# ---------------------------------------------------------------------------
# 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",
"GetUserPreview", "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",
# ---- 歌曲解锁(解禁)----
"build_unlock_items", "UNLOCK_MASTER_MIN_MUSIC_ID",
"ITEM_KIND_MUSIC_UNLOCK", "ITEM_KIND_MUSIC_UNLOCK_MASTER",
"ITEM_KIND_MUSIC_UNLOCK_REMASTER", "ITEM_KIND_MUSIC_UNLOCK_STRONG",
# ---- 购买功能票 ----
"UpsertUserChargelog", "ChargelogConfirmError", "ChargelogGuardError",
# ---- 买票前置闸门(GetUserCharge 必须先跑过)----
"CHARGE_READ_FRESH_SEC", "has_fresh_read", "held_charges", "last_charge_list",
"last_read_age", "note_charge_purchase", "note_user_charge_read",
"purchased_charge_ids", "reset_charge_guard",
"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",
# ---- 快照编排 ----
"GetNeedUserData", "save_snapshot", "load_snapshot",
"summarize_snapshot", "DEFAULT_SNAPSHOT_NODES",
"PROJECT_ROOT", "DATA_DIR", "user_data_path",
"save_user_data", "load_user_data", "list_saved_user_ids",
# ---- 取数公共底座 ----
"GetterEmptyRequestVO", "GetterUserIdRequestVO", "UserIdPageRequestVO",
"UserLoginBonusRequestVO", "UserItemRequestVO", "UserMusicRequestVO",
"UserCourseRequestVO", "UserFavoriteRequestVO", "UserFavoriteItemRequestVO",
"UserShopStockRequestVO", "UserRivalRequestVO", "UserRivalMusicRequestVO",
"get_all_pages",
"ITEM_KINDS", "ITEM_NEXT_INDEX_FACTOR", "FAVORITE_ITEM_KINDS", "RIVAL_MUSIC_LEVELS",
# ---- 取数脚本 ----
"GetUserData", "GetUserExtend", "GetUserOption", "GetUserCharacter", "GetUserGhost",
"GetUserMap", "GetUserMapAll",
"GetUserLoginBonus", "GetUserLoginBonusAll",
"GetUserRating",
"GetUserItem", "GetUserItemAll",
"GetUserMusicDetail", "GetUserMusicDetailAll",
"GetUserCourse", "GetUserCourseAll",
"GetUserCharge",
"GetUserFavorite", "GetUserFavoriteAll",
"GetUserActivity", "GetUserMissionData", "GetUserIntimate", "GetUserShopStock",
"GetUserFavoriteItem", "GetUserFavoriteItemAll",
"FAVORITE_ITEM_KIND_MUSIC", "FAVORITE_ITEM_KIND_RIVAL",
"GetUserRivalData", "GetUserRivalMusic", "GetUserRivalMusicAll",
"GetUserFriendBonus", "extract_gift_points",
"GetUserKaleidxScope", "GetGameKaleidxScope",
"GetUserRegion",
"GetUserRecommendRateMusic", "GetUserRecommendSelectMusic",
"GetUserNewItemList", "UserNewItemListRequestVO",
"GetGameCharge", "GameChargeRequestVO",
# --- 协议常量(原在 config.py,已下沉到各 api 模块)---
"BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM",
"AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH",
"QR_SIGN_KEY", "QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT",
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
"TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
]