diff --git a/README.md b/README.md
index 4245d90..5881ccb 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,8 @@
-
WaHappy
+
+
+
+
+Astral
舞萌DX 的游戏助手
@@ -11,21 +15,22 @@
## 这是什么
-WaHappy 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
+Astral 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
它不修改游戏、不注入进程,只是把游戏客户端做的事用脚本重做一遍:
-扫码登录 → 取数据 → 上传成绩 → 登出。
+扫码登录 → 取数据 → 上传成绩 / 解锁歌曲 → 登出。
> 协议实现全部来自反编译源码与真机网络日志的双向验证,
> 每个关键常量在代码注释里都标了**源码行号**。
---
-## 当前状态:v0
+## 当前状态:v0 plus 附加功能
| 脚本 | 状态 |
|---|---|
| **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 |
+| **`unlockmusic.py`** | ✅ **可用** —— 解锁(解禁)歌曲 / 难度(复用 `upsert.py` 整套流程) |
| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票 |
| **`use_ticket.py`** | ✅ **可用** —— 消耗当前持有的功能票 |
| **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生) |
@@ -59,35 +64,44 @@ v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结
## 项目框架
```
-WaHappy/
+Astral/
├── config.py 运行时可配置项(机台标识 / 歌曲数据 / 日志级别)★ 需自建
├── config.py.example 配置模板
+├── icon/ Logo
├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出
+├── unlockmusic.py 主脚本:解锁(解禁)歌曲(复用 upsert.py 的 run())
├── buy_ticket.py 主脚本:购买功能票
├── use_ticket.py 主脚本:消耗当前持有的功能票
├── reincarnate.py 主脚本:账号数据迁移(转生)
├── netdelivery.py 主脚本:查 / 下 .app / .opt 更新包
-├── selftest.py 零网络自检(249 项,判据 = 源码 + 真机日志)
+├── selftest.py 零网络自检(284 项,判据 = 源码 + 真机日志)
│
├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本)
│ ├── __init__.py 统一导出 + 日志自动跟踪
-│ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
-│ ├── log_setup.py 日志(INFO / DEBUG 双模式)
-│ ├── upsert_user_all.py 上传成绩(36 节点 / 110 字段 / 差分)
-│ ├── get_need_user_data.py 结算快照编排 + 落盘 data/.json
+│ ├── MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
+│ ├── LogSetup.py 日志(INFO / DEBUG 双模式)
+│ ├── ChargeGuard.py 买票前置闸门(必须先跑过 GetUserCharge)
+│ ├── UpsertUserAll.py 上传成绩 / 解锁歌曲(36 节点 / 110 字段 / 差分)
+│ ├── GetNeedUserData.py 结算快照编排 + 落盘 data/.json
│ └── ... 40+ 个接口脚本
│
├── data/ 每账号一份用户数据快照(.json)★ 不入库
├── download/ netdelivery.py 下载的 .app / .opt ★ 不入库
-├── logs/ 日志输出(latest.log + 日期.log)★ 不入库
-└── source/ 反编译源码 ★ 不入库,需自备
+└── logs/ 日志输出(latest.log + 日期.log)★ 不入库
```
**设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。
主脚本只负责编排,日志和协议细节都在 `api/` 内部。
-**命名约定**:服务端 API 名里带 `Get` 的,**脚本名与函数名一律带 `get` / `Get`**
-(不要模棱两可)。所以取数脚本全是 `get_*.py`,函数是 `GetXxx`。
+**命名约定**:**文件名 = 函数名**,都按首字母大写的驼峰切分,一一对应:
+
+| 服务端接口 | 脚本 | 函数 |
+|---|---|---|
+| `GetUserDataApi` | `api/GetUserData.py` | `GetUserData()` |
+| `UpsertUserAllApi` | `api/UpsertUserAll.py` | `UpsertUserAll()` |
+| `UserLoginApi` | `api/UserLogin.py` | `UserLogin()` |
+
+服务端 API 名里带 `Get` 的,脚本名与函数名一律带 `Get`(不要模棱两可)。
### 分支说明
@@ -97,7 +111,10 @@ WaHappy/
| **`docs`** | 孤儿分支 —— 只有 `docs/`,开发文档与踩坑记录 |
两个分支**没有共同的提交历史**,各自独立。
-`source/` 是反编译源码,**需要自备**。
+
+> 🛠 **给开发者**:协议实现对照的反编译源码(`Assembly-CSharp` / `AMDaemon.NET` 等)
+> 不入库,需要自己准备一份放在 `source/`;代码注释里的行号都指向它。
+> 只是用工具的话**完全不需要**这个目录。
---
@@ -136,7 +153,7 @@ copy config.py.example config.py # Windows
### 3. 跑自检
```bash
-python selftest.py # 零网络自检,应该 249 项全绿(不依赖你的 config)
+python selftest.py # 零网络自检,应该 284 项全绿(不依赖你的 config)
```
### 主脚本一览
@@ -144,6 +161,7 @@ python selftest.py # 零网络自检,应该 249 项全绿(不依赖
| 脚本 | 干什么 | 副作用 | 需要二维码 |
|---|---|---|---|
| `upsert.py` | 上传一局成绩 | ★ 有 | ✅ |
+| `unlockmusic.py` | 解锁(解禁)歌曲 / 难度 | ★ 有 | ✅ |
| `buy_ticket.py` | 购买功能票 | ★ 有 | ✅ |
| `use_ticket.py` | 消耗当前持有的功能票 | ★ 有 | ✅ |
| `reincarnate.py` | 账号数据迁移(转生) | ★ 有 | ✅(目标号;源号可选) |
@@ -168,8 +186,9 @@ python upsert.py "<二维码>" --send
# 常用开关
python upsert.py "<二维码>" --send --debug # 日志里带完整收发报文
-python upsert.py "<二维码>" --send --cooldown 0 # 跳过发包前 60 秒冷却
+python upsert.py "<二维码>" --send --cooldown 0 # 跳过发包前 15 秒冷却
python upsert.py "<二维码>" --send --no-verify # 跳过上传后的落库回查
+python unlockmusic.py "<二维码>" --send --music 11414 # 解锁歌曲 / 解禁难度
```
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`,不用动任何代码。**
@@ -199,7 +218,7 @@ python upsert.py "<二维码>" --send --extra-data extra.json
| `1` | **新增** —— 旧数据里**没有**同主键的条目 | INSERT |
| `2` | **无变化** —— 只有 `userFavoriteList` 用,其他节点不会出现 | 跳过 |
-主键见 `api/upsert_user_all.py` 的 `DIFF_PRIMARY_KEYS`
+主键见 `api/UpsertUserAll.py` 的 `DIFF_PRIMARY_KEYS`
(如 `userKaleidxScopeList` 用 `gateId`、`userMusicDetailList` 用 `musicId`+`level`)。
> ⚠️ **对已存在的条目填 `1` 会 HTTP 500** —— 服务端会去 INSERT 一条重复记录。
@@ -210,10 +229,52 @@ python upsert.py "<二维码>" --send --extra-data extra.json
---
+## unlockmusic.py —— 解锁(解禁)歌曲
+
+```bash
+python unlockmusic.py "<二维码>" --music 11414 # 预检,不发包
+python unlockmusic.py "<二维码>" --music 11414 --send # ★ 真实解锁
+python unlockmusic.py "<二维码>" --music 1085,11414 --send # 多首,逗号分隔
+python unlockmusic.py "<二维码>" --music 11414 --send --no-unlock-remaster
+```
+
+**它没有自己的协议实现** —— 整个流程直接复用 `upsert.py` 的 `run()`:
+扫码 → 预览 → 登录 → `UpsertUserAll` → 落库回查 → 登出,一模一样。
+唯一多出来的东西是「要解锁哪些歌」这个入参。
+
+**解锁也不是单独的接口** —— 它是往 `userItemList` 里补几条 `UserItem`:
+
+| itemKind | 解锁什么 |
+|---|---|
+| `5` | 歌曲本体(`MusicUnlockList`) |
+| `6` | Master 难度(`MusicUnlockMasterList`) |
+| `7` | Re:Master 难度(`MusicUnlockReMasterList`) |
+
+条目形如 `{"itemKind":5,"itemId":1085,"stock":1,"isValid":true}`。
+
+- **标准谱(`musicId < 10000`)的 Master / Re:Master 天生解锁**,只写 `kind5`;
+ 只有 DX 谱(`>= 10000`)才需要写 `kind6` / `kind7`。
+- 服务器上**已存在的条目会自动跳过** —— 重复插入会让服务端直接 HTTP 500。
+- Re:Master 还受活动开关约束,那是机台侧的事,脚本改不了;写了也可能进游戏仍是灰的。
+
+| 参数 | 说明 |
+|---|---|
+| `--music ID[,ID...]` | 要解锁的 musicId,可重复、可逗号分隔(`--unlock-music` 也认) |
+| `--send` | 真正发包(不加就是预检,零副作用) |
+| `--no-unlock-master` | 不解禁 Master |
+| `--no-unlock-remaster` | 不解禁 Re:Master |
+| `--cooldown N` / `--no-verify` / `--debug` | 和 `upsert.py` 完全一样 |
+
+> 依据反编译源码 `VOExtensions.cs:981-1008`(四个解锁列表并进 `userItemList`)
+> 与 `NotesListManager.cs:415/455`(`if (id < 10000) return true;`)。
+> 这部分**没有真机抓包可对照**(机台侧的解锁不上报),是按源码实现的。
+
+---
+
## buy_ticket.py —— 购买功能票
```bash
-python buy_ticket.py "<二维码>" --list # 只列当前票券和可买的票
+python buy_ticket.py "<二维码>" --list # 登录后只列当前票券,不购买
python buy_ticket.py "<二维码>" --charge-id 2 # 预检:只看报文
python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
```
@@ -228,10 +289,17 @@ python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
### 它做的安全检查
-1. 买之前查 `GetGameChargeApi` —— 目标票不在可购买清单里就**连登录都不做**直接退出
-2. 跑前查 `GetUserChargeApi` —— **账号已持有功能票时直接拒绝购买**(重复购买有封号风险)
+1. 跑前查 `GetUserChargeApi` —— **账号已持有功能票时直接拒绝购买**(重复购买有封号风险)
+2. 买票前 **15 秒等待**(贴近真机节奏)
3. 跑后再查一次确认状态
+> **票源只看 `GetUserChargeApi`**(账号自己的票券表),不再看 `GetGameChargeApi`
+> (那是机台本地的上架清单,跟账号状态无关)。
+>
+> `UpsertUserChargelog` 内部还有两道硬闸门:
+> **必须先跑过 `GetUserCharge`**(`api/ChargeGuard.py` 记账),
+> 以及**同一账号同一进程只允许买一次票** —— 都拦在发包之前。
+
---
## use_ticket.py —— 消耗当前持有的功能票
@@ -372,7 +440,8 @@ if __name__ == "__main__":
- ✅ 账号数据快照落盘(`data/.json`)
- ✅ 账号数据迁移(转生)
- ✅ 查 / 下 `.app` / `.opt` 更新包
-- ✅ 零网络自检 249 项
+- ✅ 解锁歌曲 / 解禁 Master 与 Re:Master 难度
+- ✅ 零网络自检 284 项
- ✅ MelonLoader 抓包 Mod(`gamehook/`)
**将来可能会加**
@@ -412,6 +481,7 @@ if __name__ == "__main__":
- **志成🍥** —— 提供脱壳 DLL
- [**WorkBuddy AI**](https://www.workbuddy.ai/)—— 提供 AI 模型
- **DeepSeek V4.1 Flash** —— 主要代码编写
+- **GPT-Image-2** —— Logo 设计
- [**NVIDIA NIM**](https://build.nvidia.com/models)、
[**AMD**](https://developer.amd.com.cn/radeon/tokenfactory)、
[**Kilo**](https://kilo.ai/)—— 提供 AI 模型
diff --git a/api/ChargeGuard.py b/api/ChargeGuard.py
new file mode 100644
index 0000000..64d5f58
--- /dev/null
+++ b/api/ChargeGuard.py
@@ -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)
diff --git a/api/chime.py b/api/Chime.py
similarity index 98%
rename from api/chime.py
rename to api/Chime.py
index bdb858e..aa7b5b6 100644
--- a/api/chime.py
+++ b/api/Chime.py
@@ -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")
diff --git a/api/get_game_charge.py b/api/GetGameCharge.py
similarity index 89%
rename from api/get_game_charge.py
rename to api/GetGameCharge.py
index dc840dc..f6d3ae9 100644
--- a/api/get_game_charge.py
+++ b/api/GetGameCharge.py
@@ -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
diff --git a/api/get_game_settings.py b/api/GetGameSettings.py
similarity index 95%
rename from api/get_game_settings.py
rename to api/GetGameSettings.py
index 42281e9..303dc96 100644
--- a/api/get_game_settings.py
+++ b/api/GetGameSettings.py
@@ -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:
diff --git a/api/get_need_user_data.py b/api/GetNeedUserData.py
similarity index 92%
rename from api/get_need_user_data.py
rename to api/GetNeedUserData.py
index 3ffe97d..bde27eb 100644
--- a/api/get_need_user_data.py
+++ b/api/GetNeedUserData.py
@@ -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")
diff --git a/api/get_user_activity.py b/api/GetUserActivity.py
similarity index 87%
rename from api/get_user_activity.py
rename to api/GetUserActivity.py
index 878e396..c99f129 100644
--- a/api/get_user_activity.py
+++ b/api/GetUserActivity.py
@@ -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(
diff --git a/api/get_user_character.py b/api/GetUserCharacter.py
similarity index 86%
rename from api/get_user_character.py
rename to api/GetUserCharacter.py
index 0a1d187..6bfa128 100644
--- a/api/get_user_character.py
+++ b/api/GetUserCharacter.py
@@ -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(
diff --git a/api/GetUserCharge.py b/api/GetUserCharge.py
new file mode 100644
index 0000000..b6d719b
--- /dev/null
+++ b/api/GetUserCharge.py
@@ -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
diff --git a/api/get_user_course.py b/api/GetUserCourse.py
similarity index 90%
rename from api/get_user_course.py
rename to api/GetUserCourse.py
index e1a0a34..f4adf46 100644
--- a/api/get_user_course.py
+++ b/api/GetUserCourse.py
@@ -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(
diff --git a/api/get_user_data.py b/api/GetUserData.py
similarity index 91%
rename from api/get_user_data.py
rename to api/GetUserData.py
index bc66644..a6a8cdd 100644
--- a/api/get_user_data.py
+++ b/api/GetUserData.py
@@ -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(
diff --git a/api/get_user_extend.py b/api/GetUserExtend.py
similarity index 86%
rename from api/get_user_extend.py
rename to api/GetUserExtend.py
index ce04197..15d4e39 100644
--- a/api/get_user_extend.py
+++ b/api/GetUserExtend.py
@@ -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(
diff --git a/api/get_user_favorite.py b/api/GetUserFavorite.py
similarity index 93%
rename from api/get_user_favorite.py
rename to api/GetUserFavorite.py
index f62ce22..d72f6cf 100644
--- a/api/get_user_favorite.py
+++ b/api/GetUserFavorite.py
@@ -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(
diff --git a/api/get_user_favorite_item.py b/api/GetUserFavoriteItem.py
similarity index 92%
rename from api/get_user_favorite_item.py
rename to api/GetUserFavoriteItem.py
index 2e2dc8a..f5dc0d2 100644
--- a/api/get_user_favorite_item.py
+++ b/api/GetUserFavoriteItem.py
@@ -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 # 收藏乐曲
diff --git a/api/get_user_friend_bonus.py b/api/GetUserFriendBonus.py
similarity index 89%
rename from api/get_user_friend_bonus.py
rename to api/GetUserFriendBonus.py
index 81d608e..47ae068 100644
--- a/api/get_user_friend_bonus.py
+++ b/api/GetUserFriendBonus.py
@@ -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(
diff --git a/api/get_user_ghost.py b/api/GetUserGhost.py
similarity index 87%
rename from api/get_user_ghost.py
rename to api/GetUserGhost.py
index 1e69116..97a0957 100644
--- a/api/get_user_ghost.py
+++ b/api/GetUserGhost.py
@@ -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(
diff --git a/api/get_user_intimate.py b/api/GetUserIntimate.py
similarity index 84%
rename from api/get_user_intimate.py
rename to api/GetUserIntimate.py
index f6cbb5b..8c7015b 100644
--- a/api/get_user_intimate.py
+++ b/api/GetUserIntimate.py
@@ -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(
diff --git a/api/get_user_item.py b/api/GetUserItem.py
similarity index 94%
rename from api/get_user_item.py
rename to api/GetUserItem.py
index 21a7632..e50deb8 100644
--- a/api/get_user_item.py
+++ b/api/GetUserItem.py
@@ -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(
diff --git a/api/get_user_kaleidx_scope.py b/api/GetUserKaleidxScope.py
similarity index 88%
rename from api/get_user_kaleidx_scope.py
rename to api/GetUserKaleidxScope.py
index 301beaf..ed02184 100644
--- a/api/get_user_kaleidx_scope.py
+++ b/api/GetUserKaleidxScope.py
@@ -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(
diff --git a/api/get_user_login_bonus.py b/api/GetUserLoginBonus.py
similarity index 90%
rename from api/get_user_login_bonus.py
rename to api/GetUserLoginBonus.py
index a4b9d14..19a7a7a 100644
--- a/api/get_user_login_bonus.py
+++ b/api/GetUserLoginBonus.py
@@ -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(
diff --git a/api/get_user_map.py b/api/GetUserMap.py
similarity index 91%
rename from api/get_user_map.py
rename to api/GetUserMap.py
index d6513d0..a31d252 100644
--- a/api/get_user_map.py
+++ b/api/GetUserMap.py
@@ -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(
diff --git a/api/get_user_mission.py b/api/GetUserMission.py
similarity index 87%
rename from api/get_user_mission.py
rename to api/GetUserMission.py
index 516a6cf..0766665 100644
--- a/api/get_user_mission.py
+++ b/api/GetUserMission.py
@@ -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(
diff --git a/api/get_user_music.py b/api/GetUserMusic.py
similarity index 93%
rename from api/get_user_music.py
rename to api/GetUserMusic.py
index dc0073e..19982a7 100644
--- a/api/get_user_music.py
+++ b/api/GetUserMusic.py
@@ -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(
diff --git a/api/get_user_new_item.py b/api/GetUserNewItem.py
similarity index 95%
rename from api/get_user_new_item.py
rename to api/GetUserNewItem.py
index 7df9afb..36880c8 100644
--- a/api/get_user_new_item.py
+++ b/api/GetUserNewItem.py
@@ -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
diff --git a/api/get_user_option.py b/api/GetUserOption.py
similarity index 87%
rename from api/get_user_option.py
rename to api/GetUserOption.py
index 298ef01..028b13c 100644
--- a/api/get_user_option.py
+++ b/api/GetUserOption.py
@@ -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(
diff --git a/api/get_user_preview.py b/api/GetUserPreview.py
similarity index 95%
rename from api/get_user_preview.py
rename to api/GetUserPreview.py
index a0eb785..ba76dfb 100644
--- a/api/get_user_preview.py
+++ b/api/GetUserPreview.py
@@ -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")
diff --git a/api/get_user_rating.py b/api/GetUserRating.py
similarity index 86%
rename from api/get_user_rating.py
rename to api/GetUserRating.py
index 1dae87d..74772e9 100644
--- a/api/get_user_rating.py
+++ b/api/GetUserRating.py
@@ -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(
diff --git a/api/get_user_recommend_music.py b/api/GetUserRecommendMusic.py
similarity index 89%
rename from api/get_user_recommend_music.py
rename to api/GetUserRecommendMusic.py
index efc5458..5835218 100644
--- a/api/get_user_recommend_music.py
+++ b/api/GetUserRecommendMusic.py
@@ -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(
diff --git a/api/get_user_region.py b/api/GetUserRegion.py
similarity index 84%
rename from api/get_user_region.py
rename to api/GetUserRegion.py
index d29253f..2f34105 100644
--- a/api/get_user_region.py
+++ b/api/GetUserRegion.py
@@ -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(
diff --git a/api/get_user_rival.py b/api/GetUserRival.py
similarity index 95%
rename from api/get_user_rival.py
rename to api/GetUserRival.py
index 3c5436d..e515c30 100644
--- a/api/get_user_rival.py
+++ b/api/GetUserRival.py
@@ -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,
diff --git a/api/get_user_shop_stock.py b/api/GetUserShopStock.py
similarity index 89%
rename from api/get_user_shop_stock.py
rename to api/GetUserShopStock.py
index 68fd2a8..7839f90 100644
--- a/api/get_user_shop_stock.py
+++ b/api/GetUserShopStock.py
@@ -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(
diff --git a/api/log_setup.py b/api/LogSetup.py
similarity index 99%
rename from api/log_setup.py
rename to api/LogSetup.py
index 241cc94..7473db1 100644
--- a/api/log_setup.py
+++ b/api/LogSetup.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
-log_setup.py —— 统一日志模块(INFO / DEBUG 双模式)
+LogSetup.py —— 统一日志模块(INFO / DEBUG 双模式)
================================================================================
【两种模式】
diff --git a/api/maimai_net.py b/api/MaimaiNet.py
similarity index 99%
rename from api/maimai_net.py
rename to api/MaimaiNet.py
index a83e314..7953dbf 100644
--- a/api/maimai_net.py
+++ b/api/MaimaiNet.py
@@ -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
# =============================================================================
# 协议常量
diff --git a/api/ping.py b/api/Ping.py
similarity index 93%
rename from api/ping.py
rename to api/Ping.py
index f587713..ff97376 100644
--- a/api/ping.py
+++ b/api/Ping.py
@@ -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")
diff --git a/api/upsert_user_all.py b/api/UpsertUserAll.py
similarity index 93%
rename from api/upsert_user_all.py
rename to api/UpsertUserAll.py
index 9fae97d..bcde612 100644
--- a/api/upsert_user_all.py
+++ b/api/UpsertUserAll.py
@@ -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")
diff --git a/api/upsert_user_chargelog.py b/api/UpsertUserChargelog.py
similarity index 88%
rename from api/upsert_user_chargelog.py
rename to api/UpsertUserChargelog.py
index 4d982d5..db1e5ca 100644
--- a/api/upsert_user_chargelog.py
+++ b/api/UpsertUserChargelog.py
@@ -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")
diff --git a/api/user_getter_common.py b/api/UserGetterCommon.py
similarity index 97%
rename from api/user_getter_common.py
rename to api/UserGetterCommon.py
index bfc308b..47af408 100644
--- a/api/user_getter_common.py
+++ b/api/UserGetterCommon.py
@@ -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
# =============================================================================
diff --git a/api/userlogin.py b/api/UserLogin.py
similarity index 97%
rename from api/userlogin.py
rename to api/UserLogin.py
index bf03e3a..9e83cb8 100644
--- a/api/userlogin.py
+++ b/api/UserLogin.py
@@ -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")
diff --git a/api/userlogout.py b/api/UserLogout.py
similarity index 97%
rename from api/userlogout.py
rename to api/UserLogout.py
index 77276a6..c0e4504 100644
--- a/api/userlogout.py
+++ b/api/UserLogout.py
@@ -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")
diff --git a/api/__init__.py b/api/__init__.py
index 1a6ab6c..c3b19f3 100644
--- a/api/__init__.py
+++ b/api/__init__.py
@@ -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/.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",
diff --git a/api/get_user_charge.py b/api/get_user_charge.py
deleted file mode 100644
index 7f165aa..0000000
--- a/api/get_user_charge.py
+++ /dev/null
@@ -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)
diff --git a/buy_ticket.py b/buy_ticket.py
index eb7ba02..8d2266f 100644
--- a/buy_ticket.py
+++ b/buy_ticket.py
@@ -8,11 +8,14 @@ buy_ticket.py —— 主脚本范例:扫码登录 → 购买功能票 → 登
ParseQRCode -> GetUserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
+★ 本脚本判断「能买什么票」**只看 GetUserChargeApi**(账号自己的票券表),
+ 不再调 GetGameChargeApi(那是机台本地的上架清单,跟账号状态无关)。
+
UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」)
2. 调 GetUserDataApi 取 playCount / playerRating
3. 按 TicketManager.cs:564 算 validDate(现在 -4h +90d,取日期补 04:00:00)
- 4. 查 TICKET_CREDIT_NUM(已下沉到 api/upsert_user_chargelog.py)得到
+ 4. 查 TICKET_CREDIT_NUM(已下沉到 api/UpsertUserChargelog.py)得到
userChargelog.price(= ticket.creditNum)
5. 闸门 → 发包
@@ -20,7 +23,7 @@ UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
用法
--------------------------------------------------------------------------------
python buy_ticket.py <二维码> # 预检:只看报文,不发包
- python buy_ticket.py <二维码> --list # 只列当前票券和可买的票
+ python buy_ticket.py <二维码> --list # 登录后只列当前票券,不购买
python buy_ticket.py <二维码> --charge-id 3 # 指定票(默认 5 = 5倍券)
python buy_ticket.py <二维码> --charge-id 2 --send # ★ 真实购买
python buy_ticket.py <二维码> --charge-id 2 --send --debug
@@ -60,7 +63,6 @@ from api import (
ChargelogConfirmError,
ChargelogGuardError,
GetUserCharge,
- GetGameCharge,
ticket_name,
GetNeedUserData,
summarize_snapshot,
@@ -99,7 +101,7 @@ def show_charges(user_id, label):
def parse_args(argv):
opts = {"qr": "", "charge_id": 5, "send": False, "list": False,
- "debug": False, "skip_entry": False, "wait_before": 30.0}
+ "debug": False, "skip_entry": False, "wait_before": 15.0}
i = 0
while i < len(argv):
a = argv[i]
@@ -136,27 +138,6 @@ def main() -> int:
setup_logging("buy_ticket")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
- # ---- ★★ 最早的前置检查:这张票当前**上架了吗** ----
- # ★ GetGameChargeApi 不需要登录(user_id=0),所以放在**扫码之前**,
- # 不满足就直接退出 —— 不浪费二维码,也不产生任何登录会话。
- # 依据 TicketManager.cs:137-150:真机也只买 GetGameChargeApi 列出来的票。
- shop = GetGameCharge().get("gameChargeList") or []
- avail = [int(g.get("chargeId")) for g in shop
- if isinstance(g, dict) and g.get("chargeId") is not None]
- log_info("当前可购买 chargeId:" + str(avail))
- for g in shop:
- log_info(" chargeId=" + str(g.get("chargeId"))
- + " " + ticket_name(g.get("chargeId"))
- + " money price=" + str(g.get("price"))
- + " 有效期 " + str(g.get("startDate")) + " ~ " + str(g.get("endDate")))
- if opts["charge_id"] not in avail:
- log_error("chargeId=" + str(opts["charge_id"])
- + "(" + ticket_name(opts["charge_id"])
- + ")**不在当前可购买清单里** —— 连登录都不做,直接退出。")
- log_error(" 原因:服务端对未上架的票会 returnCode=0(实测过)。")
- log_error(" 想买请用清单里的 chargeId:" + str(avail))
- return 4
-
# ---- ★ 心跳:决定后续所有请求的 number 头 ----
# 真机序列里 Ping 在 [11],紧接着才是 GetUserPreviewApi [12]。
# 不跑的话 number 恒为 "0"(真机是 75)。
@@ -187,6 +168,10 @@ def main() -> int:
# ---- ★★ 跑前:必须确认账号当前**没有**持有功能票 ----
held_before = show_charges(user_id, "购买前")
+ if opts["list"]:
+ log_info("[--list] 只查询票券状态,不购买。")
+ return 0
+
aborted = False
if held_before:
log_error("账号当前已持有功能票,**拒绝购买**(重复购买有封号风险):")
@@ -199,6 +184,7 @@ def main() -> int:
aborted = True
else:
# ---- ★ 买票前等待(贴近真机节奏:真机 login 13:19:45 -> 买票 13:20:10,间隔 25s)----
+ # 默认 15 秒(与 UpsertUserAll 的发包冷却一致)
if opts["wait_before"] > 0:
log_info("买票前等待 " + str(opts["wait_before"]) + " 秒 ...")
time.sleep(opts["wait_before"])
@@ -211,6 +197,7 @@ def main() -> int:
dry_run=not opts["send"],
confirm=opts["send"],
guard_no_stock=True,
+ guard_purchasable=False, # ★ 不再按 GetGameCharge 上架清单拦截
place_id=PLACE_ID, region_id=REGION_ID,
)
diff --git a/config.py.example b/config.py.example
index 10abdb8..aed46c8 100644
--- a/config.py.example
+++ b/config.py.example
@@ -31,7 +31,7 @@ config.py —— 舞萌DX 纯网络脚本工程 · 运行时可配置项
# 一、网络与加密常量
# =============================================================================
-# ★ BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM 已下沉到 api/maimai_net.py
+# ★ BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM 已下沉到 api/MaimaiNet.py
# --- 报文版本标识 ---
# 依据:source/Assembly-CSharp/Net/NetHttpClient.cs:69 DefaultHttpHeaderEncryption = "1.55"
# (请求头 Mai-Encoding 的值;1.56 ROM 实测仍发 1.55)
@@ -109,7 +109,7 @@ AUTHV1_WRITE_CONFIG = False
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
# + docs/舞萌DX_扫码登录协议技术规范.md
-# ★ aimedb / QR 常量已下沉到 api/chime.py
+# ★ aimedb / QR 常量已下沉到 api/Chime.py
# =============================================================================
# 三之二、★ 功能票(Ticket)主数据
# =============================================================================
@@ -128,7 +128,7 @@ AUTHV1_WRITE_CONFIG = False
# 1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
# ★ TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS /
# TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS
-# 已下沉到 api/upsert_user_chargelog.py
+# 已下沉到 api/UpsertUserChargelog.py
#: 买票闸门(和 UpsertUserAll 一样,默认不放行)
UPSERT_CHARGELOG_CONFIRM = False
@@ -227,7 +227,7 @@ PLAY_IS_FREEDOM_MODE = False
# 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。
# 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。
# 真机实测:19:21 那一局 = 9,22:31 那一局 = 10。
-# ★ PLAY_LOCAL_PLAY_COUNT 恒为 2(已下沉到 api/upsert_user_all.py)
+# ★ PLAY_LOCAL_PLAY_COUNT 恒为 2(已下沉到 api/UpsertUserAll.py)
# --- ★ playSpecial 里的 "CalcSpecialNum"(机台硬件标志位)---
#
# 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378
@@ -256,7 +256,7 @@ PLAY_IS_FREEDOM_MODE = False
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
#
# 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。
-# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/upsert_user_all.py
+# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/UpsertUserAll.py
# --- 用户总游玩次数增量 ---
#
# ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。
diff --git a/icon/Astral.png b/icon/Astral.png
new file mode 100644
index 0000000..59a811b
Binary files /dev/null and b/icon/Astral.png differ
diff --git a/selftest.py b/selftest.py
index 577861a..09dff85 100644
--- a/selftest.py
+++ b/selftest.py
@@ -27,6 +27,8 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import api
+import unlockmusic
+import upsert
from api import (
BITS_TO_ARRAY,
JUDGE_BOXES,
@@ -38,6 +40,7 @@ from api import (
build_delta,
build_favorite_delta,
build_play_from_config,
+ build_unlock_items,
build_user_all,
build_user_game_playlog,
build_user_playlog,
@@ -49,6 +52,13 @@ from api import (
UpsertUserChargelog,
ChargelogConfirmError,
ChargelogGuardError,
+ has_fresh_read,
+ held_charges,
+ last_read_age,
+ note_charge_purchase,
+ note_user_charge_read,
+ purchased_charge_ids,
+ reset_charge_guard,
ticket_name,
ticket_price,
calc_valid_date,
@@ -70,13 +80,13 @@ from api import (
login_date_time_now,
local_utc_offset_seconds,
)
-from api.upsert_user_all import (
+from api.UpsertUserAll import (
ConfirmRequiredError,
PatchError,
UpsertUserAll,
UserAllRequestVO,
)
-from api.upsert_user_chargelog import _now_string
+from api.UpsertUserChargelog import _now_string
# ★ 这些常量原先在 config.py,2026-10-06 已下沉到各 api 模块 —— 从 api 取。
from api import (
@@ -351,7 +361,7 @@ fav_server_raw.append({"itemKind": None, "itemIdList": []})
_, fav_flags_raw = build_favorite_delta(fav_local, fav_server_raw)
check("itemKind=null 的那条 -> 1(复刻真机 22212)", fav_flags_raw == "22212", fav_flags_raw)
-# 归一化兜底:基线被 get_user_favorite.py 补齐 itemKind 后,服务器列表里 5 个 kind 齐全,
+# 归一化兜底:基线被 GetUserFavorite.py 补齐 itemKind 后,服务器列表里 5 个 kind 齐全,
# 单看列表已看不出 itemKind=10 原本是 null,必须靠 assume_missing 显式声明。
fav_server_norm = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)]
_, fav_flags_norm = build_favorite_delta(fav_local, fav_server_norm, (10,))
@@ -1102,6 +1112,149 @@ except ChargelogConfirmError:
check("买票未开闸门抛 ChargelogConfirmError(未发包)", True)
+# =============================================================================
+# 13.7 歌曲解锁(解禁)
+# =============================================================================
+
+section("13.7 歌曲解锁(解禁)")
+
+# 依据 source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:981-1008
+# kind5 = MusicUnlockList(歌曲本体)
+# kind6 = MusicUnlockMasterList(Master)
+# kind7 = MusicUnlockReMasterList(Re:Master)
+# 依据 source/Assembly-CSharp/Manager/NotesListManager.cs:415/455
+# `if (id < 10000) return true;` -> 标准谱 Master/Re:Master 天生解锁
+
+_unl_std, _unl_std_notes = build_unlock_items([1085])
+check("标准谱(<10000)只写 kind5,不写 kind6/7",
+ [it["itemKind"] for it in _unl_std] == [5], str([it["itemKind"] for it in _unl_std]))
+check("解锁条目形态 = {itemKind,itemId,stock,isValid}",
+ _unl_std[0] == {"itemKind": 5, "itemId": 1085, "stock": 1, "isValid": True},
+ str(_unl_std[0]))
+check("标准谱给出「天生解锁」说明",
+ any("天生解锁" in n for n in _unl_std_notes), str(_unl_std_notes))
+
+_unl_dx, _ = build_unlock_items([11414])
+check("DX 谱写 kind5 + kind6 + kind7",
+ [it["itemKind"] for it in _unl_dx] == [5, 6, 7], str([it["itemKind"] for it in _unl_dx]))
+
+_unl_m, _ = build_unlock_items([11414], master=False)
+check("master=False 不写 kind6", [it["itemKind"] for it in _unl_m] == [5, 7],
+ str([it["itemKind"] for it in _unl_m]))
+_unl_r, _ = build_unlock_items([11414], remaster=False)
+check("remaster=False 不写 kind7", [it["itemKind"] for it in _unl_r] == [5, 6],
+ str([it["itemKind"] for it in _unl_r]))
+
+_unl_dup, _ = build_unlock_items([11414], existing=[{"itemKind": 5, "itemId": 11414}])
+check("已存在的条目自动跳过(不重复插入)",
+ [it["itemKind"] for it in _unl_dup] == [6, 7], str([it["itemKind"] for it in _unl_dup]))
+_unl_all, _ = build_unlock_items(
+ [11414], existing=[{"itemKind": k, "itemId": 11414} for k in (5, 6, 7)])
+check("全都有时返回空列表", _unl_all == [], str(_unl_all))
+check("musicId 重复只写一次", len(build_unlock_items([1085, 1085])[0]) == 1)
+
+_ua_unl = build_user_all(baseline, login_date_time=1791159576, playlog_id=1,
+ unlock_music_ids=[11414])
+check("build_user_all 把解锁条目追加进 userItemList",
+ [it["itemKind"] for it in _ua_unl["userItemList"]] == [5, 6, 7],
+ str(_ua_unl["userItemList"]))
+check("解锁条目的位串全是 1(新增)", _ua_unl["isNewItemList"] == "111",
+ _ua_unl["isNewItemList"])
+check("位串长度与 userItemList 条数一致",
+ len(_ua_unl["isNewItemList"]) == len(_ua_unl["userItemList"]))
+check("不给 unlock_music_ids 时不产生 userItem 条目",
+ build_user_all(baseline, login_date_time=1791159576,
+ playlog_id=1)["userItemList"] == [])
+
+_ua_unl3 = build_user_all(
+ baseline, login_date_time=1791159576, playlog_id=1,
+ extra_data={"userItemList": [{"itemKind": 5, "itemId": 1085,
+ "stock": 1, "isValid": True}],
+ "isNewItemList": "1"},
+ unlock_music_ids=[11414])
+check("extra_data 给了 userItemList 时解锁条目追加在其后",
+ [it["itemId"] for it in _ua_unl3["userItemList"]] == [1085, 11414, 11414, 11414],
+ str([it["itemId"] for it in _ua_unl3["userItemList"]]))
+check("已有条目的位串保留、新条目补 1", _ua_unl3["isNewItemList"] == "1111",
+ _ua_unl3["isNewItemList"])
+
+
+# =============================================================================
+# 13.8 unlockmusic.py 的参数解析(主脚本复用 upsert.run)
+# =============================================================================
+
+section("13.8 unlockmusic.py 参数解析")
+
+_uopts = unlockmusic.parse_args(["QRCODE", "--music", "11414", "--send"])
+check("--music 解析成 int 列表", _uopts["unlock_music"] == [11414],
+ str(_uopts["unlock_music"]))
+check("二维码取第一个非 -- 参数", _uopts["qr"] == "QRCODE", _uopts["qr"])
+check("--send 置位", _uopts["send"] is True)
+
+_uopts = unlockmusic.parse_args(["QR", "--music", "1085,11414", "--music", "7"])
+check("逗号(含中文逗号)+ 可重复都认",
+ _uopts["unlock_music"] == [1085, 11414, 7], str(_uopts["unlock_music"]))
+
+_uopts = unlockmusic.parse_args(["QR", "--unlock-music", "11414",
+ "--no-unlock-master", "--no-unlock-remaster"])
+check("--unlock-music 是 --music 的别名", _uopts["unlock_music"] == [11414])
+check("两个难度开关默认 True、可关掉",
+ _uopts["unlock_master"] is False and _uopts["unlock_remaster"] is False)
+
+_uopts = unlockmusic.parse_args(["QR", "--music", "abc"])
+check("非法 musicId 记进 unlock_bad", _uopts.get("unlock_bad") == "abc")
+
+check("upsert.parse_args 不再认识 --unlock-music",
+ "unlock_music" not in upsert.parse_args(["QR", "--unlock-music", "11414"]))
+check("upsert.run 存在且可被别的脚本复用", callable(getattr(upsert, "run", None)))
+
+
+# =============================================================================
+# 13.9 买票前置闸门(必须先跑 GetUserCharge)
+# =============================================================================
+
+section("13.9 买票前置闸门(必须先跑 GetUserCharge)")
+
+reset_charge_guard()
+check("没查过票时 has_fresh_read 为 False", has_fresh_read(55555) is False)
+check("没查过票时 last_read_age 为 None", last_read_age(55555) is None)
+
+note_user_charge_read(55555, [{"chargeId": 2, "stock": 0},
+ {"chargeId": 5, "stock": 1}])
+check("查过之后 has_fresh_read 为 True", has_fresh_read(55555) is True)
+check("held_charges 只挑 stock>0 的",
+ [c["chargeId"] for c in held_charges(55555)] == [5], str(held_charges(55555)))
+
+try:
+ UpsertUserChargelog(66666, 2, 1791159576, user_data={},
+ guard_purchasable=False, dry_run=True)
+ check("没查票就买票应抛 ChargelogGuardError", False, "未抛异常")
+except ChargelogGuardError as exc:
+ check("没查票就买票抛 ChargelogGuardError(未发包)", True)
+ check("报错信息里点明要先调 GetUserCharge",
+ "GetUserCharge" in str(exc), str(exc)[:120])
+
+check("显式传 charge_list 视为已查票(可通过)",
+ UpsertUserChargelog(66666, 2, 1791159576, charge_list=[], user_data={},
+ guard_purchasable=False,
+ dry_run=True).userCharge.chargeId == 2)
+
+note_charge_purchase(77777, 2)
+check("purchased_charge_ids 记录已买", purchased_charge_ids(77777) == [2])
+note_user_charge_read(77777, [])
+try:
+ UpsertUserChargelog(77777, 2, 1791159576, charge_list=[], user_data={},
+ guard_purchasable=False, dry_run=True)
+ check("同进程重复买票应被拦下", False, "未抛异常")
+except ChargelogGuardError as exc:
+ check("同进程重复买票抛 ChargelogGuardError", True)
+ check("报错信息点明已经买过", "已经买过" in str(exc), str(exc)[:120])
+
+reset_charge_guard()
+check("reset 之后记忆清空",
+ has_fresh_read(55555) is False and purchased_charge_ids(77777) == [])
+
+
# =============================================================================
# 14. 副作用闸门
# =============================================================================
diff --git a/unlockmusic.py b/unlockmusic.py
new file mode 100644
index 0000000..3a758f3
--- /dev/null
+++ b/unlockmusic.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+unlockmusic.py —— 主脚本:扫码登录 → 解锁(解禁)歌曲 → 登出
+================================================================================
+
+**它没有自己的协议实现** —— 整个流程直接复用 `upsert.py` 的 `run()`:
+扫码 → 预览 → 登录 → UpsertUserAll → 落库回查 → 登出,一模一样。
+唯一多出来的东西是"要解锁哪些歌"这个入参。
+
+--------------------------------------------------------------------------------
+为什么解锁不用单独的接口
+--------------------------------------------------------------------------------
+游戏里解锁不是一次独立的网络请求,而是**往 userItemList 里补几条 UserItem**:
+
+ itemKind = 5 歌曲本体解锁(MusicUnlockList)
+ itemKind = 6 Master 难度解锁(MusicUnlockMasterList)
+ itemKind = 7 Re:Master 难度解锁(MusicUnlockReMasterList)
+
+依据 source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:981-1008
+(UserData.cs:132-138 那四个解锁列表不是独立节点,是并进 userItemList 的)。
+
+条目长这样:
+
+ {"itemKind": 5, "itemId": 11414, "stock": 1, "isValid": true}
+
+★ 标准谱(musicId < 10000)的 Master / Re:Master **天生解锁**,不会写 kind6/7
+ (source/Assembly-CSharp/Manager/NotesListManager.cs:415/455 开头就是
+ `if (id < 10000) return true;`);只有 DX 谱(>= 10000)才需要额外解禁。
+★ 服务器上已经有的条目**自动跳过** —— 重复插入会让服务端直接 HTTP 500。
+★ Re:Master 还受活动开关约束(IsUnlockReMaster 里的 IsOpenEvent),
+ 那是机台侧开关,脚本改不了;写了也可能进游戏仍是灰的。
+★ 本功能**没有真机抓包可对照**(机台侧的解锁不上报),是按源码实现的。
+
+--------------------------------------------------------------------------------
+用法
+--------------------------------------------------------------------------------
+ python unlockmusic.py <二维码> --music 11414 # 预检,不发包
+ python unlockmusic.py <二维码> --music 11414 --send # ★ 真实解锁
+ python unlockmusic.py <二维码> --music 1085,11414 --send # 多首,逗号分隔
+ python unlockmusic.py <二维码> --music 11414 --send --no-unlock-remaster
+
+ # 只要歌曲本体,不解禁任何难度
+ python unlockmusic.py <二维码> --music 11414 --send --no-unlock-master --no-unlock-remaster
+
+ # 和其它主脚本一样的通用开关
+ python unlockmusic.py <二维码> --music 11414 --send --debug
+ python unlockmusic.py <二维码> --music 11414 --send --cooldown 0
+ python unlockmusic.py <二维码> --music 11414 --send --no-verify
+
+| 参数 | 说明 |
+|---|---|
+| `--music ID[,ID...]` | 要解锁的 musicId,可重复、可逗号分隔(`--unlock-music` 也认) |
+| `--send` | 真正发包(不加就是预检,零副作用) |
+| `--no-unlock-master` | 不解禁 Master |
+| `--no-unlock-remaster` | 不解禁 Re:Master |
+| `--cooldown N` | 发包前冷却秒数,默认取 config 的 15 秒 |
+| `--no-verify` | 跳过上传后的落库回查 |
+| `--debug` | 本次运行打印完整收发报文 |
+
+日志同样是 logs/latest.log(覆盖)+ logs/YYYY-MM-DD.log(追加)。
+"""
+
+from __future__ import annotations
+
+import sys
+
+import upsert
+from api import log_error, setup_logging, teardown_logging
+
+
+def parse_args(argv):
+ """极简命令行解析:第一个非 -- 开头的参数是二维码。"""
+ opts = {"qr": "", "send": False, "debug": False, "no_verify": False,
+ "cooldown": None, "extra_data": [],
+ "unlock_music": [], "unlock_master": True, "unlock_remaster": True}
+ i = 0
+ while i < len(argv):
+ a = argv[i]
+ if a == "--send":
+ opts["send"] = True
+ elif a == "--debug":
+ opts["debug"] = True
+ elif a == "--no-verify":
+ opts["no_verify"] = True
+ elif a == "--extra-data" and i + 1 < len(argv):
+ opts["extra_data"].append(argv[i + 1])
+ i += 1
+ elif a == "--cooldown" and i + 1 < len(argv):
+ opts["cooldown"] = float(argv[i + 1])
+ i += 1
+ elif a in ("--music", "--unlock-music") and i + 1 < len(argv):
+ # 可重复;也支持逗号分隔(中英文逗号都认)
+ for part in str(argv[i + 1]).replace(",", ",").split(","):
+ part = part.strip()
+ if not part:
+ continue
+ try:
+ opts["unlock_music"].append(int(part))
+ except ValueError:
+ opts["unlock_bad"] = part
+ i += 1
+ elif a == "--no-unlock-master":
+ opts["unlock_master"] = False
+ elif a == "--no-unlock-remaster":
+ opts["unlock_remaster"] = False
+ elif not a.startswith("--") and not opts["qr"]:
+ opts["qr"] = a
+ i += 1
+ return opts
+
+
+def main() -> int:
+ opts = parse_args(sys.argv[1:])
+ if not opts["qr"] or not opts["unlock_music"]:
+ # 没有二维码、或者没有指定要解锁哪首 —— 直接把用法打出来
+ print(__doc__)
+ return 1
+
+ if opts.get("unlock_bad"):
+ # 参数本身就不对,连扫码都不用做
+ setup_logging("unlockmusic")
+ log_error("--music 里有非法 musicId:" + str(opts["unlock_bad"]))
+ teardown_logging()
+ return 6
+
+ # ★ 整个流程复用 upsert.run(),这里只多传三个解锁参数
+ return upsert.run(
+ opts,
+ unlock_music=opts["unlock_music"],
+ unlock_master=opts["unlock_master"],
+ unlock_remaster=opts["unlock_remaster"],
+ log_name="unlockmusic",
+ )
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/upsert.py b/upsert.py
index 0337631..0afb7d2 100644
--- a/upsert.py
+++ b/upsert.py
@@ -29,9 +29,12 @@ upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
python upsert.py <二维码> # 预检:登录 + 抓快照 + 组包,不发包
python upsert.py <二维码> --send # 真实上传成绩(★ 有副作用,不可撤销)
python upsert.py <二维码> --send --debug # 同上,日志里带完整收发报文
- python upsert.py <二维码> --send --cooldown 0 # 跳过发包前的 60 秒冷却
+ python upsert.py <二维码> --send --cooldown 0 # 跳过发包前的 15 秒冷却
python upsert.py <二维码> --send --no-verify # 跳过上传后的落库回查
+ # ★ 解锁(解禁)歌曲请用另一个主脚本(它复用本脚本的整套流程)
+ python unlockmusic.py <二维码> --send --music 11414
+
# ★ 额外塞节点(--extra-data,可重复,后给的覆盖先给的)
python upsert.py <二维码> --send --extra-data extra.json
python upsert.py <二维码> --send --extra-data '{"isNewMapList":"1"}'
@@ -83,6 +86,17 @@ upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
新增条目请显式传 "1","0" 表示存量更新。
★ JSON 在**扫码之前**就校验,写错了直接退出,不会浪费二维码。
+--------------------------------------------------------------------------------
+★ 这个文件同时是一个可复用的模块
+--------------------------------------------------------------------------------
+ `run(opts, ...)` 就是整套流程(扫码 → 预览 → 登录 → UpsertUserAll → 登出),
+ `main()` 只做参数解析然后调它。所以别的主脚本可以直接:
+
+ import upsert
+ sys.exit(upsert.run({"qr": qr, "send": True, ...}, log_name="unlockmusic"))
+
+ unlockmusic.py 就是这么写的 —— 它只多了一个"要解锁哪些歌"的入参。
+
── 在代码里直接调 api 的等价写法 ──
UpsertUserAll(
@@ -146,19 +160,24 @@ def parse_args(argv):
return opts
-def main() -> int:
- opts = parse_args(sys.argv[1:])
- if not opts["qr"]:
+def run(opts, *, unlock_music=None, unlock_master=True, unlock_remaster=True,
+ log_name="upsert") -> int:
+ """跑完整套流程。upsert.py 自己用,unlockmusic.py 也复用它。
+
+ opts 里必须有的键:qr / send / debug / no_verify / cooldown / extra_data
+ unlock_music / unlock_master / unlock_remaster 三个是解锁用的,upsert.py 不传。
+ """
+ if not opts.get("qr"):
print(__doc__)
return 1
# --debug 只影响本次运行,不改 config.py
- if opts["debug"]:
+ if opts.get("debug"):
import config as _cfg
_cfg.LOG_LEVEL = "debug"
# 日志会话:开头一句。之后每次 api 调用都会自动记录,主脚本不用再写日志
- setup_logging("upsert")
+ setup_logging(log_name)
# ---- ★ 先解析 --extra-data(放在扫码之前:JSON 写错就别浪费二维码了)----
extra = None
@@ -186,6 +205,13 @@ def main() -> int:
extra = merged
log_info("--extra-data 合计 " + str(len(extra)) + " 个节点:" + ", ".join(extra))
+ # ---- 解锁参数校验(同样放在扫码之前)----
+ if unlock_music:
+ log_info("要解锁 " + str(len(unlock_music)) + " 首:"
+ + ", ".join(str(m) for m in unlock_music)
+ + "(Master=" + str(unlock_master)
+ + " / Re:Master=" + str(unlock_remaster) + ")")
+
# 会话状态:保证异常时也能登出(否则账号会卡在 isLogin=True,下次登录 returnCode=100)
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
@@ -210,6 +236,9 @@ def main() -> int:
auth_date_time=login.dateTime,
token=token,
extra_data=extra,
+ unlock_music_ids=unlock_music or None,
+ unlock_master=unlock_master,
+ unlock_remaster=unlock_remaster,
dry_run=not opts["send"],
confirm=opts["send"],
cooldown_sec=opts["cooldown"],
@@ -235,5 +264,9 @@ def main() -> int:
teardown_logging()
+def main() -> int:
+ return run(parse_args(sys.argv[1:]), log_name="upsert")
+
+
if __name__ == "__main__":
sys.exit(main())
diff --git a/use_ticket.py b/use_ticket.py
index ab45dad..7ce3795 100644
--- a/use_ticket.py
+++ b/use_ticket.py
@@ -78,7 +78,7 @@ from api import (
ConfirmRequiredError,
ticket_name,
Ping,
- # ★ 原先在 config.py,已下沉到 api/upsert_user_chargelog.py
+ # ★ 原先在 config.py,已下沉到 api/UpsertUserChargelog.py
TICKET_CREDIT_NUM,
)
from config import (