feat: WaHappy v0 —— 舞萌DX 纯网络游戏助手(占位版)
当前仅 upsert.py 可用:扫码登录 -> 上传一局成绩 -> 落库回查 -> 登出。 - api/ 下 40+ 个接口模块已实现,236 项零网络自检全绿 - 上传成绩链路已真机跑通,报文与真机抓包逐字节一致 - 开发文档见 docs 分支;source/ 与 logs/ 不入库
This commit is contained in:
+497
@@ -0,0 +1,497 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
api 包 —— 统一导出
|
||||
================================================================================
|
||||
|
||||
主脚本只需要 from api import ... 就能拿到全部功能。
|
||||
|
||||
★ 本文件在导入时就给下面 _API_FUNCS 里列出的每个函数套上「日志外壳」,
|
||||
所以主脚本拿到的已经是带日志的版本 —— 调用时会自动产出:
|
||||
|
||||
=== ParseQRCode 开始运行 ===
|
||||
[接口说明] 解析二维码并换取 userId 与 token。
|
||||
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
|
||||
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
|
||||
=== ParseQRCode 运行结束 ===
|
||||
|
||||
主脚本**不需要**写装饰器,也不需要自己埋点。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
目录结构(一个 api 一个脚本)
|
||||
--------------------------------------------------------------------------------
|
||||
基础设施
|
||||
log_setup.py 统一日志(INFO / DEBUG 双模式)
|
||||
maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
|
||||
user_getter_common.py 取数脚本公共底座(请求 VO / _get / 翻页)
|
||||
|
||||
会话链路
|
||||
chime.py 扫码登录 -> userId + token
|
||||
gamesettings.py 取游戏设置
|
||||
ping.py 心跳探活
|
||||
userpreview.py 用户预览
|
||||
userlogin.py 正式登录 -> loginId + loginDateTime
|
||||
userlogout.py 登出
|
||||
|
||||
上传成绩
|
||||
upsert_user_all.py ★ 上传成绩(唯一有副作用的接口)
|
||||
upsert_snapshot.py 结算快照编排(统一调用下面的取数脚本)
|
||||
|
||||
取数(一个接口一个脚本)
|
||||
userdata.py GetUserDataApi
|
||||
user_extend.py GetUserExtendApi
|
||||
user_option.py GetUserOptionApi
|
||||
user_character.py GetUserCharacterApi
|
||||
user_ghost.py GetUserGhostApi
|
||||
user_map.py GetUserMapApi
|
||||
user_login_bonus.py GetUserLoginBonusApi
|
||||
user_rating.py GetUserRatingApi
|
||||
user_item.py GetUserItemApi
|
||||
user_music.py GetUserMusicApi
|
||||
user_course.py GetUserCourseApi
|
||||
user_charge.py GetUserChargeApi
|
||||
user_favorite.py GetUserFavoriteApi
|
||||
user_activity.py GetUserActivityApi
|
||||
user_mission.py GetUserMissionDataApi
|
||||
user_intimate.py GetUserIntimateApi
|
||||
user_shop_stock.py GetUserShopStockApi
|
||||
user_favorite_item.py GetUserFavoriteItemApi
|
||||
user_rival.py GetUserRivalDataApi / GetUserRivalMusicApi
|
||||
user_friend_bonus.py GetUserFriendBonusApi
|
||||
user_kaleidx_scope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
|
||||
user_region.py GetUserRegionApi
|
||||
user_recommend_music.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
|
||||
user_new_item.py GetUserNewItemListApi
|
||||
game_ranking.py GetGameRankingApi
|
||||
game_event.py GetGameEventApi
|
||||
game_charge.py GetGameChargeApi
|
||||
game_weekly_data.py GetGameWeeklyDataApi
|
||||
game_tournament_info.py GetGameTournamentInfoApi
|
||||
game_ng_music_id.py GetGameNgMusicIdApi
|
||||
"""
|
||||
|
||||
from .log_setup import (
|
||||
# 会话
|
||||
setup_logging,
|
||||
teardown_logging,
|
||||
# 自动跟踪
|
||||
autowrap,
|
||||
get_called_apis,
|
||||
# 模式
|
||||
get_level,
|
||||
set_level,
|
||||
is_debug,
|
||||
LEVEL_INFO,
|
||||
LEVEL_DEBUG,
|
||||
# 装饰器 / 兼容
|
||||
api_log,
|
||||
get_logger,
|
||||
# 流程日志(INFO 可见)
|
||||
log_info,
|
||||
log_step,
|
||||
log_result,
|
||||
log_warning,
|
||||
log_error,
|
||||
log_section,
|
||||
log_field,
|
||||
log_debug,
|
||||
# 收发数据日志(DEBUG 专属)
|
||||
log_request,
|
||||
log_response,
|
||||
log_payload,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1) 网络核心
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .maimai_net import (
|
||||
MaimaiNetClient,
|
||||
MaimaiNetError,
|
||||
get_default_client,
|
||||
close_default_client,
|
||||
request,
|
||||
request_dict,
|
||||
request_vo,
|
||||
obfuscate_api_name,
|
||||
build_api_url,
|
||||
build_user_agent,
|
||||
adler32,
|
||||
aes_encrypt,
|
||||
aes_decrypt,
|
||||
deflate_compress,
|
||||
deflate_decompress,
|
||||
vo_to_json,
|
||||
vo_from_json,
|
||||
local_utc_offset_seconds,
|
||||
login_date_time_now,
|
||||
EmptyRequestVO,
|
||||
UserIdRequestVO,
|
||||
ReturnCodeVO,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2) 会话链路
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .chime import ParseQRCode
|
||||
|
||||
from .gamesettings import (
|
||||
GetGameSettings,
|
||||
GameSetting,
|
||||
GameSettingRequestVO,
|
||||
GameSettingResponseVO,
|
||||
is_maintenance,
|
||||
is_ping_disabled,
|
||||
)
|
||||
|
||||
from .ping import Ping
|
||||
|
||||
from .userpreview import (
|
||||
UserPreview,
|
||||
UserPreviewRequestVO,
|
||||
UserPreviewResponseVO,
|
||||
)
|
||||
|
||||
from .userlogin import (
|
||||
UserLogin,
|
||||
UserLoginRequestVO,
|
||||
UserLoginResponseVO,
|
||||
)
|
||||
|
||||
from .userlogout import (
|
||||
UserLogout,
|
||||
LogoutType,
|
||||
DelayLog,
|
||||
RequestAPI,
|
||||
UserLogoutRequestVO,
|
||||
UserLogoutRequest2VO,
|
||||
UserLogoutResponseVO,
|
||||
create_empty_delay_log,
|
||||
create_delay_log_from_stats,
|
||||
DELAY_LOG_SLOTS,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3) 上传成绩
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .upsert_user_all import (
|
||||
UpsertUserAll,
|
||||
PlayData,
|
||||
build_play_from_config,
|
||||
derive_play_characters,
|
||||
UserAllRequestVO,
|
||||
UpsertResponseVO,
|
||||
UpsertUserAllError,
|
||||
ConfirmRequiredError,
|
||||
PatchError,
|
||||
# 节点结构
|
||||
NODE_TYPES,
|
||||
NODE_ORDER,
|
||||
BITS_TO_ARRAY,
|
||||
ARRAY_TO_BITS,
|
||||
DIFF_PRIMARY_KEYS,
|
||||
MAY_BE_EMPTY_NODES,
|
||||
DEFAULT_EMPTY_NODES,
|
||||
# 组包辅助
|
||||
empty_node,
|
||||
empty_user_all,
|
||||
coerce_node_value,
|
||||
sync_bits_to_arrays,
|
||||
build_delta,
|
||||
build_favorite_delta,
|
||||
build_user_all,
|
||||
build_user_playlog,
|
||||
build_user_game_playlog,
|
||||
build_post_play_user_data,
|
||||
validate_user_playlog,
|
||||
validate_before_send,
|
||||
apply_play_effects,
|
||||
# ★ 额外 JSON 合并 / 落库回查
|
||||
load_extra_data,
|
||||
merge_extra_data,
|
||||
verify_upsert_landing,
|
||||
default_user_weekly_data,
|
||||
default_user_2p_playlog,
|
||||
calc_play_special,
|
||||
normalize_snapshot,
|
||||
wait_send_cooldown,
|
||||
# 字段表
|
||||
PLAYLOG_FIELDS,
|
||||
PLAYLOG_BOOL_FIELDS,
|
||||
PLAYLOG_STRING_FIELDS,
|
||||
JUDGE_TYPES,
|
||||
JUDGE_BOXES,
|
||||
USER_DATA_DROP_KEYS,
|
||||
USER_OPTION_DROP_KEYS,
|
||||
USER_CHARACTER_KEEP_KEYS,
|
||||
USER_MUSIC_DETAIL_DROP_KEYS,
|
||||
USER_CHARGE_DROP_KEYS,
|
||||
USER_MISSION_DROP_KEYS,
|
||||
)
|
||||
|
||||
from .upsert_snapshot import (
|
||||
fetch_upsert_snapshot,
|
||||
save_snapshot,
|
||||
load_snapshot,
|
||||
summarize_snapshot,
|
||||
DEFAULT_SNAPSHOT_NODES,
|
||||
)
|
||||
|
||||
from .upsert_user_chargelog import (
|
||||
UpsertUserChargelog,
|
||||
ChargelogConfirmError,
|
||||
ChargelogGuardError,
|
||||
UserChargeVO,
|
||||
UserChargelogVO,
|
||||
UserChargelogRequestVO,
|
||||
ticket_name,
|
||||
ticket_price,
|
||||
calc_valid_date,
|
||||
parse_wall_time,
|
||||
now_cst,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) 取数公共底座
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .user_getter_common import (
|
||||
EmptyRequestVO as GetterEmptyRequestVO,
|
||||
UserIdRequestVO as GetterUserIdRequestVO,
|
||||
UserIdPageRequestVO,
|
||||
UserLoginBonusRequestVO,
|
||||
UserItemRequestVO,
|
||||
UserMusicRequestVO,
|
||||
UserCourseRequestVO,
|
||||
UserFavoriteRequestVO,
|
||||
UserFavoriteItemRequestVO,
|
||||
UserShopStockRequestVO,
|
||||
UserRivalRequestVO,
|
||||
UserRivalMusicRequestVO,
|
||||
fetch_all_pages,
|
||||
ITEM_KINDS,
|
||||
ITEM_NEXT_INDEX_FACTOR,
|
||||
FAVORITE_ITEM_KINDS,
|
||||
RIVAL_MUSIC_LEVELS,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5) 取数脚本(一个接口一个脚本)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .userdata import UserData
|
||||
from .user_extend import UserExtend
|
||||
from .user_option import UserOption
|
||||
from .user_character import UserCharacter
|
||||
from .user_ghost import UserGhost
|
||||
from .user_map import UserMap, UserMapAll
|
||||
from .user_login_bonus import UserLoginBonus, UserLoginBonusAll
|
||||
from .user_rating import UserRating
|
||||
from .user_item import UserItem, UserItemAll
|
||||
from .user_music import UserMusicDetail, UserMusicDetailAll
|
||||
from .user_course import UserCourse, UserCourseAll
|
||||
from .user_charge import UserCharge
|
||||
from .user_favorite import UserFavorite, UserFavoriteAll
|
||||
from .user_activity import UserActivity
|
||||
from .user_mission import UserMissionData
|
||||
from .user_intimate import UserIntimate
|
||||
from .user_shop_stock import UserShopStock
|
||||
from .user_favorite_item import (
|
||||
UserFavoriteItem,
|
||||
UserFavoriteItemAll,
|
||||
FAVORITE_ITEM_KIND_MUSIC,
|
||||
FAVORITE_ITEM_KIND_RIVAL,
|
||||
)
|
||||
from .user_rival import UserRivalData, UserRivalMusic, UserRivalMusicAll
|
||||
from .user_friend_bonus import UserFriendBonus, extract_gift_points
|
||||
from .user_kaleidx_scope import UserKaleidxScope, GameKaleidxScope
|
||||
from .user_region import UserRegion
|
||||
from .user_recommend_music import UserRecommendRateMusic, UserRecommendSelectMusic
|
||||
from .user_new_item import UserNewItemList, UserNewItemListRequestVO
|
||||
|
||||
from .game_ranking import GetGameRanking, GameRankingRequestVO
|
||||
from .game_event import GetGameEvent, GameEventRequestVO
|
||||
from .game_charge import GetGameCharge, GameChargeRequestVO
|
||||
from .game_weekly_data import GetGameWeeklyData
|
||||
from .game_tournament_info import GetGameTournamentInfo
|
||||
from .game_ng_music_id import GetGameNgMusicId
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_API_FUNCS = (
|
||||
# --- 会话链路 ---
|
||||
"ParseQRCode",
|
||||
"GetGameSettings",
|
||||
"Ping",
|
||||
"UserPreview",
|
||||
"UserLogin",
|
||||
"UserLogout",
|
||||
# --- 上传成绩 ---
|
||||
"UpsertUserAll",
|
||||
"build_play_from_config",
|
||||
"derive_play_characters",
|
||||
"merge_extra_data",
|
||||
"verify_upsert_landing",
|
||||
"build_user_all",
|
||||
# --- 购买功能票 ---
|
||||
"UpsertUserChargelog",
|
||||
"ticket_name",
|
||||
"ticket_price",
|
||||
"calc_valid_date",
|
||||
"build_user_playlog",
|
||||
"build_user_game_playlog",
|
||||
"validate_user_playlog",
|
||||
"validate_before_send",
|
||||
"apply_play_effects",
|
||||
"build_post_play_user_data",
|
||||
"build_delta",
|
||||
"build_favorite_delta",
|
||||
"normalize_snapshot",
|
||||
"fetch_upsert_snapshot",
|
||||
"summarize_snapshot",
|
||||
"UserNewItemList",
|
||||
# --- 取数:单接口 ---
|
||||
"UserData", # GetUserDataApi
|
||||
"UserExtend", # GetUserExtendApi
|
||||
"UserOption", # GetUserOptionApi
|
||||
"UserCharacter", # GetUserCharacterApi
|
||||
"UserGhost", # GetUserGhostApi
|
||||
"UserMap", # GetUserMapApi(单页)
|
||||
"UserMapAll", # GetUserMapApi(全量)
|
||||
"UserLoginBonus", # GetUserLoginBonusApi(单页)
|
||||
"UserLoginBonusAll", # GetUserLoginBonusApi(全量)
|
||||
"UserRating", # GetUserRatingApi
|
||||
"UserItem", # GetUserItemApi(单类)
|
||||
"UserItemAll", # GetUserItemApi(全量)
|
||||
"UserMusicDetail", # GetUserMusicApi(单页)
|
||||
"UserMusicDetailAll", # GetUserMusicApi(全量)
|
||||
"UserCourse", # GetUserCourseApi(单页)
|
||||
"UserCourseAll", # GetUserCourseApi(全量)
|
||||
"UserCharge", # GetUserChargeApi
|
||||
"UserFavorite", # GetUserFavoriteApi(单类)
|
||||
"UserFavoriteAll", # GetUserFavoriteApi(全量)
|
||||
"UserActivity", # GetUserActivityApi
|
||||
"UserMissionData", # GetUserMissionDataApi
|
||||
"UserIntimate", # GetUserIntimateApi
|
||||
"UserShopStock", # GetUserShopStockApi
|
||||
"UserFavoriteItem", # GetUserFavoriteItemApi(单页)
|
||||
"UserFavoriteItemAll", # GetUserFavoriteItemApi(全量)
|
||||
"UserRivalData", # GetUserRivalDataApi
|
||||
"UserRivalMusic", # GetUserRivalMusicApi(单页)
|
||||
"UserRivalMusicAll", # GetUserRivalMusicApi(全量)
|
||||
"UserFriendBonus", # GetUserFriendBonusApi
|
||||
"UserKaleidxScope", # GetUserKaleidxScopeApi
|
||||
"GameKaleidxScope", # GetGameKaleidxScopeApi
|
||||
"UserRegion", # GetUserRegionApi
|
||||
"UserRecommendRateMusic", # GetUserRecommendRateMusicApi
|
||||
"UserRecommendSelectMusic", # GetUserRecommendSelectMusicApi
|
||||
"UserNewItemList", # GetUserNewItemListApi
|
||||
# --- 取数:游戏侧 ---
|
||||
"GetGameRanking", # GetGameRankingApi
|
||||
"GetGameEvent", # GetGameEventApi
|
||||
"GetGameCharge", # GetGameChargeApi
|
||||
"GetGameWeeklyData", # GetGameWeeklyDataApi
|
||||
"GetGameTournamentInfo", # GetGameTournamentInfoApi
|
||||
"GetGameNgMusicId", # GetGameNgMusicIdApi
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7) 导入即包装:保证 from api import X 拿到的是带日志的版本
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
autowrap(globals(), _API_FUNCS)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8) 统一导出
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
__all__ = [
|
||||
# ---- 日志:会话 ----
|
||||
"setup_logging", "teardown_logging",
|
||||
# ---- 日志:自动跟踪 ----
|
||||
"autowrap", "get_called_apis",
|
||||
# ---- 日志:模式 ----
|
||||
"get_level", "set_level", "is_debug", "LEVEL_INFO", "LEVEL_DEBUG",
|
||||
# ---- 日志:装饰器 / 兼容 ----
|
||||
"api_log", "get_logger",
|
||||
# ---- 日志:流程 ----
|
||||
"log_info", "log_step", "log_result", "log_warning", "log_error",
|
||||
"log_section", "log_field",
|
||||
# ---- 日志:收发数据 ----
|
||||
"log_debug", "log_request", "log_response", "log_payload",
|
||||
# ---- 网络核心 ----
|
||||
"MaimaiNetClient", "MaimaiNetError", "get_default_client", "close_default_client",
|
||||
"request", "request_dict", "request_vo",
|
||||
"obfuscate_api_name", "build_api_url", "build_user_agent", "adler32",
|
||||
"aes_encrypt", "aes_decrypt", "deflate_compress", "deflate_decompress",
|
||||
"vo_to_json", "vo_from_json",
|
||||
"local_utc_offset_seconds", "login_date_time_now",
|
||||
"EmptyRequestVO", "UserIdRequestVO", "ReturnCodeVO",
|
||||
# ---- 会话链路 ----
|
||||
"ParseQRCode",
|
||||
"GetGameSettings", "GameSetting", "GameSettingRequestVO", "GameSettingResponseVO",
|
||||
"is_maintenance", "is_ping_disabled",
|
||||
"Ping",
|
||||
"UserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO",
|
||||
"UserLogin", "UserLoginRequestVO", "UserLoginResponseVO",
|
||||
"UserLogout", "LogoutType", "DelayLog", "RequestAPI",
|
||||
"UserLogoutRequestVO", "UserLogoutRequest2VO", "UserLogoutResponseVO",
|
||||
"create_empty_delay_log", "create_delay_log_from_stats", "DELAY_LOG_SLOTS",
|
||||
# ---- 上传成绩 ----
|
||||
"UpsertUserAll", "PlayData", "build_play_from_config", "derive_play_characters",
|
||||
"load_extra_data", "merge_extra_data", "verify_upsert_landing",
|
||||
# ---- 购买功能票 ----
|
||||
"UpsertUserChargelog", "ChargelogConfirmError", "ChargelogGuardError",
|
||||
"UserChargeVO", "UserChargelogVO", "UserChargelogRequestVO",
|
||||
"ticket_name", "ticket_price", "calc_valid_date", "parse_wall_time", "now_cst",
|
||||
"UserAllRequestVO", "UpsertResponseVO",
|
||||
"UpsertUserAllError", "ConfirmRequiredError", "PatchError",
|
||||
"NODE_TYPES", "NODE_ORDER", "BITS_TO_ARRAY", "ARRAY_TO_BITS",
|
||||
"DIFF_PRIMARY_KEYS", "MAY_BE_EMPTY_NODES", "DEFAULT_EMPTY_NODES",
|
||||
"empty_node", "empty_user_all", "coerce_node_value", "sync_bits_to_arrays",
|
||||
"build_delta", "build_favorite_delta", "build_user_all",
|
||||
"build_user_playlog", "build_user_game_playlog", "build_post_play_user_data",
|
||||
"validate_user_playlog", "validate_before_send", "apply_play_effects",
|
||||
"default_user_weekly_data", "default_user_2p_playlog", "calc_play_special",
|
||||
"normalize_snapshot", "wait_send_cooldown",
|
||||
"PLAYLOG_FIELDS", "PLAYLOG_BOOL_FIELDS", "PLAYLOG_STRING_FIELDS",
|
||||
"JUDGE_TYPES", "JUDGE_BOXES",
|
||||
"USER_DATA_DROP_KEYS", "USER_OPTION_DROP_KEYS", "USER_CHARACTER_KEEP_KEYS",
|
||||
"USER_MUSIC_DETAIL_DROP_KEYS", "USER_CHARGE_DROP_KEYS", "USER_MISSION_DROP_KEYS",
|
||||
# ---- 快照编排 ----
|
||||
"fetch_upsert_snapshot", "save_snapshot", "load_snapshot",
|
||||
"summarize_snapshot", "DEFAULT_SNAPSHOT_NODES",
|
||||
# ---- 取数公共底座 ----
|
||||
"GetterEmptyRequestVO", "GetterUserIdRequestVO", "UserIdPageRequestVO",
|
||||
"UserLoginBonusRequestVO", "UserItemRequestVO", "UserMusicRequestVO",
|
||||
"UserCourseRequestVO", "UserFavoriteRequestVO", "UserFavoriteItemRequestVO",
|
||||
"UserShopStockRequestVO", "UserRivalRequestVO", "UserRivalMusicRequestVO",
|
||||
"fetch_all_pages",
|
||||
"ITEM_KINDS", "ITEM_NEXT_INDEX_FACTOR", "FAVORITE_ITEM_KINDS", "RIVAL_MUSIC_LEVELS",
|
||||
# ---- 取数脚本 ----
|
||||
"UserData", "UserExtend", "UserOption", "UserCharacter", "UserGhost",
|
||||
"UserMap", "UserMapAll",
|
||||
"UserLoginBonus", "UserLoginBonusAll",
|
||||
"UserRating",
|
||||
"UserItem", "UserItemAll",
|
||||
"UserMusicDetail", "UserMusicDetailAll",
|
||||
"UserCourse", "UserCourseAll",
|
||||
"UserCharge",
|
||||
"UserFavorite", "UserFavoriteAll",
|
||||
"UserActivity", "UserMissionData", "UserIntimate", "UserShopStock",
|
||||
"UserFavoriteItem", "UserFavoriteItemAll",
|
||||
"FAVORITE_ITEM_KIND_MUSIC", "FAVORITE_ITEM_KIND_RIVAL",
|
||||
"UserRivalData", "UserRivalMusic", "UserRivalMusicAll",
|
||||
"UserFriendBonus", "extract_gift_points",
|
||||
"UserKaleidxScope", "GameKaleidxScope",
|
||||
"UserRegion",
|
||||
"UserRecommendRateMusic", "UserRecommendSelectMusic",
|
||||
"UserNewItemList", "UserNewItemListRequestVO",
|
||||
"GetGameRanking", "GameRankingRequestVO",
|
||||
"GetGameEvent", "GameEventRequestVO",
|
||||
"GetGameCharge", "GameChargeRequestVO",
|
||||
"GetGameWeeklyData", "GetGameTournamentInfo", "GetGameNgMusicId",
|
||||
]
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
chime.py —— 扫码登录(二维码 -> userId + token)
|
||||
================================================================================
|
||||
|
||||
功能:把微信二维码串(或二维码 URL)交给 aimedb 服务器,换取 userId 与 token。
|
||||
|
||||
对应接口:POST https://ai.sys-allnet.cn/wc_aime/api/get_data
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
协议依据
|
||||
--------------------------------------------------------------------------------
|
||||
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
|
||||
CCommGetUserData_Create(strGameID, strChipID, strCommonKey,
|
||||
strQRData, strTitleKey, serverURLIndex)
|
||||
strGameID = "MAID"
|
||||
strChipID = KeychipID
|
||||
strQRData = 二维码串[20:] <- 见 CCommQRCode.EraseHashIdentifier()
|
||||
strTitleKey= "SDGB"
|
||||
|
||||
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommQRCode.cs
|
||||
IsValid() : 前缀 "SGWC"(Chime.WechatID) + 偏移 4 处为 gameID
|
||||
GetTimeStamp() : [8:20] 12 位 yymmddHHMMss
|
||||
EraseHashIdentifier() : [20:] 64 位载荷
|
||||
|
||||
source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250-255
|
||||
KeychipID = AMDaemon.System.KeychipId.Value
|
||||
TitleKey = AMDaemon.System.GameId
|
||||
GameID = "MAID"
|
||||
|
||||
docs/舞萌DX_扫码登录协议技术规范.md
|
||||
- 签名 key = SHA-256(chipID + timestamp + SIGN_KEY) 大写 hex
|
||||
- ★ 请求头【绝不能】带 Content-Type,否则服务端返回 errorID=57
|
||||
- qrCode 字段传的是 64 位载荷,不是完整 84 字符串
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
调用示例
|
||||
--------------------------------------------------------------------------------
|
||||
from api import ParseQRCode
|
||||
|
||||
user_id, token = ParseQRCode("SGWCMAID2610022219463A...")
|
||||
# 也可以直接传二维码 URL
|
||||
user_id, token = ParseQRCode("https://wq.wahlap.net/qrcode/req/MAID261002....html?l=...")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import re
|
||||
import ssl
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from config import (
|
||||
AIMEDB_HOST,
|
||||
AIMEDB_PATH,
|
||||
AIMEDB_PORT,
|
||||
KEYCHIP_ID,
|
||||
QR_OPEN_GAME_ID,
|
||||
QR_SIGN_KEY,
|
||||
QR_TITLE_KEY,
|
||||
QR_USER_AGENT,
|
||||
)
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info, log_request, log_response
|
||||
|
||||
# =============================================================================
|
||||
# 二维码格式常量(CCommQRCode.cs + 技术规范)
|
||||
# =============================================================================
|
||||
|
||||
QR_PREFIX = "SGWC" # Chime.WechatID
|
||||
QR_GAME_ID = "MAID"
|
||||
QR_PREFIX_LEN = 20 # SGWC(4) + MAID(4) + timestamp(12)
|
||||
QR_PAYLOAD_LEN = 64
|
||||
QR_TOTAL_LEN = QR_PREFIX_LEN + QR_PAYLOAD_LEN # 84
|
||||
|
||||
#: 默认机台 chipID
|
||||
#:
|
||||
#: ★★ 必须用**完整形式**(带连字符),不是短值 —— 这是个容易搞错的点:
|
||||
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
|
||||
#: KeychipID = AMDaemon.System.KeychipId.Value; // 完整形式
|
||||
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:255
|
||||
#: Chime.Instance.Init(GameID, KeychipID, _chimeDevice, TitleKey);
|
||||
#: 而游戏服务器那边用的是短值:
|
||||
#: source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
|
||||
#: clientId = AMDaemon.System.KeychipId.ShortValue; // 短值
|
||||
#:
|
||||
#: 即:aimedb 扫码用完整 KeychipId,游戏服务器用 ShortValue。
|
||||
#: docs/舞萌DX_扫码登录协议技术规范.md §3.4 的抓包验证向量也是
|
||||
#: chipID = "XXXX-XXXXXXXXXXX"。
|
||||
#: token 与 chipID 绑定,用错会导致后续接口「returnCode=1 但不落库」。
|
||||
DEFAULT_CHIP_ID = KEYCHIP_ID
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 内部工具
|
||||
# =============================================================================
|
||||
|
||||
def _extract_qr_from_url(url: str) -> str:
|
||||
"""
|
||||
从二维码 URL 提取完整 84 字符串。
|
||||
|
||||
URL 形如:
|
||||
https://wq.wahlap.net/qrcode/req/MAID2610022219463A....html?l=...&t=...&d=...
|
||||
取 /qrcode/req/ 与 .html 之间的 80 字符,前面补 "SGWC"。
|
||||
"""
|
||||
path = urlparse(url).path
|
||||
match = re.search(r"/qrcode/req/([^/]+)\.html", path)
|
||||
if not match:
|
||||
raise ValueError("URL 路径格式不符合预期,无法提取二维码数据:" + url)
|
||||
return QR_PREFIX + match.group(1)
|
||||
|
||||
|
||||
def _normalize_qr(qr_input: str) -> str:
|
||||
"""URL 或字符串 -> 标准 84 字符二维码串(含格式校验)。"""
|
||||
qr = (qr_input or "").strip()
|
||||
|
||||
if qr.startswith("http://") or qr.startswith("https://"):
|
||||
log_debug("输入是 URL,先提取二维码字符串")
|
||||
qr = _extract_qr_from_url(qr)
|
||||
|
||||
if len(qr) != QR_TOTAL_LEN:
|
||||
raise ValueError(
|
||||
"二维码长度应为 " + str(QR_TOTAL_LEN) + " 字符,实际 " + str(len(qr)) + " 字符:" + qr
|
||||
)
|
||||
if not qr.startswith(QR_PREFIX):
|
||||
raise ValueError("二维码必须以 '" + QR_PREFIX + "' 开头:" + qr)
|
||||
if qr[4:8] != QR_GAME_ID:
|
||||
raise ValueError(
|
||||
"二维码 GameID 应为 '" + QR_GAME_ID + "',实际 '" + qr[4:8] + "':" + qr
|
||||
)
|
||||
if not all(c in "0123456789ABCDEF" for c in qr[QR_PREFIX_LEN:]):
|
||||
raise ValueError("二维码载荷必须为大写十六进制:" + qr)
|
||||
|
||||
log_debug(
|
||||
"二维码校验通过:timestamp=" + qr[8:20]
|
||||
+ " payload_len=" + str(len(qr[QR_PREFIX_LEN:]))
|
||||
)
|
||||
return qr
|
||||
|
||||
|
||||
def _build_body(qr_string: str, chip_id: str) -> Tuple[bytes, str, str]:
|
||||
"""
|
||||
构造请求体。
|
||||
|
||||
签名(技术规范 §4.1):
|
||||
key = SHA-256(chipID + timestamp + SIGN_KEY).hexdigest().upper()
|
||||
★ 拼接里不含 qrCode、不含 titlekey
|
||||
|
||||
qrCode 字段(CCommQRCode.EraseHashIdentifier):
|
||||
qr_string[20:] 即 64 位载荷
|
||||
|
||||
返回:
|
||||
(body_bytes, timestamp, key)
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%y%m%d%H%M%S")
|
||||
key = hashlib.sha256((chip_id + timestamp + QR_SIGN_KEY).encode()).hexdigest().upper()
|
||||
|
||||
body_dict = {
|
||||
"chipID": chip_id,
|
||||
"openGameID": QR_OPEN_GAME_ID,
|
||||
"key": key,
|
||||
"qrCode": qr_string[QR_PREFIX_LEN:],
|
||||
"timestamp": timestamp,
|
||||
"titlekey": QR_TITLE_KEY,
|
||||
}
|
||||
body_bytes = json.dumps(body_dict, separators=(",", ":")).encode("utf-8")
|
||||
return body_bytes, timestamp, key
|
||||
|
||||
|
||||
def _send(body_bytes: bytes) -> dict:
|
||||
"""
|
||||
发送 HTTPS POST 并解析 JSON 响应。
|
||||
|
||||
★ 请求头只放 Connection / User-Agent / Content-Length。
|
||||
绝不能加 Content-Type —— 加了会得到 errorID=57(技术规范 §3.3)。
|
||||
"""
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
headers = {
|
||||
"Connection": "Keep-Alive",
|
||||
"User-Agent": QR_USER_AGENT,
|
||||
"Content-Length": str(len(body_bytes)),
|
||||
# ★ 此处刻意不加 Content-Type
|
||||
}
|
||||
|
||||
conn = http.client.HTTPSConnection(AIMEDB_HOST, AIMEDB_PORT, context=ctx, timeout=20)
|
||||
try:
|
||||
conn.request("POST", AIMEDB_PATH, body=body_bytes, headers=headers)
|
||||
resp = conn.getresponse()
|
||||
text = resp.read().decode("utf-8")
|
||||
status = resp.status
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_response(status=status, body=text, api_name="aimedb/get_data")
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception as exc:
|
||||
raise RuntimeError("aimedb 响应不是合法 JSON:" + text[:300]) from exc
|
||||
|
||||
|
||||
def _parse(resp: dict) -> Tuple[int, str]:
|
||||
"""解析响应,返回 (user_id, token)。"""
|
||||
error_id = resp.get("errorID", -1)
|
||||
user_id = resp.get("userID", -1)
|
||||
token = resp.get("token", "") or ""
|
||||
|
||||
if error_id != 0:
|
||||
raise RuntimeError(
|
||||
"扫码登录失败:errorID=" + str(error_id) + ",响应=" + json.dumps(resp, ensure_ascii=False)
|
||||
+ "\n 提示:errorID=57 通常是二维码已失效/已被使用,或请求头带了 Content-Type"
|
||||
)
|
||||
if user_id == -1 or not token:
|
||||
raise RuntimeError(
|
||||
"扫码登录失败:userID=" + str(user_id) + ", token='" + token + "'"
|
||||
)
|
||||
return int(user_id), str(token)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 公开 API
|
||||
# =============================================================================
|
||||
|
||||
def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int, str]:
|
||||
"""
|
||||
解析二维码并换取 userId 与 token。
|
||||
|
||||
参数:
|
||||
qr_input: 84 字符二维码串(SGWCMAID...),或二维码 URL
|
||||
chip_id: 机台 Keychip 短值(可选,默认取 config.CLIENT_ID)
|
||||
|
||||
返回:
|
||||
(user_id, token) 元组,可直接传给 UserPreview / UserLogin 等
|
||||
|
||||
异常:
|
||||
ValueError: 二维码格式不合法
|
||||
RuntimeError: 服务器返回错误或网络异常
|
||||
"""
|
||||
log_info("解析二维码登录:chip_id=" + chip_id)
|
||||
|
||||
qr_string = _normalize_qr(qr_input)
|
||||
body_bytes, timestamp, key = _build_body(qr_string, chip_id)
|
||||
|
||||
log_request(
|
||||
url="https://" + AIMEDB_HOST + ":" + str(AIMEDB_PORT) + AIMEDB_PATH,
|
||||
body={
|
||||
"chipID": chip_id,
|
||||
"openGameID": QR_OPEN_GAME_ID,
|
||||
"key": key,
|
||||
"qrCode": qr_string[QR_PREFIX_LEN:],
|
||||
"timestamp": timestamp,
|
||||
"titlekey": QR_TITLE_KEY,
|
||||
},
|
||||
headers={"Connection": "Keep-Alive", "User-Agent": QR_USER_AGENT,
|
||||
"Content-Length": str(len(body_bytes))},
|
||||
api_name="aimedb/get_data",
|
||||
)
|
||||
|
||||
log_info("发送登录请求:timestamp=" + timestamp + " host=" + AIMEDB_HOST + AIMEDB_PATH)
|
||||
resp = _send(body_bytes)
|
||||
user_id, token = _parse(resp)
|
||||
|
||||
log_info("扫码登录成功:user_id=" + str(user_id) + " token=" + token[:8] + "...(长度 "
|
||||
+ str(len(token)) + ")")
|
||||
return user_id, token
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 单独运行(调试)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("chime_test")
|
||||
try:
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python -m api.chime <二维码串或URL> [chip_id]")
|
||||
return 1
|
||||
chip = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_CHIP_ID
|
||||
user_id, token = ParseQRCode(sys.argv[1], chip_id=chip)
|
||||
print("")
|
||||
print("user_id =", user_id)
|
||||
print("token =", token)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log_error("扫码登录失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
game_charge.py —— 取游戏计费配置(GetGameChargeApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的计费档位。
|
||||
|
||||
对应接口:GetGameChargeApi
|
||||
请求体 :{"isAll": false}(GameChargeRequestVO)
|
||||
响应 :{length, gameChargeList:[{orderId,chargeId,price,startDate,endDate}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameChargeRequestVO.cs
|
||||
source/2026-10-05.log:9 真机请求 {"isAll":false}
|
||||
|
||||
调用示例:
|
||||
from api import GetGameCharge
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameChargeRequestVO:
|
||||
"""请求 VO(对应 GameChargeRequestVO)。"""
|
||||
isAll: bool = False
|
||||
|
||||
|
||||
def GetGameCharge(
|
||||
*,
|
||||
is_all: bool = False,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取游戏计费配置(对应 GetGameChargeApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameChargeList
|
||||
"""
|
||||
log_info("取游戏计费配置:isAll=" + str(is_all))
|
||||
return request_dict(
|
||||
"GetGameChargeApi", GameChargeRequestVO(isAll=is_all),
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
game_event.py —— 取游戏活动(GetGameEventApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的活动列表。
|
||||
|
||||
对应接口:GetGameEventApi
|
||||
请求体 :{"type":1, "isAllEvent":true}(GameEventRequestVO)
|
||||
响应 :{type, length, gameEventList:[{type,id,startDate,endDate}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameEventRequestVO.cs
|
||||
source/2026-10-05.log:5 真机请求 {"type":1,"isAllEvent":true}
|
||||
|
||||
调用示例:
|
||||
from api import GetGameEvent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameEventRequestVO:
|
||||
"""请求 VO(对应 GameEventRequestVO)。"""
|
||||
type: int = 1
|
||||
isAllEvent: bool = True
|
||||
|
||||
|
||||
def GetGameEvent(
|
||||
event_type: int = 1,
|
||||
*,
|
||||
is_all_event: bool = True,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取游戏活动列表(对应 GetGameEventApi)。
|
||||
|
||||
参数:
|
||||
event_type: 活动类型(真机用 1)
|
||||
is_all_event: 是否取全部活动(真机 True)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameEventList
|
||||
"""
|
||||
log_info("取游戏活动:type=" + str(event_type) + " isAllEvent=" + str(is_all_event))
|
||||
return request_dict(
|
||||
"GetGameEventApi",
|
||||
GameEventRequestVO(type=event_type, isAllEvent=is_all_event),
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
game_ng_music_id.py —— 取禁用曲目列表(GetGameNgMusicIdApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的 NG(不可游玩)曲目 ID 列表。
|
||||
|
||||
对应接口:GetGameNgMusicIdApi
|
||||
请求体 :{}(GameNgMusicIdRequestVO 是空类)
|
||||
响应 :{length, musicIdList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameNgMusicIdRequestVO.cs(空类)
|
||||
source/2026-10-05.log:11
|
||||
|
||||
调用示例:
|
||||
from api import GetGameNgMusicId
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
def GetGameNgMusicId(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取禁用曲目列表(对应 GetGameNgMusicIdApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 musicIdList
|
||||
"""
|
||||
log_info("取禁用曲目列表")
|
||||
return request_dict(
|
||||
"GetGameNgMusicIdApi", {},
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
game_ranking.py —— 取游戏排行(GetGameRankingApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的排行榜数据。
|
||||
|
||||
对应接口:GetGameRankingApi
|
||||
请求体 :{"type": <排行类型>}(GameRankingRequestVO.type 是 long)
|
||||
响应 :{type, gameRankingList:[{id,point,userName}], gameRankingInstantList}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameRankingRequestVO.cs
|
||||
source/2026-10-05.log:3 真机请求 {"type":1}
|
||||
|
||||
调用示例:
|
||||
from api import GetGameRanking
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameRankingRequestVO:
|
||||
"""请求 VO(对应 GameRankingRequestVO)。"""
|
||||
type: int = 1
|
||||
|
||||
|
||||
def GetGameRanking(
|
||||
ranking_type: int = 1,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取游戏排行(对应 GetGameRankingApi)。
|
||||
|
||||
参数:
|
||||
ranking_type: 排行类型(真机用 1)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameRankingList
|
||||
"""
|
||||
log_info("取游戏排行:type=" + str(ranking_type))
|
||||
return request_dict(
|
||||
"GetGameRankingApi", GameRankingRequestVO(type=ranking_type),
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
game_tournament_info.py —— 取大会信息(GetGameTournamentInfoApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的大会/赛事信息。
|
||||
|
||||
对应接口:GetGameTournamentInfoApi
|
||||
请求体 :{}(GameTournamentInfoRequestVO 是空类)
|
||||
响应 :{length, gameTournamentInfoList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameTournamentInfoRequestVO.cs(空类)
|
||||
source/2026-10-05.log:7
|
||||
|
||||
调用示例:
|
||||
from api import GetGameTournamentInfo
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
def GetGameTournamentInfo(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取大会信息(对应 GetGameTournamentInfoApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameTournamentInfoList
|
||||
"""
|
||||
log_info("取大会信息")
|
||||
return request_dict(
|
||||
"GetGameTournamentInfoApi", {},
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
game_weekly_data.py —— 取游戏周常配置(GetGameWeeklyDataApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的周常周期信息。
|
||||
|
||||
对应接口:GetGameWeeklyDataApi
|
||||
请求体 :{}(GameWeeklyRequestVO 是空类)
|
||||
响应 :{gameWeeklyData:{missionCategory, updateDate, beforeDate}}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameWeeklyRequestVO.cs(空类)
|
||||
source/2026-10-05.log:13
|
||||
|
||||
调用示例:
|
||||
from api import GetGameWeeklyData
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
def GetGameWeeklyData(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取游戏周常配置(对应 GetGameWeeklyDataApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameWeeklyData
|
||||
"""
|
||||
log_info("取游戏周常配置")
|
||||
return request_dict(
|
||||
"GetGameWeeklyDataApi", {},
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
gamesettings.py —— 获取游戏设置(GetGameSettingApi)
|
||||
================================================================================
|
||||
|
||||
功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。
|
||||
|
||||
对应接口:GetGameSettingApi
|
||||
请求体 :{"placeId": <店铺ID>, "clientId": "<机台短值>"}
|
||||
source/Assembly-CSharp/Net.VO.Mai2/GameSettingRequestVO.cs
|
||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:21-24
|
||||
|
||||
★ 真机调用时机:会话最开始、Ping 之前(source/2026-10-05.log:1)。
|
||||
|
||||
调用示例:
|
||||
from api import GetGameSettings
|
||||
resp = GetGameSettings()
|
||||
print(resp.gameSetting.isMaintenance, resp.gameSetting.requestInterval)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from config import CLIENT_ID, PLACE_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VO(字段对照 source/Assembly-CSharp/Net.VO.Mai2/GameSetting.cs 与真机响应)
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class GameSetting:
|
||||
"""游戏运行时配置(对应 Net.VO.Mai2.GameSetting)。"""
|
||||
isMaintenance: bool = False
|
||||
requestInterval: int = 0
|
||||
rebootStartTime: str = ""
|
||||
rebootEndTime: str = ""
|
||||
rebootInterval: int = 0
|
||||
movieUploadLimit: int = 0
|
||||
movieStatus: int = 0
|
||||
movieServerUri: str = ""
|
||||
deliverServerUri: str = ""
|
||||
oldServerUri: str = ""
|
||||
usbDlServerUri: str = ""
|
||||
maxCountRivalMusic: int = 0
|
||||
replicationDelayLimit: int = 0
|
||||
exclusionStartTime: str = ""
|
||||
exclusionEndTime: str = ""
|
||||
pingDisable: bool = False
|
||||
packetTimeout: int = 0
|
||||
packetTimeoutLong: int = 0
|
||||
packetRetryCount: int = 0
|
||||
userDataDlErrTimeout: int = 0
|
||||
userDataDlErrRetryCount: int = 0
|
||||
userDataDlErrSamePacketRetryCount: int = 0
|
||||
userDataUpSkipTimeout: int = 0
|
||||
userDataUpSkipRetryCount: int = 0
|
||||
iconPhotoDisable: bool = False
|
||||
uploadPhotoDisable: bool = False
|
||||
photoDisableArea: Optional[str] = None
|
||||
collectDelayInfoDisable: bool = False
|
||||
maxCountMusic: int = 0
|
||||
maxCountItem: int = 0
|
||||
nameEntryDisable: bool = False
|
||||
packetRecreateCount: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameSettingRequestVO:
|
||||
"""请求 VO(对应 GameSettingRequestVO)。"""
|
||||
placeId: int = 0
|
||||
clientId: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameSettingResponseVO:
|
||||
"""响应 VO(对应 GameSettingResponseVO)。"""
|
||||
isAouAccession: bool = False
|
||||
gameSetting: GameSetting = field(default_factory=GameSetting)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 公开 API
|
||||
# =============================================================================
|
||||
|
||||
def GetGameSettings(
|
||||
*,
|
||||
place_id: int = PLACE_ID,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> GameSettingResponseVO:
|
||||
"""
|
||||
获取游戏设置(服务器下发的运行时配置)。
|
||||
|
||||
参数:
|
||||
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
|
||||
client_id: 机台短值(可选,默认 config.CLIENT_ID)
|
||||
timeout_ms: 超时毫秒(可选)
|
||||
client: 可复用的 MaimaiNetClient(可选)
|
||||
|
||||
返回:
|
||||
GameSettingResponseVO
|
||||
"""
|
||||
log_info("获取游戏设置:place_id=" + str(place_id) + " client_id=" + client_id)
|
||||
|
||||
raw = request_dict(
|
||||
"GetGameSettingApi",
|
||||
GameSettingRequestVO(placeId=place_id, clientId=client_id),
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
|
||||
gs_raw = raw.get("gameSetting") or {}
|
||||
known = {f for f in GameSetting.__dataclass_fields__}
|
||||
gs = GameSetting(**{k: v for k, v in gs_raw.items() if k in known})
|
||||
resp = GameSettingResponseVO(
|
||||
isAouAccession=bool(raw.get("isAouAccession", False)),
|
||||
gameSetting=gs,
|
||||
)
|
||||
|
||||
if gs.isMaintenance:
|
||||
log_warning("服务器处于维护状态(isMaintenance=True)")
|
||||
if gs.pingDisable:
|
||||
log_info("服务器已禁用心跳(pingDisable=True)")
|
||||
|
||||
log_info(
|
||||
"游戏设置获取完成:isMaintenance=" + str(gs.isMaintenance)
|
||||
+ " requestInterval=" + str(gs.requestInterval)
|
||||
+ " packetTimeout=" + str(gs.packetTimeout)
|
||||
+ " packetTimeoutLong=" + str(gs.packetTimeoutLong)
|
||||
+ " pingDisable=" + str(gs.pingDisable)
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def is_maintenance(settings: GameSettingResponseVO) -> bool:
|
||||
"""便捷判断:服务器是否处于维护状态。"""
|
||||
return bool(settings.gameSetting.isMaintenance)
|
||||
|
||||
|
||||
def is_ping_disabled(settings: GameSettingResponseVO) -> bool:
|
||||
"""便捷判断:是否已禁用心跳。"""
|
||||
return bool(settings.gameSetting.pingDisable)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 单独运行(调试)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("gamesettings_test")
|
||||
try:
|
||||
resp = GetGameSettings()
|
||||
gs = resp.gameSetting
|
||||
print("")
|
||||
for name in GameSetting.__dataclass_fields__:
|
||||
print(" " + name.ljust(34) + "= " + str(getattr(gs, name)))
|
||||
return 0
|
||||
except Exception as exc:
|
||||
from .log_setup import log_error
|
||||
log_error("获取游戏设置失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,704 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
log_setup.py —— 统一日志模块(INFO / DEBUG 双模式)
|
||||
================================================================================
|
||||
|
||||
【两种模式】
|
||||
|
||||
INFO (默认) 只展示 api 脚本的运行情况和结果:
|
||||
=== ParseQRCode 开始运行 ===
|
||||
[接口说明] ...
|
||||
[调用参数] ...
|
||||
[运行结果] ...
|
||||
=== ParseQRCode 运行结束 ===
|
||||
|
||||
DEBUG 在 INFO 基础上,额外展示「向 api 发送的数据」和「api 返回的数据」:
|
||||
-> 请求 GetUserPreviewApi
|
||||
URL : https://...
|
||||
请求头 : {...}
|
||||
请求体 : {...JSON 全文...}
|
||||
<- 响应 GetUserPreviewApi
|
||||
状态码 : 200
|
||||
响应体 : {...JSON 全文...}
|
||||
|
||||
【模式从哪来】
|
||||
|
||||
优先级: setup_logging(level=...) 显式传参 > config.py 的 LOG_LEVEL > INFO
|
||||
|
||||
日常只改 config.py 一行:
|
||||
|
||||
LOG_LEVEL = "info" # 只看流程
|
||||
LOG_LEVEL = "debug" # 连收发数据一起看
|
||||
|
||||
【日志文件(两种形式同时存在、同时写入)】
|
||||
|
||||
logs/latest.log 覆盖模式("w")—— 只包含【当次】主脚本运行产出的日志,
|
||||
下一次运行主脚本时被覆盖
|
||||
logs/YYYY-MM-DD.log 追加模式("a")—— 存放【当日】产生的所有日志
|
||||
|
||||
每行都带 [YYYY-MM-DD HH:MM:SS] 时间戳。
|
||||
|
||||
【主脚本怎么用】
|
||||
|
||||
from api import setup_logging, teardown_logging, ParseQRCode, UserLogin
|
||||
|
||||
def main():
|
||||
setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录
|
||||
try:
|
||||
user_id, token = ParseQRCode(qr) # 自动产出「开始运行 / 运行结束」
|
||||
login = UserLogin(user_id, token)
|
||||
finally:
|
||||
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
|
||||
|
||||
api 函数不需要自己写 print,也不需要装饰器 —— api/__init__.py 在导入时就
|
||||
统一套上了日志外壳(见 autowrap)。
|
||||
|
||||
【api 脚本怎么用】
|
||||
|
||||
from api import log_info, log_debug, log_request, log_response
|
||||
|
||||
def UserPreview(user_id, token, ...):
|
||||
# 函数的第一行 docstring 会成为日志里的「[接口说明]」
|
||||
# 例如: '''获取用户预览信息'''
|
||||
log_info(f"请求用户预览 user_id={user_id}") # 可选,想额外记话时才写
|
||||
|
||||
log_request(url=url, body=req, headers=hdr, api_name="GetUserPreviewApi")
|
||||
raw = _http_post(...)
|
||||
log_response(status=200, body=raw, headers=hdr, api_name="GetUserPreviewApi")
|
||||
|
||||
return parsed
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Iterable, Optional
|
||||
|
||||
# =============================================================================
|
||||
# 常量与全局状态
|
||||
# =============================================================================
|
||||
|
||||
LEVEL_INFO = "info"
|
||||
LEVEL_DEBUG = "debug"
|
||||
|
||||
_LEVEL_RANK = {LEVEL_INFO: 20, LEVEL_DEBUG: 10}
|
||||
|
||||
# 项目根目录(本文件在 api/ 下,根目录是上一级)
|
||||
_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_LOGS_DIR = os.path.join(_ROOT_DIR, "logs")
|
||||
|
||||
_SEPARATOR = "=" * 66
|
||||
_INDENT = " "
|
||||
|
||||
# 全局状态
|
||||
_session_started = False
|
||||
_session_script_name = ""
|
||||
_current_level = LEVEL_INFO
|
||||
_value_max = 0
|
||||
|
||||
_date_log_path: Optional[str] = None
|
||||
_latest_log_path: Optional[str] = None
|
||||
_date_log_file = None
|
||||
_latest_log_file = None
|
||||
|
||||
# 被自动跟踪的 api 函数名(供横幅展示)
|
||||
_tracked_api_names: list = []
|
||||
# 本次会话实际调用过的 api(按首次调用顺序)
|
||||
_called_apis: list = []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 模式判定
|
||||
# =============================================================================
|
||||
|
||||
def _normalize_level(level: Any) -> str:
|
||||
"""把各种写法的模式名归一化成 'info' / 'debug'。"""
|
||||
if level is None:
|
||||
return LEVEL_INFO
|
||||
text = str(level).strip().lower()
|
||||
if text in ("debug", "dbg", "verbose", "详细", "调试"):
|
||||
return LEVEL_DEBUG
|
||||
return LEVEL_INFO
|
||||
|
||||
|
||||
def _config_level() -> str:
|
||||
"""从 config.py 读 LOG_LEVEL;读不到就用 INFO。"""
|
||||
try:
|
||||
import config # type: ignore
|
||||
return _normalize_level(getattr(config, "LOG_LEVEL", LEVEL_INFO))
|
||||
except Exception:
|
||||
return LEVEL_INFO
|
||||
|
||||
|
||||
def _config_value_max() -> int:
|
||||
"""从 config.py 读 LOG_VALUE_MAX(0 = 不截断)。"""
|
||||
try:
|
||||
import config # type: ignore
|
||||
return int(getattr(config, "LOG_VALUE_MAX", 0) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def get_level() -> str:
|
||||
"""返回当前日志模式('info' / 'debug')。"""
|
||||
return _current_level
|
||||
|
||||
|
||||
def is_debug() -> bool:
|
||||
"""当前是否 DEBUG 模式。"""
|
||||
return _LEVEL_RANK.get(_current_level, 20) <= _LEVEL_RANK[LEVEL_DEBUG]
|
||||
|
||||
|
||||
def set_level(level: Any) -> str:
|
||||
"""运行时切换模式(一般不用,改 config.py 即可)。返回切换后的模式。"""
|
||||
global _current_level
|
||||
_current_level = _normalize_level(level)
|
||||
return _current_level
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 日志文件
|
||||
# =============================================================================
|
||||
|
||||
def _ensure_logs_dir() -> None:
|
||||
os.makedirs(_LOGS_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _get_date_log_path() -> str:
|
||||
return os.path.join(_LOGS_DIR, datetime.now().strftime("%Y-%m-%d") + ".log")
|
||||
|
||||
|
||||
def _get_latest_log_path() -> str:
|
||||
return os.path.join(_LOGS_DIR, "latest.log")
|
||||
|
||||
|
||||
def _write_files(message: str) -> None:
|
||||
"""把一段文本写进两个日志文件(每行带时间戳)。"""
|
||||
timestamp = datetime.now().strftime("[%Y-%m-%d %H:%M:%S]")
|
||||
lines = message.splitlines() or [""]
|
||||
payload = "".join(timestamp + line + "\n" for line in lines)
|
||||
for handle in (_date_log_file, _latest_log_file):
|
||||
if handle:
|
||||
try:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _emit(message: str = "", *, console: bool = True) -> None:
|
||||
"""统一出口:控制台 + 两个日志文件。"""
|
||||
if console:
|
||||
try:
|
||||
print(message)
|
||||
except Exception:
|
||||
try:
|
||||
print(message.encode("utf-8", "replace").decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
pass
|
||||
_write_files(message)
|
||||
|
||||
|
||||
def _close_log_files() -> None:
|
||||
global _date_log_file, _latest_log_file
|
||||
for name in ("_date_log_file", "_latest_log_file"):
|
||||
handle = globals()[name]
|
||||
if handle:
|
||||
try:
|
||||
handle.close()
|
||||
except Exception:
|
||||
pass
|
||||
globals()[name] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 自动跟踪:给 api 函数套日志外壳
|
||||
# =============================================================================
|
||||
#
|
||||
# 目标:主脚本只写 setup_logging("名字") + 业务代码,
|
||||
# 调用的每个 api 函数自动产出「===函数名 开始运行=== / ===函数名 运行结束===」,
|
||||
# 调用方不必自己写装饰器,也不必自己埋点。
|
||||
#
|
||||
# 原理:api/__init__.py 在导入完各 api 脚本后调用 autowrap(globals(), _API_FUNCS),
|
||||
# 把命名空间里的函数替换成带日志的版本 —— 于是 from api import ParseQRCode
|
||||
# 拿到的已经是包装后的函数。
|
||||
|
||||
def _describe(func: Callable) -> str:
|
||||
"""取函数 docstring 首行,作为「[接口说明]」。"""
|
||||
doc = (func.__doc__ or "").strip()
|
||||
if not doc:
|
||||
return ""
|
||||
return doc.splitlines()[0].strip()
|
||||
|
||||
|
||||
def _make_tracked(func: Callable) -> Callable:
|
||||
"""生成带日志外壳的包装函数。"""
|
||||
name = func.__name__
|
||||
summary = _describe(func)
|
||||
|
||||
@functools.wraps(func)
|
||||
def tracked(*args, **kwargs) -> Any:
|
||||
_note_api_call(name)
|
||||
|
||||
_emit("")
|
||||
_emit("=== " + name + " 开始运行 ===")
|
||||
if summary:
|
||||
_emit(_INDENT + "[接口说明] " + summary)
|
||||
|
||||
# 调用参数:DEBUG 展开多行 JSON,INFO 记单行摘要
|
||||
if args:
|
||||
if is_debug():
|
||||
_emit(_INDENT + "[位置参数]")
|
||||
_emit(_fmt_pretty(list(args)))
|
||||
else:
|
||||
_emit(_INDENT + "[位置参数] " + ", ".join(_fmt(a) for a in args))
|
||||
if kwargs:
|
||||
shown = {k: v for k, v in kwargs.items() if v is not None}
|
||||
if shown:
|
||||
if is_debug():
|
||||
_emit(_INDENT + "[关键字参数]")
|
||||
_emit(_fmt_pretty(shown))
|
||||
else:
|
||||
_emit(_INDENT + "[关键字参数] "
|
||||
+ ", ".join(k + "=" + _fmt(v) for k, v in shown.items()))
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
except BaseException as exc:
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
_emit(_INDENT + "[运行结果] 异常 " + type(exc).__name__ + ": "
|
||||
+ str(exc) + "(耗时 " + str(ms) + " ms)")
|
||||
_emit("=== " + name + " 运行结束 ===")
|
||||
_emit("")
|
||||
raise
|
||||
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
_emit(_INDENT + "[运行结果] 成功 " + _fmt_result(result)
|
||||
+ "(耗时 " + str(ms) + " ms)")
|
||||
if is_debug() and result is not None:
|
||||
_emit(_INDENT + "[返回数据]")
|
||||
_emit(_fmt_pretty(result))
|
||||
_emit("=== " + name + " 运行结束 ===")
|
||||
_emit("")
|
||||
return result
|
||||
|
||||
tracked.__mai_logged__ = True # type: ignore[attr-defined]
|
||||
tracked.__mai_original__ = func # type: ignore[attr-defined]
|
||||
return tracked
|
||||
|
||||
|
||||
def autowrap(namespace: dict, names: Iterable[str]) -> list:
|
||||
"""
|
||||
给命名空间里的函数套上日志外壳(导入时调用,幂等)。
|
||||
|
||||
参数:
|
||||
namespace: 目标命名空间,通常是 api/__init__.py 的 globals()
|
||||
names: 要包装的函数名序列
|
||||
|
||||
返回:
|
||||
实际完成包装的函数名列表
|
||||
"""
|
||||
wrapped = []
|
||||
for name in names:
|
||||
obj = namespace.get(name)
|
||||
if obj is None or not callable(obj):
|
||||
continue
|
||||
if getattr(obj, "__mai_logged__", False):
|
||||
wrapped.append(name)
|
||||
continue
|
||||
try:
|
||||
namespace[name] = _make_tracked(obj)
|
||||
wrapped.append(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for n in wrapped:
|
||||
if n not in _tracked_api_names:
|
||||
_tracked_api_names.append(n)
|
||||
return wrapped
|
||||
|
||||
|
||||
def _note_api_call(name: str) -> None:
|
||||
"""记录主脚本调用过的 api(去重,保留首次调用顺序)。"""
|
||||
if name not in _called_apis:
|
||||
_called_apis.append(name)
|
||||
|
||||
|
||||
def get_called_apis() -> list:
|
||||
"""返回本次会话实际调用过的 api 名称(按首次调用顺序)。"""
|
||||
return list(_called_apis)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 会话:初始化 / 收尾
|
||||
# =============================================================================
|
||||
|
||||
def setup_logging(script_name: str, level: Any = None) -> str:
|
||||
"""
|
||||
初始化日志会话(主脚本开头调用一次)。
|
||||
|
||||
做三件事:
|
||||
1. 确定日志模式(level 显式传参 > config.LOG_LEVEL > INFO)
|
||||
2. 打开两个日志文件:日期日志(追加)+ latest.log(覆盖)
|
||||
3. 打印会话横幅
|
||||
|
||||
参数:
|
||||
script_name: 脚本名,显示在横幅上
|
||||
level: 日志模式;None(默认)= 读 config.py 的 LOG_LEVEL
|
||||
|
||||
返回:
|
||||
实际生效的模式('info' / 'debug')
|
||||
"""
|
||||
global _session_started, _session_script_name, _value_max
|
||||
global _date_log_path, _latest_log_path, _date_log_file, _latest_log_file
|
||||
|
||||
set_level(level if level is not None else _config_level())
|
||||
_value_max = _config_value_max()
|
||||
|
||||
if _session_started:
|
||||
return _current_level
|
||||
|
||||
_ensure_logs_dir()
|
||||
_session_script_name = script_name
|
||||
_date_log_path = _get_date_log_path()
|
||||
_latest_log_path = _get_latest_log_path()
|
||||
|
||||
_called_apis.clear()
|
||||
|
||||
_date_log_file = open(_date_log_path, "a", encoding="utf-8") # 追加:当日全部
|
||||
_latest_log_file = open(_latest_log_path, "w", encoding="utf-8") # 覆盖:仅当次
|
||||
|
||||
_print_banner(script_name)
|
||||
_session_started = True
|
||||
return _current_level
|
||||
|
||||
|
||||
def _print_banner(script_name: str) -> None:
|
||||
mode_text = "DEBUG(含收发数据)" if is_debug() else "INFO(仅运行情况与结果)"
|
||||
lines = [
|
||||
_SEPARATOR,
|
||||
_INDENT + "日志会话开始 —— " + script_name,
|
||||
_SEPARATOR,
|
||||
_INDENT * 2 + "主脚本 : " + script_name,
|
||||
_INDENT * 2 + "开始时间 : " + datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
_INDENT * 2 + "日志模式 : " + mode_text,
|
||||
_INDENT * 2 + "Python : " + platform.python_version()
|
||||
+ " (" + platform.python_implementation() + " " + platform.machine() + ")",
|
||||
_INDENT * 2 + "工作目录 : " + os.getcwd(),
|
||||
_INDENT * 2 + "已挂载 api : " + str(len(_tracked_api_names))
|
||||
+ " 个(自动跟踪,主脚本无需写日志代码)",
|
||||
_INDENT * 2 + "当次日志 : " + str(_latest_log_path),
|
||||
_INDENT * 2 + "日期日志 : " + str(_date_log_path),
|
||||
_SEPARATOR,
|
||||
]
|
||||
_emit("\n".join(lines))
|
||||
|
||||
|
||||
def teardown_logging() -> None:
|
||||
"""结束日志会话(主脚本结尾调用;不调用也会在退出时自动收尾)。"""
|
||||
global _session_started
|
||||
if not _session_started:
|
||||
return
|
||||
|
||||
lines = [_SEPARATOR, _INDENT + "本次运行调用的 api", _SEPARATOR]
|
||||
if _called_apis:
|
||||
for i, name in enumerate(_called_apis, 1):
|
||||
lines.append(_INDENT * 2 + str(i).rjust(2) + ". " + name)
|
||||
else:
|
||||
lines.append(_INDENT * 2 + "(未调用任何 api)")
|
||||
lines.append(_SEPARATOR)
|
||||
lines.append(_INDENT + "日志会话结束 —— " + _session_script_name)
|
||||
lines.append(_SEPARATOR)
|
||||
|
||||
_emit("\n".join(lines))
|
||||
_close_log_files()
|
||||
_session_started = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 值格式化
|
||||
# =============================================================================
|
||||
|
||||
def _truncate(text: str) -> str:
|
||||
if _value_max and len(text) > _value_max:
|
||||
return text[:_value_max] + "...(共 " + str(len(text)) + " 字符)"
|
||||
return text
|
||||
|
||||
|
||||
def _fmt(value: Any) -> str:
|
||||
"""把一个值渲染成紧凑单行文本。"""
|
||||
if isinstance(value, str):
|
||||
return _truncate(value)
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return repr(value)
|
||||
if isinstance(value, (dict, list, tuple)):
|
||||
try:
|
||||
return _truncate(json.dumps(value, ensure_ascii=False,
|
||||
separators=(",", ":"), default=str))
|
||||
except Exception:
|
||||
return _truncate(repr(value))
|
||||
if hasattr(value, "__dict__"):
|
||||
try:
|
||||
data = {k: v for k, v in vars(value).items() if not k.startswith("_")}
|
||||
return _truncate(json.dumps(data, ensure_ascii=False,
|
||||
separators=(",", ":"), default=str))
|
||||
except Exception:
|
||||
return _truncate(repr(value))
|
||||
return _truncate(repr(value))
|
||||
|
||||
|
||||
def _to_plain(value: Any) -> Any:
|
||||
"""把 dataclass / 普通对象递归转成可 JSON 化的结构。"""
|
||||
if value is None or isinstance(value, (int, float, bool, str)):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {k: _to_plain(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_to_plain(v) for v in value]
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {k: _to_plain(getattr(value, k, None)) for k in value.__dataclass_fields__}
|
||||
if hasattr(value, "__dict__"):
|
||||
return {k: _to_plain(v) for k, v in vars(value).items() if not k.startswith("_")}
|
||||
return str(value)
|
||||
|
||||
|
||||
def _fmt_pretty(value: Any, indent: str = " ") -> str:
|
||||
"""把 dict / list / VO 渲染成多行缩进的 JSON。DEBUG 下完整输出,不折叠。"""
|
||||
data = _to_plain(value)
|
||||
if isinstance(data, str):
|
||||
stripped = data.strip()
|
||||
if stripped[:1] in ("{", "["):
|
||||
try:
|
||||
data = json.loads(stripped)
|
||||
except Exception:
|
||||
return "\n".join(indent + line for line in data.splitlines())
|
||||
try:
|
||||
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
|
||||
except Exception:
|
||||
text = repr(data)
|
||||
|
||||
if _value_max and len(text) > _value_max:
|
||||
text = text[:_value_max] + "\n" + indent + "...(共 " + str(len(text)) + " 字符,已截断)"
|
||||
return "\n".join(indent + line for line in text.splitlines())
|
||||
|
||||
|
||||
def _fmt_result(result: Any) -> str:
|
||||
"""结果的紧凑摘要(INFO 用)。"""
|
||||
if isinstance(result, list):
|
||||
return "list(" + str(len(result)) + " 项)"
|
||||
if isinstance(result, tuple):
|
||||
return "tuple(" + str(len(result)) + " 项)"
|
||||
if isinstance(result, dict):
|
||||
return "dict(" + str(len(result)) + " 键)"
|
||||
if isinstance(result, str):
|
||||
return repr(result) if len(result) <= 60 else "str(" + str(len(result)) + " 字符)"
|
||||
if hasattr(result, "__dataclass_fields__"):
|
||||
return type(result).__name__ + "(" + str(len(result.__dataclass_fields__)) + " 字段)"
|
||||
return repr(result)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 流程日志(INFO 可见)
|
||||
# =============================================================================
|
||||
|
||||
def log_info(message: str) -> None:
|
||||
"""普通信息 —— 相当于「会进日志文件的 print」。"""
|
||||
_emit(str(message))
|
||||
|
||||
|
||||
def log_step(title: str, *, index: int = 0, total: int = 0) -> None:
|
||||
"""记一个运行步骤:log_step("扫码登录", index=1, total=5) -> [步骤 1/5] 扫码登录"""
|
||||
if index and total:
|
||||
_emit("[步骤 " + str(index) + "/" + str(total) + "] " + title)
|
||||
elif index:
|
||||
_emit("[步骤 " + str(index) + "] " + title)
|
||||
else:
|
||||
_emit("[步骤] " + title)
|
||||
|
||||
|
||||
def log_result(message: Any) -> None:
|
||||
"""记一条运行结果。"""
|
||||
_emit("[结果] " + (message if isinstance(message, str) else _fmt(message)))
|
||||
|
||||
|
||||
def log_warning(message: str) -> None:
|
||||
"""警告(不影响流程,但需要留意)。"""
|
||||
_emit("[警告] " + str(message))
|
||||
|
||||
|
||||
def log_error(message: str) -> None:
|
||||
"""错误。"""
|
||||
_emit("[错误] " + str(message))
|
||||
|
||||
|
||||
def log_section(title: str) -> None:
|
||||
"""记一个分节(带分隔线),用于主脚本分段。"""
|
||||
_emit("\n" + "-" * 60 + "\n" + title + "\n" + "-" * 60)
|
||||
|
||||
|
||||
def log_field(name: str, value: Any, *, debug_only: bool = False) -> None:
|
||||
"""
|
||||
记一个「字段: 值」;DEBUG 下 dict / list / VO 自动多行展开。
|
||||
|
||||
log_field("user_id", 12345)
|
||||
log_field("请求体", {"userId": 12345}, debug_only=True) # 只在 DEBUG 出现
|
||||
"""
|
||||
if debug_only and not is_debug():
|
||||
return
|
||||
if is_debug() and (isinstance(value, (dict, list)) or hasattr(value, "__dataclass_fields__")):
|
||||
_emit(name + ":")
|
||||
_emit(_fmt_pretty(value))
|
||||
return
|
||||
_emit(name + ": " + _fmt(value))
|
||||
|
||||
|
||||
def log_debug(message: Any) -> None:
|
||||
"""调试信息 —— 仅 DEBUG 模式输出。"""
|
||||
if not is_debug():
|
||||
return
|
||||
_emit("[调试] " + _fmt(message))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 收发数据日志(DEBUG 专属)—— api 脚本用它记录「发送的数据 / 返回的数据」
|
||||
# =============================================================================
|
||||
|
||||
def log_request(
|
||||
url: str = "",
|
||||
body: Any = None,
|
||||
headers: Any = None,
|
||||
*,
|
||||
api_name: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
记录一次「向 api 发送的数据」(仅 DEBUG 模式输出)。
|
||||
|
||||
参数:
|
||||
url: 完整请求地址
|
||||
body: 请求体(dict / VO / JSON 字符串),展开成 JSON 全文
|
||||
headers: 请求头(dict)
|
||||
api_name: 接口名
|
||||
"""
|
||||
if not is_debug():
|
||||
return
|
||||
_emit("")
|
||||
_emit("-> 请求 " + api_name if api_name else "-> 请求")
|
||||
if url:
|
||||
_emit(" URL : " + str(url))
|
||||
if headers:
|
||||
_emit(" 请求头 : " + _fmt(headers))
|
||||
if body is not None:
|
||||
_emit(" 请求体 :")
|
||||
_emit(_fmt_pretty(body))
|
||||
|
||||
|
||||
def log_response(
|
||||
status: Any = None,
|
||||
body: Any = None,
|
||||
headers: Any = None,
|
||||
*,
|
||||
api_name: str = "",
|
||||
elapsed_ms: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
记录一次「api 返回的数据」(仅 DEBUG 模式输出)。
|
||||
|
||||
参数:
|
||||
status: HTTP 状态码
|
||||
body: 响应体(JSON 字符串 / dict / VO)
|
||||
headers: 响应头(dict)
|
||||
api_name: 接口名
|
||||
elapsed_ms: 耗时(毫秒)
|
||||
"""
|
||||
if not is_debug():
|
||||
return
|
||||
_emit("")
|
||||
_emit("<- 响应 " + api_name if api_name else "<- 响应")
|
||||
if status is not None:
|
||||
_emit(" 状态码 : " + str(status))
|
||||
if elapsed_ms is not None:
|
||||
_emit(" 耗时 : " + str(elapsed_ms) + " ms")
|
||||
if headers:
|
||||
_emit(" 响应头 : " + _fmt(headers))
|
||||
if body is not None:
|
||||
_emit(" 响应体 :")
|
||||
_emit(_fmt_pretty(body))
|
||||
|
||||
|
||||
def log_payload(label: str, data: Any) -> None:
|
||||
"""记录任意报文 / 数据结构(仅 DEBUG 模式输出)。"""
|
||||
if not is_debug():
|
||||
return
|
||||
_emit("")
|
||||
_emit(" " + label + " :")
|
||||
_emit(_fmt_pretty(data))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 兼容:装饰器与 logger 对象
|
||||
# =============================================================================
|
||||
|
||||
def api_log(func: Callable) -> Callable:
|
||||
"""
|
||||
api 函数装饰器 —— 与自动跟踪等价(重复包装会被跳过)。
|
||||
|
||||
一般【不需要】手写:api/__init__.py 的 autowrap 已经统一套好外壳。
|
||||
这里保留是为了让 api 脚本单独运行(python -m api.xxx)时也能记录。
|
||||
"""
|
||||
if getattr(func, "__mai_logged__", False):
|
||||
return func
|
||||
return _make_tracked(func)
|
||||
|
||||
|
||||
class _Logger:
|
||||
"""兼容旧写法的 logger 对象;新代码直接用模块级函数即可。"""
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
log_info(message)
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
log_warning(message)
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
log_error(message)
|
||||
|
||||
def debug(self, message: Any) -> None:
|
||||
log_debug(message)
|
||||
|
||||
def step(self, title: str, **kwargs) -> None:
|
||||
log_step(title, **kwargs)
|
||||
|
||||
def result(self, message: Any) -> None:
|
||||
log_result(message)
|
||||
|
||||
|
||||
_logger_instance = _Logger()
|
||||
|
||||
|
||||
def get_logger() -> _Logger:
|
||||
"""获取全局 logger 实例(等价于直接用模块级函数)。"""
|
||||
return _logger_instance
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 退出时自动收尾
|
||||
# =============================================================================
|
||||
|
||||
def _auto_teardown() -> None:
|
||||
try:
|
||||
if _session_started:
|
||||
teardown_logging()
|
||||
else:
|
||||
_close_log_files()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
atexit.register(_auto_teardown)
|
||||
@@ -0,0 +1,773 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
maimai_net.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
|
||||
================================================================================
|
||||
|
||||
本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
一、协议依据(全部来自 source/ 反编译源码)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
1) 服务器基址与 API 路径
|
||||
source/Assembly-CSharp/Manager/OperationManager.cs:225,338
|
||||
ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
|
||||
source/Assembly-CSharp/Net.Packet/Packet.cs:116
|
||||
Client = NetHttpClient.Create(BaseUrl + Obfuscator(query.Api))
|
||||
source/Assembly-CSharp/Net.Packet/Packet.cs:485
|
||||
Obfuscator(s) = md5_hex_lower(s + "8bF76dE9")
|
||||
source/Assembly-CSharp/Net.VO/NetQuery.cs:14
|
||||
Api = api + "MaimaiChn"
|
||||
|
||||
=> 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "8bF76dE9")
|
||||
|
||||
已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "8bF76dE9")
|
||||
= 83a5d5a20b062c5c2ad817460b8f7f76
|
||||
(source/2026-10-05.log:1 的请求就是打到这个尾段)
|
||||
|
||||
2) 请求体封装(三段:JSON -> deflate -> AES)
|
||||
source/Assembly-CSharp/Net/NetHttpClient.cs:262-300 RequestCallback
|
||||
a. DeflateStream(CompressionMode.Compress) 压缩 JSON 字节
|
||||
b. 手工拼 zlib 头 0x78 0x9C + 压缩数据 + Adler32(4 字节大端)
|
||||
c. CipherAES.Encrypt(...) 整体 AES 加密
|
||||
source/Assembly-CSharp/Net/CipherAES.cs:11-30
|
||||
AES-256-CBC / PKCS7 / Key 32B / IV 16B
|
||||
|
||||
3) 响应体解封装(逆过程)
|
||||
source/Assembly-CSharp/Net/NetHttpClient.cs:355-380 Decrypt + Decompress
|
||||
a. AES 解密
|
||||
b. 去掉前 2 字节 zlib 头与后 4 字节 Adler32
|
||||
c. Deflate 解压 -> UTF-8 JSON
|
||||
|
||||
4) 请求头
|
||||
source/Assembly-CSharp/Net/NetHttpClient.cs:174-190 Request()
|
||||
Content-Type : application/json
|
||||
charset : UTF-8
|
||||
Mai-Encoding : 1.55 (DefaultHttpHeaderEncryption)
|
||||
Content-Encoding: deflate (有请求体时)
|
||||
User-Agent : <混淆后的API名>#<userId 或 clientId>
|
||||
(source/Assembly-CSharp/Net/NetPacketUtil.cs:12-19)
|
||||
number : <ping 毫秒>(非 Ping 接口;Packet.cs:189)
|
||||
|
||||
★ number 的语义:GameManager.PingMsec,只由 Ping 心跳包刷新
|
||||
(PingChecker.cs:227 <- PacketPing.cs:34 的 sw.ElapsedMilliseconds)。
|
||||
真机整局 53 个业务请求的 number 恒为同一个值(实测 63)。
|
||||
所以调用方**应当先跑一次 Ping()**,之后的请求才会带上真实 number;
|
||||
没跑过时 number = 0,与真机开机后第一屏的行为一致。
|
||||
|
||||
★ Cookie:★ UserLoginApi 的响应会下发
|
||||
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
|
||||
(Packet.cs:228-234 只在 UserLogin 响应上把它收进 CookieContainer,
|
||||
之后 Packet.cs:190-193 每个带 userId 的请求都挂回去)。
|
||||
Ping 等接口的响应头里没有 Set-Cookie,所以必须跑过登录才会拿到。
|
||||
这是**真实的会话粘滞**,必须带着。
|
||||
requests.Session 会自动收下并自动带上,无需额外代码 ——
|
||||
但前提是**全程复用同一个 MaimaiNetClient**(用 get_default_client())。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
二、★ 一个必须注意的坑:Accept-Encoding 必须为空
|
||||
--------------------------------------------------------------------------------
|
||||
requests 默认会发 Accept-Encoding: gzip, deflate。
|
||||
本协议响应体是 AES 密文(二进制),服务端若据此做内容编码,会返回空 body。
|
||||
实测:不发该头 -> HTTP 200 / 0 字节;显式置空 -> 正常返回。
|
||||
C# 端不需要是因为 HttpWebRequest 默认 AutomaticDecompression = None。
|
||||
所以这里固定 Accept-Encoding: ""。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
三、对外接口
|
||||
--------------------------------------------------------------------------------
|
||||
MaimaiNetClient 客户端类(连接复用 + Cookie 管理)
|
||||
get_default_client() 取全局默认客户端
|
||||
request(...) 便捷函数:发一次请求并返回原始 JSON 字符串
|
||||
request_vo(...) 便捷函数:发一次请求并返回映射后的 dataclass
|
||||
|
||||
加解密工具:aes_encrypt / aes_decrypt / deflate_compress / deflate_decompress
|
||||
协议工具 :obfuscate_api_name / build_api_url / adler32
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import zlib
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from typing import Any, Dict, List, Optional, Type, TypeVar
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
|
||||
from config import (
|
||||
AES_KEY,
|
||||
AES_IV,
|
||||
BASE_URL,
|
||||
CLIENT_ID,
|
||||
MAI_ENCODING,
|
||||
OBFUSCATE_PARAM,
|
||||
TIMEOUT_LONG_MS,
|
||||
TIMEOUT_MS,
|
||||
)
|
||||
|
||||
from .log_setup import log_debug, log_error, log_request, log_response
|
||||
|
||||
# =============================================================================
|
||||
# 协议常量
|
||||
# =============================================================================
|
||||
|
||||
#: API 名后缀(NetQuery.cs:14 Api = api + "MaimaiChn")
|
||||
API_SUFFIX = "MaimaiChn"
|
||||
|
||||
#: HTTP 头(NetHttpClient.cs:174-190)
|
||||
HEADER_CONTENT_TYPE = "application/json"
|
||||
HEADER_CHARSET = "UTF-8"
|
||||
HEADER_CONTENT_ENCODING = "deflate"
|
||||
|
||||
#: AES 参数
|
||||
AES_BLOCK_SIZE = 16
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 一、协议工具
|
||||
# =============================================================================
|
||||
|
||||
def obfuscate_api_name(api_name: str) -> str:
|
||||
"""
|
||||
对逻辑接口名做 MD5 混淆,得到 URL 尾段(对应 Packet.Obfuscator)。
|
||||
|
||||
逻辑名 "GetUserPreviewApi" -> "GetUserPreviewApiMaimaiChn"
|
||||
-> md5(上面 + "8bF76dE9")
|
||||
|
||||
参数:
|
||||
api_name: 逻辑接口名,**不要**自己带 MaimaiChn 后缀
|
||||
|
||||
返回:
|
||||
32 位小写十六进制字符串
|
||||
"""
|
||||
full_name = api_name if api_name.endswith(API_SUFFIX) else api_name + API_SUFFIX
|
||||
return hashlib.md5((full_name + OBFUSCATE_PARAM).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def build_api_url(api_name: str) -> str:
|
||||
"""构建完整的请求 URL。"""
|
||||
return urljoin(BASE_URL, obfuscate_api_name(api_name))
|
||||
|
||||
|
||||
def build_user_agent(api_name: str, user_id: int = 0, client_id: str = CLIENT_ID) -> str:
|
||||
"""
|
||||
构建 User-Agent(对应 NetPacketUtil.GetUserAgent)。
|
||||
|
||||
userId != 0 -> "<混淆API名>#<userId>"
|
||||
userId == 0 -> "<混淆API名>#<clientId>"
|
||||
"""
|
||||
tail = user_id if user_id else client_id
|
||||
return obfuscate_api_name(api_name) + "#" + str(tail)
|
||||
|
||||
|
||||
def adler32(data: bytes) -> int:
|
||||
"""
|
||||
计算 Adler-32 校验和(对应 NetHttpClient.Adler32:437-452)。
|
||||
|
||||
★ 注意与 zlib.adler32 的区别:C# 端是自己实现的,
|
||||
返回值是 (b << 16) + a,与 zlib.adler32 一致,这里保留原实现以便对照。
|
||||
"""
|
||||
a = 1
|
||||
b = 0
|
||||
for byte in data:
|
||||
a = (a + byte) % 65521
|
||||
b = (b + a) % 65521
|
||||
return (b << 16) | a
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 二、加解密 / 压缩
|
||||
# =============================================================================
|
||||
|
||||
def aes_encrypt(data: bytes) -> bytes:
|
||||
"""AES-256-CBC 加密(PKCS7 填充)。对应 CipherAES.Encrypt。"""
|
||||
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
|
||||
return cipher.encrypt(pad(data, AES_BLOCK_SIZE))
|
||||
|
||||
|
||||
def aes_decrypt(data: bytes) -> bytes:
|
||||
"""AES-256-CBC 解密(PKCS7 去填充)。对应 CipherAES.Decrypt。"""
|
||||
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
|
||||
return unpad(cipher.decrypt(data), AES_BLOCK_SIZE)
|
||||
|
||||
|
||||
def deflate_compress(data: bytes) -> bytes:
|
||||
"""
|
||||
原始 deflate 压缩 + 手工拼 zlib 头与 Adler32。
|
||||
|
||||
对应 NetHttpClient.cs:262-300 RequestCallback:
|
||||
memoryStream.WriteByte(120); // 0x78
|
||||
memoryStream.WriteByte(156); // 0x9C
|
||||
... 原始 deflate 数据 ...
|
||||
... Adler32 大端 4 字节 ...
|
||||
|
||||
★ C# 的 DeflateStream 产生的是**原始 deflate**(无 zlib 头),
|
||||
所以 Python 侧要用 compressobj(wbits=-zlib.MAX_WBITS)。
|
||||
"""
|
||||
compressor = zlib.compressobj(wbits=-zlib.MAX_WBITS)
|
||||
compressed = compressor.compress(data) + compressor.flush()
|
||||
checksum = adler32(data)
|
||||
return bytes([0x78, 0x9C]) + compressed + checksum.to_bytes(4, "big")
|
||||
|
||||
|
||||
def deflate_decompress(data: bytes) -> bytes:
|
||||
"""
|
||||
去掉 zlib 头与 Adler32 后做原始 deflate 解压。
|
||||
|
||||
对应 NetHttpClient.cs:355-380 Decompress:
|
||||
_temporaryStream.Position = 2; // 跳过 2 字节头
|
||||
_temporaryStream.SetLength(_temporaryStream.Length - 4); // 去掉 4 字节校验
|
||||
"""
|
||||
if len(data) < 6:
|
||||
return b""
|
||||
return zlib.decompress(data[2:-4], -zlib.MAX_WBITS)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 三、真机时间戳
|
||||
# =============================================================================
|
||||
|
||||
def local_utc_offset_seconds() -> int:
|
||||
"""取本机时区相对 UTC 的偏移秒数(东八区 = +28800)。"""
|
||||
offset = -time.timezone
|
||||
if time.daylight and time.localtime().tm_isdst > 0:
|
||||
offset = -time.altzone
|
||||
return int(offset)
|
||||
|
||||
|
||||
def login_date_time_now() -> int:
|
||||
"""
|
||||
生成真机 UserLoginApi 的 loginDateTime。
|
||||
|
||||
★ 真机算法(source/Assembly-CSharp/MAI2/DateTime.cs + PacketUserLogin.cs:29):
|
||||
MAI2.DateTime.Now 返回 DateTime.UtcNow + LocalOffset,Kind = Unspecified;
|
||||
new DateTimeOffset(该 Unspecified 值) 会再按本地时区加一次偏移,
|
||||
最终 ToUnixTimeSeconds() = 标准 Unix 秒 + 本地时区偏移。
|
||||
|
||||
东八区即 now + 28800。
|
||||
|
||||
★ 为什么必须一致:
|
||||
PacketUserLogin.cs:43 会把请求值回填进响应,
|
||||
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
|
||||
与登录时不一致会被服务端拒绝。
|
||||
"""
|
||||
return int(time.time()) + local_utc_offset_seconds()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 四、VO 序列化 / 反序列化
|
||||
# =============================================================================
|
||||
|
||||
def vo_to_json(obj: Any) -> str:
|
||||
"""
|
||||
把请求对象序列化成紧凑 JSON(对应 VOSerializer.Serialize)。
|
||||
|
||||
规则:
|
||||
- dataclass -> 按字段声明顺序,跳过值为 None 的字段
|
||||
- 带 __dict__ 的对象 -> 同上
|
||||
- dict / 其他 -> 直接 json.dumps
|
||||
"""
|
||||
if obj is None:
|
||||
return "{}"
|
||||
if is_dataclass(obj) and not isinstance(obj, type):
|
||||
data = {k: getattr(obj, k) for k in obj.__dataclass_fields__}
|
||||
data = {k: v for k, v in data.items() if v is not None}
|
||||
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
||||
if isinstance(obj, dict):
|
||||
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
||||
if hasattr(obj, "__dict__"):
|
||||
data = {k: v for k, v in vars(obj).items() if v is not None and not k.startswith("_")}
|
||||
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
||||
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
||||
|
||||
|
||||
def _json_default(value: Any) -> Any:
|
||||
"""json.dumps 的兜底转换:dataclass / 普通对象 -> dict。"""
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
return {k: getattr(value, k) for k in value.__dataclass_fields__}
|
||||
if hasattr(value, "__dict__"):
|
||||
return {k: v for k, v in vars(value).items() if not k.startswith("_")}
|
||||
return str(value)
|
||||
|
||||
|
||||
def _convert_value(value: Any, target_type: Any) -> Any:
|
||||
"""按目标类型递归转换嵌套 dataclass。"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(target_type, type) and is_dataclass(target_type) and isinstance(value, dict):
|
||||
kwargs = {}
|
||||
for fname, fdef in target_type.__dataclass_fields__.items():
|
||||
if fname in value:
|
||||
kwargs[fname] = _convert_value(value[fname], fdef.type)
|
||||
return target_type(**kwargs)
|
||||
|
||||
origin = getattr(target_type, "__origin__", None)
|
||||
if origin is list and isinstance(value, list):
|
||||
args = getattr(target_type, "__args__", None)
|
||||
if args and isinstance(args[0], type) and is_dataclass(args[0]):
|
||||
return [_convert_value(v, args[0]) for v in value]
|
||||
return value
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def vo_from_json(cls: Type[T], json_str: str) -> T:
|
||||
"""
|
||||
把响应 JSON 反序列化成 dataclass(对应 VOSerializer.Deserialize)。
|
||||
|
||||
★ 只填充类里声明过的字段 —— 服务端多给的字段会被忽略,不会报错。
|
||||
"""
|
||||
data = json.loads(json_str)
|
||||
if not isinstance(data, dict):
|
||||
return data # type: ignore[return-value]
|
||||
|
||||
if is_dataclass(cls):
|
||||
kwargs = {}
|
||||
for fname, fdef in cls.__dataclass_fields__.items():
|
||||
if fname in data:
|
||||
kwargs[fname] = _convert_value(data[fname], fdef.type)
|
||||
return cls(**kwargs)
|
||||
|
||||
obj = cls() # type: ignore[call-arg]
|
||||
for k, v in data.items():
|
||||
if hasattr(obj, k):
|
||||
setattr(obj, k, v)
|
||||
return obj
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 五、网络客户端
|
||||
# =============================================================================
|
||||
|
||||
class MaimaiNetError(RuntimeError):
|
||||
"""网络层 / 协议层错误。"""
|
||||
|
||||
|
||||
class MaimaiNetClient:
|
||||
"""
|
||||
舞萌DX 游戏服务器客户端。
|
||||
|
||||
一次完整请求的流程:
|
||||
|
||||
请求方向:VO -> 紧凑 JSON -> 原始 deflate -> 0x78 0x9C + Adler32 -> AES-256-CBC -> HTTP POST
|
||||
响应方向:HTTP Body -> AES 解密 -> 去 2 字节头 / 4 字节尾 -> deflate 解压 -> JSON -> VO
|
||||
|
||||
Cookie 由 requests.Session 自动管理;登录接口返回的 Set-Cookie 会被保存,
|
||||
后续请求自动带上(对应 C# 的 CookieContainer,Packet.cs:183-190)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = BASE_URL,
|
||||
timeout_ms: int = TIMEOUT_MS,
|
||||
verify_ssl: bool = False,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/") + "/"
|
||||
self.timeout = timeout_ms / 1000.0
|
||||
self.verify_ssl = verify_ssl
|
||||
self.session = requests.Session()
|
||||
#: ★ number 头的值(Packet.cs:189 GameManager.PingMsec)。
|
||||
#: 只由 **Ping 心跳包**更新(对应 PingChecker.cs:227 PingSuccess),
|
||||
#: 普通业务请求不会改动它 —— 真机整局 53 个请求的 number 恒为 63。
|
||||
self.ping_msec: int = 0
|
||||
|
||||
if not verify_ssl:
|
||||
try:
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 内部
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_headers(
|
||||
self,
|
||||
api_name: str,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
has_body: bool = True,
|
||||
ping_msec: Optional[int] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
构建请求头(对应 NetHttpClient.Request:174-190 + Packet.ProcImpl:166-176)。
|
||||
|
||||
★ 关于 number 头:
|
||||
source/Assembly-CSharp/Net.Packet/Packet.cs:187-194
|
||||
|
||||
if (!IsPingPacket())
|
||||
{
|
||||
Client.AddHeader("number", GameManager.PingMsec.ToString());
|
||||
if (Query.UserId != 0L) { Client.AddCookie(Query.UserId); }
|
||||
}
|
||||
|
||||
即**除 Ping 外的每个接口**都会带一个 number 头,值是本机到服务器的
|
||||
ping 毫秒数(GameManager.PingMsec)。
|
||||
|
||||
★ number 的来源(源码链路,逐条可查):
|
||||
Main/PingChecker.cs:166 StartPacket(new PacketPing(PingSuccess, PingError))
|
||||
Net.Packet.Mai2/PacketPing.cs:34
|
||||
_onDone((int)sw.ElapsedMilliseconds)
|
||||
→ 值 = **Ping 包的往返毫秒**
|
||||
Main/PingChecker.cs:227 GameManager.PingMsec = pingMsec
|
||||
|
||||
所以 number **只由 Ping 心跳刷新**,普通业务请求不会改它。
|
||||
实测(gamehook 抓真机一局 70 个请求):
|
||||
#1 ~ #10 number = 0 ← 还没跑过 Ping
|
||||
#11 ~ #17 Ping 心跳(不带 number 头)
|
||||
#18 ~ #70 number = 63 ← Ping 往返 63ms,此后恒定
|
||||
本类在 request() 里对 Ping 包做同样的事:把往返毫秒写进 self.ping_msec。
|
||||
|
||||
★ 关于 Cookie(Packet.cs:190-193 + 228-234):
|
||||
Client.AddCookie(Query.UserId) 把「登录时服务端 Set-Cookie 存下来的
|
||||
CookieContainer」挂到本次请求上(NetHttpClient.cs:170-177)。
|
||||
|
||||
★ 实测 **UserLoginApi 的响应头里确实有**:
|
||||
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
|
||||
而 Ping 等接口的响应头里没有 Set-Cookie(只有 Date / Content-Type /
|
||||
Content-Length / Connection / Pragma / Cache-Control)。
|
||||
所以「登录前没有 Cookie、登录后一直带着 JSESSIONID」正是真机行为。
|
||||
|
||||
Cookie 由 requests.Session 自动管理:收到就存、后续自动带 —— 无需额外代码,
|
||||
但**必须全程复用同一个 MaimaiNetClient**,否则会丢掉 JSESSIONID。
|
||||
"""
|
||||
headers = {
|
||||
"Content-Type": HEADER_CONTENT_TYPE,
|
||||
"charset": HEADER_CHARSET,
|
||||
"Mai-Encoding": MAI_ENCODING,
|
||||
"User-Agent": build_user_agent(api_name, user_id, client_id),
|
||||
# ★ 必须显式置空,见模块 docstring 第二节
|
||||
"Accept-Encoding": "",
|
||||
}
|
||||
if has_body:
|
||||
headers["Content-Encoding"] = HEADER_CONTENT_ENCODING
|
||||
# ★ number 头(Packet.cs:189):除 Ping 外的接口都要带
|
||||
if not api_name.startswith("Ping"):
|
||||
value = self.ping_msec if ping_msec is None else ping_msec
|
||||
headers["number"] = str(int(value))
|
||||
return headers
|
||||
|
||||
@property
|
||||
def last_ping_msec(self) -> int:
|
||||
"""number 头的当前值(= 最近一次 Ping 的往返毫秒)。兼容旧字段名。"""
|
||||
return self.ping_msec
|
||||
|
||||
def _encrypt_request(self, json_str: str) -> bytes:
|
||||
"""JSON -> deflate(zlib头+Adler32) -> AES。"""
|
||||
return aes_encrypt(deflate_compress(json_str.encode("utf-8")))
|
||||
|
||||
def _decrypt_response(self, encrypted: bytes) -> str:
|
||||
"""AES -> 去头尾 -> deflate -> UTF-8 字符串。"""
|
||||
return deflate_decompress(aes_decrypt(encrypted)).decode("utf-8")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 公开
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def request(
|
||||
self,
|
||||
api_name: str,
|
||||
request_vo: Any = None,
|
||||
*,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
发送一次请求,返回**原始 JSON 响应字符串**。
|
||||
|
||||
参数:
|
||||
api_name: 逻辑接口名(如 "GetUserPreviewApi",不要带 MaimaiChn)
|
||||
request_vo: 请求体对象(dataclass / dict / None 表示空对象 {})
|
||||
user_id: 用户 ID(0 表示未登录请求;影响 User-Agent 与 Cookie)
|
||||
client_id: 客户端 ID
|
||||
timeout_ms: 超时覆盖(毫秒)
|
||||
extra_headers: 额外请求头(如 number)
|
||||
|
||||
返回:
|
||||
响应 JSON 字符串
|
||||
|
||||
异常:
|
||||
MaimaiNetError: 网络异常 / 空响应 / 解密或解压失败
|
||||
"""
|
||||
url = urljoin(self.base_url, obfuscate_api_name(api_name))
|
||||
timeout = (timeout_ms or self.timeout * 1000) / 1000.0
|
||||
|
||||
request_json = vo_to_json(request_vo)
|
||||
body = self._encrypt_request(request_json)
|
||||
|
||||
headers = self._build_headers(api_name, user_id, client_id, has_body=True)
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
# ★ DEBUG 模式:记录「向 api 发送的数据」
|
||||
log_request(url=url, body=request_json, headers=headers, api_name=api_name)
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
response = self.session.post(
|
||||
url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
verify=self.verify_ssl,
|
||||
)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
log_error("接口 " + api_name + " 请求失败:" + str(exc))
|
||||
raise MaimaiNetError("接口 " + api_name + " 请求失败:" + str(exc)) from exc
|
||||
|
||||
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||||
# ★ 只有 Ping 包会刷新 number(对应 PingChecker.cs:224-229 PingSuccess)。
|
||||
# 普通业务包不动它,否则 number 会随每个包的耗时抖动,与真机不符。
|
||||
if api_name.startswith("Ping"):
|
||||
self.ping_msec = elapsed_ms
|
||||
content = response.content
|
||||
|
||||
# 0 字节响应:请求未进入业务层
|
||||
if len(content) == 0:
|
||||
detail = (
|
||||
"接口 " + api_name + " 返回 HTTP " + str(response.status_code)
|
||||
+ ",响应体为 0 字节(空响应)。\n"
|
||||
" 含义:服务端没有返回任何内容,请求很可能未进入业务层。\n"
|
||||
" 排查:userId/token 是否有效、接口名与请求体字段是否与源码一致、"
|
||||
"Accept-Encoding 是否被置空。\n"
|
||||
" 响应头:" + repr(dict(response.headers))
|
||||
)
|
||||
log_error(detail)
|
||||
raise MaimaiNetError(detail)
|
||||
|
||||
try:
|
||||
response_json = self._decrypt_response(content)
|
||||
except ValueError as exc:
|
||||
detail = (
|
||||
"接口 " + api_name + " 响应解密失败(HTTP " + str(response.status_code)
|
||||
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
|
||||
" 含义:服务器返回了内容,但不是合法的 AES 密文,或长度不是 16 的倍数。"
|
||||
)
|
||||
log_error(detail)
|
||||
raise MaimaiNetError(detail) from exc
|
||||
except zlib.error as exc:
|
||||
detail = (
|
||||
"接口 " + api_name + " 响应解压失败(HTTP " + str(response.status_code)
|
||||
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
|
||||
" 含义:AES 解密成功但 deflate 解压失败,响应不是预期的压缩格式"
|
||||
"(可能是网关/防火墙返回的页面)。"
|
||||
)
|
||||
log_error(detail)
|
||||
raise MaimaiNetError(detail) from exc
|
||||
|
||||
if not response_json:
|
||||
detail = (
|
||||
"接口 " + api_name + " 返回空明文(HTTP " + str(response.status_code)
|
||||
+ ",密文 " + str(len(content)) + " 字节,解密后为空串)。\n"
|
||||
" 含义:链路与加密都正常,但服务端没有返回内容。\n"
|
||||
" 排查:userId / token 是否失效、keychip 与该卡是否匹配、该卡是否正在游戏中。"
|
||||
)
|
||||
log_error(detail)
|
||||
raise MaimaiNetError(detail)
|
||||
|
||||
# ★ DEBUG 模式:记录「api 返回的数据」
|
||||
try:
|
||||
shown_body: Any = json.loads(response_json)
|
||||
except Exception:
|
||||
shown_body = response_json
|
||||
log_response(
|
||||
status=response.status_code,
|
||||
body=shown_body,
|
||||
headers={k: v for k, v in response.headers.items()},
|
||||
api_name=api_name,
|
||||
elapsed_ms=elapsed_ms,
|
||||
)
|
||||
|
||||
return response_json
|
||||
|
||||
def request_vo(
|
||||
self,
|
||||
api_name: str,
|
||||
request_vo: Any,
|
||||
response_cls: Type[T],
|
||||
*,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> T:
|
||||
"""发送请求并把响应映射成 dataclass。"""
|
||||
raw = self.request(
|
||||
api_name, request_vo,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
||||
)
|
||||
return vo_from_json(response_cls, raw)
|
||||
|
||||
def request_dict(
|
||||
self,
|
||||
api_name: str,
|
||||
request_vo: Any = None,
|
||||
*,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
发送请求并返回**原始响应 dict**。
|
||||
|
||||
★ 取数脚本一律用这个:upsertUserAll 需要响应原文,
|
||||
映射成 dataclass 会丢掉服务端多给的字段。
|
||||
"""
|
||||
raw = self.request(
|
||||
api_name, request_vo,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
||||
)
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, dict) else {"_data": data}
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭底层会话。"""
|
||||
try:
|
||||
self.session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> "MaimaiNetClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 六、全局默认客户端
|
||||
# =============================================================================
|
||||
|
||||
_default_client: Optional[MaimaiNetClient] = None
|
||||
|
||||
|
||||
def get_default_client() -> MaimaiNetClient:
|
||||
"""获取(必要时创建)全局默认客户端 —— 连接与 Cookie 会自动复用。"""
|
||||
global _default_client
|
||||
if _default_client is None:
|
||||
_default_client = MaimaiNetClient()
|
||||
return _default_client
|
||||
|
||||
|
||||
def close_default_client() -> None:
|
||||
"""关闭全局默认客户端(一般不需要手动调用)。"""
|
||||
global _default_client
|
||||
if _default_client is not None:
|
||||
_default_client.close()
|
||||
_default_client = None
|
||||
|
||||
|
||||
def request(
|
||||
api_name: str,
|
||||
request_vo: Any = None,
|
||||
*,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> str:
|
||||
"""便捷函数:用默认客户端发一次请求,返回原始 JSON 字符串。"""
|
||||
net = client or get_default_client()
|
||||
return net.request(
|
||||
api_name, request_vo,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
|
||||
def request_dict(
|
||||
api_name: str,
|
||||
request_vo: Any = None,
|
||||
*,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""便捷函数:用默认客户端发一次请求,返回原始响应 dict。"""
|
||||
net = client or get_default_client()
|
||||
return net.request_dict(
|
||||
api_name, request_vo,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
|
||||
def request_vo(
|
||||
api_name: str,
|
||||
request_vo: Any,
|
||||
response_cls: Type[T],
|
||||
*,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> T:
|
||||
"""便捷函数:用默认客户端发一次请求,返回映射后的 dataclass。"""
|
||||
net = client or get_default_client()
|
||||
return net.request_vo(
|
||||
api_name, request_vo, response_cls,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 七、通用请求 / 响应 VO
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class EmptyRequestVO:
|
||||
"""空请求体 —— 对应 C# 里没有任何字段的 RequestVO(如 PingRequestVO)。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserIdRequestVO:
|
||||
"""只有 userId 的请求体(对应 UserDetailRequestVO / UserCharacterRequestVO 等)。"""
|
||||
userId: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReturnCodeVO:
|
||||
"""通用「只有 returnCode」的响应体。"""
|
||||
returnCode: int = 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 单独运行:协议自检(不发业务请求)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== maimai_net 协议自检 ===")
|
||||
print("BASE_URL :", BASE_URL)
|
||||
print("GetGameSettingApi :", build_api_url("GetGameSettingApi"))
|
||||
print(" 期望尾段 : 83a5d5a20b062c5c2ad817460b8f7f76")
|
||||
print("User-Agent 示例 :", build_user_agent("UserLoginApi", 12345678))
|
||||
|
||||
sample = '{"userId":12345678,"token":"abc"}'
|
||||
packed = deflate_compress(sample.encode("utf-8"))
|
||||
print("deflate 头/尾 :", packed[:2].hex(), packed[-4:].hex(),
|
||||
" Adler32 =", hex(adler32(sample.encode("utf-8"))))
|
||||
assert deflate_decompress(packed).decode("utf-8") == sample
|
||||
|
||||
enc = aes_encrypt(packed)
|
||||
assert aes_decrypt(enc) == packed
|
||||
print("AES 往返 : OK(密文", len(enc), "字节,16 的倍数:", len(enc) % 16 == 0, ")")
|
||||
print("自检通过")
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ping.py —— 心跳探活(PingMaimaiChn)
|
||||
================================================================================
|
||||
|
||||
功能:向游戏服务器发一次心跳,确认链路可达。
|
||||
|
||||
对应接口:PingMaimaiChn
|
||||
请求体 :{}(source/Assembly-CSharp/Net.VO.Mai2/PingRequestVO.cs 是空类)
|
||||
响应 :{"result": "Pong"}
|
||||
|
||||
★ 接口名(容易写错,注意):
|
||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketPing.cs:22
|
||||
new NetQuery<PingRequestVO, PingResponseVO>("Ping", 0uL);
|
||||
客户端传的逻辑名是 **"Ping"**,NetQuery.cs:14 再补上 "MaimaiChn" 后缀,
|
||||
所以混淆用的全名是 "PingMaimaiChn":
|
||||
md5("PingMaimaiChn" + "8bF76dE9") = 3bae8c1162b3148abcfc38a89c03e3e7
|
||||
本模块传 "Ping" 即可,obfuscate_api_name 会自动补后缀。
|
||||
|
||||
(Packet.cs:96 用 Query.Api.StartsWith("Ping") 判断是否心跳包,
|
||||
也是基于补完后缀的 "PingMaimaiChn"。)
|
||||
|
||||
★ 为什么要跑它(不只是探活):
|
||||
Packet.cs:189 非 Ping 接口都会带 HTTP 头 number = GameManager.PingMsec
|
||||
PingChecker.cs:227 GameManager.PingMsec = Ping 包的往返毫秒
|
||||
|
||||
所以 **Ping 是 number 头的唯一来源**。先 Ping 一次,后面的业务请求才会
|
||||
带上真实 number(真机实测 63ms);不 Ping 则 number = 0,
|
||||
与真机开机后第一屏(还没跑过心跳)的行为一致。
|
||||
MaimaiNetClient 在收到 Ping 响应后会自动把往返毫秒记进 client.ping_msec。
|
||||
|
||||
调用示例:
|
||||
from api import Ping
|
||||
if Ping():
|
||||
print("服务器可达")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
def Ping(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
心跳探活。
|
||||
|
||||
参数:
|
||||
client_id: 机台短值(可选)
|
||||
timeout_ms: 超时毫秒(可选)
|
||||
client: 可复用的 MaimaiNetClient(可选)
|
||||
|
||||
返回:
|
||||
True = 服务器返回 "Pong"
|
||||
"""
|
||||
log_info("发送心跳探活")
|
||||
|
||||
raw = request_dict(
|
||||
"Ping", {},
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
|
||||
result = raw.get("result", "")
|
||||
ok = (result == "Pong")
|
||||
if ok:
|
||||
log_info("心跳正常:result=Pong")
|
||||
else:
|
||||
log_warning("心跳返回异常:result=" + str(result))
|
||||
return ok
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("ping_test")
|
||||
try:
|
||||
ok = Ping()
|
||||
print("")
|
||||
print("服务器可达:" + str(ok))
|
||||
return 0 if ok else 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,319 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
upsert_snapshot.py —— 结算快照编排(统一调用各取数脚本)
|
||||
================================================================================
|
||||
|
||||
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。
|
||||
|
||||
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(userdata.py / user_map.py …)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
为什么需要快照
|
||||
--------------------------------------------------------------------------------
|
||||
服务器对 userData 是**整表覆盖**语义,对差分节点是**只回传变化项**语义。
|
||||
要把本局的变化正确表达出来,必须先有一份「登录时的服务器状态」作基线,
|
||||
再把本局的影响叠加到基线上 —— 这就是快照的作用。
|
||||
|
||||
真机对照(source/2026-10-05.log):
|
||||
|
||||
| 节点 | Get 侧(基线) | Upsert 侧(本局后) |
|
||||
|---------------------|-------------------|----------------------|
|
||||
| userData.playCount | 1257 | 1258 (+1) |
|
||||
| userMapList | 104 条 | 仅 1 条(distance 变)|
|
||||
| userMusicDetailList | 739 组 | 仅 1 条(playCount+1)|
|
||||
| userItemList | 1070 条 | [](无变化) |
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
调用示例
|
||||
--------------------------------------------------------------------------------
|
||||
from api import fetch_upsert_snapshot
|
||||
|
||||
snapshot = fetch_upsert_snapshot(user_id, token=token)
|
||||
# 之后直接传给 UpsertUserAll(snapshot=snapshot, ...)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from config import CLIENT_ID, PLACE_ID, PLAY_SHOP_ITEM_IDS, REGION_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_debug, log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient
|
||||
|
||||
# 各取数脚本(一个接口一个脚本)
|
||||
from .userdata import UserData
|
||||
from .user_extend import UserExtend
|
||||
from .user_option import UserOption
|
||||
from .user_character import UserCharacter
|
||||
from .user_ghost import UserGhost
|
||||
from .user_map import UserMapAll
|
||||
from .user_login_bonus import UserLoginBonusAll
|
||||
from .user_rating import UserRating
|
||||
from .user_item import UserItemAll
|
||||
from .user_music import UserMusicDetailAll
|
||||
from .user_course import UserCourseAll
|
||||
from .user_charge import UserCharge
|
||||
from .user_favorite import UserFavoriteAll
|
||||
from .user_activity import UserActivity
|
||||
from .user_mission import UserMissionData
|
||||
from .user_intimate import UserIntimate
|
||||
from .user_shop_stock import UserShopStock
|
||||
from .user_favorite_item import UserFavoriteItemAll
|
||||
from .user_kaleidx_scope import UserKaleidxScope
|
||||
|
||||
|
||||
#: 默认要抓的节点(顺序即抓取顺序)
|
||||
#: ★ 只列 UpsertUserAll 真正需要的;纯展示类接口(排行/活动/大会…)不在此列。
|
||||
DEFAULT_SNAPSHOT_NODES: tuple = (
|
||||
"userData",
|
||||
"userExtend",
|
||||
"userOption",
|
||||
"userCharacterList",
|
||||
"userMapList",
|
||||
"userLoginBonusList",
|
||||
"userRatingList",
|
||||
"userItemList",
|
||||
"userMusicDetailList",
|
||||
"userCourseList",
|
||||
"userChargeList",
|
||||
"userFavoriteList",
|
||||
"userActivityList",
|
||||
"userMissionDataList",
|
||||
"userIntimateList",
|
||||
"userShopItemStockList",
|
||||
"userFavoritemusicList",
|
||||
"userKaleidxScopeList",
|
||||
"userGhost",
|
||||
)
|
||||
|
||||
|
||||
def fetch_upsert_snapshot(
|
||||
user_id: int,
|
||||
*,
|
||||
token: str = "",
|
||||
client_id: str = CLIENT_ID,
|
||||
place_id: int = PLACE_ID,
|
||||
region_id: int = REGION_ID,
|
||||
shop_item_ids: Optional[Sequence[int]] = None,
|
||||
nodes: Optional[Sequence[str]] = None,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
strict: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
抓取结算所需的全部基线数据。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
token: 会话令牌(可选,多数取数接口不需要)
|
||||
client_id: 机台短值
|
||||
place_id: 店铺 ID
|
||||
region_id: 区域 ID
|
||||
shop_item_ids: 商店商品清单(可选,默认 config.PLAY_SHOP_ITEM_IDS)
|
||||
nodes: 要抓的节点(可选,默认 DEFAULT_SNAPSHOT_NODES)
|
||||
timeout_ms: 超时毫秒
|
||||
client: 可复用的 MaimaiNetClient
|
||||
strict: True = 任一接口失败就抛异常;
|
||||
False(默认)= 记录失败并跳过,保证链路不崩
|
||||
|
||||
返回:
|
||||
{
|
||||
"userData": <GetUserDataApi 原始响应>,
|
||||
"userExtend": <GetUserExtendApi 原始响应>,
|
||||
"userMapList": {"userMapList": [...]}, # 已摊平/翻页
|
||||
"userMusicDetailList": {"userMusicList": [...]},# 已摊平成单条
|
||||
...
|
||||
"_failed": [ {"node":.., "error":..}, ... ]
|
||||
}
|
||||
这个结构可以直接传给 UpsertUserAll(snapshot=...),也可 json 落盘。
|
||||
"""
|
||||
wanted = list(nodes) if nodes else list(DEFAULT_SNAPSHOT_NODES)
|
||||
log_info("抓取结算快照:user_id=" + str(user_id) + " 节点数=" + str(len(wanted)))
|
||||
|
||||
raw: Dict[str, Any] = {}
|
||||
failed: List[Dict[str, str]] = []
|
||||
|
||||
def _call(node: str, fn, *args, **kwargs):
|
||||
"""逐节点容错:单个接口失败不影响整体。"""
|
||||
try:
|
||||
raw[node] = fn(*args, **kwargs)
|
||||
return True
|
||||
except Exception as exc:
|
||||
failed.append({"node": node, "error": type(exc).__name__ + ": " + str(exc)})
|
||||
log_warning("节点 " + node + " 抓取失败:" + type(exc).__name__ + ": " + str(exc))
|
||||
if strict:
|
||||
raise
|
||||
return False
|
||||
|
||||
common = dict(client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
|
||||
# ---- 逐个抓取(顺序与 DEFAULT_SNAPSHOT_NODES 一致)----
|
||||
if "userData" in wanted:
|
||||
_call("userData", UserData, user_id, **common)
|
||||
|
||||
if "userExtend" in wanted:
|
||||
_call("userExtend", UserExtend, user_id, **common)
|
||||
|
||||
if "userOption" in wanted:
|
||||
_call("userOption", UserOption, user_id, **common)
|
||||
|
||||
if "userCharacterList" in wanted:
|
||||
_call("userCharacterList", UserCharacter, user_id, **common)
|
||||
|
||||
if "userMapList" in wanted:
|
||||
_call("userMapList", UserMapAll, user_id, **common)
|
||||
|
||||
if "userLoginBonusList" in wanted:
|
||||
_call("userLoginBonusList", UserLoginBonusAll, user_id, **common)
|
||||
|
||||
if "userRatingList" in wanted:
|
||||
_call("userRatingList", UserRating, user_id, **common)
|
||||
|
||||
if "userItemList" in wanted:
|
||||
_call("userItemList", UserItemAll, user_id, **common)
|
||||
|
||||
if "userMusicDetailList" in wanted:
|
||||
_call("userMusicDetailList", UserMusicDetailAll, user_id, **common)
|
||||
|
||||
if "userCourseList" in wanted:
|
||||
_call("userCourseList", UserCourseAll, user_id, **common)
|
||||
|
||||
if "userChargeList" in wanted:
|
||||
_call("userChargeList", UserCharge, user_id, **common)
|
||||
|
||||
if "userFavoriteList" in wanted:
|
||||
# ★ 该接口额外带一份**原始** itemKind 副本(_raw),
|
||||
# 供 UpsertUserAll 生成 isNewFavoriteList 位串用
|
||||
# —— 真机 itemKind=10 回的是 null,正是它产生了位串里的 "1"。
|
||||
if _call("userFavoriteList", UserFavoriteAll, user_id, **common):
|
||||
fav_raw = (raw.get("userFavoriteList") or {}).get("_raw")
|
||||
if fav_raw:
|
||||
raw["_raw_favorite"] = fav_raw
|
||||
|
||||
if "userActivityList" in wanted:
|
||||
_call("userActivityList", UserActivity, user_id, **common)
|
||||
|
||||
if "userMissionDataList" in wanted:
|
||||
# ★ 该接口的响应里同时带着 userWeeklyData(周界),
|
||||
# 它是 Upsert 侧 userWeeklyData 节点的来源,会被自动提取。
|
||||
_call("userMissionDataList", UserMissionData, user_id, **common)
|
||||
|
||||
if "userIntimateList" in wanted:
|
||||
_call("userIntimateList", UserIntimate, user_id, **common)
|
||||
|
||||
if "userShopItemStockList" in wanted:
|
||||
ids = list(shop_item_ids) if shop_item_ids is not None else list(PLAY_SHOP_ITEM_IDS)
|
||||
if ids:
|
||||
_call("userShopItemStockList", UserShopStock, user_id,
|
||||
shop_item_id_list=ids, **common)
|
||||
else:
|
||||
log_debug("未配置 PLAY_SHOP_ITEM_IDS,跳过商店库存节点")
|
||||
|
||||
if "userFavoritemusicList" in wanted:
|
||||
_call("userFavoritemusicList", UserFavoriteItemAll, user_id, 1, **common)
|
||||
|
||||
if "userKaleidxScopeList" in wanted:
|
||||
_call("userKaleidxScopeList", UserKaleidxScope, user_id, **common)
|
||||
|
||||
if "userGhost" in wanted:
|
||||
_call("userGhost", UserGhost, user_id, **common)
|
||||
|
||||
raw["_failed"] = failed
|
||||
log_info("结算快照抓取完成:成功 " + str(len(raw) - 1) + " 个节点,失败 "
|
||||
+ str(len(failed)) + " 个")
|
||||
return raw
|
||||
|
||||
|
||||
def save_snapshot(snapshot: Dict[str, Any], path: str) -> str:
|
||||
"""把快照写到 JSON 文件(便于离线复用 / 人工核对)。"""
|
||||
with open(path, "w", encoding="utf-8") as fp:
|
||||
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
|
||||
log_info("快照已保存:" + path)
|
||||
return path
|
||||
|
||||
|
||||
def load_snapshot(path: str) -> Dict[str, Any]:
|
||||
"""从 JSON 文件读回快照。"""
|
||||
with open(path, "r", encoding="utf-8") as fp:
|
||||
data = json.load(fp)
|
||||
log_info("快照已加载:" + path)
|
||||
return data
|
||||
|
||||
|
||||
def summarize_snapshot(snapshot: Dict[str, Any]) -> Dict[str, int]:
|
||||
"""
|
||||
统计快照各节点的条数,便于快速核对(返回 {节点名: 条数})。
|
||||
|
||||
条数含义:
|
||||
userData 这类单元素数组 -> 1
|
||||
userMusicDetailList 这类列表 -> 摊平后的条数
|
||||
"""
|
||||
out: Dict[str, int] = {}
|
||||
for node, value in (snapshot or {}).items():
|
||||
if node.startswith("_"):
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
out[node] = len(value)
|
||||
elif isinstance(value, dict):
|
||||
# 从响应里挑出列表
|
||||
found = False
|
||||
for key, sub in value.items():
|
||||
if isinstance(sub, list):
|
||||
out[node] = len(sub)
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
out[node] = 1
|
||||
else:
|
||||
out[node] = 0
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 单独运行(调试)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .log_setup import log_error, setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("upsert_snapshot_test")
|
||||
try:
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python -m api.upsert_snapshot <user_id> [--token TOKEN] [--save 路径]")
|
||||
return 1
|
||||
|
||||
user_id = int(sys.argv[1])
|
||||
token = ""
|
||||
save_path = ""
|
||||
args = sys.argv[2:]
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--token" and i + 1 < len(args):
|
||||
token = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--save" and i + 1 < len(args):
|
||||
save_path = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
snap = fetch_upsert_snapshot(user_id, token=token)
|
||||
summary = summarize_snapshot(snap)
|
||||
print("")
|
||||
for node, count in summary.items():
|
||||
print(" " + node.ljust(28) + "= " + str(count) + " 条")
|
||||
if save_path:
|
||||
save_snapshot(snap, save_path)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log_error("抓取快照失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,531 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
|
||||
================================================================================
|
||||
|
||||
功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。
|
||||
|
||||
对应接口:UpsertUserChargelogApi
|
||||
请求体 :UserChargelogRequestVO(4 字段,顺序照 C# 声明)
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs
|
||||
public ulong userId;
|
||||
public UserChargelog userChargelog;
|
||||
public UserCharge userCharge;
|
||||
public long loginDateTime;
|
||||
响应 :{returnCode, apiName}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
一、真机的购买流程(源码逐行可查)
|
||||
--------------------------------------------------------------------------------
|
||||
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
|
||||
├─ ticket.ticketKind.id == 1(付费票)
|
||||
├─ 从 OperationManager.GetGameChargeDataList() 找该 chargeId 的 price
|
||||
│ (= GetGameChargeApi 的响应)
|
||||
├─ TicketManager.cs:207 SetBuyPaidTicketData(playerId, ticketId)
|
||||
│ ├─ ChargeList 里已有该 chargeId -> stock++、更新 purchaseDate / validDate
|
||||
│ └─ 没有 -> 新增一条 stock=1
|
||||
└─ TicketManager.cs:234 RequestUserCharge(playerId)
|
||||
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
|
||||
│ -> 取**购买后**(stock > 0)的那条 UserCharge
|
||||
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
|
||||
│ -> chargeId = ticketId
|
||||
│ price = DataManager.GetTicket(ticketId).creditNum ★ 不是 money price
|
||||
│ purchaseDate = TimeManager.GetNowDateString()
|
||||
│ playCount = 玩家总游玩次数
|
||||
│ playerRating = 玩家 Rating
|
||||
│ placeId / regionId / clientId = 当前机台
|
||||
└─ 发 UpsertUserChargelogApi
|
||||
|
||||
★ 成功后 TicketManager.cs:255 RequestSuccess 会把本地 stock 再 --,
|
||||
因为那张票马上就被这一局用掉了。本模块只负责「买」,
|
||||
所以默认传的是**购买后**的库存(现有 + 1)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
二、票的有效期算法
|
||||
--------------------------------------------------------------------------------
|
||||
Manager/TicketManager.cs:564
|
||||
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||
|
||||
真机验证(source/2026-10-05.log:123 的 userChargeList):
|
||||
chargeId=5 purchaseDate="2026-10-04 18:22:10"
|
||||
-> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02
|
||||
-> validDate="2027-01-02 04:00:00" ✅ 完全吻合
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
三、ticket.creditNum 从哪来
|
||||
--------------------------------------------------------------------------------
|
||||
游戏本地主数据:
|
||||
<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
|
||||
已抄进 config.TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
四、安全闸门
|
||||
--------------------------------------------------------------------------------
|
||||
本接口会**真实写入一条购买记录**,所以默认不发包:
|
||||
UpsertUserChargelog(...) -> 抛 ChargelogConfirmError
|
||||
UpsertUserChargelog(..., dry_run=True) -> 只构造报文
|
||||
UpsertUserChargelog(..., confirm=True) -> 真实发送
|
||||
全局开关:config.UPSERT_CHARGELOG_CONFIRM(默认 False)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
五、调用示例
|
||||
--------------------------------------------------------------------------------
|
||||
from api import setup_logging, ParseQRCode, UserLogin, UserLogout,
|
||||
LogoutType, UpsertUserChargelog
|
||||
|
||||
setup_logging("买票")
|
||||
user_id, token = ParseQRCode(qr)
|
||||
login = UserLogin(user_id, token)
|
||||
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
|
||||
UserLogout(user_id, token, login.loginDateTime, logout_type=LogoutType.TestIn)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import config
|
||||
from config import (
|
||||
CLIENT_ID,
|
||||
PLACE_ID,
|
||||
REGION_ID,
|
||||
TICKET_CREDIT_NUM,
|
||||
TICKET_NAMES,
|
||||
TICKET_VALID_DAYS,
|
||||
TICKET_VALID_HOUR_OFFSET,
|
||||
TIMEOUT_MS,
|
||||
UPSERT_CHARGELOG_CONFIRM,
|
||||
)
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
from .upsert_user_all import UpsertResponseVO, resolve_api_func
|
||||
|
||||
|
||||
class ChargelogConfirmError(RuntimeError):
|
||||
"""有副作用的接口被安全闸门拦下(此时**未发包**)。"""
|
||||
|
||||
|
||||
class ChargelogGuardError(RuntimeError):
|
||||
"""
|
||||
安全检查未通过(此时**未发包**)。
|
||||
|
||||
★ 目前唯一的触发条件:**账号当前已经持有功能票**。
|
||||
|
||||
真机行为:`UseTicketPrepare` 只在「本局要用这张票」时才走购买分支,
|
||||
而一张票买到手后会立刻被本局用掉(`RequestSuccess` 里 stock--)。
|
||||
所以正常状态下 `userChargeList` 里的 stock 全是 0。
|
||||
|
||||
如果账号**已经持有**一张功能票还去发购买请求,
|
||||
服务端看到的是「重复购买 / 状态矛盾」,有**封号风险**。
|
||||
所以本模块默认硬拦。
|
||||
"""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 一、VO(字段顺序照 source/Assembly-CSharp/Net.VO.Mai2/*.cs 声明)
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class UserChargeVO:
|
||||
"""UserCharge —— 一张票的持有状态(UserCharge.cs,4 字段)。"""
|
||||
chargeId: int = 0
|
||||
stock: int = 0
|
||||
purchaseDate: str = ""
|
||||
validDate: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserChargelogVO:
|
||||
"""UserChargelog —— 一条购买记录(UserChargelog.cs,8 字段)。"""
|
||||
chargeId: int = 0
|
||||
price: int = 0
|
||||
purchaseDate: str = ""
|
||||
playCount: int = 0
|
||||
playerRating: int = 0
|
||||
placeId: int = 0
|
||||
regionId: int = 0
|
||||
clientId: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserChargelogRequestVO:
|
||||
"""请求 VO(UserChargelogRequestVO.cs,4 字段,顺序照声明)。"""
|
||||
userId: int
|
||||
userChargelog: UserChargelogVO
|
||||
userCharge: UserChargeVO
|
||||
loginDateTime: int = 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 二、工具
|
||||
# =============================================================================
|
||||
|
||||
def ticket_name(charge_id: int) -> str:
|
||||
"""取票名(只用于日志)。"""
|
||||
return TICKET_NAMES.get(int(charge_id), "未知票(" + str(charge_id) + ")")
|
||||
|
||||
|
||||
def ticket_price(charge_id: int, override: Optional[int] = None) -> int:
|
||||
"""
|
||||
取 userChargelog.price —— 即 ticket.creditNum。
|
||||
|
||||
依据 VOExtensions.cs:410 result.price = ticket.creditNum;
|
||||
数据来源 config.TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
|
||||
"""
|
||||
if override is not None:
|
||||
return int(override)
|
||||
if int(charge_id) not in TICKET_CREDIT_NUM:
|
||||
log_warning("config.TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
|
||||
+ " —— 按 0 处理,建议补进配置")
|
||||
return 0
|
||||
return int(TICKET_CREDIT_NUM[int(charge_id)])
|
||||
|
||||
|
||||
#: 北京时间(CST = UTC+8)
|
||||
#: 依据 Manager/TimeManager.cs:9 CSToffset = new TimeSpan(8, 0, 0)
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def now_cst(now: Optional[datetime] = None) -> datetime:
|
||||
"""
|
||||
取「北京时间现在」;传了 now 就用它(视作已是北京时间)。
|
||||
|
||||
★ 为什么要显式 +8 而不是 datetime.now():
|
||||
TimeManager.GetNowTime() 返回 **DateTime.UtcNow**,
|
||||
GetDateString() 再 Add(CSToffset) —— 即游戏里的日期串**恒是北京时间**,
|
||||
与本机时区无关。脚本跑在非东八区机器上时 datetime.now() 会错。
|
||||
"""
|
||||
if now is not None:
|
||||
return now
|
||||
return datetime.now(timezone.utc).astimezone(CST)
|
||||
|
||||
|
||||
def _now_string(now: Optional[datetime] = None) -> str:
|
||||
"""
|
||||
TimeManager.GetNowDateString() 的等价物。
|
||||
|
||||
依据 Manager/TimeManager.cs:7,62-70:
|
||||
DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f"
|
||||
GetDateString(unix) = GetDateTime(unix).ToString("yyyy-MM-dd HH:mm:ss.f")
|
||||
|
||||
★★ 注意末尾那个 **.f**(一位小数)—— 真机报文长这样:
|
||||
"purchaseDate":"2026-10-06 13:20:10.0"
|
||||
少写这个 ".0" 服务端会直接 returnCode=0(实测踩过)。
|
||||
★ 而 validDate 用的是另一套格式(见 calc_valid_date),**不带**小数。
|
||||
"""
|
||||
d = now_cst(now)
|
||||
return d.strftime("%Y-%m-%d %H:%M:%S") + "." + str(d.microsecond // 100000)
|
||||
|
||||
|
||||
def calc_valid_date(
|
||||
now: Optional[datetime] = None,
|
||||
*,
|
||||
hour_offset: int = TICKET_VALID_HOUR_OFFSET,
|
||||
days: int = TICKET_VALID_DAYS,
|
||||
) -> str:
|
||||
"""
|
||||
算票的有效期字符串。
|
||||
|
||||
依据 TicketManager.cs:562-565:
|
||||
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||
|
||||
★ 注意这里用的是**显式格式串**(没有 .f),所以结果形如
|
||||
"2027-01-04 04:00:00" —— 与 purchaseDate 的 "…10.0" 不同。
|
||||
"""
|
||||
base = now_cst(now)
|
||||
d = (base + timedelta(hours=hour_offset) + timedelta(days=days)).date()
|
||||
return d.strftime("%Y-%m-%d") + " 04:00:00"
|
||||
|
||||
|
||||
def parse_wall_time(text: Any) -> Optional[datetime]:
|
||||
"""
|
||||
解析墙上时间串,兼容两种格式:
|
||||
|
||||
"2026-10-06 13:20:10.0" ← TimeManager.DateTimeFormat(一位小数,真机报文用这个)
|
||||
"2026-10-06 13:20:10" ← 无小数(服务端回给 GetUserChargeApi 的是这个)
|
||||
|
||||
解析不出来返回 None。
|
||||
"""
|
||||
s = str(text or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _find_charge(charge_list: Any, charge_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""在 GetUserChargeApi 的 userChargeList 里找指定 chargeId。"""
|
||||
if not isinstance(charge_list, list):
|
||||
return None
|
||||
for c in charge_list:
|
||||
if isinstance(c, dict) and int(c.get("chargeId", -1) or -1) == int(charge_id):
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 三、主函数
|
||||
# =============================================================================
|
||||
|
||||
def UpsertUserChargelog(
|
||||
user_id: int,
|
||||
charge_id: int,
|
||||
login_date_time: int,
|
||||
*,
|
||||
charge_list: Optional[List[Dict[str, Any]]] = None,
|
||||
game_charge_list: Optional[List[Dict[str, Any]]] = None,
|
||||
user_data: Optional[Dict[str, Any]] = None,
|
||||
stock: Optional[int] = None,
|
||||
price: Optional[int] = None,
|
||||
purchase_date: Optional[str] = None,
|
||||
valid_date: Optional[str] = None,
|
||||
play_count: Optional[int] = None,
|
||||
player_rating: Optional[int] = None,
|
||||
place_id: int = PLACE_ID,
|
||||
region_id: int = REGION_ID,
|
||||
client_id: str = CLIENT_ID,
|
||||
guard_no_stock: bool = True,
|
||||
guard_purchasable: bool = True,
|
||||
confirm: Optional[bool] = None,
|
||||
dry_run: bool = False,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Union[UserChargelogRequestVO, UpsertResponseVO]:
|
||||
"""
|
||||
购买一张功能票(UpsertUserChargelogApi,★ 有副作用)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
charge_id: 要买的票 ID(必填,= GetGameChargeApi 里的 chargeId)
|
||||
★ 付费票是 2/3/4/5/6(config.PAID_TICKET_IDS)
|
||||
login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime)
|
||||
charge_list: 当前票券列表(可选)。不传则自动调 GetUserChargeApi。
|
||||
用来算「购买后库存」和是否已有该票。
|
||||
user_data: 当前 userData(可选)。不传则自动调 GetUserDataApi。
|
||||
用来填 playCount / playerRating。
|
||||
stock: ★ 购买后该票的库存;不传 = 现有 + 1
|
||||
price: ★ userChargelog.price;不传 = config.TICKET_CREDIT_NUM[charge_id]
|
||||
purchase_date: 购买时刻字符串;不传 = 现在
|
||||
valid_date: 有效期字符串;不传 = 现在-4h+90d(见 calc_valid_date)
|
||||
play_count: 覆盖 playCount(不传则取 userData.playCount)
|
||||
player_rating: 覆盖 playerRating(不传则取 userData.playerRating)
|
||||
guard_purchasable: ★ 默认 True = **只允许买 GetGameChargeApi 列出来的票**
|
||||
(不在清单里就抛 ChargelogGuardError,未发包)。
|
||||
依据 TicketManager.cs:137-150 —— 真机也是先在
|
||||
GetGameChargeDataList() 里找到该 chargeId 才发购买请求,
|
||||
找不到直接 return false。服务端对未上架的票会 returnCode=0。
|
||||
实测:本机台只上架 chargeId=2/3,买 5 会失败。
|
||||
game_charge_list: 可购买清单(可选)。不传则自动调 GetGameChargeApi。
|
||||
guard_no_stock: ★ 默认 True = **账号当前若已持有任何功能票就直接拒绝购买**
|
||||
(抛 ChargelogGuardError,未发包)。
|
||||
理由见 ChargelogGuardError 的 docstring —— 有封号风险。
|
||||
确认要强行购买才传 False。
|
||||
confirm: 副作用闸门;None 时取 config.UPSERT_CHARGELOG_CONFIRM
|
||||
dry_run: True = 只构造并返回报文,绝不发包
|
||||
|
||||
返回:
|
||||
dry_run=True -> UserChargelogRequestVO
|
||||
dry_run=False -> UpsertResponseVO
|
||||
"""
|
||||
if not login_date_time:
|
||||
raise ValueError("缺少 login_date_time:必须传 UserLoginApi 的 loginDateTime"
|
||||
"(PacketUploadUserChargelog.cs:23)")
|
||||
|
||||
name = ticket_name(charge_id)
|
||||
log_info("购买功能票:user_id=" + str(user_id) + " chargeId=" + str(charge_id)
|
||||
+ "(" + name + ")dry_run=" + str(dry_run))
|
||||
|
||||
if int(charge_id) not in tuple(config.PAID_TICKET_IDS):
|
||||
log_warning("chargeId=" + str(charge_id) + " 不在 config.PAID_TICKET_IDS="
|
||||
+ str(tuple(config.PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
|
||||
|
||||
# ---------- 1. 取当前状态 ----------
|
||||
if charge_list is None:
|
||||
log_info("未传 charge_list,自动抓取当前票券 ...")
|
||||
raw = resolve_api_func("UserCharge")(
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
charge_list = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
|
||||
|
||||
if user_data is None:
|
||||
log_info("未传 user_data,自动抓取当前档案 ...")
|
||||
raw_ud = resolve_api_func("UserData")(
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
user_data = (raw_ud.get("userData") if isinstance(raw_ud, dict) else None) or {}
|
||||
|
||||
# ---------- 1.4 ★ 安全检查:这张票当前**上架了吗** ----------
|
||||
# 依据 TicketManager.cs:137-150:真机先在 GetGameChargeDataList() 里找该
|
||||
# chargeId,找到才 RequestUserCharge,找不到直接 return false(不买)。
|
||||
# 服务端对未上架的票会 returnCode=0 —— 实测买 chargeId=5 就是这种情况。
|
||||
if guard_purchasable:
|
||||
if game_charge_list is None:
|
||||
log_info("未传 game_charge_list,自动抓取可购买清单 ...")
|
||||
_gc = resolve_api_func("GetGameCharge")(
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
game_charge_list = (_gc.get("gameChargeList")
|
||||
if isinstance(_gc, dict) else None) or []
|
||||
avail = [int(g.get("chargeId")) for g in (game_charge_list or [])
|
||||
if isinstance(g, dict) and g.get("chargeId") is not None]
|
||||
log_info("当前可购买 chargeId:" + str(avail))
|
||||
if int(charge_id) not in avail:
|
||||
msg = ("安全检查未通过:chargeId=" + str(charge_id) + "(" + name
|
||||
+ ")**不在当前可购买清单里**。\n"
|
||||
" 可购买:" + (str(avail) if avail else "(空)") + "\n"
|
||||
" 依据 TicketManager.cs:137-150 —— 真机也只买 GetGameChargeApi\n"
|
||||
" 列出来的票,找不到就不发购买请求。\n"
|
||||
" 服务端对未上架的票会 returnCode=0(实测过)。\n"
|
||||
" 确需强行购买请传 guard_purchasable=False。")
|
||||
log_error(msg)
|
||||
raise ChargelogGuardError(msg)
|
||||
log_info("安全检查通过:chargeId=" + str(charge_id) + " 在可购买清单里")
|
||||
|
||||
# ---------- 1.5 ★ 安全检查:账号当前是否已持有功能票 ----------
|
||||
held = [c for c in (charge_list or [])
|
||||
if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
|
||||
if held:
|
||||
desc = "、".join(
|
||||
"chargeId=" + str(c.get("chargeId")) + "(" + ticket_name(c.get("chargeId"))
|
||||
+ ") stock=" + str(c.get("stock")) + " validDate=" + str(c.get("validDate"))
|
||||
for c in held)
|
||||
log_warning("★ 账号当前已持有 " + str(len(held)) + " 张功能票:" + desc)
|
||||
if guard_no_stock:
|
||||
msg = ("安全检查未通过:账号当前**已持有功能票**(" + desc + "),拒绝再次购买。\n"
|
||||
" 原因:真机只在「本局要用这张票」时才购买,且买到手会立刻用掉"
|
||||
"(stock--),正常状态 stock 恒为 0。\n"
|
||||
" 已经持票还发购买请求,服务端看到的是重复购买,有**封号风险**。\n"
|
||||
" 确需强行购买请传 guard_no_stock=False。")
|
||||
log_error(msg)
|
||||
raise ChargelogGuardError(msg)
|
||||
log_warning("guard_no_stock=False —— 已跳过该检查,继续购买")
|
||||
else:
|
||||
log_info("安全检查通过:账号当前没有持有任何功能票")
|
||||
|
||||
existing = _find_charge(charge_list, charge_id)
|
||||
old_stock = int(existing.get("stock", 0) or 0) if existing else 0
|
||||
if stock is None:
|
||||
stock = old_stock + 1
|
||||
stock = int(stock)
|
||||
|
||||
if price is None:
|
||||
price = ticket_price(charge_id)
|
||||
price = int(price)
|
||||
|
||||
if play_count is None:
|
||||
play_count = int(user_data.get("playCount", 0) or 0)
|
||||
if player_rating is None:
|
||||
player_rating = int(user_data.get("playerRating", 0) or 0)
|
||||
|
||||
now = now_cst()
|
||||
if not purchase_date:
|
||||
purchase_date = _now_string(now)
|
||||
if not valid_date:
|
||||
# ★ 真机里 purchaseDate 与 validDate 都来自同一个 DateTime.Now
|
||||
# (TicketManager.cs:216-219 与 :564),所以调用方若显式给了 purchaseDate,
|
||||
# 有效期就该从**那个时刻**推,而不是从现在推。
|
||||
base = parse_wall_time(purchase_date)
|
||||
if base is None:
|
||||
log_warning("purchase_date 解析不了(应为 'YYYY-MM-DD HH:MM:SS[.f]'),"
|
||||
"validDate 改按「现在」计算:" + str(purchase_date))
|
||||
base = now
|
||||
valid_date = calc_valid_date(base)
|
||||
|
||||
log_info(" 票名 : " + name)
|
||||
log_info(" 库存 : " + str(old_stock) + " -> " + str(stock))
|
||||
log_info(" price : " + str(price) + "(= ticket.creditNum)")
|
||||
log_info(" purchaseDate: " + purchase_date)
|
||||
log_info(" validDate : " + valid_date)
|
||||
|
||||
# ---------- 2. 构造请求 ----------
|
||||
req = UserChargelogRequestVO(
|
||||
userId=int(user_id),
|
||||
userChargelog=UserChargelogVO(
|
||||
chargeId=int(charge_id),
|
||||
price=price,
|
||||
purchaseDate=purchase_date,
|
||||
playCount=int(play_count),
|
||||
playerRating=int(player_rating),
|
||||
placeId=int(place_id),
|
||||
regionId=int(region_id),
|
||||
clientId=client_id,
|
||||
),
|
||||
userCharge=UserChargeVO(
|
||||
chargeId=int(charge_id),
|
||||
stock=stock,
|
||||
purchaseDate=purchase_date,
|
||||
validDate=valid_date,
|
||||
),
|
||||
loginDateTime=int(login_date_time),
|
||||
)
|
||||
|
||||
# ---------- 3. dry-run ----------
|
||||
if dry_run:
|
||||
log_info("[dry-run] 已构造购买报文,未发包")
|
||||
log_debug("dry-run 报文:" + __import__("json").dumps(
|
||||
req, ensure_ascii=False, default=str))
|
||||
return req
|
||||
|
||||
# ---------- 4. 副作用闸门 ----------
|
||||
effective = UPSERT_CHARGELOG_CONFIRM if confirm is None else bool(confirm)
|
||||
if not effective:
|
||||
msg = ("UpsertUserChargelogApi 会**真实写入一条购买记录**(不可撤销)。"
|
||||
"本脚本默认不放行。确认要发请传 confirm=True,"
|
||||
"或改 config.UPSERT_CHARGELOG_CONFIRM = True。"
|
||||
"只想看报文请传 dry_run=True。")
|
||||
log_error(msg)
|
||||
raise ChargelogConfirmError(msg)
|
||||
|
||||
# ---------- 5. 发包 ----------
|
||||
log_info("闸门已放行,开始上传购买记录 ...")
|
||||
raw = request_dict(
|
||||
"UpsertUserChargelogApi", req,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
resp = UpsertResponseVO(
|
||||
returnCode=int(raw.get("returnCode", 0) or 0),
|
||||
apiName=str(raw.get("apiName", "") or ""),
|
||||
)
|
||||
if resp.returnCode != 1:
|
||||
msg = ("购买失败:returnCode=" + str(resp.returnCode)
|
||||
+ " apiName=" + resp.apiName)
|
||||
log_error(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
log_info("购买成功:returnCode=" + str(resp.returnCode) + " apiName=" + resp.apiName)
|
||||
log_info(" 票名 " + name + " 库存 " + str(old_stock) + " -> " + str(stock))
|
||||
return resp
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 四、单独运行(调试)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("chargelog_test")
|
||||
try:
|
||||
log_info("构造一张 2 倍券的购买报文(不发包)")
|
||||
req = UpsertUserChargelog(
|
||||
12345678, 2, 1791159576,
|
||||
charge_list=[], user_data={"playCount": 1260, "playerRating": 15000},
|
||||
dry_run=True,
|
||||
)
|
||||
log_info("userChargelog = " + str(req.userChargelog))
|
||||
log_info("userCharge = " + str(req.userCharge))
|
||||
log_info("validDate 算法自检:")
|
||||
log_info(" 2026-10-04 18:22:10 买 5 倍券 -> "
|
||||
+ calc_valid_date(datetime(2026, 10, 4, 18, 22, 10))
|
||||
+ "(真机是 2027-01-02 04:00:00)")
|
||||
return 0
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_activity.py —— 取用户活动记录(GetUserActivityApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的活动/成就记录(playList 与 musicList 两个列表)。
|
||||
|
||||
对应接口:GetUserActivityApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userActivity:{playList:[...], musicList:[...]}}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserActivityRequestVO.cs
|
||||
|
||||
★ 形状特例(36 个 Upsert 节点里唯一一个 Get 与 Upsert 形状不同的):
|
||||
Get 侧 :单个对象 userActivity = {playList, musicList}
|
||||
Upsert 侧:**单元素数组** userActivityList = [ {playList, musicList} ]
|
||||
不是把两个数组首尾相接摊平。
|
||||
|
||||
调用示例:
|
||||
from api import UserActivity
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserActivity(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户活动记录(对应 GetUserActivityApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userActivity 子对象
|
||||
"""
|
||||
log_info("取用户活动记录:user_id=" + str(user_id))
|
||||
return _get("GetUserActivityApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_character.py —— 取用户角色列表(GetUserCharacterApi)
|
||||
================================================================================
|
||||
|
||||
取玩家所有角色的等级与觉醒度(真机 312 条)。
|
||||
|
||||
对应接口:GetUserCharacterApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, length, userCharacterList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserCharacterRequestVO.cs
|
||||
|
||||
★ 回传 Upsert 时只保留 characterId / level / awakening / useCount 四个字段
|
||||
(真机 source/2026-10-05.log:123 的 userCharacterList 就是这 4 个键)。
|
||||
|
||||
调用示例:
|
||||
from api import UserCharacter
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserCharacter(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户角色列表(对应 GetUserCharacterApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userCharacterList
|
||||
"""
|
||||
log_info("取用户角色列表:user_id=" + str(user_id))
|
||||
return _get("GetUserCharacterApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_charge.py —— 取用户票券(GetUserChargeApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的票券持有情况(真机 3 条)。
|
||||
|
||||
对应接口:GetUserChargeApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, length, userChargeList:[{chargeId,stock,purchaseDate,validDate,extNum1}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserChargeRequestVO.cs
|
||||
|
||||
★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。
|
||||
|
||||
调用示例:
|
||||
from api import UserCharge
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserCharge(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户票券(对应 GetUserChargeApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userChargeList
|
||||
"""
|
||||
log_info("取用户票券:user_id=" + str(user_id))
|
||||
return _get("GetUserChargeApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_course.py —— 取用户课题模式成绩(GetUserCourseApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的 COURSE(课题)模式通关记录(真机 16 条)。
|
||||
|
||||
对应接口:GetUserCourseApi
|
||||
请求体 :UserCourseRequestVO(userId + nextIndex,**没有 maxCount**)
|
||||
响应 :{userId, length, nextIndex, userCourseList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserCourseRequestVO.cs
|
||||
source/2026-10-05.log:55 请求体就是 {"userId":..,"nextIndex":0}
|
||||
|
||||
调用示例:
|
||||
from api import UserCourse
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserCourseRequestVO, fetch_all_pages
|
||||
|
||||
|
||||
def UserCourse(
|
||||
user_id: int,
|
||||
*,
|
||||
next_index: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户课题模式成绩(单页,对应 GetUserCourseApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userCourseList
|
||||
"""
|
||||
log_info("取用户课题成绩:user_id=" + str(user_id) + " next_index=" + str(next_index))
|
||||
return _get(
|
||||
"GetUserCourseApi",
|
||||
UserCourseRequestVO(userId=user_id, nextIndex=next_index),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def UserCourseAll(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户课题成绩(自动翻页取全)。
|
||||
|
||||
返回:
|
||||
{"userCourseList": [...]}
|
||||
"""
|
||||
log_info("取用户课题成绩(全量):user_id=" + str(user_id))
|
||||
items = fetch_all_pages(
|
||||
"GetUserCourseApi", user_id, "userCourseList",
|
||||
body_factory=lambda ni: UserCourseRequestVO(userId=user_id, nextIndex=ni or 0),
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
log_info("课题成绩获取完成:" + str(len(items)) + " 条")
|
||||
return {"userCourseList": items}
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_extend.py —— 取用户扩展设置(GetUserExtendApi)
|
||||
================================================================================
|
||||
|
||||
取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。
|
||||
|
||||
对应接口:GetUserExtendApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, userExtend:{...}}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserExtendRequestVO.cs
|
||||
|
||||
★ 其中 categoryIndex(曲种索引)与 userPlaylog 的 extNum4 必须一致
|
||||
(真机两处都是 2022),组包时要注意。
|
||||
|
||||
调用示例:
|
||||
from api import UserExtend
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserExtend(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户扩展设置(对应 GetUserExtendApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userExtend 子对象
|
||||
"""
|
||||
log_info("取用户扩展设置:user_id=" + str(user_id))
|
||||
return _get("GetUserExtendApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,102 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_favorite.py —— 取用户收藏(GetUserFavoriteApi)
|
||||
================================================================================
|
||||
|
||||
取玩家各类收藏夹(称号 / 头像 / 相框 / 搭档 / 背景…)。
|
||||
|
||||
对应接口:GetUserFavoriteApi
|
||||
请求体 :UserFavoriteRequestVO(userId + itemKind)
|
||||
响应 :{userId, userFavorite:{itemKind, itemIdList:[...]}}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFavoriteRequestVO.cs
|
||||
source/2026-10-05.log:59-68 真机依次请求 itemKind = 3,1,2,10,11
|
||||
|
||||
★ 回传 Upsert 时 userFavoriteList 是**恒 5 条**的完整数组(不做剔除),
|
||||
位串 isNewFavoriteList 是三值 "0"/"1"/"2"。
|
||||
|
||||
调用示例:
|
||||
from api import UserFavorite, UserFavoriteAll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
|
||||
|
||||
|
||||
def UserFavorite(
|
||||
user_id: int,
|
||||
item_kind: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户某一类收藏(对应 GetUserFavoriteApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
item_kind: 收藏类别(必填):3=头像 1=称号 2=名牌 10=搭档 11=相框
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userFavorite
|
||||
"""
|
||||
log_info("取用户收藏:user_id=" + str(user_id) + " itemKind=" + str(item_kind))
|
||||
return _get(
|
||||
"GetUserFavoriteApi",
|
||||
UserFavoriteRequestVO(userId=user_id, itemKind=item_kind),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def UserFavoriteAll(
|
||||
user_id: int,
|
||||
*,
|
||||
item_kinds: Optional[List[int]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户全部收藏(按真机顺序请求 5 个类别)。
|
||||
|
||||
参数:
|
||||
item_kinds: 类别列表(可选,默认 FAVORITE_ITEM_KINDS = (3,1,2,10,11))
|
||||
|
||||
返回:
|
||||
{"userFavoriteList": [{itemKind, itemIdList}, ...]},恒 5 条
|
||||
"""
|
||||
kinds = list(item_kinds) if item_kinds else list(FAVORITE_ITEM_KINDS)
|
||||
log_info("取用户全部收藏:user_id=" + str(user_id) + " kinds=" + str(kinds))
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
raw_items: List[Dict[str, Any]] = []
|
||||
for kind in kinds:
|
||||
resp = _get(
|
||||
"GetUserFavoriteApi",
|
||||
UserFavoriteRequestVO(userId=user_id, itemKind=kind),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
fav = resp.get("userFavorite") or {}
|
||||
if not fav:
|
||||
log_warning("itemKind=" + str(kind) + " 未返回 userFavorite,按空处理")
|
||||
fav = {"itemKind": kind, "itemIdList": []}
|
||||
|
||||
# ★ 真机里 itemKind=10 的响应回的是 {"itemKind":null,"itemIdList":[]}(服务端怪癖)。
|
||||
# 保留一份**原始**副本供 Upsert 差分用 —— 位串里的 "1" 正是它产生的。
|
||||
raw_items.append(dict(fav))
|
||||
|
||||
# 回传用的副本按请求值补齐 itemKind,避免报文里出现 null
|
||||
filled = dict(fav)
|
||||
if not filled.get("itemKind"):
|
||||
filled["itemKind"] = kind
|
||||
result.append(filled)
|
||||
|
||||
log_info("收藏获取完成:" + str(len(result)) + " 类")
|
||||
return {"userFavoriteList": result, "_raw": raw_items}
|
||||
@@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
|
||||
================================================================================
|
||||
|
||||
取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。
|
||||
|
||||
对应接口:GetUserFavoriteItemApi
|
||||
请求体 :UserFavoriteItemRequestVO(userId + kind + nextIndex + maxCount=100
|
||||
+ isAllFavoriteItem)
|
||||
响应 :{userId, kind, length, nextIndex, userFavoriteItemList:[{orderId,id}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFavoriteItemRequestVO.cs
|
||||
source/2026-10-05.log:93,107 真机 kind=2 与 kind=1 各一次
|
||||
|
||||
★ 字段名是 kind(不是 itemKind),别写错。
|
||||
|
||||
调用示例:
|
||||
from api import UserFavoriteItem, UserFavoriteItemAll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserFavoriteItemRequestVO, fetch_all_pages
|
||||
|
||||
#: kind 取值
|
||||
FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲
|
||||
FAVORITE_ITEM_KIND_RIVAL = 2 # 收藏对手
|
||||
|
||||
|
||||
def UserFavoriteItem(
|
||||
user_id: int,
|
||||
kind: int = FAVORITE_ITEM_KIND_MUSIC,
|
||||
*,
|
||||
next_index: int = 0,
|
||||
is_all_favorite_item: bool = False,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户收藏项(单页,对应 GetUserFavoriteItemApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
kind: 1=收藏乐曲 2=收藏对手(默认 1)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userFavoriteItemList
|
||||
"""
|
||||
log_info("取用户收藏项:user_id=" + str(user_id) + " kind=" + str(kind)
|
||||
+ " next_index=" + str(next_index))
|
||||
return _get(
|
||||
"GetUserFavoriteItemApi",
|
||||
UserFavoriteItemRequestVO(
|
||||
userId=user_id, kind=kind, nextIndex=next_index,
|
||||
maxCount=100, isAllFavoriteItem=is_all_favorite_item,
|
||||
),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def UserFavoriteItemAll(
|
||||
user_id: int,
|
||||
kind: int = FAVORITE_ITEM_KIND_MUSIC,
|
||||
*,
|
||||
is_all_favorite_item: bool = False,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户收藏项(自动翻页取全)。
|
||||
|
||||
返回:
|
||||
{"userFavoriteItemList": [...]}
|
||||
"""
|
||||
log_info("取用户收藏项(全量):user_id=" + str(user_id) + " kind=" + str(kind))
|
||||
items = fetch_all_pages(
|
||||
"GetUserFavoriteItemApi", user_id, "userFavoriteItemList",
|
||||
body_factory=lambda ni: UserFavoriteItemRequestVO(
|
||||
userId=user_id, kind=kind, nextIndex=ni or 0,
|
||||
maxCount=100, isAllFavoriteItem=is_all_favorite_item),
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
log_info("收藏项获取完成:" + str(len(items)) + " 条")
|
||||
return {"userFavoriteItemList": items}
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi)
|
||||
================================================================================
|
||||
|
||||
取玩家本次可领取的好友里程(getMiles)。
|
||||
|
||||
对应接口:GetUserFriendBonusApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{returnCode, userId, getMiles}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFriendBonusRequestVO.cs
|
||||
source/2026-10-05.log:111 真机响应 {"returnCode":1,"userId":..,"getMiles":90}
|
||||
|
||||
调用示例:
|
||||
from api import UserFriendBonus, extract_gift_points
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserFriendBonus(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户好友里程(对应 GetUserFriendBonusApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict:{"returnCode":1, "userId":.., "getMiles":..}
|
||||
"""
|
||||
log_info("取用户好友里程:user_id=" + str(user_id))
|
||||
resp = _get("GetUserFriendBonusApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
|
||||
if int(resp.get("returnCode", 0) or 0) != 1:
|
||||
log_warning("好友里程接口返回 returnCode=" + str(resp.get("returnCode")))
|
||||
return resp
|
||||
|
||||
|
||||
def extract_gift_points(friend_bonus: Dict[str, Any]) -> int:
|
||||
"""
|
||||
从好友里程响应里取出 getMiles(本次可领取的里程数)。
|
||||
|
||||
参数:
|
||||
friend_bonus: UserFriendBonus 的返回值
|
||||
|
||||
返回:
|
||||
getMiles 整数(取不到则为 0)
|
||||
"""
|
||||
try:
|
||||
return int((friend_bonus or {}).get("getMiles", 0) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
@@ -0,0 +1,245 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_getter_common.py —— 各取数脚本的公共底座
|
||||
================================================================================
|
||||
|
||||
只提供三样东西,取数逻辑都在各自的脚本里:
|
||||
|
||||
1. 各接口的请求 VO(严格对应 source/.../Net.VO.Mai2/*RequestVO.cs)
|
||||
2. _get() 发一次请求并返回**原始响应 dict**
|
||||
3. fetch_all_pages() 自动翻页
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 为什么单独抽出来:各接口的请求体并不统一
|
||||
--------------------------------------------------------------------------------
|
||||
权威依据 source/Assembly-CSharp/Net.VO.Mai2/ 下各自的 XxxRequestVO.cs:
|
||||
|
||||
GameKaleidxScopeRequestVO : 空类,一个字段都没有
|
||||
UserDetailRequestVO : 只有 userId
|
||||
UserMapRequestVO : userId + nextIndex + maxCount = 1000
|
||||
UserLoginBonusRequestVO : userId + nextIndex + maxCount = 20
|
||||
UserItemRequestVO : userId + nextIndex + maxCount = 100
|
||||
UserMusicRequestVO : userId + nextIndex + maxCount = 50
|
||||
UserFavoriteRequestVO : userId + itemKind
|
||||
UserFavoriteItemRequestVO : userId + kind + nextIndex + maxCount = 100 + isAllFavoriteItem
|
||||
UserShopStockRequestVO : userId + shopItemIdList
|
||||
UserRivalMusicRequestVO : userId + rivalId + nextIndex + userRivalMusicLevelList
|
||||
|
||||
发错字段名或漏字段,服务端会返回异常或空数据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_debug, log_warning
|
||||
from .maimai_net import MaimaiNetClient, get_default_client
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 请求 VO(逐字段对应 C# 的 XxxRequestVO)
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class EmptyRequestVO:
|
||||
"""空请求体(对应 GameKaleidxScopeRequestVO —— 它确实没有任何字段)。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserIdRequestVO:
|
||||
"""只有 userId(对应 UserDetailRequestVO / UserCharacterRequestVO / UserOptionRequestVO 等)。"""
|
||||
userId: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserIdPageRequestVO:
|
||||
"""通用分页(对应 UserMapRequestVO,maxCount 默认 1000)。"""
|
||||
userId: int
|
||||
nextIndex: int = 0
|
||||
maxCount: int = 1000
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserLoginBonusRequestVO:
|
||||
"""对应 UserLoginBonusRequestVO(maxCount 默认 20)。"""
|
||||
userId: int
|
||||
nextIndex: int = 0
|
||||
maxCount: int = 20
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserItemRequestVO:
|
||||
"""对应 UserItemRequestVO(maxCount 默认 100)。"""
|
||||
userId: int
|
||||
nextIndex: int = 0
|
||||
maxCount: int = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserMusicRequestVO:
|
||||
"""对应 UserMusicRequestVO(maxCount 默认 50)。"""
|
||||
userId: int
|
||||
nextIndex: int = 0
|
||||
maxCount: int = 50
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserCourseRequestVO:
|
||||
"""对应 UserCourseRequestVO(只有 userId + nextIndex,**没有** maxCount)。"""
|
||||
userId: int
|
||||
nextIndex: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserFavoriteRequestVO:
|
||||
"""对应 UserFavoriteRequestVO(按 itemKind 分次请求)。"""
|
||||
userId: int
|
||||
itemKind: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserFavoriteItemRequestVO:
|
||||
"""对应 UserFavoriteItemRequestVO(注意字段名是 kind,不是 itemKind)。"""
|
||||
userId: int
|
||||
kind: int = 1
|
||||
nextIndex: int = 0
|
||||
maxCount: int = 100
|
||||
isAllFavoriteItem: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserShopStockRequestVO:
|
||||
"""对应 UserShopStockRequestVO。"""
|
||||
userId: int
|
||||
shopItemIdList: List[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserRivalRequestVO:
|
||||
"""对应 UserRivalRequestVO。"""
|
||||
userId: int
|
||||
rivalId: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserRivalMusicRequestVO:
|
||||
"""对应 UserRivalMusicRequestVO(level 列表固定 6 档)。"""
|
||||
userId: int
|
||||
rivalId: int = 0
|
||||
nextIndex: int = 0
|
||||
userRivalMusicLevelList: List[Dict[str, int]] = field(
|
||||
default_factory=lambda: [{"level": lv} for lv in (0, 1, 2, 3, 4, 10)]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 常量
|
||||
# =============================================================================
|
||||
|
||||
#: GetUserItemApi 的 itemKind 取值(对应 Net.VO.Mai2/ItemKind.cs)
|
||||
#: 客户端在 StateUserDownload.cs 里依次下载这些 kind
|
||||
ITEM_KINDS: tuple = (1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 14)
|
||||
|
||||
#: ★ GetUserItemApi 首页 nextIndex 的哨兵系数。
|
||||
#: 依据 source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
|
||||
#: Create(GetNetQuery(userId, itemId * 10000000000L));
|
||||
#: 传 0 会被服务端拒绝。真机日志 source/2026-10-05.log:32 也是 10000000000。
|
||||
ITEM_NEXT_INDEX_FACTOR: int = 10000000000
|
||||
|
||||
#: GetUserFavoriteApi 的 itemKind(真机顺序固定 3/1/2/10/11,见 source/2026-10-05.log:59-68)
|
||||
FAVORITE_ITEM_KINDS: tuple = (3, 1, 2, 10, 11)
|
||||
|
||||
#: GetUserRivalMusicApi 的 level 档位(真机 source/2026-10-05.log:101)
|
||||
RIVAL_MUSIC_LEVELS: tuple = (0, 1, 2, 3, 4, 10)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 内部工具
|
||||
# =============================================================================
|
||||
|
||||
def _get(
|
||||
api_name: str,
|
||||
body: Any,
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
发一次请求,返回**原始响应 dict**(不做 VO 映射,保留服务端全部字段)。
|
||||
|
||||
★ 取数脚本一律走这里:upsertUserAll 需要响应原文,
|
||||
映射成 dataclass 会丢掉服务端多给的字段。
|
||||
"""
|
||||
net = client or get_default_client()
|
||||
raw = net.request(
|
||||
api_name, body,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS,
|
||||
)
|
||||
if isinstance(raw, str):
|
||||
return json.loads(raw) if raw else {}
|
||||
return raw or {}
|
||||
|
||||
|
||||
def fetch_all_pages(
|
||||
api_name: str,
|
||||
user_id: int,
|
||||
list_key: str,
|
||||
*,
|
||||
body_factory: Optional[Callable[[Optional[int]], Any]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
max_pages: int = 200,
|
||||
) -> List[Any]:
|
||||
"""
|
||||
自动翻页抓取某个列表接口的全部条目。
|
||||
|
||||
分页约定(各 Packet 的 Proc 里都是这个逻辑):
|
||||
nextIndex == 0 -> 没有更多数据
|
||||
nextIndex != 0 -> 下一页的起始索引
|
||||
|
||||
参数:
|
||||
api_name: 逻辑接口名
|
||||
list_key: 响应里承载列表的键名(如 "userMapList")
|
||||
body_factory: 可选;接收 next_index 返回请求 VO 的工厂。
|
||||
★ 首页会以 next_index=None 调用它 —— 这是为了支持
|
||||
GetUserItemApi 这类「首发要带哨兵值」的接口
|
||||
(首包 nextIndex = itemKind * 1e10,不能是 0)。
|
||||
max_pages: 翻页上限(防御性)
|
||||
|
||||
返回:
|
||||
合并后的全部条目
|
||||
"""
|
||||
all_items: List[Any] = []
|
||||
next_index: Optional[int] = None
|
||||
for page in range(max_pages):
|
||||
if body_factory is not None:
|
||||
body = body_factory(next_index)
|
||||
else:
|
||||
body = UserIdPageRequestVO(userId=user_id, nextIndex=next_index or 0)
|
||||
|
||||
resp = _get(api_name, body, user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
items = resp.get(list_key) or []
|
||||
if isinstance(items, dict):
|
||||
items = [items]
|
||||
all_items.extend(items)
|
||||
|
||||
next_index = int(resp.get("nextIndex") or 0)
|
||||
log_debug(
|
||||
api_name + " 第 " + str(page + 1) + " 页:本页 " + str(len(items))
|
||||
+ " 条,累计 " + str(len(all_items)) + " 条,nextIndex=" + str(next_index)
|
||||
)
|
||||
if not next_index:
|
||||
break
|
||||
else:
|
||||
log_warning(api_name + " 超过 " + str(max_pages) + " 页仍未结束,已中止翻页")
|
||||
|
||||
return all_items
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_ghost.py —— 取用户幽灵数据(GetUserGhostApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的「幽灵」对战记录(真机 15 条)。
|
||||
|
||||
对应接口:GetUserGhostApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{length, userGhostList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserGhostRequestVO.cs
|
||||
|
||||
★ Upsert 侧 userGhost 默认留空:源码 VOExtensions.cs:235 是
|
||||
dst.userGhost = (!src.MyGhost.NeedSave) ? null : new UserGhost[1] {...};
|
||||
只有「本局产生了幽灵」才回传,纯网络脚本复刻不出幽灵数据。
|
||||
|
||||
调用示例:
|
||||
from api import UserGhost
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserGhost(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户幽灵数据(对应 GetUserGhostApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userGhostList
|
||||
"""
|
||||
log_info("取用户幽灵数据:user_id=" + str(user_id))
|
||||
return _get("GetUserGhostApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
|
||||
================================================================================
|
||||
|
||||
取玩家与各搭档的亲密度(真机 2 条)。
|
||||
|
||||
对应接口:GetUserIntimateApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, length, userIntimateList:[{partnerId,intimateLevel,intimateCountRewarded}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserIntimateRequestVO.cs
|
||||
|
||||
调用示例:
|
||||
from api import UserIntimate
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserIntimate(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户搭档亲密度(对应 GetUserIntimateApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userIntimateList
|
||||
"""
|
||||
log_info("取用户搭档亲密度:user_id=" + str(user_id))
|
||||
return _get("GetUserIntimateApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,124 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_item.py —— 取用户道具(GetUserItemApi)
|
||||
================================================================================
|
||||
|
||||
取玩家各类道具的库存(真机按 kind 分 12 次请求)。
|
||||
|
||||
对应接口:GetUserItemApi
|
||||
请求体 :UserItemRequestVO(userId + nextIndex + maxCount=100)
|
||||
响应 :{userId, length, nextIndex, itemKind, userItemList:[{itemKind,itemId,stock,isValid}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserItemRequestVO.cs
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★★ 首发 nextIndex 必须带哨兵值(最容易踩的坑)
|
||||
--------------------------------------------------------------------------------
|
||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
|
||||
|
||||
public PacketGetUserItem(ulong userId, long itemId, ...)
|
||||
{
|
||||
Create(GetNetQuery(userId, itemId * 10000000000L)); // <- 首包哨兵
|
||||
}
|
||||
// Proc(): nextIndex != 0 时继续翻页
|
||||
|
||||
真机日志 source/2026-10-05.log:32 也是 nextIndex=10000000000。
|
||||
传 0 会被服务端拒绝(HTTP 500)。
|
||||
|
||||
★ 请求体里**没有 itemKind 字段** —— 类别是靠 nextIndex 的哨兵值表达的:
|
||||
第 1 页 nextIndex = kind * 1e10
|
||||
后续翻页沿用响应回的 nextIndex
|
||||
|
||||
调用示例:
|
||||
from api import UserItem, UserItemAll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_debug, log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR
|
||||
|
||||
|
||||
def UserItem(
|
||||
user_id: int,
|
||||
item_kind: int,
|
||||
*,
|
||||
next_index: Optional[int] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户某一类道具(对应 GetUserItemApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
item_kind: 道具类别(必填):1=名牌 2=称号 3=头像 4=礼物 5=乐曲 …
|
||||
next_index: 分页索引;**None(默认)表示首页**,会自动换算成
|
||||
item_kind * 10000000000 的哨兵值。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userItemList
|
||||
"""
|
||||
if next_index is None:
|
||||
next_index = item_kind * ITEM_NEXT_INDEX_FACTOR
|
||||
|
||||
log_info("取用户道具:user_id=" + str(user_id) + " item_kind=" + str(item_kind)
|
||||
+ " nextIndex=" + str(next_index))
|
||||
|
||||
return _get(
|
||||
"GetUserItemApi",
|
||||
UserItemRequestVO(userId=user_id, nextIndex=next_index, maxCount=100),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def UserItemAll(
|
||||
user_id: int,
|
||||
*,
|
||||
item_kinds: Optional[List[int]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户全部道具(按 ITEM_KINDS 逐个类别抓取,并自动翻页)。
|
||||
|
||||
参数:
|
||||
item_kinds: 类别列表(可选,默认 ITEM_KINDS)
|
||||
|
||||
返回:
|
||||
{"userItemList": [...]},已合并所有类别与分页
|
||||
"""
|
||||
kinds = list(item_kinds) if item_kinds else list(ITEM_KINDS)
|
||||
log_info("取用户全部道具:user_id=" + str(user_id) + " kinds=" + str(kinds))
|
||||
|
||||
merged: List[Dict[str, Any]] = []
|
||||
for kind in kinds:
|
||||
next_index: Optional[int] = kind * ITEM_NEXT_INDEX_FACTOR
|
||||
page = 0
|
||||
while next_index:
|
||||
page += 1
|
||||
resp = _get(
|
||||
"GetUserItemApi",
|
||||
UserItemRequestVO(userId=user_id, nextIndex=next_index, maxCount=100),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
items = resp.get("userItemList") or []
|
||||
if isinstance(items, dict):
|
||||
items = [items]
|
||||
merged.extend(items)
|
||||
|
||||
next_index = int(resp.get("nextIndex") or 0)
|
||||
log_debug("item_kind=" + str(kind) + " 第 " + str(page) + " 页:"
|
||||
+ str(len(items)) + " 条,nextIndex=" + str(next_index))
|
||||
if page >= 200:
|
||||
log_warning("item_kind=" + str(kind) + " 翻页超过 200 页,已中止")
|
||||
break
|
||||
|
||||
log_info("道具获取完成:" + str(len(merged)) + " 条")
|
||||
return {"userItemList": merged}
|
||||
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_kaleidx_scope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
|
||||
================================================================================
|
||||
|
||||
对应接口:
|
||||
GetUserKaleidxScopeApi 请求体 UserIdRequestVO(userId)
|
||||
响应 {userId, userKaleidxScopeList:[...]}
|
||||
GetGameKaleidxScopeApi 请求体 **空对象 {}**(GameKaleidxScopeRequestVO 没有任何字段)
|
||||
响应 {gameKaleidxScopeList:[{gateId, phaseId}]}
|
||||
依据:
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserKaleidxScopeRequestVO.cs
|
||||
source/Assembly-CSharp/Net.VO.Mai2/GameKaleidxScopeRequestVO.cs(空类)
|
||||
source/Assembly-CSharp/Net.VO.Mai2/GameKaleidxScopeResponseVO.cs
|
||||
source/2026-10-05.log:15,119
|
||||
|
||||
★★ 重要区分(曾导致 userData 取空)
|
||||
--------------------------------------------------------------------------------
|
||||
GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。
|
||||
玩家档案 userData 的正确来源是 GetUserDataApi(见 userdata.py)。
|
||||
|
||||
真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的
|
||||
两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId,
|
||||
多发字段会让服务端返回空内容。
|
||||
|
||||
调用示例:
|
||||
from api import UserKaleidxScope, GameKaleidxScope
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, EmptyRequestVO, UserIdRequestVO
|
||||
|
||||
|
||||
def UserKaleidxScope(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取玩家 KALEIDX 门进度(对应 GetUserKaleidxScopeApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userKaleidxScopeList
|
||||
"""
|
||||
log_info("取玩家 KALEIDX 门进度:user_id=" + str(user_id))
|
||||
return _get("GetUserKaleidxScopeApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
|
||||
|
||||
def GameKaleidxScope(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取全服 KALEIDX 门开放进度(对应 GetGameKaleidxScopeApi)。
|
||||
|
||||
★ 请求体是空对象 {} —— 该 RequestVO 一个字段都没有,**不要传 userId**。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameKaleidxScopeList
|
||||
"""
|
||||
log_info("取全服 KALEIDX 门进度")
|
||||
return _get("GetGameKaleidxScopeApi", EmptyRequestVO(), 0,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的登录奖励领取进度(真机 38 条,分两页)。
|
||||
|
||||
对应接口:GetUserLoginBonusApi
|
||||
请求体 :UserLoginBonusRequestVO(userId + nextIndex + maxCount=20)
|
||||
响应 :{userId, length, nextIndex, userLoginBonusList:[{bonusId,point,isCurrent,isComplete}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserLoginBonusRequestVO.cs
|
||||
|
||||
★ 回传 Upsert 时只回传 isCurrent=true 的那一条(本次签到的那条)。
|
||||
|
||||
调用示例:
|
||||
from api import UserLoginBonus, UserLoginBonusAll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserLoginBonusRequestVO, fetch_all_pages
|
||||
|
||||
|
||||
def UserLoginBonus(
|
||||
user_id: int,
|
||||
*,
|
||||
next_index: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户登录奖励(单页,对应 GetUserLoginBonusApi)。
|
||||
|
||||
参数:
|
||||
next_index: 分页起始索引(0 = 首页)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userLoginBonusList
|
||||
"""
|
||||
log_info("取用户登录奖励:user_id=" + str(user_id) + " next_index=" + str(next_index))
|
||||
return _get(
|
||||
"GetUserLoginBonusApi",
|
||||
UserLoginBonusRequestVO(userId=user_id, nextIndex=next_index, maxCount=20),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def UserLoginBonusAll(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户登录奖励(自动翻页取全)。
|
||||
|
||||
返回:
|
||||
{"userLoginBonusList": [...]}
|
||||
"""
|
||||
log_info("取用户登录奖励(全量):user_id=" + str(user_id))
|
||||
items = fetch_all_pages(
|
||||
"GetUserLoginBonusApi", user_id, "userLoginBonusList",
|
||||
body_factory=lambda ni: UserLoginBonusRequestVO(
|
||||
userId=user_id, nextIndex=ni or 0, maxCount=20),
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
log_info("登录奖励获取完成:" + str(len(items)) + " 条")
|
||||
return {"userLoginBonusList": items}
|
||||
@@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_map.py —— 取用户地图进度(GetUserMapApi)
|
||||
================================================================================
|
||||
|
||||
取玩家各地图的推进距离(真机 104 条)。
|
||||
|
||||
对应接口:GetUserMapApi
|
||||
请求体 :UserMapRequestVO(userId + nextIndex + maxCount=1000)
|
||||
响应 :{userId, length, nextIndex, userMapList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMapRequestVO.cs
|
||||
|
||||
★ Get 侧下发 100+ 条;Upsert 只回传「本次距离变化」的那 1 条
|
||||
(真机:mapId=550003 的 distance 1424000 -> 1435000)。
|
||||
|
||||
调用示例:
|
||||
from api import UserMap / UserMapAll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdPageRequestVO, fetch_all_pages
|
||||
|
||||
|
||||
def UserMap(
|
||||
user_id: int,
|
||||
*,
|
||||
next_index: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户地图进度(单页,对应 GetUserMapApi)。
|
||||
|
||||
参数:
|
||||
next_index: 分页起始索引(0 = 首页)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userMapList
|
||||
"""
|
||||
log_info("取用户地图进度:user_id=" + str(user_id) + " next_index=" + str(next_index))
|
||||
return _get(
|
||||
"GetUserMapApi",
|
||||
UserIdPageRequestVO(userId=user_id, nextIndex=next_index, maxCount=1000),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def UserMapAll(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户地图进度(自动翻页取全)。
|
||||
|
||||
返回:
|
||||
{"userMapList": [...]}
|
||||
"""
|
||||
log_info("取用户地图进度(全量):user_id=" + str(user_id))
|
||||
items = fetch_all_pages(
|
||||
"GetUserMapApi", user_id, "userMapList",
|
||||
body_factory=lambda ni: UserIdPageRequestVO(
|
||||
userId=user_id, nextIndex=ni or 0, maxCount=1000),
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
log_info("地图进度获取完成:" + str(len(items)) + " 条")
|
||||
return {"userMapList": items}
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的任务进度与周常数据(真机 6 条任务)。
|
||||
|
||||
对应接口:GetUserMissionDataApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, userWeeklyData:{...}, userMissionDataList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMissionRequestVO.cs
|
||||
|
||||
★ 一个接口同时下发两个 Upsert 节点:
|
||||
userMissionDataList 任务列表
|
||||
userWeeklyData 周常数据(★ 是**对象**不是数组)
|
||||
★ 两者回传 Upsert 时都要丢掉各自的 userId 字段。
|
||||
|
||||
调用示例:
|
||||
from api import UserMissionData
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserMissionData(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户任务与周数据(对应 GetUserMissionDataApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict:{"userId":.., "userWeeklyData":{..}, "userMissionDataList":[..]}
|
||||
"""
|
||||
log_info("取用户任务与周数据:user_id=" + str(user_id))
|
||||
return _get("GetUserMissionDataApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_music.py —— 取用户曲目成绩(GetUserMusicApi)
|
||||
================================================================================
|
||||
|
||||
取玩家全部曲目的历史成绩归档(真机 739 组)。
|
||||
|
||||
对应接口:GetUserMusicApi
|
||||
请求体 :UserMusicRequestVO(userId + nextIndex + maxCount=50)
|
||||
响应 :{userId, nextIndex, userMusicList:[{userMusicDetailList:[...], length}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMusicRequestVO.cs
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserMusic.cs(外层按曲分组)
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserMusicDetail.cs(内层按难度)
|
||||
|
||||
★ 响应是**两层嵌套**:外层每元素是一首歌,内层的 userMusicDetailList 才是
|
||||
按 (musicId, level) 的成绩条目。取数时要摊平。
|
||||
|
||||
★ 回传 Upsert 时只回传本局玩的那一条(playCount +1),其余不动。
|
||||
|
||||
调用示例:
|
||||
from api import UserMusicDetail, UserMusicDetailFlat
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserMusicRequestVO, fetch_all_pages
|
||||
|
||||
|
||||
def UserMusicDetail(
|
||||
user_id: int,
|
||||
*,
|
||||
next_index: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户曲目成绩(单页,对应 GetUserMusicApi)。
|
||||
|
||||
参数:
|
||||
next_index: 分页起始索引(0 = 首页)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userMusicList(两层嵌套)
|
||||
"""
|
||||
log_info("取用户曲目成绩:user_id=" + str(user_id) + " next_index=" + str(next_index))
|
||||
return _get(
|
||||
"GetUserMusicApi",
|
||||
UserMusicRequestVO(userId=user_id, nextIndex=next_index, maxCount=50),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def UserMusicDetailAll(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户曲目成绩(自动翻页取全,已摊平成单层)。
|
||||
|
||||
返回:
|
||||
{"userMusicList": [ {"musicId":..,"level":..,"playCount":..}, ... ]}
|
||||
每元素是**单条 (musicId, level) 成绩**,不是按曲分组的嵌套结构。
|
||||
"""
|
||||
log_info("取用户曲目成绩(全量):user_id=" + str(user_id))
|
||||
groups = fetch_all_pages(
|
||||
"GetUserMusicApi", user_id, "userMusicList",
|
||||
body_factory=lambda ni: UserMusicRequestVO(
|
||||
userId=user_id, nextIndex=ni or 0, maxCount=50),
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
flat: List[Dict[str, Any]] = []
|
||||
for group in groups:
|
||||
if isinstance(group, dict):
|
||||
details = group.get("userMusicDetailList") or []
|
||||
if isinstance(details, dict):
|
||||
details = [details]
|
||||
flat.extend(details)
|
||||
log_info("曲目成绩获取完成:" + str(len(flat)) + " 条(原始 "
|
||||
+ str(len(groups)) + " 组)")
|
||||
return {"userMusicList": flat}
|
||||
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_new_item.py —— 取新增道具(GetUserNewItemListApi)
|
||||
================================================================================
|
||||
|
||||
上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。
|
||||
|
||||
对应接口:GetUserNewItemListApi
|
||||
请求体 :UserNewItemListRequestVO(userId + version + userData[] + userPlaylogList[])
|
||||
响应 :{userId, userItemList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserNewItemListRequestVO.cs
|
||||
source/2026-10-05.log:121 真机请求带 userData 与 userPlaylogList
|
||||
|
||||
★ 这是**结算之后**才调的接口,用于回查本局获得的新道具。
|
||||
★ 请求体里的 userData / userPlaylogList 需要传,内容与 UpsertUserAll 一致。
|
||||
|
||||
调用示例:
|
||||
from api import UserNewItemList
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, PLAY_VERSION_INT, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserNewItemListRequestVO:
|
||||
"""请求 VO(对应 UserNewItemListRequestVO)。"""
|
||||
userId: int
|
||||
version: int = PLAY_VERSION_INT
|
||||
userData: List[Any] = field(default_factory=list)
|
||||
userPlaylogList: List[Any] = field(default_factory=list)
|
||||
|
||||
|
||||
def UserNewItemList(
|
||||
user_id: int,
|
||||
*,
|
||||
version: int = PLAY_VERSION_INT,
|
||||
user_data: Optional[List[Any]] = None,
|
||||
user_playlog_list: Optional[List[Any]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取本次新增道具(对应 GetUserNewItemListApi)。
|
||||
|
||||
★ 这是**结算流程的前置接口**,真机在 UpsertUserAll 之前约 8 秒调用
|
||||
(source/2026-10-05-new.log: 19:22:53 -> 19:23:02)。
|
||||
|
||||
★★ 传入的 user_data 必须是**游玩后**的档案,不是登录时的旧值。
|
||||
真机实证(source/2026-10-05-new.log 的该请求):
|
||||
userData[0].playCount = 1258 (登录时是 1258,本局后 1259)
|
||||
userData[0].point = 4460 (登录时是 4110,本局后 4460)
|
||||
userData[0].lastPlayDate = "2026-10-05 19:22:53.0"
|
||||
即:**与 UpsertUserAll 里的 userData 完全一致**。
|
||||
所以正确用法是先把本局演算跑完,再拿演算后的 userData 传进来。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
version: ROM 版本整数(默认取 config.PLAY_VERSION_INT)
|
||||
user_data: 玩家档案数组(通常传 [游玩后的 userData])
|
||||
user_playlog_list: 本局成绩数组(通常传 [playlog])
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userItemList
|
||||
"""
|
||||
log_info("取新增道具:user_id=" + str(user_id) + " version=" + str(version))
|
||||
return request_dict(
|
||||
"GetUserNewItemListApi",
|
||||
UserNewItemListRequestVO(
|
||||
userId=user_id, version=version,
|
||||
userData=list(user_data or []),
|
||||
userPlaylogList=list(user_playlog_list or []),
|
||||
),
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_option.py —— 取用户游戏设置(GetUserOptionApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。
|
||||
|
||||
对应接口:GetUserOptionApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :userOption 的字段直接平铺在响应顶层
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserOptionRequestVO.cs
|
||||
|
||||
★ 形态提醒:Get 侧响应是**平铺**的(顶层直接就是 optionKind/noteSpeed/...),
|
||||
而回传 Upsert 时要包成单元素数组 userOption:[{...}]。
|
||||
裁剪时要丢掉 tempoVolume(真机 Upsert 报文里没有这个字段)。
|
||||
|
||||
调用示例:
|
||||
from api import UserOption
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserOption(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户游戏设置(对应 GetUserOptionApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,设置字段平铺在顶层
|
||||
"""
|
||||
log_info("取用户游戏设置:user_id=" + str(user_id))
|
||||
return _get("GetUserOptionApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_rating.py —— 取用户 Rating 明细(GetUserRatingApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。
|
||||
|
||||
对应接口:GetUserRatingApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, userRating:{rating, ratingList, newRatingList, nextRatingList,
|
||||
nextNewRatingList, udemae}}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserRatingRequestVO.cs
|
||||
|
||||
★ 回传 Upsert 时是**单元素数组** userRatingList:[{...}],不是对象。
|
||||
|
||||
调用示例:
|
||||
from api import UserRating
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserRating(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户 Rating 明细(对应 GetUserRatingApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userRating 子对象
|
||||
"""
|
||||
log_info("取用户 Rating 明细:user_id=" + str(user_id))
|
||||
return _get("GetUserRatingApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_recommend_music.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
|
||||
================================================================================
|
||||
|
||||
对应接口:
|
||||
GetUserRecommendRateMusicApi 请求体 UserIdRequestVO
|
||||
响应 {userId, userRecommendRateMusicIdList:[...]}
|
||||
GetUserRecommendSelectMusicApi 请求体 UserIdRequestVO
|
||||
响应 {userId, userRecommendSelectionMusicIdList:[...]}
|
||||
依据:
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserRecommendRateMusicRequestVO.cs
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserRecommendSelectionMusicRequestVO.cs
|
||||
source/2026-10-05.log:79,81
|
||||
|
||||
调用示例:
|
||||
from api import UserRecommendRateMusic, UserRecommendSelectMusic
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserRecommendRateMusic(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取推荐上分曲目(对应 GetUserRecommendRateMusicApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userRecommendRateMusicIdList
|
||||
"""
|
||||
log_info("取推荐上分曲目:user_id=" + str(user_id))
|
||||
return _get("GetUserRecommendRateMusicApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
|
||||
|
||||
def UserRecommendSelectMusic(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取推荐选曲(对应 GetUserRecommendSelectMusicApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userRecommendSelectionMusicIdList
|
||||
"""
|
||||
log_info("取推荐选曲:user_id=" + str(user_id))
|
||||
return _get("GetUserRecommendSelectMusicApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_region.py —— 取用户地区游玩统计(GetUserRegionApi)
|
||||
================================================================================
|
||||
|
||||
取玩家在各地区的游玩次数。
|
||||
|
||||
对应接口:GetUserRegionApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, length, userRegionList:[{regionId,playCount,created}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserRegionRequestVO.cs
|
||||
source/2026-10-05.log:77
|
||||
|
||||
调用示例:
|
||||
from api import UserRegion
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserRegion(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户地区游玩统计(对应 GetUserRegionApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userRegionList
|
||||
"""
|
||||
log_info("取用户地区统计:user_id=" + str(user_id))
|
||||
return _get("GetUserRegionApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_rival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的对手名单及其曲目成绩。
|
||||
|
||||
对应接口:
|
||||
GetUserRivalDataApi 请求体 UserRivalRequestVO(userId + rivalId)
|
||||
响应 {userId, userRivalData:{rivalId, rivalName}}
|
||||
GetUserRivalMusicApi 请求体 UserRivalMusicRequestVO(userId + rivalId + nextIndex
|
||||
+ userRivalMusicLevelList)
|
||||
响应 {userId, rivalId, length, nextIndex, userRivalMusicList}
|
||||
依据:
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserRivalRequestVO.cs
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserRivalMusicRequestVO.cs
|
||||
source/2026-10-05.log:95,101
|
||||
|
||||
★ 对手 ID 需要调用方提供(先由 GetUserFavoriteItemApi 的 kind=2 取到)。
|
||||
|
||||
调用示例:
|
||||
from api import UserRivalData, UserRivalMusic
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import (
|
||||
_get,
|
||||
UserRivalRequestVO,
|
||||
UserRivalMusicRequestVO,
|
||||
RIVAL_MUSIC_LEVELS,
|
||||
fetch_all_pages,
|
||||
)
|
||||
|
||||
|
||||
def UserRivalData(
|
||||
user_id: int,
|
||||
rival_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取对手资料(对应 GetUserRivalDataApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
rival_id: 对手 ID(必填)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userRivalData
|
||||
"""
|
||||
log_info("取对手资料:user_id=" + str(user_id) + " rival_id=" + str(rival_id))
|
||||
return _get(
|
||||
"GetUserRivalDataApi",
|
||||
UserRivalRequestVO(userId=user_id, rivalId=rival_id),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def UserRivalMusic(
|
||||
user_id: int,
|
||||
rival_id: int,
|
||||
*,
|
||||
next_index: int = 0,
|
||||
levels: Optional[List[int]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取对手曲目成绩(单页,对应 GetUserRivalMusicApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
rival_id: 对手 ID(必填)
|
||||
next_index: 分页索引(0 = 首页)
|
||||
levels: 难度档位列表(可选,默认 0/1/2/3/4/10)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userRivalMusicList
|
||||
"""
|
||||
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
|
||||
log_info("取对手曲目成绩:user_id=" + str(user_id) + " rival_id=" + str(rival_id)
|
||||
+ " levels=" + str(lv))
|
||||
return _get(
|
||||
"GetUserRivalMusicApi",
|
||||
UserRivalMusicRequestVO(
|
||||
userId=user_id, rivalId=rival_id, nextIndex=next_index,
|
||||
userRivalMusicLevelList=[{"level": x} for x in lv],
|
||||
),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def UserRivalMusicAll(
|
||||
user_id: int,
|
||||
rival_id: int,
|
||||
*,
|
||||
levels: Optional[List[int]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取对手曲目成绩(自动翻页取全)。
|
||||
|
||||
返回:
|
||||
{"userRivalMusicList": [...]}
|
||||
"""
|
||||
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
|
||||
log_info("取对手曲目成绩(全量):user_id=" + str(user_id) + " rival_id=" + str(rival_id))
|
||||
items = fetch_all_pages(
|
||||
"GetUserRivalMusicApi", user_id, "userRivalMusicList",
|
||||
body_factory=lambda ni: UserRivalMusicRequestVO(
|
||||
userId=user_id, rivalId=rival_id, nextIndex=ni or 0,
|
||||
userRivalMusicLevelList=[{"level": x} for x in lv]),
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
log_info("对手曲目获取完成:" + str(len(items)) + " 条")
|
||||
return {"userRivalMusicList": items}
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi)
|
||||
================================================================================
|
||||
|
||||
取玩家在各商店商品的已兑换次数。
|
||||
|
||||
对应接口:GetUserShopStockApi
|
||||
请求体 :UserShopStockRequestVO(userId + shopItemIdList)
|
||||
响应 :{userId, userShopStockList:[{shopItemId,tradeCount}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserShopStockRequestVO.cs
|
||||
source/2026-10-05.log:117 真机请求体带 100+ 个 shopItemId
|
||||
|
||||
★ shopItemIdList 需要调用方提供(来自游戏 Master 数据,脚本无法自行推导)。
|
||||
不传时按空列表发送,服务端返回空 userShopStockList —— 这是合法的。
|
||||
|
||||
调用示例:
|
||||
from api import UserShopStock
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, PLAY_SHOP_ITEM_IDS, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserShopStockRequestVO
|
||||
|
||||
|
||||
def UserShopStock(
|
||||
user_id: int,
|
||||
*,
|
||||
shop_item_id_list: Optional[List[int]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户商店库存(对应 GetUserShopStockApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
shop_item_id_list: 商品 ID 清单(可选,默认取 config.PLAY_SHOP_ITEM_IDS)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userShopStockList
|
||||
"""
|
||||
ids = list(shop_item_id_list) if shop_item_id_list is not None \
|
||||
else list(PLAY_SHOP_ITEM_IDS)
|
||||
|
||||
log_info("取用户商店库存:user_id=" + str(user_id) + " 商品数=" + str(len(ids)))
|
||||
return _get(
|
||||
"GetUserShopStockApi",
|
||||
UserShopStockRequestVO(userId=user_id, shopItemIdList=ids),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
userdata.py —— 取用户全量数据(GetUserDataApi)
|
||||
================================================================================
|
||||
|
||||
取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。
|
||||
|
||||
对应接口:GetUserDataApi
|
||||
请求体 :UserIdRequestVO(只有 userId)
|
||||
响应 :{userId, userData:{86 键}, banState}
|
||||
依据 :source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserData.cs:17
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserDetailResponseVO.cs
|
||||
|
||||
★ banState 在 Get 侧是**顶层**字段(UserDetailResponseVO.cs:15),
|
||||
回传 Upsert 时要搬进 userData 内部(真机 source/2026-10-05.log:123 的 userData 里有 banState)。
|
||||
|
||||
⚠ 常见误区:有资料认为 userData 由 GetGameKaleidxScopeApi 下发。
|
||||
但 GameKaleidxScopeResponseVO.cs 里只有 gameKaleidxScopeList 字段,根本没有 userData。
|
||||
真机日志里那一次「GetGameKaleidxScope 的响应带 userData」,是抓包工具按时间相邻
|
||||
记录造成的假象(两条响应被合并显示)。
|
||||
|
||||
调用示例:
|
||||
from api import UserData
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def UserData(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户全量数据(对应 GetUserDataApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
client_id: 机台短值(可选)
|
||||
timeout_ms: 超时毫秒(可选)
|
||||
client: 可复用的 MaimaiNetClient(可选)
|
||||
|
||||
返回:
|
||||
原始响应 dict:{"userId":.., "userData":{..}, "banState":..}
|
||||
"""
|
||||
log_info("取用户全量数据:user_id=" + str(user_id))
|
||||
return _get("GetUserDataApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,222 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
userlogin.py —— 用户正式登录(UserLoginApi)
|
||||
================================================================================
|
||||
|
||||
功能:创建登录会话,返回 loginId / loginDateTime / 登录天数统计。
|
||||
loginId 与 loginDateTime 是后续结算上报与登出的**必需输入**。
|
||||
|
||||
对应接口:UserLoginApi
|
||||
请求体 :10 个字段,见 source/Assembly-CSharp/Net.VO.Mai2/UserLoginRequestVO.cs
|
||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:21-31
|
||||
响应 :UserLoginResponseVO(returnCode=1 表示成功)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 关于 loginDateTime(与后续接口强相关,务必理解)
|
||||
--------------------------------------------------------------------------------
|
||||
真机取的是「本地墙上时间被当作 UTC 时刻」的秒数,即
|
||||
|
||||
loginDateTime = 标准 Unix 时间戳 + 本地时区偏移
|
||||
|
||||
东八区即 now + 28800。
|
||||
|
||||
成因:MAI2.DateTime.cs 里 DateTime.UtcNow + LocalOffset 得到 Kind=Unspecified
|
||||
的值,PacketUserLogin.cs:29 再用 new DateTimeOffset(该值) 又加一次本地偏移。
|
||||
|
||||
影响:PacketUserLogin.cs:43 会把请求值回填响应;
|
||||
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
|
||||
与登录时不一致会被服务端拒绝。
|
||||
|
||||
本模块默认按真机算法生成;需要手动指定时传 login_date_time。
|
||||
|
||||
★ 关于 dateTime 与 loginDateTime 的区别
|
||||
dateTime = ALL.Net 认证时刻(Auth.AuthTime,标准 Unix 秒)
|
||||
loginDateTime= 本次登录时刻(上面的特殊算法)
|
||||
两者都出现在 UserLoginRequestVO 里,但只有 loginDateTime 会被后续接口复用。
|
||||
|
||||
调用示例:
|
||||
from api import UserLogin
|
||||
login = UserLogin(12345678, "TESTTOKEN0000000000", region_id=1, place_id=1000)
|
||||
print(login.loginId, login.loginDateTime)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_LONG_MS
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info
|
||||
from .maimai_net import (
|
||||
MaimaiNetClient,
|
||||
local_utc_offset_seconds,
|
||||
login_date_time_now,
|
||||
request_dict,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserLoginRequestVO:
|
||||
"""请求 VO(对应 UserLoginRequestVO,字段顺序照 C# 声明)。"""
|
||||
userId: int
|
||||
accessCode: str = ""
|
||||
regionId: int = 0
|
||||
placeId: int = 0
|
||||
clientId: str = ""
|
||||
dateTime: int = 0
|
||||
loginDateTime: int = 0
|
||||
isContinue: bool = False
|
||||
genericFlag: int = 0
|
||||
token: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserLoginResponseVO:
|
||||
"""
|
||||
响应 VO(对应 UserLoginResponseVO)。
|
||||
|
||||
★ 后两个字段是**客户端回填**的,服务端不返回:
|
||||
loginDateTime —— PacketUserLogin.cs:43 用请求值回填,
|
||||
UpsertUserAll / UserLogout 都复用它
|
||||
dateTime —— 本次请求发出的 ALL.Net 认证时刻,
|
||||
UpsertUserAll 的 userData.dateTime 要用它
|
||||
(真机 source/2026-10-05.log:123 两处都是 1791134329)
|
||||
"""
|
||||
returnCode: int = 0
|
||||
lastLoginDate: str = ""
|
||||
loginCount: int = 0
|
||||
consecutiveLoginCount: int = 0
|
||||
loginId: int = 0
|
||||
loginDateTime: int = 0
|
||||
token: str = ""
|
||||
dateTime: int = 0
|
||||
|
||||
|
||||
def UserLogin(
|
||||
user_id: int,
|
||||
token: str,
|
||||
*,
|
||||
access_code: str = "",
|
||||
region_id: int = REGION_ID,
|
||||
place_id: int = PLACE_ID,
|
||||
client_id: str = CLIENT_ID,
|
||||
is_continue: bool = False,
|
||||
generic_flag: int = 0,
|
||||
date_time: Optional[int] = None,
|
||||
login_date_time: Optional[int] = None,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> UserLoginResponseVO:
|
||||
"""
|
||||
用户正式登录(创建登录会话)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
token: 会话令牌(必填,来自 ParseQRCode)
|
||||
access_code: 访问码(可选,通常为空)
|
||||
region_id: 区域 ID(可选,默认 config.REGION_ID)
|
||||
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
|
||||
client_id: 机台短值(可选)
|
||||
is_continue: 是否续玩(可选)
|
||||
generic_flag: 通用标志位(可选)
|
||||
date_time: ALL.Net 认证时刻(可选,默认当前 Unix 秒)
|
||||
login_date_time: 本次登录时刻(可选,默认按真机算法生成)
|
||||
timeout_ms: 超时毫秒(可选,默认 60 秒)
|
||||
client: 可复用的 MaimaiNetClient(可选)
|
||||
|
||||
返回:
|
||||
UserLoginResponseVO(loginDateTime 已按真机行为回填)
|
||||
|
||||
异常:
|
||||
RuntimeError: returnCode != 1
|
||||
"""
|
||||
log_info(
|
||||
"用户正式登录:user_id=" + str(user_id)
|
||||
+ " region_id=" + str(region_id) + " place_id=" + str(place_id)
|
||||
)
|
||||
|
||||
now = int(time.time())
|
||||
if date_time is None:
|
||||
date_time = now
|
||||
if login_date_time is None:
|
||||
login_date_time = login_date_time_now()
|
||||
|
||||
log_debug(
|
||||
"时间戳:dateTime=" + str(date_time) + " loginDateTime=" + str(login_date_time)
|
||||
+ "(本地时区偏移 " + str(local_utc_offset_seconds()) + " 秒)"
|
||||
)
|
||||
|
||||
raw = request_dict(
|
||||
"UserLoginApi",
|
||||
UserLoginRequestVO(
|
||||
userId=user_id,
|
||||
accessCode=access_code,
|
||||
regionId=region_id,
|
||||
placeId=place_id,
|
||||
clientId=client_id,
|
||||
dateTime=date_time,
|
||||
loginDateTime=login_date_time,
|
||||
isContinue=is_continue,
|
||||
genericFlag=generic_flag,
|
||||
token=token,
|
||||
),
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_LONG_MS, client=client,
|
||||
)
|
||||
|
||||
known = {f for f in UserLoginResponseVO.__dataclass_fields__}
|
||||
resp = UserLoginResponseVO(**{k: v for k, v in raw.items() if k in known})
|
||||
|
||||
if resp.returnCode != 1:
|
||||
msg = "登录失败:returnCode=" + str(resp.returnCode)
|
||||
log_error(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
# ★ 回填 loginDateTime(对应 PacketUserLogin.cs:43)
|
||||
# 服务端不返回该字段,客户端用请求值回填;后续 UpsertUserAll / UserLogout 复用。
|
||||
if not resp.loginDateTime:
|
||||
resp.loginDateTime = login_date_time
|
||||
log_debug("服务端未返回 loginDateTime,按真机行为回填为 " + str(login_date_time))
|
||||
|
||||
# ★ 回填 dateTime:本次请求实际发出的 ALL.Net 认证时刻。
|
||||
# UpsertUserAll 的 userData.dateTime 要用它(真机两处一致)。
|
||||
resp.dateTime = date_time
|
||||
|
||||
log_info(
|
||||
"用户登录成功:loginId=" + str(resp.loginId)
|
||||
+ " loginDateTime=" + str(resp.loginDateTime)
|
||||
+ " dateTime=" + str(resp.dateTime)
|
||||
+ " lastLoginDate=" + resp.lastLoginDate
|
||||
+ " loginCount=" + str(resp.loginCount)
|
||||
+ " consecutiveLoginCount=" + str(resp.consecutiveLoginCount)
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("userlogin_test")
|
||||
try:
|
||||
if len(sys.argv) < 3:
|
||||
print("用法: python -m api.userlogin <user_id> <token> [region_id] [place_id]")
|
||||
return 1
|
||||
rid = int(sys.argv[3]) if len(sys.argv) > 3 else REGION_ID
|
||||
pid = int(sys.argv[4]) if len(sys.argv) > 4 else PLACE_ID
|
||||
lg = UserLogin(int(sys.argv[1]), sys.argv[2], region_id=rid, place_id=pid)
|
||||
print("")
|
||||
for name in UserLoginResponseVO.__dataclass_fields__:
|
||||
print(" " + name.ljust(24) + "= " + str(getattr(lg, name)))
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log_error("用户登录失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,254 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
userlogout.py —— 用户登出(UserLogoutApi)
|
||||
================================================================================
|
||||
|
||||
功能:结束登录会话,可选上报本次会话的网络统计(delayLog)。
|
||||
|
||||
对应接口:UserLogoutApi
|
||||
请求体 :两种形态,见 source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogout.cs:22-63
|
||||
type == 1 (Logout) -> UserLogoutRequest2VO(含 delayLog)
|
||||
其他 type -> UserLogoutRequestVO (不含 delayLog)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
登出类型(source/Assembly-CSharp/Net.VO.Mai2/LogoutType.cs)
|
||||
--------------------------------------------------------------------------------
|
||||
None = 0
|
||||
Logout = 1 正常登出(带 delayLog 统计)
|
||||
Cancel = 2 取消 / 强制登出指定座席(NetPacketUtil.cs:47)
|
||||
Error = 3 错误强制登出(GameMain.cs:91)
|
||||
TestIn = 4 测试登入 / 顶号(GameMain.cs:104)
|
||||
Quit = 5 退出时强制登出,可释放残留会话(GameMainObject.cs:200)
|
||||
|
||||
★ 真机会话结束时用的是 type=4(TestIn),见 source/2026-10-05.log:125。
|
||||
|
||||
★ delayLog 结构(source/Assembly-CSharp/Net.VO.Mai2/DelayLog.cs)
|
||||
request 是定长 26 的数组(DelayLog.cs:17 new RequestAPI[26]),
|
||||
每个元素 {count, size, msec, retry}。
|
||||
|
||||
调用示例:
|
||||
from api import UserLogout, LogoutType
|
||||
UserLogout(12345678, token, login.loginDateTime,
|
||||
logout_type=LogoutType.TestIn, region_id=1, place_id=1000)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
#: DelayLog.request 的定长(DelayLog.cs:17)
|
||||
DELAY_LOG_SLOTS = 26
|
||||
|
||||
|
||||
class LogoutType:
|
||||
"""登出类型(对应 Net.VO.Mai2.LogoutType)。"""
|
||||
None_ = 0
|
||||
Logout = 1 # 正常登出(带 delayLog)
|
||||
Cancel = 2 # 取消 / 强制登出
|
||||
Error = 3 # 错误强制登出
|
||||
TestIn = 4 # 测试登入 / 顶号(真机会话结束用这个)
|
||||
Quit = 5 # 退出强制登出
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestAPI:
|
||||
"""单个接口的统计(对应 Net.VO.Mai2/RequestAPI.cs)。"""
|
||||
count: int = 0
|
||||
size: int = 0
|
||||
msec: int = 0
|
||||
retry: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DelayLog:
|
||||
"""会话网络统计(对应 Net.VO.Mai2/DelayLog.cs)。"""
|
||||
dlRequests: int = 0
|
||||
dlSize: int = 0
|
||||
dlRetry: int = 0
|
||||
loginMsec: int = 0
|
||||
saveMsec: int = 0
|
||||
reductionMusic: int = 0
|
||||
reductionItem: int = 0
|
||||
request: List[RequestAPI] = field(
|
||||
default_factory=lambda: [RequestAPI() for _ in range(DELAY_LOG_SLOTS)]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserLogoutRequestVO:
|
||||
"""简单登出请求 VO(对应 UserLogoutRequestVO)。"""
|
||||
userId: int
|
||||
accessCode: str = ""
|
||||
regionId: int = 0
|
||||
placeId: int = 0
|
||||
clientId: str = ""
|
||||
loginDateTime: int = 0
|
||||
type: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserLogoutRequest2VO:
|
||||
"""完整登出请求 VO(对应 UserLogoutRequest2VO,含 delayLog)。"""
|
||||
userId: int
|
||||
accessCode: str = ""
|
||||
regionId: int = 0
|
||||
placeId: int = 0
|
||||
clientId: str = ""
|
||||
loginDateTime: int = 0
|
||||
type: int = 0
|
||||
delayLog: Optional[DelayLog] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserLogoutResponseVO:
|
||||
"""响应 VO(对应 UserLogoutResponseVO)。"""
|
||||
returnCode: int = 0
|
||||
|
||||
|
||||
def create_empty_delay_log() -> DelayLog:
|
||||
"""创建全零的 DelayLog(26 个槽位)。"""
|
||||
return DelayLog()
|
||||
|
||||
|
||||
def create_delay_log_from_stats(
|
||||
requests_stats: Optional[List[Dict[str, Any]]] = None,
|
||||
*,
|
||||
dl_requests: int = 0,
|
||||
dl_size: int = 0,
|
||||
dl_retry: int = 0,
|
||||
login_msec: int = 0,
|
||||
save_msec: int = 0,
|
||||
reduction_music: int = 0,
|
||||
reduction_item: int = 0,
|
||||
) -> DelayLog:
|
||||
"""
|
||||
用统计数据构造 DelayLog。
|
||||
|
||||
参数:
|
||||
requests_stats: 每个接口的统计列表,元素形如
|
||||
{"count":1,"size":2090,"msec":73,"retry":0}
|
||||
按 DelayLog.request 的槽位顺序填入(最多 26 个)
|
||||
"""
|
||||
dl = DelayLog(
|
||||
dlRequests=dl_requests, dlSize=dl_size, dlRetry=dl_retry,
|
||||
loginMsec=login_msec, saveMsec=save_msec,
|
||||
reductionMusic=reduction_music, reductionItem=reduction_item,
|
||||
)
|
||||
for i, stat in enumerate(requests_stats or []):
|
||||
if i >= DELAY_LOG_SLOTS:
|
||||
break
|
||||
dl.request[i] = RequestAPI(
|
||||
count=int(stat.get("count", 0)),
|
||||
size=int(stat.get("size", 0)),
|
||||
msec=int(stat.get("msec", 0)),
|
||||
retry=int(stat.get("retry", 0)),
|
||||
)
|
||||
return dl
|
||||
|
||||
|
||||
def UserLogout(
|
||||
user_id: int,
|
||||
token: str,
|
||||
login_date_time: int,
|
||||
*,
|
||||
logout_type: int = LogoutType.TestIn,
|
||||
access_code: str = "",
|
||||
region_id: int = REGION_ID,
|
||||
place_id: int = PLACE_ID,
|
||||
client_id: str = CLIENT_ID,
|
||||
delay_log: Optional[DelayLog] = None,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> UserLogoutResponseVO:
|
||||
"""
|
||||
用户登出。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
token: 会话令牌(必填)
|
||||
login_date_time: 登录时刻(必填,取自 UserLogin 的 loginDateTime)
|
||||
logout_type: 登出类型(可选,默认 4=TestIn,与真机一致)
|
||||
access_code: 访问码(可选)
|
||||
region_id: 区域 ID(可选)
|
||||
place_id: 店铺 ID(可选)
|
||||
client_id: 机台短值(可选)
|
||||
delay_log: 会话统计(可选;仅 logout_type=1 时随报文发出)
|
||||
timeout_ms: 超时毫秒(可选)
|
||||
client: 可复用的 MaimaiNetClient(可选)
|
||||
|
||||
返回:
|
||||
UserLogoutResponseVO
|
||||
|
||||
异常:
|
||||
RuntimeError: returnCode != 1
|
||||
"""
|
||||
log_info(
|
||||
"用户登出:user_id=" + str(user_id) + " logout_type=" + str(logout_type)
|
||||
+ " loginDateTime=" + str(login_date_time)
|
||||
)
|
||||
|
||||
if logout_type == LogoutType.Logout:
|
||||
body: Any = UserLogoutRequest2VO(
|
||||
userId=user_id, accessCode=access_code,
|
||||
regionId=region_id, placeId=place_id, clientId=client_id,
|
||||
loginDateTime=login_date_time, type=logout_type,
|
||||
delayLog=delay_log if delay_log is not None else create_empty_delay_log(),
|
||||
)
|
||||
log_debug("使用完整登出报文(含 delayLog)")
|
||||
else:
|
||||
body = UserLogoutRequestVO(
|
||||
userId=user_id, accessCode=access_code,
|
||||
regionId=region_id, placeId=place_id, clientId=client_id,
|
||||
loginDateTime=login_date_time, type=logout_type,
|
||||
)
|
||||
log_debug("使用简单登出报文(不含 delayLog)")
|
||||
|
||||
raw = request_dict(
|
||||
"UserLogoutApi", body,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
|
||||
resp = UserLogoutResponseVO(returnCode=int(raw.get("returnCode", 0) or 0))
|
||||
if resp.returnCode != 1:
|
||||
msg = "登出失败:returnCode=" + str(resp.returnCode)
|
||||
log_error(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
log_info("用户登出成功:returnCode=" + str(resp.returnCode))
|
||||
return resp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("userlogout_test")
|
||||
try:
|
||||
if len(sys.argv) < 4:
|
||||
print("用法: python -m api.userlogout <user_id> <token> <loginDateTime> "
|
||||
"[logout_type] [region_id] [place_id]")
|
||||
return 1
|
||||
lt = int(sys.argv[4]) if len(sys.argv) > 4 else LogoutType.TestIn
|
||||
rid = int(sys.argv[5]) if len(sys.argv) > 5 else REGION_ID
|
||||
pid = int(sys.argv[6]) if len(sys.argv) > 6 else PLACE_ID
|
||||
resp = UserLogout(int(sys.argv[1]), sys.argv[2], int(sys.argv[3]),
|
||||
logout_type=lt, region_id=rid, place_id=pid)
|
||||
print("")
|
||||
print("returnCode =", resp.returnCode)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log_error("用户登出失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,156 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
userpreview.py —— 用户预览(GetUserPreviewApi)
|
||||
================================================================================
|
||||
|
||||
功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。
|
||||
|
||||
对应接口:GetUserPreviewApi
|
||||
请求体 :{"userId":.., "segaIdAuthKey":"", "token":"..", "clientId":".."}
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserPreviewRequestVO.cs
|
||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserPreview.cs:20-25
|
||||
响应 :见 UserPreviewResponseVO(23 字段,含 errorId)
|
||||
|
||||
★ 真机调用时机:扫码拿到 userId/token 之后、UserLoginApi 之前
|
||||
(source/2026-10-05.log:23)。
|
||||
★ 客户端行为:isLogin=True 时拒绝继续登录,避免顶号
|
||||
(source/Assembly-CSharp/Process.Entry.State/ConfirmPlay.cs:151)。
|
||||
|
||||
调用示例:
|
||||
from api import UserPreview
|
||||
pv = UserPreview(12345678, "TESTTOKEN0000000000")
|
||||
print(pv.userName, pv.playerRating)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserPreviewRequestVO:
|
||||
"""请求 VO(对应 UserPreviewRequestVO)。"""
|
||||
userId: int
|
||||
segaIdAuthKey: str = ""
|
||||
token: str = ""
|
||||
clientId: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserPreviewResponseVO:
|
||||
"""
|
||||
响应 VO(对应 UserPreviewResponseVO + 真机响应字段)。
|
||||
|
||||
★ 字段顺序按 source/Assembly-CSharp/Net.VO.Mai2/UserPreviewResponseVO.cs
|
||||
与 source/2026-10-05.log:24 的真机响应合并。
|
||||
"""
|
||||
userId: int = 0
|
||||
userName: str = ""
|
||||
isLogin: bool = False
|
||||
lastGameId: Optional[str] = None
|
||||
lastRomVersion: str = ""
|
||||
lastDataVersion: str = ""
|
||||
lastLoginDate: str = ""
|
||||
lastPlayDate: str = ""
|
||||
playerRating: int = 0
|
||||
nameplateId: int = 0
|
||||
iconId: int = 0
|
||||
trophyId: int = 0
|
||||
partnerId: int = 0
|
||||
frameId: int = 0
|
||||
dispRate: int = 0
|
||||
totalAwake: int = 0
|
||||
isNetMember: int = 0
|
||||
dailyBonusDate: str = ""
|
||||
headPhoneVolume: Optional[int] = None
|
||||
isInherit: bool = False
|
||||
banState: int = 0
|
||||
errorId: int = 0
|
||||
|
||||
|
||||
def UserPreview(
|
||||
user_id: int,
|
||||
token: str,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
sega_id_auth_key: str = "",
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> UserPreviewResponseVO:
|
||||
"""
|
||||
获取用户预览信息。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
token: 会话令牌(必填,来自 ParseQRCode)
|
||||
client_id: 机台短值(可选)
|
||||
sega_id_auth_key: SEGA ID 鉴权 key(可选,通常为空)
|
||||
timeout_ms: 超时毫秒(可选)
|
||||
client: 可复用的 MaimaiNetClient(可选)
|
||||
|
||||
返回:
|
||||
UserPreviewResponseVO
|
||||
"""
|
||||
log_info("获取用户预览:user_id=" + str(user_id) + " client_id=" + client_id)
|
||||
|
||||
raw = request_dict(
|
||||
"GetUserPreviewApi",
|
||||
UserPreviewRequestVO(
|
||||
userId=user_id,
|
||||
segaIdAuthKey=sega_id_auth_key,
|
||||
token=token,
|
||||
clientId=client_id,
|
||||
),
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
|
||||
known = {f for f in UserPreviewResponseVO.__dataclass_fields__}
|
||||
resp = UserPreviewResponseVO(**{k: v for k, v in raw.items() if k in known})
|
||||
|
||||
if resp.errorId != 0:
|
||||
log_warning("服务器返回 errorId=" + str(resp.errorId))
|
||||
if resp.banState != 0:
|
||||
log_warning("账号可能被封禁:banState=" + str(resp.banState))
|
||||
if resp.isLogin:
|
||||
log_warning("账号处于已登录状态(isLogin=True),继续登录会顶号")
|
||||
|
||||
log_info(
|
||||
"用户预览获取完成:userName=" + resp.userName
|
||||
+ " playerRating=" + str(resp.playerRating)
|
||||
+ " isLogin=" + str(resp.isLogin)
|
||||
+ " isNetMember=" + str(resp.isNetMember)
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .log_setup import log_error, setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("userpreview_test")
|
||||
try:
|
||||
if len(sys.argv) < 3:
|
||||
print("用法: python -m api.userpreview <user_id> <token> [client_id]")
|
||||
return 1
|
||||
cid = sys.argv[3] if len(sys.argv) > 3 else CLIENT_ID
|
||||
pv = UserPreview(int(sys.argv[1]), sys.argv[2], client_id=cid)
|
||||
print("")
|
||||
for name in UserPreviewResponseVO.__dataclass_fields__:
|
||||
print(" " + name.ljust(18) + "= " + str(getattr(pv, name)))
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log_error("获取用户预览失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
Reference in New Issue
Block a user