重构:Get* 命名统一 + 用户数据落盘 + use_ticket / reincarnate

1) 命名统一(服务端 API 名带 Get 的,脚本名与函数名一律带 get/Get)
   - 26 个 api 脚本改名:userdata.py -> get_user_data.py 等
   - 7 个 game_*.py -> get_game_*.py
   - 38 个函数改名:UserData -> GetUserData 等
   - upsert_snapshot.py -> get_need_user_data.py,
     fetch_upsert_snapshot -> GetNeedUserData
   - user_getter_common.fetch_all_pages -> get_all_pages

2) GetNeedUserData 抓到的数据自动落盘
   - <工程根>/data/<userId>.json,新数据总是覆盖老数据
   - 新增 save_user_data / load_user_data / user_data_path /
     list_saved_user_ids / DATA_DIR / PROJECT_ROOT
   - data/ 已加入 .gitignore

3) use_ticket.py(新)
   - 读取账号当前持有的功能票,用 UpsertUserAll 消耗掉
   - 依据:GetUserChargeApi 与 UpsertUserAllApi 的 userChargeList 格式相同;
     真机实证 userChargeList.stock -1 + useTicketId + playCredit
   - playCredit = 投币数 + 票的 creditNum(VOExtensions.cs:838-845)

4) reincarnate.py(新)
   - 账号数据迁移(转生):A 的数据克隆到 B
   - 每包最多 4 首(真机一局上限),包间用续关登录换新 loginId
     续关依据:ContinueProcess.cs:403 -> PacketUserLogin(isContinue: true)
   - playlog 从曲目记录反推合成(没有对应的 Get 接口)

自检 238 项全绿。
This commit is contained in:
2026-10-06 23:32:09 +08:00
parent c297818d8a
commit 8e3bd6fcaa
46 changed files with 1234 additions and 356 deletions
+156
View File
@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""
get_user_preview.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 .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict
@dataclass
class UserPreviewRequestVO:
"""请求 VO(对应 UserPreviewRequestVO)。"""
userId: int
segaIdAuthKey: str = ""
token: str = ""
clientId: str = ""
@dataclass
class UserPreviewResponseVO:
"""
响应 VO(对应 UserPreviewResponseVO + 真机响应字段)。
★ 字段顺序按 source/Assembly-CSharp/Net.VO.Mai2/UserPreviewResponseVO.cs
与 source/2026-10-05.log:24 的真机响应合并。
"""
userId: int = 0
userName: str = ""
isLogin: bool = False
lastGameId: Optional[str] = None
lastRomVersion: str = ""
lastDataVersion: str = ""
lastLoginDate: str = ""
lastPlayDate: str = ""
playerRating: int = 0
nameplateId: int = 0
iconId: int = 0
trophyId: int = 0
partnerId: int = 0
frameId: int = 0
dispRate: int = 0
totalAwake: int = 0
isNetMember: int = 0
dailyBonusDate: str = ""
headPhoneVolume: Optional[int] = None
isInherit: bool = False
banState: int = 0
errorId: int = 0
def 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 .log_setup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("userpreview_test")
try:
if len(sys.argv) < 3:
print("用法: python -m api.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())