v0 plus附加功能

- 新增 unlockmusic.py:解锁(解禁)歌曲 + 解禁 Master / Re:Master 难度,整个流程复用 upsert.py 的 run()
- 买票前置闸门 api/ChargeGuard.py:必须先跑过 GetUserCharge,且同进程只允许买一次
- buy_ticket.py 票源改为 GetUserCharge,不再调用 GetGameCharge
- UpsertUserAll 与买票的冷却统一为 15 秒
- api/ 文件名统一 PascalCase(文件名 = 函数名)
- 项目改名 Astral,README 加 Logo、鸣谢与开发者说明
- 自检 284 项
This commit is contained in:
2026-10-17 17:39:55 +08:00
parent b1d7e33c7b
commit 25ac522391
48 changed files with 1137 additions and 348 deletions
+150
View File
@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
"""
ChargeGuard.py —— 买票前置闸门(GetUserCharge 必须已经跑过)
================================================================================
★ 这个模块解决什么问题:
````
错误写法(危险):
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
# ↑ 没先查 GetUserCharge —— 万一账号已经持有一张功能票,
# 服务端看到的就是「重复购买」,有封号风险。
```
正确写法:
```
held = GetUserCharge(user_id) # ① 先查:有没有持票
...
UpsertUserChargelog(user_id, 2, ...) # ② 再买
```
本模块在 ① 里记一笔「这个 user_id 刚查过票券」,在 ② 里检查这笔记录,
没查过就抛 `ChargelogGuardError`(**未发包**)。
★ 还顺手挡第二件事:**同一个账号在同一个进程里只允许买一次票**。
真机买票是「买完立刻被这一局用掉」(stock--),正常状态 stock 恒为 0;
脚本连着买两张是明显的异常行为,这里直接拦掉。
★ 记忆只存在于**当前进程**(内存字典),脚本退出即清空 —— 不做持久化,
免得下次运行时被上一次的记录误拦。
依据:
source/Assembly-CSharp/Manager/TicketManager.cs:130-174 UseTicketPrepare
source/Assembly-CSharp/Manager/TicketManager.cs:234-253 RequestUserCharge
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserCharge.cs:20
source/Assembly-CSharp/Net.Packet.Mai2/PacketUploadUserChargelog.cs:19
"""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
#: GetUserCharge 结果的「新鲜度」窗口(秒)。
#: 超过这个时间才买票,认为那次查询已经过期,需要重查。
CHARGE_READ_FRESH_SEC = 600.0
#: user_id -> 最近一次 GetUserCharge 的 monotonic 时刻
_LAST_READ: Dict[int, float] = {}
#: user_id -> 最近一次 GetUserCharge 返回的 userChargeList(原样)
_LAST_LIST: Dict[int, List[Dict[str, Any]]] = {}
#: user_id -> 本进程内已经买过的 chargeId 列表
_PURCHASES: Dict[int, List[int]] = {}
# =============================================================================
# 一、GetUserCharge 侧:记一笔
# =============================================================================
def note_user_charge_read(
user_id: int,
charge_list: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""
记录「刚刚为这个 user_id 查过票券」。
★ 由 GetUserCharge() 在拿到响应后自动调用,调用方不用管。
"""
try:
uid = int(user_id)
except (TypeError, ValueError):
return
_LAST_READ[uid] = time.monotonic()
if isinstance(charge_list, list):
_LAST_LIST[uid] = [c for c in charge_list if isinstance(c, dict)]
def last_read_age(user_id: int) -> Optional[float]:
"""距上次 GetUserCharge 过了多少秒;从没查过返回 None。"""
try:
uid = int(user_id)
except (TypeError, ValueError):
return None
ts = _LAST_READ.get(uid)
if ts is None:
return None
return max(0.0, time.monotonic() - ts)
def has_fresh_read(
user_id: int,
max_age: float = CHARGE_READ_FRESH_SEC,
) -> bool:
"""这个 user_id 最近(max_age 秒内)查过 GetUserCharge 吗?"""
age = last_read_age(user_id)
if age is None:
return False
return age <= float(max_age)
def last_charge_list(user_id: int) -> List[Dict[str, Any]]:
"""最近一次 GetUserCharge 返回的 userChargeList(没有则空表)。"""
try:
return list(_LAST_LIST.get(int(user_id)) or [])
except (TypeError, ValueError):
return []
def held_charges(user_id: int) -> List[Dict[str, Any]]:
"""最近一次查到的「持有中」(stock > 0)的票券。"""
return [c for c in last_charge_list(user_id) if int(c.get("stock", 0) or 0) > 0]
# =============================================================================
# 二、UpsertUserChargelog 侧:买之前查
# =============================================================================
def note_charge_purchase(user_id: int, charge_id: int) -> None:
"""记录「这个 user_id 刚刚买了一张 chargeId 的票」。"""
try:
uid = int(user_id)
except (TypeError, ValueError):
return
_PURCHASES.setdefault(uid, []).append(int(charge_id))
def purchased_charge_ids(user_id: int) -> List[int]:
"""本进程内这个 user_id 已经买过的 chargeId。"""
try:
return list(_PURCHASES.get(int(user_id)) or [])
except (TypeError, ValueError):
return []
def reset(user_id: Optional[int] = None) -> None:
"""清空记忆(测试用;不传 user_id 就全清)。"""
if user_id is None:
_LAST_READ.clear()
_LAST_LIST.clear()
_PURCHASES.clear()
return
try:
uid = int(user_id)
except (TypeError, ValueError):
return
_LAST_READ.pop(uid, None)
_LAST_LIST.pop(uid, None)
_PURCHASES.pop(uid, None)
+3 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
chime.py —— 扫码登录(二维码 -> userId + token)
Chime.py —— 扫码登录(二维码 -> userId + token)
================================================================================
功能:把微信二维码串(或二维码 URL)交给 aimedb 服务器,换取 userId 与 token。
@@ -76,7 +76,7 @@ QR_OPEN_GAME_ID = "MAID"
QR_TITLE_KEY = "SDGB"
QR_USER_AGENT = "WC_AIME_LIB"
from .log_setup import log_debug, log_error, log_info, log_request, log_response
from .LogSetup import log_debug, log_error, log_info, log_request, log_response
# =============================================================================
# 二维码格式常量(CCommQRCode.cs + 技术规范)
@@ -288,7 +288,7 @@ def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int,
if __name__ == "__main__":
import sys
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("chime_test")
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_game_charge.py —— 取游戏计费配置(GetGameChargeApi)
GetGameCharge.py —— 取游戏计费配置(GetGameChargeApi)
================================================================================
取服务器下发的计费档位。
@@ -22,8 +22,8 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_game_settings.py —— 获取游戏设置(GetGameSettingApi)
GetGameSettings.py —— 获取游戏设置(GetGameSettingApi)
================================================================================
功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。
@@ -25,8 +25,8 @@ 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
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
# =============================================================================
@@ -154,7 +154,7 @@ def is_ping_disabled(settings: GameSettingResponseVO) -> bool:
# =============================================================================
if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("gamesettings_test")
@@ -166,7 +166,7 @@ if __name__ == "__main__":
print(" " + name.ljust(34) + "= " + str(getattr(gs, name)))
return 0
except Exception as exc:
from .log_setup import log_error
from .LogSetup import log_error
log_error("获取游戏设置失败:" + str(exc))
return 1
finally:
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
"""
get_need_user_data.py —— 结算快照编排(统一调用各取数脚本)
GetNeedUserData.py —— 结算快照编排(统一调用各取数脚本)
================================================================================
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(get_user_data.py / get_user_map.py …)。
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(GetUserData.py / GetUserMap.py …)。
--------------------------------------------------------------------------------
为什么需要快照
@@ -41,29 +41,29 @@ 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 .LogSetup import log_debug, log_info, log_warning
from .MaimaiNet import MaimaiNetClient
# 各取数脚本(一个接口一个脚本)
from .get_user_data import GetUserData
from .get_user_extend import GetUserExtend
from .get_user_option import GetUserOption
from .get_user_character import GetUserCharacter
from .get_user_ghost import GetUserGhost
from .get_user_map import GetUserMapAll
from .get_user_login_bonus import GetUserLoginBonusAll
from .get_user_rating import GetUserRating
from .get_user_item import GetUserItemAll
from .get_user_music import GetUserMusicDetailAll
from .get_user_course import GetUserCourseAll
from .get_user_charge import GetUserCharge
from .get_user_favorite import GetUserFavoriteAll
from .get_user_activity import GetUserActivity
from .get_user_mission import GetUserMissionData
from .get_user_intimate import GetUserIntimate
from .get_user_shop_stock import GetUserShopStock
from .get_user_favorite_item import GetUserFavoriteItemAll
from .get_user_kaleidx_scope import GetUserKaleidxScope
from .GetUserData import GetUserData
from .GetUserExtend import GetUserExtend
from .GetUserOption import GetUserOption
from .GetUserCharacter import GetUserCharacter
from .GetUserGhost import GetUserGhost
from .GetUserMap import GetUserMapAll
from .GetUserLoginBonus import GetUserLoginBonusAll
from .GetUserRating import GetUserRating
from .GetUserItem import GetUserItemAll
from .GetUserMusic import GetUserMusicDetailAll
from .GetUserCourse import GetUserCourseAll
from .GetUserCharge import GetUserCharge
from .GetUserFavorite import GetUserFavoriteAll
from .GetUserActivity import GetUserActivity
from .GetUserMission import GetUserMissionData
from .GetUserIntimate import GetUserIntimate
from .GetUserShopStock import GetUserShopStock
from .GetUserFavoriteItem import GetUserFavoriteItemAll
from .GetUserKaleidxScope import GetUserKaleidxScope
# =============================================================================
@@ -372,7 +372,7 @@ def summarize_snapshot(snapshot: Dict[str, Any]) -> Dict[str, int]:
if __name__ == "__main__":
import sys
from .log_setup import log_error, setup_logging, teardown_logging
from .LogSetup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("get_need_user_data_test")
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_activity.py —— 取用户活动记录(GetUserActivityApi)
GetUserActivity.py —— 取用户活动记录(GetUserActivityApi)
================================================================================
取玩家的活动/成就记录(playList 与 musicList 两个列表)。
@@ -25,9 +25,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserActivity(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_character.py —— 取用户角色列表(GetUserCharacterApi)
GetUserCharacter.py —— 取用户角色列表(GetUserCharacterApi)
================================================================================
取玩家所有角色的等级与觉醒度(真机 312 条)。
@@ -23,9 +23,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserCharacter(
+63
View File
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""
GetUserCharge.py —— 取用户票券(GetUserChargeApi)
================================================================================
取玩家的票券持有情况(真机 3 条)。
对应接口:GetUserChargeApi
请求体 :UserIdRequestVO
响应 :{userId, length, userChargeList:[{chargeId,stock,purchaseDate,validDate,extNum1}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserChargeRequestVO.cs
★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。
★ 调用本接口会**登记一次「票券已查询」**(见 api/ChargeGuard.py)。
UpsertUserChargelog 发包前会检查这笔登记 —— 没查过票就买票会被直接拒绝。
调用示例:
from api import GetUserCharge
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .ChargeGuard import note_user_charge_read
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserCharge(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户票券(对应 GetUserChargeApi)。
返回:
原始响应 dict,含 userChargeList
"""
log_info("取用户票券:user_id=" + str(user_id))
raw = _get("GetUserChargeApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
# ★ 记一笔「这个账号刚查过票券」——
# UpsertUserChargelog 发包前会检查这笔记录,没查过就拒绝购买。
# 见 api/ChargeGuard.py 的说明。
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
note_user_charge_read(user_id, lst)
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
if held:
log_info("★ 该账号当前持有 " + str(len(held)) + " 张功能票:"
+ "、".join("chargeId=" + str(c.get("chargeId"))
+ " stock=" + str(c.get("stock")) for c in held))
else:
log_info("该账号当前没有持有功能票")
return raw
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_course.py —— 取用户课题模式成绩(GetUserCourseApi)
GetUserCourse.py —— 取用户课题模式成绩(GetUserCourseApi)
================================================================================
取玩家的 COURSE(课题)模式通关记录(真机 16 条)。
@@ -21,9 +21,9 @@ 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, get_all_pages
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserCourseRequestVO, get_all_pages
def GetUserCourse(
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_data.py —— 取用户全量数据(GetUserDataApi)
GetUserData.py —— 取用户全量数据(GetUserDataApi)
================================================================================
取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。
@@ -29,9 +29,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserData(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_extend.py —— 取用户扩展设置(GetUserExtendApi)
GetUserExtend.py —— 取用户扩展设置(GetUserExtendApi)
================================================================================
取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。
@@ -23,9 +23,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserExtend(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_favorite.py —— 取用户收藏(GetUserFavoriteApi)
GetUserFavorite.py —— 取用户收藏(GetUserFavoriteApi)
================================================================================
取玩家各类收藏夹(称号 / 头像 / 相框 / 搭档 / 背景…)。
@@ -24,9 +24,9 @@ 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
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
def GetUserFavorite(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
GetUserFavoriteItem.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
================================================================================
取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。
@@ -24,9 +24,9 @@ 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, get_all_pages
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserFavoriteItemRequestVO, get_all_pages
#: kind 取值
FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi)
GetUserFriendBonus.py —— 取用户好友里程(GetUserFriendBonusApi)
================================================================================
取玩家本次可领取的好友里程(getMiles)。
@@ -21,9 +21,9 @@ 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
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserFriendBonus(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_ghost.py —— 取用户幽灵数据(GetUserGhostApi)
GetUserGhost.py —— 取用户幽灵数据(GetUserGhostApi)
================================================================================
取玩家的「幽灵」对战记录(真机 15 条)。
@@ -24,9 +24,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserGhost(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
GetUserIntimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
================================================================================
取玩家与各搭档的亲密度(真机 2 条)。
@@ -20,9 +20,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserIntimate(
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_item.py —— 取用户道具(GetUserItemApi)
GetUserItem.py —— 取用户道具(GetUserItemApi)
================================================================================
取玩家各类道具的库存(真机按 kind 分 12 次请求)。
@@ -38,9 +38,9 @@ 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
from .LogSetup import log_debug, log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR
def GetUserItem(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_kaleidx_scope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
GetUserKaleidxScope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
================================================================================
对应接口:
@@ -17,7 +17,7 @@ get_user_kaleidx_scope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi
★★ 重要区分(曾导致 userData 取空)
--------------------------------------------------------------------------------
GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。
玩家档案 userData 的正确来源是 GetUserDataApi(见 get_user_data.py)。
玩家档案 userData 的正确来源是 GetUserDataApi(见 GetUserData.py)。
真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的
两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId,
@@ -33,9 +33,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, EmptyRequestVO, UserIdRequestVO
def GetUserKaleidxScope(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
GetUserLoginBonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
================================================================================
取玩家的登录奖励领取进度(真机 38 条,分两页)。
@@ -22,9 +22,9 @@ 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, get_all_pages
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserLoginBonusRequestVO, get_all_pages
def GetUserLoginBonus(
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_map.py —— 取用户地图进度(GetUserMapApi)
GetUserMap.py —— 取用户地图进度(GetUserMapApi)
================================================================================
取玩家各地图的推进距离(真机 104 条)。
@@ -23,9 +23,9 @@ 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, get_all_pages
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdPageRequestVO, get_all_pages
def GetUserMap(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi)
GetUserMission.py —— 取用户任务与周数据(GetUserMissionDataApi)
================================================================================
取玩家的任务进度与周常数据(真机 6 条任务)。
@@ -25,9 +25,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserMissionData(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_music.py —— 取用户曲目成绩(GetUserMusicApi)
GetUserMusic.py —— 取用户曲目成绩(GetUserMusicApi)
================================================================================
取玩家全部曲目的历史成绩归档(真机 739 组)。
@@ -27,9 +27,9 @@ 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, get_all_pages
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserMusicRequestVO, get_all_pages
def GetUserMusicDetail(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_new_item.py —— 取新增道具(GetUserNewItemListApi)
GetUserNewItem.py —— 取新增道具(GetUserNewItemListApi)
================================================================================
上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。
@@ -25,8 +25,8 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_option.py —— 取用户游戏设置(GetUserOptionApi)
GetUserOption.py —— 取用户游戏设置(GetUserOptionApi)
================================================================================
取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。
@@ -24,9 +24,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserOption(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_preview.py —— 用户预览(GetUserPreviewApi)
GetUserPreview.py —— 用户预览(GetUserPreviewApi)
================================================================================
功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。
@@ -29,8 +29,8 @@ 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
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass
@@ -133,7 +133,7 @@ def GetUserPreview(
if __name__ == "__main__":
import sys
from .log_setup import log_error, setup_logging, teardown_logging
from .LogSetup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("userpreview_test")
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_rating.py —— 取用户 Rating 明细(GetUserRatingApi)
GetUserRating.py —— 取用户 Rating 明细(GetUserRatingApi)
================================================================================
取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。
@@ -23,9 +23,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRating(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_recommend_music.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
GetUserRecommendMusic.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
================================================================================
对应接口:
@@ -23,9 +23,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRecommendRateMusic(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_region.py —— 取用户地区游玩统计(GetUserRegionApi)
GetUserRegion.py —— 取用户地区游玩统计(GetUserRegionApi)
================================================================================
取玩家在各地区的游玩次数。
@@ -21,9 +21,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRegion(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_rival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
GetUserRival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
================================================================================
取玩家的对手名单及其曲目成绩。
@@ -28,9 +28,9 @@ 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 (
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import (
_get,
UserRivalRequestVO,
UserRivalMusicRequestVO,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
get_user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi)
GetUserShopStock.py —— 取用户商店库存(GetUserShopStockApi)
================================================================================
取玩家在各商店商品的已兑换次数。
@@ -24,9 +24,9 @@ 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
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserShopStockRequestVO
def GetUserShopStock(
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
log_setup.py —— 统一日志模块(INFO / DEBUG 双模式)
LogSetup.py —— 统一日志模块(INFO / DEBUG 双模式)
================================================================================
【两种模式】
+2 -2
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
maimai_net.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
MaimaiNet.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
================================================================================
本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。
@@ -133,7 +133,7 @@ AES_IV = b"F>;24DjU9W6ZsRH["
#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
OBFUSCATE_PARAM = "8bF76dE9"
from .log_setup import log_debug, log_error, log_request, log_response
from .LogSetup import log_debug, log_error, log_request, log_response
# =============================================================================
# 协议常量
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
ping.py —— 心跳探活(PingMaimaiChn)
Ping.py —— 心跳探活(PingMaimaiChn)
================================================================================
功能:向游戏服务器发一次心跳,确认链路可达。
@@ -41,8 +41,8 @@ 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
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
def Ping(
@@ -80,7 +80,7 @@ def Ping(
if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("ping_test")
+168 -9
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
upsert_user_all.py —— 上传成绩(UpsertUserAllApi)
UpsertUserAll.py —— 上传成绩(UpsertUserAllApi)
================================================================================
功能:把一局游玩的完整结算数据写进服务器。这是**唯一有副作用**的接口。
@@ -158,8 +158,8 @@ PLAY_AFTER_GRADE_RANK = 1
#: True = 从曲目记录推(仅调试用)
DERIVE_GRADE_FROM_MUSIC_DETAIL = False
from .log_setup import log_debug, log_error, log_info, log_warning
from .maimai_net import (
from .LogSetup import log_debug, log_error, log_info, log_warning
from .MaimaiNet import (
MaimaiNetClient,
local_utc_offset_seconds,
login_date_time_now,
@@ -552,7 +552,7 @@ def build_favorite_delta(
"2" = 服务器侧有对应 itemKind(无论内容是否一致)
"1" = 服务器侧查不到该 itemKind
★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 get_user_favorite.py
★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 GetUserFavorite.py
的「补 itemKind」逻辑归一化 —— 真机里 itemKind=10 的响应回的是
{"itemKind":null, "itemIdList":[]},正因为查不到才自然产生 "1"。
@@ -1894,6 +1894,117 @@ def apply_play_effects(
# 十二、组装 upsertUserAll
# =============================================================================
# -----------------------------------------------------------------------------
# ★ 歌曲解锁(解禁):MusicUnlock* 不是独立节点,而是并入 userItemList 的 UserItem
# -----------------------------------------------------------------------------
# 依据 source/Assembly-CSharp/Manager/UserData.cs:132-138 —— 客户端有四个解锁列表:
# MusicUnlockList / MusicUnlockMasterList / MusicUnlockReMasterList / MusicUnlockStrongList
# 它们**没有**对应的 userXxxList 节点,导出时统一并进 userItemList:
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:981-1008
# MusicUnlockList -> itemKind = 5
# MusicUnlockMasterList -> itemKind = 6
# MusicUnlockReMasterList -> itemKind = 7
# MusicUnlockStrongList -> itemKind = 8
# 条目形如 {"itemKind":5,"itemId":1085,"stock":1,"isValid":true}
# (真机 data/12570152.json 的 userItemList 里 kind5=77 条、kind6=446 条、kind7=34 条)
#
# ★ 难度解锁的「天生免费」规则:
# source/Assembly-CSharp/Manager/NotesListManager.cs:415-453 IsUnlockMaster
# source/Assembly-CSharp/Manager/NotesListManager.cs:455-494 IsUnlockReMaster
# 两个函数开头都有 `if (id < 10000) return true;`
# -> musicId < 10000(标准谱)的 Master / Re:Master **天生解锁**,不必写 kind6/7;
# 只有 musicId >= 10000(DX 谱)才需要额外解锁。
# ★ 另外 Re:Master 还受活动开关约束(IsUnlockReMaster 里的
# `if (!instance.IsOpenEvent(subEventId)) return false;`),
# 那是机台/服务端侧的开关,脚本改不了 —— 写了 kind7 也可能进游戏仍是灰的。
#
# ★ 写 kind5 只是「把这首歌标记为已解锁」,不携带任何成绩;
# 成绩另由 userMusicDetailList 承载,纯解锁不需要打歌。
#: UserItem.itemKind 取值(source/Assembly-CSharp/Manager/UserData.cs:132-138)
ITEM_KIND_MUSIC_UNLOCK = 5
ITEM_KIND_MUSIC_UNLOCK_MASTER = 6
ITEM_KIND_MUSIC_UNLOCK_REMASTER = 7
ITEM_KIND_MUSIC_UNLOCK_STRONG = 8
#: 小于这个 musicId 的谱面 Master / Re:Master 天生解锁(NotesListManager.cs:415/455)
UNLOCK_MASTER_MIN_MUSIC_ID = 10000
def build_unlock_items(
music_ids: Sequence[int],
existing: Optional[Sequence[Any]] = None,
*,
master: bool = True,
remaster: bool = True,
unlock_base: bool = True,
) -> Tuple[List[Dict[str, Any]], List[str]]:
"""
构造「解锁歌曲 / 解禁难度」要追加的 userItemList 条目。
★ 只返回**服务器上还没有**的条目;已存在的跳过(重复插入会让服务端报错)。
参数:
music_ids: 要解锁的 musicId 列表(重复的自动去掉)
existing: 服务器现有的 userItemList(GetUserData 里的那份),
用来判断哪些条目已经存在
master: True = 同时解锁 Master 难度(仅 musicId >= 10000 才写 kind6)
remaster: True = 同时解锁 Re:Master 难度(仅 musicId >= 10000 才写 kind7)
unlock_base: True = 解锁歌曲本体(kind5)
返回:
(条目列表, 说明文字列表)
条目形如 {"itemKind":5,"itemId":1085,"stock":1,"isValid":true}
"""
have: set = set()
for it in existing or []:
if isinstance(it, dict):
try:
have.add((int(it.get("itemKind")), int(it.get("itemId"))))
except (TypeError, ValueError):
continue
ids: List[int] = []
for raw in music_ids or []:
try:
mid = int(raw)
except (TypeError, ValueError):
continue
if mid <= 0 or mid in ids:
continue
ids.append(mid)
items: List[Dict[str, Any]] = []
notes: List[str] = []
def _add(kind: int, mid: int, what: str) -> None:
if (kind, mid) in have:
notes.append("解锁检查:musicId=" + str(mid) + " 的" + what
+ "已经解锁(itemKind=" + str(kind) + "),跳过")
return
have.add((kind, mid))
items.append({"itemKind": kind, "itemId": mid, "stock": 1, "isValid": True})
notes.append("解锁:musicId=" + str(mid) + " " + what
+ "(itemKind=" + str(kind) + ")")
for mid in ids:
if unlock_base:
_add(ITEM_KIND_MUSIC_UNLOCK, mid, "歌曲本体")
if mid < UNLOCK_MASTER_MIN_MUSIC_ID:
# ★ 标准谱(musicId < 10000)Master/Re:Master 天生解锁,不需要条目
if master or remaster:
notes.append("解锁检查:musicId=" + str(mid)
+ " 是标准谱(< " + str(UNLOCK_MASTER_MIN_MUSIC_ID)
+ "),Master/Re:Master 天生解锁,跳过")
continue
if master:
_add(ITEM_KIND_MUSIC_UNLOCK_MASTER, mid, "Master 难度")
if remaster:
_add(ITEM_KIND_MUSIC_UNLOCK_REMASTER, mid, "Re:Master 难度")
return items, notes
def build_user_all(
snapshot: Optional[Any] = None,
play: Optional[PlayData] = None,
@@ -1909,6 +2020,9 @@ def build_user_all(
playlog_id: int = 0,
play_date_time: Optional[int] = None,
extra_data: Optional[Any] = None,
unlock_music_ids: Optional[Sequence[int]] = None,
unlock_master: bool = True,
unlock_remaster: bool = True,
) -> Dict[str, Any]:
"""
组装 upsertUserAll 的 36 个节点。
@@ -1932,6 +2046,10 @@ def build_user_all(
playlog_id: UserLoginApi 的 loginId(用于 userGamePlaylogList)
extra_data: ★ 额外 JSON(dict / JSON 字符串 / 文件路径),
自动匹配插入 36 节点,**优先级高于 patch**。见 merge_extra_data。
unlock_music_ids: ★ 要解锁(解禁)的 musicId 列表。见 build_unlock_items。
写进 userItemList:kind5=歌曲本体、kind6=Master、kind7=Re:Master。
unlock_master: 解锁时是否一并解禁 Master(仅 DX 谱 musicId >= 10000 生效)
unlock_remaster: 解锁时是否一并解禁 Re:Master(同上)
返回:
36 节点的 dict
@@ -2039,6 +2157,34 @@ def build_user_all(
for note in merge_extra_data(user_all, extra_data):
log_info(note)
# ---- ★ 歌曲解锁(解禁)----
# 放在 extra_data 之后:extra_data 可能整体替换了 userItemList,
# 解锁条目追加到最终列表末尾,位串补 "1"(= 服务端侧新增该 item)。
# 依据见本文件「歌曲解锁」注释块。
if unlock_music_ids:
unlock_items, unlock_notes = build_unlock_items(
unlock_music_ids,
existing=user_all.get("userItemList") or [],
master=unlock_master,
remaster=unlock_remaster,
)
for note in unlock_notes:
log_info(note)
if unlock_items:
old_items = list(user_all.get("userItemList") or [])
old_bits = user_all.get("isNewItemList")
if not isinstance(old_bits, str):
old_bits = ""
if len(old_bits) != len(old_items):
# 先对齐到旧列表长度,避免新条目被 sync_bits_to_arrays 补成 "0"
old_bits = (old_bits + "0" * len(old_items))[:len(old_items)]
user_all["userItemList"] = old_items + unlock_items
user_all["isNewItemList"] = old_bits + "1" * len(unlock_items)
log_info("★ 本次解锁共新增 " + str(len(unlock_items))
+ " 个 userItem 条目(位串补 " + "1" * len(unlock_items) + ")")
else:
log_info("解锁检查:指定的歌曲/难度在服务器上都已经解锁,无需改动")
# ---- 清理内部字段 ----
for key in list(user_all.keys()):
if key.startswith("_"):
@@ -2350,16 +2496,16 @@ def _resolve_api_func(name: str, fallback: Any = None) -> Any:
def _lazy(name: str) -> Any:
"""按名字延迟取 api 子模块里的原始函数(_resolve_api_func 的兜底)。"""
if name == "GetNeedUserData":
from .get_need_user_data import GetNeedUserData
from .GetNeedUserData import GetNeedUserData
return GetNeedUserData
if name == "GetUserNewItemList":
from .get_user_new_item import GetUserNewItemList
from .GetUserNewItem import GetUserNewItemList
return GetUserNewItemList
if name == "GetUserData":
from .get_user_data import GetUserData
from .GetUserData import GetUserData
return GetUserData
if name == "GetUserMusicDetailAll":
from .get_user_music import GetUserMusicDetailAll
from .GetUserMusic import GetUserMusicDetailAll
return GetUserMusicDetailAll
raise PatchError("未知的内部 api:" + str(name))
@@ -2478,6 +2624,9 @@ def UpsertUserAll(
auto_characters: bool = True,
play: Optional[PlayData] = None,
extra_data: Optional[Any] = None,
unlock_music_ids: Optional[Sequence[int]] = None,
unlock_master: bool = True,
unlock_remaster: bool = True,
patch: Optional[Dict[str, Any]] = None,
user_playlog_list: Optional[List[Any]] = None,
pre_new_item_list: bool = True,
@@ -2533,6 +2682,14 @@ def UpsertUserAll(
"isNewKaleidxScopeList": "1"}
优先级最高(覆盖快照演算出的同名字段)。
没给位串时会按数组长度补 "0" 并给出提示。
unlock_music_ids: ★ 要解锁(解禁)的 musicId 列表,例如 [1085, 11414]。
写进 userItemList:kind5=歌曲本体、kind6=Master、kind7=Re:Master。
★ musicId < 10000(标准谱)的 Master/Re:Master 天生解锁,
只写 kind5;只有 DX 谱(>= 10000)才写 kind6/7。
★ 已存在的条目自动跳过(重复插入服务端会报错)。
见 build_unlock_items 上方的源码依据。
unlock_master: 解锁时是否一并解禁 Master 难度(默认 True)
unlock_remaster: 解锁时是否一并解禁 Re:Master 难度(默认 True)
patch: 同 extra_data 的旧接口(只收 dict,不补位串提示)。
user_playlog_list: 本局成绩明细(可选;不传则用 play 自动构造一条)
pre_new_item_list: True(默认)= 发包前先调 GetUserNewItemListApi
@@ -2624,6 +2781,8 @@ def UpsertUserAll(
# ---------- 阶段 5:组装 upsertUserAll(快照基线 + 本局演算 + 差分)----------
user_all = build_user_all(
snapshot=snapshot, play=p, patch=patch, extra_data=extra_data,
unlock_music_ids=unlock_music_ids,
unlock_master=unlock_master, unlock_remaster=unlock_remaster,
login_date_time=login_date_time, auth_date_time=auth_date_time,
place_id=place_id, place_name=place_name,
region_id=region_id, region_name=region_name, client_id=client_id,
@@ -2709,7 +2868,7 @@ def UpsertUserAll(
# =============================================================================
if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("upsert_user_all_test")
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
r"""
upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
UpsertUserChargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
================================================================================
功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。
@@ -140,9 +140,15 @@ PAID_TICKET_IDS = (2, 3, 4, 5, 6)
TICKET_VALID_HOUR_OFFSET = -4
TICKET_VALID_DAYS = 90
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
from .ChargeGuard import (
has_fresh_read,
last_read_age,
note_charge_purchase,
purchased_charge_ids,
)
from .LogSetup import log_debug, log_error, log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
from .UpsertUserAll import UpsertResponseVO, resolve_api_func
class ChargelogConfirmError(RuntimeError):
@@ -334,6 +340,8 @@ def UpsertUserChargelog(
client_id: str = CLIENT_ID,
guard_no_stock: bool = True,
guard_purchasable: bool = True,
guard_require_charge_read: bool = True,
guard_single_purchase: bool = True,
confirm: Optional[bool] = None,
dry_run: bool = False,
timeout_ms: Optional[int] = None,
@@ -368,6 +376,16 @@ def UpsertUserChargelog(
(抛 ChargelogGuardError,未发包)。
理由见 ChargelogGuardError 的 docstring —— 有封号风险。
确认要强行购买才传 False。
guard_require_charge_read:
★ 默认 True = **运行本函数之前必须先调用过 GetUserCharge**
(本进程内、10 分钟内),否则直接拒绝购买。
目的:防止「没查票就买票」造成同一账号重复购买。
显式传了 charge_list 也算「查过了」。
实现见 api/ChargeGuard.py。
guard_single_purchase:
★ 默认 True = **同一账号在同一进程里只允许买一次票**。
真机买完票会立刻被本局用掉(stock 恒为 0),
连着买两张属于异常行为。实现见 api/ChargeGuard.py。
confirm: 副作用闸门;None 时取 config.UPSERT_CHARGELOG_CONFIRM
dry_run: True = 只构造并返回报文,绝不发包
@@ -387,6 +405,34 @@ def UpsertUserChargelog(
log_warning("chargeId=" + str(charge_id) + " 不在 PAID_TICKET_IDS="
+ str(tuple(PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
# ---------- 0. ★ 前置闸门:必须先跑过 GetUserCharge ----------
# ★ 为什么放在「取当前状态」之前:下面那一步在 charge_list 为空时会
# 自己调一次 GetUserCharge —— 闸门若放在后面,它自己就把自己放行了。
if guard_require_charge_read and charge_list is None:
if not has_fresh_read(user_id):
msg = ("安全检查未通过:本次购买之前**没有调用过 GetUserCharge**。\n"
" 买票前必须先查一次账号当前票券(有没有持票),否则无法排除"
"「重复购买」——服务端看到的是状态矛盾,有**封号风险**。\n"
" 正确顺序:GetUserCharge(user_id) -> UpsertUserChargelog(...)\n"
" 或者直接把查到的票券列表传进来:charge_list=[...]\n"
" 见 api/ChargeGuard.py。")
log_error(msg)
raise ChargelogGuardError(msg)
log_info("前置检查通过:本次购买前已调用 GetUserCharge("
+ str(round(float(last_read_age(user_id) or 0.0), 1)) + " 秒前)")
# ---------- 0.5 ★ 前置闸门:同一账号本进程内只允许买一次 ----------
if guard_single_purchase:
done = purchased_charge_ids(user_id)
if done:
msg = ("安全检查未通过:本进程内该账号(user_id=" + str(user_id)
+ ")**已经买过功能票**(chargeId=" + str(done) + ")。\n"
" 真机买完票会立刻被这一局用掉,正常状态 stock 恒为 0;"
"连着买两张属于异常行为,有**封号风险**。\n"
" 确需强行购买请传 guard_single_purchase=False。")
log_error(msg)
raise ChargelogGuardError(msg)
# ---------- 1. 取当前状态 ----------
if charge_list is None:
log_info("未传 charge_list,自动抓取当前票券 ...")
@@ -540,6 +586,8 @@ def UpsertUserChargelog(
log_info("购买成功:returnCode=" + str(resp.returnCode) + " apiName=" + resp.apiName)
log_info(" 票名 " + name + " 库存 " + str(old_stock) + " -> " + str(stock))
# ★ 记一笔「这个账号本进程已经买过票」,同进程再买会被前置闸门拦下
note_charge_purchase(user_id, charge_id)
return resp
@@ -548,7 +596,7 @@ def UpsertUserChargelog(
# =============================================================================
if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("chargelog_test")
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_getter_common.py —— 各取数脚本的公共底座
UserGetterCommon.py —— 各取数脚本的公共底座
================================================================================
只提供三样东西,取数逻辑都在各自的脚本里:
@@ -36,8 +36,8 @@ 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
from .LogSetup import log_debug, log_warning
from .MaimaiNet import MaimaiNetClient, get_default_client
# =============================================================================
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
userlogin.py —— 用户正式登录(UserLoginApi)
UserLogin.py —— 用户正式登录(UserLoginApi)
================================================================================
功能:创建登录会话,返回 loginId / loginDateTime / 登录天数统计。
@@ -48,8 +48,8 @@ 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 (
from .LogSetup import log_debug, log_error, log_info
from .MaimaiNet import (
MaimaiNetClient,
local_utc_offset_seconds,
login_date_time_now,
@@ -198,7 +198,7 @@ def UserLogin(
if __name__ == "__main__":
import sys
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("userlogin_test")
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
userlogout.py —— 用户登出(UserLogoutApi)
UserLogout.py —— 用户登出(UserLogoutApi)
================================================================================
功能:结束登录会话,可选上报本次会话的网络统计(delayLog)。
@@ -39,8 +39,8 @@ 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
from .LogSetup import log_debug, log_error, log_info
from .MaimaiNet import MaimaiNetClient, request_dict
#: DelayLog.request 的定长(DelayLog.cs:17)
DELAY_LOG_SLOTS = 26
@@ -228,7 +228,7 @@ def UserLogout(
if __name__ == "__main__":
import sys
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("userlogout_test")
+111 -78
View File
@@ -20,59 +20,60 @@ api 包 —— 统一导出
目录结构(一个 api 一个脚本)
--------------------------------------------------------------------------------
基础设施
log_setup.py 统一日志(INFO / DEBUG 双模式)
maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
user_getter_common.py 取数脚本公共底座(请求 VO / _get / 翻页)
LogSetup.py 统一日志(INFO / DEBUG 双模式)
MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
UserGetterCommon.py 取数脚本公共底座(请求 VO / _get / 翻页)
ChargeGuard.py ★ 买票前置闸门:买票前必须先跑过 GetUserCharge
会话链路
chime.py 扫码登录 -> userId + token
get_game_settings.py GetGameSettingApi(游戏设置)
ping.py 心跳探活
get_user_preview.py GetUserPreviewApi(用户预览)
userlogin.py UserLoginApi(正式登录 -> loginId + loginDateTime)
userlogout.py UserLogoutApi(登出)
Chime.py 扫码登录 -> userId + token
GetGameSettings.py GetGameSettingApi(游戏设置)
Ping.py 心跳探活
GetUserPreview.py GetUserPreviewApi(用户预览)
UserLogin.py UserLoginApi(正式登录 -> loginId + loginDateTime)
UserLogout.py UserLogoutApi(登出)
上传 / 结算(有副作用的接口)
upsert_user_all.py ★ UpsertUserAllApi(上传成绩)
upsert_user_chargelog.py UpsertUserChargelogApi(购买功能票)
get_need_user_data.py ★ 结算快照编排(统一调用下面的取数脚本),
UpsertUserAll.py ★ UpsertUserAllApi(上传成绩 + 解锁歌曲/解禁难度)
UpsertUserChargelog.py UpsertUserChargelogApi(购买功能票)
GetNeedUserData.py ★ 结算快照编排(统一调用下面的取数脚本),
并把结果落盘到 <工程根>/data/<userId>.json
★ 命名约定:服务端 API 名里带 Get 的,**脚本名与函数名一律带 get / Get**
(不要模棱两可)。所以下面这些文件全是 get_ 开头。
取数 —— GetUser*(一个接口一个脚本)
get_user_data.py GetUserDataApi
get_user_extend.py GetUserExtendApi
get_user_option.py GetUserOptionApi
get_user_character.py GetUserCharacterApi
get_user_ghost.py GetUserGhostApi
get_user_map.py GetUserMapApi
get_user_login_bonus.py GetUserLoginBonusApi
get_user_rating.py GetUserRatingApi
get_user_item.py GetUserItemApi
get_user_music.py GetUserMusicApi
get_user_course.py GetUserCourseApi
get_user_charge.py GetUserChargeApi
get_user_favorite.py GetUserFavoriteApi
get_user_activity.py GetUserActivityApi
get_user_mission.py GetUserMissionDataApi
get_user_intimate.py GetUserIntimateApi
get_user_shop_stock.py GetUserShopStockApi
get_user_favorite_item.py GetUserFavoriteItemApi
get_user_rival.py GetUserRivalDataApi / GetUserRivalMusicApi
get_user_friend_bonus.py GetUserFriendBonusApi
get_user_kaleidx_scope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
get_user_region.py GetUserRegionApi
get_user_recommend_music.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
get_user_new_item.py GetUserNewItemListApi
GetUserData.py GetUserDataApi
GetUserExtend.py GetUserExtendApi
GetUserOption.py GetUserOptionApi
GetUserCharacter.py GetUserCharacterApi
GetUserGhost.py GetUserGhostApi
GetUserMap.py GetUserMapApi
GetUserLoginBonus.py GetUserLoginBonusApi
GetUserRating.py GetUserRatingApi
GetUserItem.py GetUserItemApi
GetUserMusic.py GetUserMusicApi
GetUserCourse.py GetUserCourseApi
GetUserCharge.py GetUserChargeApi
GetUserFavorite.py GetUserFavoriteApi
GetUserActivity.py GetUserActivityApi
GetUserMission.py GetUserMissionDataApi
GetUserIntimate.py GetUserIntimateApi
GetUserShopStock.py GetUserShopStockApi
GetUserFavoriteItem.py GetUserFavoriteItemApi
GetUserRival.py GetUserRivalDataApi / GetUserRivalMusicApi
GetUserFriendBonus.py GetUserFriendBonusApi
GetUserKaleidxScope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
GetUserRegion.py GetUserRegionApi
GetUserRecommendMusic.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
GetUserNewItem.py GetUserNewItemListApi
取数 —— GetGame*
get_game_settings.py GetGameSettingApi
get_game_charge.py GetGameChargeApi
GetGameSettings.py GetGameSettingApi
GetGameCharge.py GetGameChargeApi
"""
from .log_setup import (
from .LogSetup import (
# 会话
setup_logging,
teardown_logging,
@@ -107,8 +108,8 @@ from .log_setup import (
# 1) 网络核心
# ---------------------------------------------------------------------------
from .maimai_net import (
# 协议常量(原在 config.py,已下沉到 maimai_net.py)
from .MaimaiNet import (
# 协议常量(原在 config.py,已下沉到 MaimaiNet.py)
BASE_URL,
AES_KEY,
AES_IV,
@@ -141,9 +142,9 @@ from .maimai_net import (
# 2) 会话链路
# ---------------------------------------------------------------------------
from .chime import (
from .Chime import (
ParseQRCode,
# aimedb / QR 常量(原在 config.py,已下沉到 chime.py)
# aimedb / QR 常量(原在 config.py,已下沉到 Chime.py)
AIMEDB_HOST,
AIMEDB_PORT,
AIMEDB_PATH,
@@ -153,7 +154,7 @@ from .chime import (
QR_USER_AGENT,
)
from .get_game_settings import (
from .GetGameSettings import (
GetGameSettings,
GameSetting,
GameSettingRequestVO,
@@ -162,21 +163,21 @@ from .get_game_settings import (
is_ping_disabled,
)
from .ping import Ping
from .Ping import Ping
from .get_user_preview import (
from .GetUserPreview import (
GetUserPreview,
UserPreviewRequestVO,
UserPreviewResponseVO,
)
from .userlogin import (
from .UserLogin import (
UserLogin,
UserLoginRequestVO,
UserLoginResponseVO,
)
from .userlogout import (
from .UserLogout import (
UserLogout,
LogoutType,
DelayLog,
@@ -193,7 +194,7 @@ from .userlogout import (
# 3) 上传成绩
# ---------------------------------------------------------------------------
from .upsert_user_all import (
from .UpsertUserAll import (
UpsertUserAll,
PlayData,
build_play_from_config,
@@ -236,6 +237,13 @@ from .upsert_user_all import (
load_extra_data,
merge_extra_data,
verify_upsert_landing,
# ★ 歌曲解锁(解禁):写进 userItemList 的 itemKind 5/6/7
build_unlock_items,
ITEM_KIND_MUSIC_UNLOCK,
ITEM_KIND_MUSIC_UNLOCK_MASTER,
ITEM_KIND_MUSIC_UNLOCK_REMASTER,
ITEM_KIND_MUSIC_UNLOCK_STRONG,
UNLOCK_MASTER_MIN_MUSIC_ID,
default_user_weekly_data,
default_user_2p_playlog,
calc_play_special,
@@ -255,7 +263,7 @@ from .upsert_user_all import (
USER_MISSION_DROP_KEYS,
)
from .get_need_user_data import (
from .GetNeedUserData import (
GetNeedUserData,
save_snapshot,
load_snapshot,
@@ -270,8 +278,24 @@ from .get_need_user_data import (
list_saved_user_ids,
)
from .upsert_user_chargelog import (
# 票券主数据(原在 config.py,已下沉到 upsert_user_chargelog.py)
# ---------------------------------------------------------------------------
# ★ 买票前置闸门:GetUserCharge 必须先跑过(见 api/ChargeGuard.py)
# ---------------------------------------------------------------------------
from .ChargeGuard import (
CHARGE_READ_FRESH_SEC,
has_fresh_read,
held_charges,
last_charge_list,
last_read_age,
note_charge_purchase,
note_user_charge_read,
purchased_charge_ids,
reset as reset_charge_guard,
)
from .UpsertUserChargelog import (
# 票券主数据(原在 config.py,已下沉到 UpsertUserChargelog.py)
TICKET_CREDIT_NUM,
TICKET_NAMES,
PAID_TICKET_IDS,
@@ -294,7 +318,7 @@ from .upsert_user_chargelog import (
# 4) 取数公共底座
# ---------------------------------------------------------------------------
from .user_getter_common import (
from .UserGetterCommon import (
EmptyRequestVO as GetterEmptyRequestVO,
UserIdRequestVO as GetterUserIdRequestVO,
UserIdPageRequestVO,
@@ -318,37 +342,37 @@ from .user_getter_common import (
# 5) 取数脚本(一个接口一个脚本)
# ---------------------------------------------------------------------------
from .get_user_data import GetUserData
from .get_user_extend import GetUserExtend
from .get_user_option import GetUserOption
from .get_user_character import GetUserCharacter
from .get_user_ghost import GetUserGhost
from .get_user_map import GetUserMap, GetUserMapAll
from .get_user_login_bonus import GetUserLoginBonus, GetUserLoginBonusAll
from .get_user_rating import GetUserRating
from .get_user_item import GetUserItem, GetUserItemAll
from .get_user_music import GetUserMusicDetail, GetUserMusicDetailAll
from .get_user_course import GetUserCourse, GetUserCourseAll
from .get_user_charge import GetUserCharge
from .get_user_favorite import GetUserFavorite, GetUserFavoriteAll
from .get_user_activity import GetUserActivity
from .get_user_mission import GetUserMissionData
from .get_user_intimate import GetUserIntimate
from .get_user_shop_stock import GetUserShopStock
from .get_user_favorite_item import (
from .GetUserData import GetUserData
from .GetUserExtend import GetUserExtend
from .GetUserOption import GetUserOption
from .GetUserCharacter import GetUserCharacter
from .GetUserGhost import GetUserGhost
from .GetUserMap import GetUserMap, GetUserMapAll
from .GetUserLoginBonus import GetUserLoginBonus, GetUserLoginBonusAll
from .GetUserRating import GetUserRating
from .GetUserItem import GetUserItem, GetUserItemAll
from .GetUserMusic import GetUserMusicDetail, GetUserMusicDetailAll
from .GetUserCourse import GetUserCourse, GetUserCourseAll
from .GetUserCharge import GetUserCharge
from .GetUserFavorite import GetUserFavorite, GetUserFavoriteAll
from .GetUserActivity import GetUserActivity
from .GetUserMission import GetUserMissionData
from .GetUserIntimate import GetUserIntimate
from .GetUserShopStock import GetUserShopStock
from .GetUserFavoriteItem import (
GetUserFavoriteItem,
GetUserFavoriteItemAll,
FAVORITE_ITEM_KIND_MUSIC,
FAVORITE_ITEM_KIND_RIVAL,
)
from .get_user_rival import GetUserRivalData, GetUserRivalMusic, GetUserRivalMusicAll
from .get_user_friend_bonus import GetUserFriendBonus, extract_gift_points
from .get_user_kaleidx_scope import GetUserKaleidxScope, GetGameKaleidxScope
from .get_user_region import GetUserRegion
from .get_user_recommend_music import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
from .get_user_new_item import GetUserNewItemList, UserNewItemListRequestVO
from .GetUserRival import GetUserRivalData, GetUserRivalMusic, GetUserRivalMusicAll
from .GetUserFriendBonus import GetUserFriendBonus, extract_gift_points
from .GetUserKaleidxScope import GetUserKaleidxScope, GetGameKaleidxScope
from .GetUserRegion import GetUserRegion
from .GetUserRecommendMusic import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
from .GetUserNewItem import GetUserNewItemList, UserNewItemListRequestVO
from .get_game_charge import GetGameCharge, GameChargeRequestVO
from .GetGameCharge import GetGameCharge, GameChargeRequestVO
# ---------------------------------------------------------------------------
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
@@ -369,6 +393,7 @@ _API_FUNCS = (
"merge_extra_data",
"verify_upsert_landing",
"build_user_all",
"build_unlock_items",
# --- 购买功能票 ---
"UpsertUserChargelog",
"ticket_name",
@@ -471,8 +496,16 @@ __all__ = [
# ---- 上传成绩 ----
"UpsertUserAll", "PlayData", "build_play_from_config", "derive_play_characters",
"load_extra_data", "merge_extra_data", "verify_upsert_landing",
# ---- 歌曲解锁(解禁)----
"build_unlock_items", "UNLOCK_MASTER_MIN_MUSIC_ID",
"ITEM_KIND_MUSIC_UNLOCK", "ITEM_KIND_MUSIC_UNLOCK_MASTER",
"ITEM_KIND_MUSIC_UNLOCK_REMASTER", "ITEM_KIND_MUSIC_UNLOCK_STRONG",
# ---- 购买功能票 ----
"UpsertUserChargelog", "ChargelogConfirmError", "ChargelogGuardError",
# ---- 买票前置闸门(GetUserCharge 必须先跑过)----
"CHARGE_READ_FRESH_SEC", "has_fresh_read", "held_charges", "last_charge_list",
"last_read_age", "note_charge_purchase", "note_user_charge_read",
"purchased_charge_ids", "reset_charge_guard",
"UserChargeVO", "UserChargelogVO", "UserChargelogRequestVO",
"ticket_name", "ticket_price", "calc_valid_date", "parse_wall_time", "now_cst",
"UserAllRequestVO", "UpsertResponseVO",
-45
View File
@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
"""
get_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 GetUserCharge
"""
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 GetUserCharge(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户票券(对应 GetUserChargeApi)。
返回:
原始响应 dict,含 userChargeList
"""
log_info("取用户票券:user_id=" + str(user_id))
return _get("GetUserChargeApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)